유대선
프로젝트로
·기술 회고·17

Architecture deep-dive: the 87 techniques actually in Talkak's codebase — each with the incident behind it

Not the bird's-eye view — the full inventory. A six-agent scan read the entire codebase and extracted every real technique in production, subsystem by subsystem: PTY/process, terminal rendering, layout/keybindings, agent augmentation, the connective graph, and the release pipeline. Almost every entry exists because something concretely broke. It all reduces to three recurring patterns: make silent failure visible, put mechanisms outside the thing that forgets, and demand receipts for every number.

Compiled from a six-agent full-codebase scan (each agent read the actual source; every entry carries the mechanism and, where it applies, the incident that forced it). ★ = most load-bearing. The high-level companion: Talkak's core architecture (2026-06-10).

Part 1 — PTY & process layer (Rust)

★ 1. The bash -c error wrapper. tmux is never exec'd bare; it runs inside /bin/bash -c "<tmux…> 2>&1; on failure echo the code + resolved path; exec $SHELL". A failing spawn prints a readable error into the pane and drops to a shell for debugging. Why: inside a GUI bundle's minimal PATH, a bare spawn dies silently — the user sees a frozen blank pane.

★ 2. A dedicated tmux server socket (-L dalkkak). Every tmux call uses a private, app-owned server — parented to launchd, so it outlives the app: relaunch just re-attaches. Why: panes once landed on the user's default tmux server — a days-old daemon another terminal had started, which lacked macOS Documents (TCC) permission; ls ~/Documents failed inside panes while the app itself could read the repo.

★ 3. new-session -A -D -s dalkkak-<paneId>. Deterministic session name per pane; -A attach-or-create (this IS crash recovery), -D detaches the dead pre-crash client; -c <dir> only when the cwd validates as a real directory. Why: tmux must be the single recovery mechanism — a renderer-side auto-claude --resume once corrupted live sessions that tmux had already recovered.

4. Healing the live tmux server's global env on every spawn. Idempotently unset every npm_config_* from the server's global env + mouse off + status off. Why: the server is long-lived and remembers its first environment — fixing only the spawn-process env never reaches it. (The npm_config_prefix → nvm refuses to load → codex: command not found incident: the code fix "didn't work" until the live server was mutated.)

★ 5. Three-move child env hygiene. Explicitly SET TERM/COLORTERM; FORWARD exactly ten interactive-shell vars; SUBTRACT all npm_config_* (count logged). Why: portable-pty seeds the child env from the current process — launch via pnpm tauri dev and pnpm's vars leak into every pane. Env hygiene is addition and subtraction.

6. PATH augmentation + wrapper-dir prepend. /opt/homebrew/bin:/usr/local/bin prepended (dedup-guarded), and ahead of those, the app's own bin dir holding the claude wrapper — so the wrapper wins only inside Talkak panes.

7. DALKKAK_PANE_ID as tmux per-session env (-e). Every process in the pane — claude, and the hook scripts claude spawns — knows its pane id. This one var powers event attribution and the per-pane gate scoping.

★ 8. Blocking read loop on a dedicated OS thread. 4096-byte reads, from_utf8_lossy (split multibyte never panics), each chunk emitted as a pty-output event. Three logged exit paths — EOF, read error, and emit failure (webview gone) → the loop kills itself instead of pumping a dead window.

9. A visible EOF marker. read() == 0 prints a yellow [pane closed] before the stream ends. A silently frozen pane is the worst UX — you can't tell dead from slow.

10. UTF-8 locale backfill. If inherited LANG isn't UTF-8 (in a Finder-launched app it's usually absent), set LANG/LC_CTYPE=en_US.UTF-8 — but never LC_ALL, which would steamroll a user's own locale. Why: Hangul rendered as __ per syllable until the bytes were allowed through.

11. find_tmux(): absolute candidates before PATH. Probe /opt/homebrew/bin/usr/local/bin/usr/bin/opt/local/bin; PATH lookup is the last resort; the chosen path is logged and printed in error messages.

12. Pathological PTY-size guard. cols/rows of 0 → 80×24.

13. Scroll mode = state-queried copy-mode toggle. Ask tmux #{pane_in_mode}, then cancel or copy-mode -u (enters and pages up in one call — without -u the cursor starts at the bottom and the viewport won't move). Why: mouse on flooded the webview with tracking escapes and broke copy-on-select; wheel scrollback was abandoned for this.

14. pty_kill: two-layer teardown, explicit-close only. Kill the PTY child and tmux kill-session (killing only the client leaves a zombie session). Never called on app quit — that asymmetry is the persistence design.

15. Orphan-session GC: double-guarded, now disabled. It reaped sessions that were detached and absent from every saved layout — but a layout-format change made loads fail, the live-set came back empty, and it nearly killed in-progress work. Policy now in a 20-line comment: a few lingering sessions beat ever auto-killing live work.

Part 2 — Terminal rendering & registry (TypeScript)

★ 16. Module-scoped terminal registry. xterm objects, PTY subscriptions, and buffers live in a plain module Map — outside React. Remount all you want; the terminal loses nothing.

★ 17. Remount re-attach moves the DOM node. If a terminal was opened once, its existing DOM subtree is appendChild-ed into the new container — scrollback, cursor, running TUI physically relocated; term.open() runs only on first mount. Effect cleanup deliberately disposes nothing.

18. PTY subscriptions bound to pane id, torn down only by explicit action. Exactly three callers ever destroy a terminal: ⌘W, Reset, startup delete.

★ 19. Hot-path split. Every chunk hits term.write immediately; parsing is deferred — chunks accumulate and a single 250ms flush feeds the parser once and ships all events in ONE IPC call. Parser exceptions are caught; rendering can't break. Why: per-chunk invoke across many verbose panes was a measured cost.

20. Focus-reporting escape stripping (input path). When a TUI enables DECSET ?1004, xterm emits CSI I/CSI O on focus changes; written to the PTY, the bare ESC reads as interrupt to Claude's TUI. Exactly those two sequences are dropped.

★ 21. Copy-on-select pipeline. Selection auto-copies (no ⌘C), debounced 120ms to the settled selection; the copy is cleaned (frame glyphs stripped) while the on-screen selection is untouched. The cleaning rules live in a pure, test-locked module.

22. Click-a-colored-run-to-copy. Per-cell foreground inspection turns colored runs (commands, paths, hashes) into copy links. Runs containing box-drawing chars are never linkable — the "──── ultracode ─" banner fix, after three wrong attempts on the wrong code path.

23. WebGL focused-only — currently parked. Structurally ≤1 GPU context (follows focus), context-loss falls back to the DOM renderer ("never a blank pane")… and none of it is currently called: WebGL provably broke Korean IME composition in WKWebView, and Korean input outranks GPU smoothness.

24. Resize debounce. ResizeObserver storms (dozens per drag) coalesce into one fit() + pty_resize per 120ms.

25. Idempotent spawn with a pre-await race guard. spawned = true is set before the await — two same-tick callers can't double-spawn; failure resets the flag for retry.

26. Window-focus re-grab with an overlay guard. Returning from another app/Space, xterm's hidden textarea has lost focus and "typing goes nowhere" — a window listener re-grabs it, unless a real input field (an open overlay) is active.

27. Two-tier clipboard + a quiet toast. navigator.clipboard first; WKWebView refusal falls back to a hidden textarea + execCommand (restoring the stolen focus). Success shows a small ✓ pill with a ≤34-char snippet of what was copied.

28. autoResume neutered. tmux persistence IS the recovery; the renderer typing claude --resume into recovered sessions was corruption, not help.

29. Construction defaults. Font fallback includes Apple SD Gothic Neo (no Hangul tofu), ClipboardAddon for OSC52 (tmux set-clipboard reaches the system clipboard through the webview), 10k scrollback.

Part 3 — Layout, keybindings & startups

★ 30. The layoutOwner cross-write guard. The layout tree travels with its owning startup id; the save effect persists only when owner == active. Why: for one commit after every switch, a stale closure held {new id, old tree} and wrote startup A's panes under B's key — quit inside that window and two startups permanently shared the same live terminals (confirmed by two adversarial reviews; the race had been described in a 5/26 post-mortem whose fix covered only the null variant, and its wrong comment protected the bug).

★ 31. Boot sanitizer: a pane id belongs to exactly one startup. Before first render, scan all saved layouts; first claim wins, later layouts get fresh leaves. Already-corrupted installs self-heal on next launch.

★ 32. Pure chord guards + the full-consumption contract. Predicates (isStartupSwitchChord …) extracted to a test-locked module; any claimed chord is consumed unconditionally — preventDefault + stopPropagation even on the out-of-range no-op path. Why: Ctrl+3 is ESC in a terminal; the leak silently interrupted Claude for a full day.

33. Arc-style modifier split. Ctrl = startups (spaces), Cmd = panes (tabs). Ctrl-arrows belong to macOS Spaces, so startup-walk is ⌘⌥-arrows — colliding with a macOS default is an instant kill, not a tradeoff.

34. One capture-phase keydown hub. Registered with capture: true so app chords are evaluated before xterm can serialize them; first line bails on unmodified keys (plain typing costs one boolean). Ordering is explicit (⌘⇧I before ⌘I; ⌘I requires !shiftKey).

35. Per-startup layout persistence with shape-validated loads. dalkkak.layout.<id>.v1; reads accept only a string leaf or a complete {direction, first, second} — anything else (old formats, garbage) returns null instead of poisoning state; saves never accept null.

36. Synchronous layout seeding. Fresh layouts are persisted to localStorage synchronously, before setState in both places that create them — the null variant of the cross-write race, closed.

37. Per-startup folder grants through one Rust chokepoint. Explicit folder picker → PathAllowlist (canonicalized). Never a blanket disk permission.

38. Ownership registration walks ALL stored layouts — background startups' panes keep their graph-push attribution, not just the active one's.

39. No tmux GC — sessions die only by explicit user action. The data-survival rule, written into a long comment where the GC used to be.

40. The mosaic tree as plain recursive data. Three ~12-line pure functions (collectIds / replaceLeaf / removeLeaf) instead of library utilities — which also lets the boot sanitizer manipulate trees without importing React or mosaic. Closing the last pane generates a fresh leaf; the workspace can't go empty.

41. One-shot legacy migration. The pre-multi-startup single layout moves under the first startup's key (skipped if occupied — re-run safe).

42. Startup delete = explicit destructive cascade. Confirm (names the consequence), destroy every pane from the stored layout (background sessions included), remove the key, hand off focus.

43. An in-app InputDialog, because window.prompt returns null in a Tauri webview — a discovered platform gap; the native path was a silent no-op.

Part 4 — Agent augmentation & AI (BYO)

★ 44. The PATH-shadowing claude wrapper — the platform's lever. A script in the app's bin dir execs the real claude with --append-system-prompt "$(cat dk-directive.txt)". One directive file = behavioral change for every agent in every startup; outside Talkak panes, the user's claude is untouched.

★ 45. The four-section DIRECTIVE. Honesty (verified-only, tag inference, quote outputs), English polish (You wrote / Polished), graph push (dk-node — durable facts only, ≤1 per turn, never distort the work), and the inline summary card contract (dk-summary + verify).

46. An append-only, marker-guarded ~/.zshrc function. A shell function beats PATH when the user's rc re-prepends its own bins; marker-guarded against double-install, backed up once, and never read-and-rewritten — only appended. A user's dotfile is the one file you can destroy; safety is structural.

★ 47. Summaries read from the transcript, never the TUI stream. TUI redraws merge and split text unpredictably (a redraw once turned "Your home directory (" into a bogus "Yourhomedirectory(" match). The transcript JSONL on disk is the machine-readable source of truth.

48. A lenient first-JSON parser with graceful degradation. Parse the first complete JSON value and ignore trailing junk (observed in the wild: a stray "</parameter>}"); on failure, fall back to the previous good card. "The parser is the durable floor" — assume the model will occasionally emit malformed output.

★ 49. The BYO headless claude -p pattern. One-shot calls (phrase correction, summaries) run the user's own claude: API-key env vars removed (forcing their auth), neutral cwd, JSON envelope error check, timeouts.

50. Per-call model pinning + MCP cold-start kill switch. Summaries pin haiku (cheap/fast); correction pins opus (quality; slow is fine; 150s). --strict-mcp-config with no config loads ZERO MCP servers — without it every call paid ~30s of the user's MCP cold-starts.

★ 51. The data-not-instruction prompt rule. User text embedded in an LLM prompt is content to transform, never a request to fulfill — with the actual incident ("give me the delete command" → it answered rm -rf) baked into the prompt as the counter-example, and <<<>>> delimiters around all fields.

52. Real-user-prompt detection. Transcript type:"user" lines include tool results; a real human prompt has string content or no tool_result block. Keystone for turn boundaries, usage splits, and the chat view.

53. Session-vs-turn token accounting; no fabricated dollars. Lifetime totals and "since the last real prompt" are split; dollars are never shown — on a subscription there is no per-token bill, so a $ figure would be invented.

54. Transcript → chat log (⌘L). Assistant messages between human prompts merge into one entry (+ tools used); AskUserQuestion blocks become {question, options, chosen} via tool_use-id matching with an anchored label check; dk-summary blocks are extracted into per-turn recaps rather than just stripped.

55. Codex usage with zero network. Read the newest rollout-*.jsonl under ~/.codex/sessions/**, find the last rate_limits snapshot — searched recursively anywhere in the JSON tree, defensive against the undocumented format moving.

56. Claude usage: Keychain OAuth → the same endpoint claude.ai uses; fallback self-calibrates. Token read from the macOS Keychain (first read = an explicit user consent prompt; never logged), then a usage query (zero tokens spent). Any failure falls back to a local 7-day transcript estimate whose 5h gauge calibrates against the user's own peak window — no fabricated official cap.

57. Fail-soft cached usage snapshots. The usage command never errors (unreadable providers come back null — the strip always paints), 60s TTL cache, heavy scans on a blocking-pool thread.

58. StreamParser for live activity. Line-buffered, ANSI-stripped heuristics; spinner lines outrank tool lines (the TUI-merge incident again). Live state only — anything precise comes from hooks + transcripts.

59. The summarizer prompt as a versioned file artifact. include_str!-ed from a maintained markdown file with an ordered kind-selection algorithm ("a waiting AI is what the founder most needs to see") and an explicit transcript-noise ignore list (the source of earlier hallucinated summaries).

Part 5 — Connective layer & hooks

★ 60. PathAllowlist: one canonicalizing chokepoint for every project-path access — symlink-resolved, hard-refusing anything ungranted, never falling back to $HOME.

★ 61. Git→change-node capture (PULL). A 20s read-only poll (git log never takes index.lock) turns new commits into confirmed change nodes whose evidence is the re-verifiable hash.

★ 62. GraphStore: append-only JSONL per startup, O(1) idempotent dedup on a hydrated node_id set. Old records are never rewritten.

63. A locked schema with a joinability policy. schema_version, additive-only fields, readers tolerate unknown fields, version-dispatch on read, node_id as the eternal join key.

64. Dual write-side validation (TypeScript validate.ts mirrored in Rust): empty titles rejected, three provenances only, confirmed requires evidence — "an inference can never masquerade as fact" is mechanical.

★ 65. Agent PUSH = an offset-persisted transcript scanner. A 20s worker maps pane→transcript (hook stream) →startup (renderer-registered), scans incrementally from byte offsets persisted to disk (restarts rescan nothing), consuming only to the last newline.

66. Hand-rolled FNV-1a 64. node_ids must be deterministic forever; std's hasher is seeded per-process. Ten lines, reference constants locked in tests.

67. Agent-node hardening. change stays git-exclusive; evidence.source is force-set to "agent" (spoof overwritten); titles/bodies clamped.

★ 68. Session events travel as a tailed FILE, not a localhost port. Hooks append one JSON line per event; Rust tails new lines every 400ms. No port conflicts, survives restarts, coexists with user hooks. (Pre-history: TUI scraping was proven unworkable — "not a tuning problem.")

69. recover_sessions(): fold to the latest line per pane. The live tail starts at EOF, so restarts would lose pane→transcript mappings (⌘L, ⌘I, usage all need them) — one full-file fold restores them; the same reader feeds the graph-push worker.

70. tauri::async_runtime::spawn, never raw tokio::spawn in setup(). The setup closure has no entered tokio runtime; a raw spawn aborts the app at launch (SIGABRT before any UI). Learned, logged, pinned as a convention.

★ 71. Usage Pulse: zero storage, read-time rollups, honest units. Re-aggregates hook stream + transcripts + graph on open (a persisted pipeline is deferred behind a falsifiable trigger). Output tokens only — counting cache reads would inflate ~200× (a real 305M:1.6M trap). Attribution is longest-prefix cwd matching; ambiguity becomes a visible unassigned row — never guessed, never dropped.

72. Rolling user-data backups outside WebKit storage. Every launch snapshots the personal stores into the app-data dir (never snapshotting emptiness — no overwriting good backups with a wiped state); timestamp filenames make lexicographic = chronological; keep 10.

★ 73. Two Stop-hook gates, fail-open. Honesty: a done-claim regex (KO+EN) with zero evidence markers → block. Summary presence: in a Talkak pane, >200 chars without <dk-summary> → block. Every error path passes (a crashed gate must never lock a session); stop_hook_active prevents re-block loops; the hook itself is under eight tests in the build gate.

74. GraphPanel: a deliberately dumb read surface — one invoke, client-side sort, provenance color badges (confirmed/inferred/hypothesis). Its job is to prove capture → store → read end-to-end; trust level is always visible.

Part 6 — Build, release & quality

★ 75. The test-gated build. build = vitest run && tsc && vite build, and tauri's beforeBuildCommand is pnpm build — the only path to a bundle is through the tests. Proven with a deliberately failing test: exit 1, dist untouched.

★ 76. The regression-tracking loop. One machine-parseable line per regression (caught by: user|me|test|hook) + a weekly stats script with a trend column. Baseline week: 6 regressions, all user-caught. Win condition: user → 0.

★ 77. Incident-pinned test suites. Each file names the dated incident it locks, and fixtures are field artifacts: the literally corrupted layout tree, the literal "──── ultracode ─" banner, an exhaustive sweep of every possible chunk-boundary split of the dk-summary block. Pure modules were extracted and the production call sites switched over — tests lock the real logic, not a copy.

78. Tests live outside tsconfig's include. Production tsc never compiles them; vitest transpiles independently; the node environment lets tests spawn the real hook script as a child process.

★ 79. Minisign-signed updates, end to end. createUpdaterArtifacts emits a signed tar.gz; the public key ships inside the binary; latest.json + artifacts are committed into the landing folder and statically served — git push is the release distribution. Clients verify against the embedded key before installing.

80. A fail-soft, consent-first update check. One check at launch; a newer build asks (with release notes) before download-install-relaunch; any error is swallowed — an update check must never hold app launch hostage.

81. Version & release conventions. The shipping version lives in tauri.conf.json; one release commit renames the artifact, rewrites latest.json, and bumps the version atomically — the release state can't be half-published.

82. INVARIANTS.md — area-indexed durable memory. "Touching keybindings? Check these rows." Every row is something that actually broke once, with the date. Rows that can be automated graduate into tests or hooks; the file covers the rest.

83. Daily-rolling, non-blocking runtime tracing. runtime.log.YYYY-MM-DD, writes never stall the app, the worker guard is deliberately leaked for process lifetime. It earned its keep: a launch SIGABRT was localized in seconds because the log showed init but never "setup complete".

84. The entitlement gate: fail-open, env-testable, swappable. A provider trait (stub → real backend later); DALKKAK_ENTITLEMENT=trial|expired|subscribed exercises every paywall state with no backend; backend errors mean entitled — the worst failure mode must be free usage, never a locked-out paying user; and nothing paywalls before payment actually exists.

85. A minimal capability allowlist — six permissions total. Dialogs, updater, restart, core. No fs, no shell: a webview that renders arbitrary terminal output, if compromised, can only call the audited command set.

86. Info.plist TCC usage descriptions. Declaring Documents/Desktop/Downloads usage turns macOS's silent denial into a one-time prompt — the consumer-appropriate alternative to Full Disk Access.

87. The honest-metrics contract (system pulse). Every figure is obtained "the same way Activity Monitor does" (sysctl + ps RSS over each pane's exact PID subtree), and the limits are documented in the module header: macOS exposes no per-process swap, so swap is reported system-wide and never attributed to a session. A cargo test runs the real collector on the live machine.

The three patterns underneath

All 87 reduce to three recurring moves:

  1. Make silent failure visible. The bash wrapper, the EOF marker, the unassigned row, TCC prompts, three logged exit paths. Silence is the worst failure mode.
  2. Put the mechanism outside the thing that forgets. Hooks outside the model, the test gate outside discipline, the sanitizer outside the race, INVARIANTS outside the context window, signatures outside trust.
  3. Receipts for every number. Provenance + evidence, output-tokens-only, no fabricated dollars or caps, commit hashes you can re-verify.

Everything else is an application of those three.