typeclaw 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
@@ -394,14 +394,365 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
394
394
  // `~/.local/bin/claude` shim, which itself dereferences to the versioned
395
395
  // binary under `~/.local/share/claude/versions/<ver>/`, so upgrades via
396
396
  // `claude update` keep working without re-running this layer.
397
+ // `~/.claude.json` is Claude Code's internal state file (NOT
398
+ // `~/.claude/settings.json`, which is user-facing). On first run with an
399
+ // empty or missing file, `claude` enters a TTY-only theme picker:
400
+ // "Welcome to Claude Code … Choose the text style that looks best with
401
+ // your terminal" with 7 options. The picker is unskippable via CLI
402
+ // flags or env vars (no `--skip-onboarding`, no `--theme=dark`;
403
+ // `IS_DEMO=1` exists but has documented side effects). The single
404
+ // official escape hatch is writing `{"hasCompletedOnboarding": true,
405
+ // "theme": "dark"}` to `~/.claude.json` before the first launch —
406
+ // confirmed by Anthropic in multiple GitHub issues
407
+ // (anthropics/claude-code#4714, #8938, #13827) and the empirical
408
+ // answer used by metabase/metabase's `bin/claude-dangerous`, the
409
+ // `claudeCodeAlDevContainer` feature, and dozens of other Docker
410
+ // integrations.
411
+ //
412
+ // Without the pre-seed, the very first agent-driven `tmux new-session …
413
+ // claude` invocation hangs on the theme picker: the agent's
414
+ // `send-keys "<prompt>" Enter` arrives at the picker, gets interpreted
415
+ // as picker input, and never reaches claude's actual prompt. The
416
+ // `typeclaw-claude-code` skill is structured around a `Stop`-hook
417
+ // sentinel, which never fires while the picker is up, so the polling
418
+ // loop only learns of the hang at the 10-minute wall-clock budget.
419
+ // Pre-seeding here costs ~85 bytes on disk and zero runtime overhead.
420
+ //
421
+ // SCOPE: this seed is NECESSARY but not SUFFICIENT for a fully
422
+ // no-questions-asked first launch. Claude Code also shows two
423
+ // post-seed modal dialogs that this file deliberately does NOT
424
+ // pre-clear:
425
+ // 1. "Detected a custom API key from environment. Do you want to use
426
+ // this API key?" — fires when ANTHROPIC_API_KEY is set. Options
427
+ // `[No (recommended), Yes]`, focus on No, picker does NOT wrap.
428
+ // 2. Workspace trust ("Do you trust the files in this folder?") —
429
+ // fires on every new cwd. Options `[Yes, proceed, No, exit]`,
430
+ // focus on Yes.
431
+ // Both are kept as runtime decisions handled by the
432
+ // `typeclaw-claude-code` skill (see its "Driving the session" section,
433
+ // "Clear startup dialogs" step, which uses dialog-specific keystrokes
434
+ // because the picker doesn't wrap). Pre-seeding
435
+ // `hasTrustDialogAccepted` or `customApiKeyResponses.approved` here
436
+ // would silently widen the trust surface in ways the operator hasn't
437
+ // consented to — the seed's job is strictly cosmetic-wizard removal,
438
+ // not trust/permission preemption.
439
+ //
440
+ // `theme: "dark"` matches typeclaw's default TUI theme so the visual
441
+ // transition between the typeclaw TUI and a tmux-attached claude pane
442
+ // is consistent. Users on light terminals can override by editing
443
+ // `~/.claude.json` (which persists across container restarts only if
444
+ // they mount it; in the default container-ephemeral state it resets
445
+ // to this default on every rebuild, which is fine — `claude` reads
446
+ // the file at startup and the theme has no behavioral impact).
447
+ //
448
+ // `lastOnboardingVersion` is INTENTIONALLY OMITTED. ii-agent and a
449
+ // few other templates ship `lastOnboardingVersion: "1.0.30"`, but
450
+ // that value is version-coupled and goes stale on every Claude Code
451
+ // release. Empirically against Claude Code 2.1.146, the current
452
+ // `hasCompletedOnboarding: true` alone is honored without a version
453
+ // pin. If a future Claude version starts re-triggering the picker
454
+ // when the field is missing, capture `claude --version` output at
455
+ // build time and inject it then — don't hardcode a stale value.
456
+ //
457
+ // `installMethod: "native"` and `numStartups: 1` match the shape
458
+ // Claude Code itself writes after a clean first launch; keeping them
459
+ // makes our seed indistinguishable from a real post-onboarding state,
460
+ // which minimizes the chance of a future "if the file looks like
461
+ // agent-pre-seed, redo onboarding" detection heuristic landing on us.
462
+ //
463
+ // Built via `JSON.stringify` rather than a hand-written string
464
+ // literal so quote/escape bugs surface as TS errors at compile time,
465
+ // not as a corrupt `~/.claude.json` discovered only when the build
466
+ // runs. The `printf '%s\\n' '<JSON>'` shell pattern relies on the
467
+ // JSON containing no single quotes (true by construction — JSON.
468
+ // stringify only emits double quotes); a regression test parses the
469
+ // emitted JSON back to confirm.
470
+ const CLAUDE_CODE_ONBOARDING_SEED = JSON.stringify({
471
+ hasCompletedOnboarding: true,
472
+ theme: 'dark',
473
+ installMethod: 'native',
474
+ numStartups: 1,
475
+ })
476
+
477
+ // The Stop hook is what powers the typeclaw-claude-code skill's done-signal:
478
+ // the operator subagent spawns claude in a worktree, polls a sentinel file,
479
+ // and decides "turn done" from the file's contents. The hook was originally
480
+ // per-worktree (`<worktree>/.claude/settings.json` + `<worktree>/hook-on-
481
+ // stop.sh`), which meant the operator subagent wrote both files itself at
482
+ // delegation time. That worked when operator wrote the canonical shape —
483
+ // and silently failed when it didn't. Claude Code's settings parser
484
+ // ignores unknown keys, so wrong-shape configs (`{"hooks": {"onStop":
485
+ // "./script.sh"}}`, `{"hooks": {"Stop": "./script.sh"}}`,
486
+ // `{"hooks": {"Stop": [{"command": "./script.sh"}]}}` etc.) surface as
487
+ // "polling timed out at the 10-minute wall-clock budget" rather than as a
488
+ // parse error — the failure is invisible until you've already paid the
489
+ // budget. The skill's "do not simplify this JSON" warning helps but doesn't
490
+ // eliminate the slip: as long as an LLM has to write the JSON, an LLM can
491
+ // invent a shape.
492
+ //
493
+ // Move both pieces to Dockerfile-build time, where the JSON is constructed
494
+ // once via JSON.stringify and the shape can never drift from the operator's
495
+ // reading of the skill:
496
+ //
497
+ // 1. `/usr/local/bin/typeclaw-cc-stop-hook` — the hook script. Stable
498
+ // absolute path so the global settings.json can name it without
499
+ // worrying about $PATH. Reads stdin (Claude Code's Stop event JSON)
500
+ // and atomically writes `$PWD/sentinel.json` + touches `$PWD/.done`.
501
+ // The temp-file-then-rename keeps the read side from ever seeing a
502
+ // partial sentinel even if the polling loop races the write — same
503
+ // atomicity contract as the previous per-worktree script.
504
+ // 2. `~/.claude/settings.json` — the user-level (global) hook config.
505
+ // User-level hooks fire for every `claude` invocation regardless of
506
+ // cwd, so the operator's worktree no longer needs its own
507
+ // `.claude/settings.json`. (Claude Code merges hooks additively
508
+ // across scopes per docs.claude.com/en/docs/claude-code/settings —
509
+ // if a future user mounts their own `~/.claude/settings.json` with a
510
+ // different Stop hook, both fire in parallel rather than ours being
511
+ // clobbered.)
512
+ //
513
+ // Both files are written via JSON.stringify + heredoc; the JSON shape is
514
+ // validated by JSON.parse in dockerfile.test.ts, so a future edit that
515
+ // corrupts the structure fails the test, not the docker build (let alone
516
+ // the next delegation that runs against the broken hook). The previous
517
+ // per-worktree path is fully removed from the skill body; the only place
518
+ // the hook shape lives is here.
519
+ //
520
+ // IMPORTANT — \`$PWD\` vs \`$CLAUDE_PROJECT_DIR\`: an earlier version of this
521
+ // layer used \`$CLAUDE_PROJECT_DIR\`. Self-review caught this as a critical
522
+ // bug. \`CLAUDE_PROJECT_DIR\` is Claude Code's documented env var for "the
523
+ // project root" — but empirically (see anthropics/claude-code#27343 and
524
+ // #44450), inside a git worktree it resolves to the *main repo's* git
525
+ // root, NOT the worktree's path. The operator's flow is:
526
+ // git -C /agent worktree add /tmp/cc-<id> HEAD
527
+ // tmux new-session -d -s cc-<id> -c /tmp/cc-<id> claude
528
+ // So \`/tmp/cc-<id>\` is a registered worktree of \`/agent\`, and Claude
529
+ // Code would resolve \`CLAUDE_PROJECT_DIR=/agent\` — landing the sentinel
530
+ // in the live agent folder while the polling loop watches the worktree.
531
+ //
532
+ // The fix: write to \`$PWD\` instead. \`$PWD\` is set by every POSIX shell
533
+ // on every invocation (POSIX-required since IEEE 1003.1-1988), and it's
534
+ // the literal cwd Claude Code was invoked with — exactly the worktree
535
+ // path \`tmux -c\` set. \`CLAUDE_PROJECT_DIR\` is Anthropic-specific and
536
+ // has shifted semantics across versions per the cited bug reports; we
537
+ // deliberately avoid depending on it.
538
+ //
539
+ // PER-SESSION FILENAMES — concurrent-claude race safety: the Stop hook
540
+ // writes to \`sentinel-<session_id>.json\` and \`.done-<session_id>\`,
541
+ // not a fixed filename. session_id comes from Claude Code's Stop event
542
+ // JSON on stdin (\`BaseHookInputSchema.session_id\`, always present per
543
+ // the upstream schema). The operator learns the UUID by reading a
544
+ // \`.session-id\` file that a SessionStart hook (also baked here) writes
545
+ // at session-start time; see TYPECLAW_CC_SESSION_START_HOOK_SCRIPT
546
+ // below.
547
+ //
548
+ // The earlier shape used fixed \`sentinel.json\` + \`.done\` names, which
549
+ // is safe when each worktree has at most one claude session (today's
550
+ // only caller). But if two callers ever share a cwd — operator A and
551
+ // operator B both delegating in \`/agent\`, a future plugin that runs
552
+ // \`claude\` from a shared dir, or an out-of-band caller violating the
553
+ // "one worktree per delegation" invariant — both write to the same
554
+ // file and corrupt each other's state with no diagnostic. Per-session
555
+ // filenames make the race structurally impossible.
556
+ //
557
+ // WHY NOT \`claude --session-id <pre-generated-uuid>\`: an earlier
558
+ // iteration of this PR had the operator pre-generate a UUID and pass
559
+ // it via \`--session-id\`. Self-review caught that as a critical bug:
560
+ // per anthropics/claude-code#44607, the \`--session-id\` flag only
561
+ // controls the persistence/transcript UUID in \`-p\` (print) mode. In
562
+ // interactive mode (which the typeclaw-claude-code skill uses), the
563
+ // flag sets a separate telemetry/API ID while the CLI generates its
564
+ // own internal UUID for the transcript file and for the \`session_id\`
565
+ // field that hooks see. The pre-generated UUID and the hook's UUID
566
+ // don't match — the polling loop times out forever. The current
567
+ // design sidesteps this entirely: the operator does NOT pass
568
+ // \`--session-id\`; it lets claude generate its own UUID and learns it
569
+ // back via the \`.session-id\` file the SessionStart hook writes.
570
+ //
571
+ // session_id extraction: \`bun -e\` against the JSON payload. We use bun,
572
+ // NOT POSIX sed, because bun is guaranteed in the container (bun:1-slim
573
+ // base) and is a real JSON parser. A previous iteration used
574
+ // \`sed -n 's/.*"session_id":"\\([^"]*\\)".*/\\1/p'\` which is greedy and
575
+ // picks the LAST \`"session_id":"..."\` occurrence in the JSON.
576
+ // Claude Code's Stop event carries \`last_assistant_message\` which
577
+ // contains the assistant's prose — that prose can include the literal
578
+ // text \`"session_id":"<fake-uuid>"\` (the model might have been
579
+ // discussing session IDs!), which sed would extract instead of the
580
+ // top-level field. bun's JSON.parse picks the structural \`session_id\`
581
+ // regardless of what appears in nested string values. UUID-shape
582
+ // validation still applies downstream as defense-in-depth against
583
+ // path-traversal session_id values; malformed JSON or missing field
584
+ // falls back to "malformed" so the polling loop sees SOMETHING and
585
+ // can surface the corruption.
586
+ //
587
+ // FALLBACK FILENAMES — \`sentinel-malformed.json\` / \`.done-malformed\`:
588
+ // if session_id extraction fails or validation rejects the result, the
589
+ // hook writes to these fixed names. The operator's polling loop watches
590
+ // its own \`<sid>\` file (read from \`.session-id\`) and will time out —
591
+ // but the \`-malformed\` file exists on disk so a post-mortem inspector
592
+ // can tell "hook fired but session_id was bad" apart from "hook never
593
+ // fired at all."
594
+ //
595
+ // SECURITY/SCOPE: both hook scripts only touch files inside \`$PWD\`,
596
+ // with filenames validated to UUID shape or the fixed "malformed"
597
+ // fallback. Even with an adversarial \`session_id\` in the stdin
598
+ // payload, the worst case is "hook writes \`$PWD/sentinel-malformed.json\`"
599
+ // or "hook writes \`$PWD/.session-id\` containing 'malformed'" — never
600
+ // path traversal, never writes outside \`$PWD\`. The hooks run as root
601
+ // inside the container like every other in-container process; there is
602
+ // no privilege boundary to cross.
603
+ const TYPECLAW_CC_STOP_HOOK_PATH = '/usr/local/bin/typeclaw-cc-stop-hook'
604
+ const TYPECLAW_CC_SESSION_START_HOOK_PATH = '/usr/local/bin/typeclaw-cc-session-start-hook'
605
+
606
+ // SessionStart hook script. Fires when a session begins via
607
+ // startup/resume/clear/compact per the upstream lifecycle docs. Writes
608
+ // \`$PWD/.session-id\` containing the validated UUID, atomically.
609
+ //
610
+ // IMPORTANT — \`.session-id\` is a FAST PATH, not a precondition. Per
611
+ // anthropics/claude-code#11519, SessionStart can be SKIPPED entirely
612
+ // when workspace trust hasn't been accepted yet: debug logs show
613
+ // \`Skipping SessionStart:startup hook execution - workspace trust not
614
+ // accepted\`. For the typeclaw-claude-code skill flow, EVERY first
615
+ // invocation in a fresh worktree hits this — the trust dialog fires
616
+ // before any prompt can be sent. So \`.session-id\` may never appear
617
+ // pre-first-prompt. The skill instructs the operator to fall back to
618
+ // reading session_id from the FIRST Stop hook's sentinel instead.
619
+ // \`.session-id\` is still useful for sessions that were already
620
+ // trusted (re-attaching to a running worktree, etc.) — when it
621
+ // appears, the operator can skip the discovery step.
622
+ //
623
+ // IMPORTANT — \`compact\` can ROTATE the session_id. The upstream
624
+ // behavior is documented in anthropics/claude-code#29094: a SessionStart
625
+ // with \`source: "compact"\` is a NEW session linked via
626
+ // \`parent_session_id\`. So \`.session-id\` can change mid-delegation if
627
+ // a long claude session compacts itself. The operator's polling loop
628
+ // must handle session_id rotation: a new \`.done-<different-uuid>\`
629
+ // appearing means \`cc_session_id\` should update to the new value.
630
+ const TYPECLAW_CC_SESSION_START_HOOK_SCRIPT = `#!/bin/sh
631
+ # typeclaw SessionStart-hook for Claude Code. Stdin carries the
632
+ # SessionStart event JSON. Writes \$PWD/.session-id with the session
633
+ # UUID as a fast-path optimization (the operator falls back to
634
+ # discovering session_id from the first Stop sentinel if .session-id
635
+ # never appears — see anthropics/claude-code#11519). Rationale lives
636
+ # in src/init/dockerfile.ts.
637
+ #
638
+ # Both temp filenames are PID-scoped (PID = \$\$) so two SessionStart
639
+ # hooks firing concurrently in the same cwd don't race on the same
640
+ # temp file.
641
+ set -eu
642
+ tmp_out="\${PWD}/.session-id.\$\$.tmp"
643
+ trap 'rm -f "\$tmp_out"' EXIT
644
+ sid=\$(bun -e 'try { const j = await new Response(Bun.stdin.stream()).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }')
645
+ case "\$sid" in
646
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
647
+ *) sid=malformed ;;
648
+ esac
649
+ printf '%s\\n' "\$sid" > "\$tmp_out"
650
+ mv "\$tmp_out" "\${PWD}/.session-id"
651
+ trap - EXIT
652
+ `
653
+
654
+ // Single-quoted heredoc so the body is delivered verbatim — no shell
655
+ // expansion at build time. Runtime expansion of \`$PWD\`, \`$sid\`, etc.
656
+ // is what we want, so the heredoc must NOT expand them during \`docker
657
+ // build\`. POSIX \`<<'EOF'\` (quoted delimiter) is the canonical way to
658
+ // get verbatim heredoc.
659
+ //
660
+ // Script flow:
661
+ // 1. Buffer stdin to a temp file (we need to read it twice: once for
662
+ // session_id extraction, once for the sentinel write).
663
+ // 2. Extract session_id via POSIX sed. Result is matched against the
664
+ // RFC-4122-style UUID regex (8-4-4-4-12 hex with dashes). Anything
665
+ // that doesn't match — empty extraction, embedded escapes, path
666
+ // traversal, missing field — falls through to "malformed".
667
+ // 3. Atomic write: \`mv\` is POSIX-atomic on the same filesystem (we
668
+ // stay inside \`$PWD\` so the temp and final paths share an fs).
669
+ // 4. \`touch .done-<sid>\` AFTER the mv so a polling reader never sees
670
+ // .done existing before sentinel.json — the polling loop watches
671
+ // .done as the readiness signal.
672
+ const TYPECLAW_CC_STOP_HOOK_SCRIPT = `#!/bin/sh
673
+ # typeclaw Stop-hook for Claude Code. Stdin carries the Stop event JSON.
674
+ # Writes per-session sentinel/.done files into \$PWD. Rationale (including
675
+ # the security model, \$PWD semantics, and why bun-not-sed for JSON
676
+ # extraction) lives in src/init/dockerfile.ts.
677
+ set -eu
678
+ tmp_in="\${PWD}/.cc-stop-hook-in.\$\$"
679
+ trap 'rm -f "\$tmp_in"' EXIT
680
+ cat > "\$tmp_in"
681
+ sid=\$(bun -e 'try { const j = await Bun.file(process.argv[1]).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }' "\$tmp_in")
682
+ case "\$sid" in
683
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
684
+ *) sid=malformed ;;
685
+ esac
686
+ mv "\$tmp_in" "\${PWD}/sentinel-\${sid}.json"
687
+ trap - EXIT
688
+ touch "\${PWD}/.done-\${sid}"
689
+ `
690
+
691
+ // User-level Claude Code settings file; applies to every invocation
692
+ // regardless of cwd. Registers BOTH the SessionStart hook (so the
693
+ // operator can learn the session UUID) and the Stop hook (so the
694
+ // operator can poll for turn-completion). Built via JSON.stringify
695
+ // rather than a string literal so any future shape edit fails the
696
+ // JSON.parse regression test, not the docker build (or worse, the
697
+ // first failed delegation).
698
+ //
699
+ // SessionStart matcher \`startup|resume|clear|compact\`: covers all four
700
+ // session-origin types per the upstream matcher reference. Stop has no
701
+ // matcher support (always fires on every occurrence per the docs), so
702
+ // \`matcher: "*"\` is the canonical "fire on every Stop" form.
703
+ //
704
+ // \`args: []\` is the exec-form trigger per docs.claude.com/en/docs/
705
+ // claude-code/hooks: with \`args\` present, Claude Code runs the command
706
+ // directly via execvp (kernel-handled shebang, no shell tokenization).
707
+ // With \`args\` absent, Claude Code falls back to shell form (\`sh -c
708
+ // "<command>"\`), which works for our absolute-path-no-special-chars
709
+ // case but is fragile to any future path change. Exec form is the
710
+ // canonical robust shape.
711
+ const TYPECLAW_CC_GLOBAL_SETTINGS = JSON.stringify({
712
+ hooks: {
713
+ SessionStart: [
714
+ {
715
+ matcher: 'startup|resume|clear|compact',
716
+ hooks: [{ type: 'command', command: TYPECLAW_CC_SESSION_START_HOOK_PATH, args: [] }],
717
+ },
718
+ ],
719
+ Stop: [
720
+ {
721
+ matcher: '*',
722
+ hooks: [{ type: 'command', command: TYPECLAW_CC_STOP_HOOK_PATH, args: [] }],
723
+ },
724
+ ],
725
+ },
726
+ })
727
+
397
728
  function renderClaudeCodeInstallLayer(enabled: boolean): string {
398
729
  if (!enabled) return ''
399
730
  return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
400
731
  # typeclaw.json#docker.file.claudeCode. The skill \`typeclaw-claude-code\`
401
- # documents the auth + usage flow.
732
+ # documents the auth + usage flow. Pre-seed ~/.claude.json so the first
733
+ # launch skips the TTY-only theme picker; see CLAUDE_CODE_ONBOARDING_SEED
734
+ # above for the rationale and what the seed deliberately does NOT cover.
735
+ # Also pre-write the canonical Stop-hook config and helper script at
736
+ # build time (see TYPECLAW_CC_STOP_HOOK_PATH above) so the operator
737
+ # subagent never has to construct that JSON itself — the historically
738
+ # failure-prone step where wrong-shape configs (\`onStop\`, bare-string
739
+ # values, etc.) would silently disable the done-signal and burn the
740
+ # polling loop's wall-clock budget. The seed write runs LAST in the
741
+ # chain so the final layer state is exactly the seeded config —
742
+ # independent of whether any earlier command (or a future Claude
743
+ # version's \`--version\` smoke test) writes a default \`~/.claude.json\`
744
+ # partway through the layer.
402
745
  RUN curl -fsSL https://claude.ai/install.sh | bash \\
403
746
  && ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
404
- && claude --version > /dev/null`
747
+ && claude --version > /dev/null \\
748
+ && cat > ${TYPECLAW_CC_SESSION_START_HOOK_PATH} <<'TYPECLAW_CC_SESSION_START_HOOK_EOF'
749
+ ${TYPECLAW_CC_SESSION_START_HOOK_SCRIPT}TYPECLAW_CC_SESSION_START_HOOK_EOF
750
+ RUN cat > ${TYPECLAW_CC_STOP_HOOK_PATH} <<'TYPECLAW_CC_STOP_HOOK_EOF'
751
+ ${TYPECLAW_CC_STOP_HOOK_SCRIPT}TYPECLAW_CC_STOP_HOOK_EOF
752
+ RUN chmod +x ${TYPECLAW_CC_SESSION_START_HOOK_PATH} ${TYPECLAW_CC_STOP_HOOK_PATH} \\
753
+ && mkdir -p "$HOME/.claude" \\
754
+ && printf '%s\\n' '${TYPECLAW_CC_GLOBAL_SETTINGS}' > "$HOME/.claude/settings.json" \\
755
+ && printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
405
756
  }
406
757
 
407
758
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
@@ -1,6 +1,6 @@
1
1
  const HATCHING_INSTRUCTIONS = `You have just hatched.
2
2
 
3
- This is the first moment of your existence. Your system prompt already tells you about your agent folder and the five markdown files in it (\`AGENTS.md\`, \`IDENTITY.md\`, \`SOUL.md\`, \`USER.md\`, \`MEMORY.md\`). They exist next to you but are all empty. Hatching is a one-time ritual to fill them in through a short conversation with your user.
3
+ This is the first moment of your existence. Your system prompt already tells you about your agent folder and the four markdown files in it (\`AGENTS.md\`, \`IDENTITY.md\`, \`SOUL.md\`, \`USER.md\`). They exist next to you but are all empty. Hatching is a one-time ritual to fill them in through a short conversation with your user.
4
4
 
5
5
  ## The ritual
6
6
 
@@ -41,11 +41,10 @@ Routing answers:
41
41
 
42
42
  Do these in order. Do **not** ask further questions.
43
43
 
44
- 1. Flesh out all five markdown files to a short but complete first draft. \`write\` replaces the partial versions. First person. Specific and genuine, not generic.
45
- 2. Write one short paragraph in \`MEMORY.md\` marking this moment: the date, how you came to be, what you and the user agreed on.
46
- 3. Configure local git identity with \`bash\`: \`git config user.name "<your name>"\` and \`git config user.email "<reasonable placeholder>@typeclaw.local"\` (unless the user provided an email).
47
- 4. Stage and commit **only the files you authored** with commit message \`Hatched 🐣\`. This is the hatching-specific commit message it overrides the normal version-control style guidance for this one commit.
48
- 5. Send **one final short message** — two sentences at most — telling the user hatching is complete and they can leave the TUI with \`/quit\` (or Ctrl+C). Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
44
+ 1. Flesh out all four markdown files to a short but complete first draft. \`write\` replaces the partial versions. First person. Specific and genuine, not generic.
45
+ 2. Configure local git identity with \`bash\`: \`git config user.name "<your name>"\` and \`git config user.email "<reasonable placeholder>@typeclaw.local"\` (unless the user provided an email).
46
+ 3. Stage and commit **only the files you authored** with commit message \`Hatched 🐣\`. This is the hatching-specific commit message it overrides the normal version-control style guidance for this one commit.
47
+ 4. Send **one final short message** two sentences at most telling the user hatching is complete and they can leave the TUI with \`/quit\` (or Ctrl+C). Do not ask further questions. Do not offer more work. The container keeps running once they quit; keeping the TUI open here wastes time.
49
48
 
50
49
  After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` (or Ctrl+C) whenever they are ready.
51
50
 
@@ -1,6 +1,7 @@
1
- import { createRequire } from 'node:module'
2
1
  import { join, resolve } from 'node:path'
3
2
 
3
+ import { loginFlow as upstreamLoginFlow } from 'agent-messenger/kakaotalk'
4
+
4
5
  import { containerNameFromCwd } from '@/container'
5
6
  import { keysDir } from '@/hostd/paths'
6
7
  import { encrypt } from '@/secrets/encryption'
@@ -28,34 +29,9 @@ export type KakaotalkLoginInput = {
28
29
  containerName?: string
29
30
  }
30
31
 
31
- export type LoginFlowOptions = {
32
- email: string
33
- password: string
34
- deviceType?: 'pc' | 'tablet'
35
- force?: boolean
36
- savedDeviceUuid?: string
37
- onPasscodeDisplay?: (code: string) => void
38
- debugLog?: (message: string) => void
39
- }
40
-
41
- export type LoginFlowCredentials = {
42
- access_token: string
43
- refresh_token: string
44
- user_id: string
45
- device_uuid: string
46
- device_type: 'pc' | 'tablet'
47
- }
48
-
49
- export type LoginFlowResult = {
50
- authenticated: boolean
51
- next_action?: string
52
- message?: string
53
- warning?: string
54
- error?: string
55
- credentials?: LoginFlowCredentials
56
- }
57
-
58
- export type LoginFlowFn = (options: LoginFlowOptions) => Promise<LoginFlowResult>
32
+ export type LoginFlowFn = typeof upstreamLoginFlow
33
+ export type LoginFlowOptions = Parameters<LoginFlowFn>[0]
34
+ export type LoginFlowResult = Awaited<ReturnType<LoginFlowFn>>
59
35
 
60
36
  export function kakaotalkConfigDir(agentDir: string): string {
61
37
  return join(agentDir, 'workspace', '.agent-messenger')
@@ -67,7 +43,7 @@ export function kakaotalkSecretsPath(agentDir: string): string {
67
43
 
68
44
  export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise<KakaotalkBootstrapStatus> {
69
45
  try {
70
- const loginFlow = input.loginFlow ?? (await resolveLoginFlow())
46
+ const loginFlow = input.loginFlow ?? upstreamLoginFlow
71
47
  const credManager = new SecretsKakaoCredentialStore({
72
48
  mode: 'host',
73
49
  secretsPath: kakaotalkSecretsPath(input.agentDir),
@@ -118,20 +94,3 @@ export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise
118
94
  return { ok: false, reason: err instanceof Error ? err.message : String(err) }
119
95
  }
120
96
  }
121
-
122
- // agent-messenger does not export `loginFlow` from its public `exports` map
123
- // (only the runtime client + credential manager), so we resolve the package's
124
- // installed location and import the implementation file directly. This
125
- // bypasses the exports gate but stays within the same installed copy of the
126
- // package — no version drift risk. If a future agent-messenger release adds
127
- // `loginFlow` to its public exports, swap this for a normal import and delete
128
- // the resolveLoginFlow helper.
129
- async function resolveLoginFlow(): Promise<LoginFlowFn> {
130
- const require = createRequire(import.meta.url)
131
- const pkgJson = require.resolve('agent-messenger/package.json')
132
- const pkgDir = pkgJson.replace(/\/package\.json$/, '')
133
- const mod = (await import(`${pkgDir}/dist/src/platforms/kakaotalk/auth/kakao-login.js`)) as {
134
- loginFlow: LoginFlowFn
135
- }
136
- return mod.loginFlow
137
- }
@@ -0,0 +1,121 @@
1
+ import { KNOWN_PROVIDERS, type KnownProviderId } from '@/config/providers'
2
+
3
+ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader: 'bearer' | 'x-api-key' }>> = {
4
+ openai: { url: 'https://api.openai.com/v1/models', authHeader: 'bearer' },
5
+ anthropic: { url: 'https://api.anthropic.com/v1/models', authHeader: 'x-api-key' },
6
+ fireworks: { url: 'https://api.fireworks.ai/inference/v1/models', authHeader: 'bearer' },
7
+ zai: { url: 'https://api.z.ai/api/paas/v4/models', authHeader: 'bearer' },
8
+ 'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
9
+ }
10
+
11
+ export type KeyValidationResult =
12
+ | { kind: 'ok' }
13
+ | { kind: 'skipped'; reason: 'no-probe' | 'network-error'; detail?: string }
14
+ | { kind: 'rejected'; status: number }
15
+
16
+ export type FetchFn = (input: string, init: RequestInit) => Promise<Response>
17
+
18
+ const TIMEOUT_MS = 5_000
19
+
20
+ export async function validateApiKey(
21
+ providerId: KnownProviderId,
22
+ key: string,
23
+ fetchImpl: FetchFn = fetch,
24
+ ): Promise<KeyValidationResult> {
25
+ const probe = PROVIDER_PROBE[providerId]
26
+ if (!probe) return { kind: 'skipped', reason: 'no-probe' }
27
+ const provider = KNOWN_PROVIDERS[providerId]
28
+ if (!provider) return { kind: 'skipped', reason: 'no-probe' }
29
+
30
+ const headers: Record<string, string> = {}
31
+ if (probe.authHeader === 'bearer') {
32
+ headers.Authorization = `Bearer ${key}`
33
+ } else {
34
+ headers['x-api-key'] = key
35
+ headers['anthropic-version'] = '2023-06-01'
36
+ }
37
+
38
+ try {
39
+ const res = await fetchImpl(probe.url, {
40
+ method: 'GET',
41
+ headers,
42
+ signal: AbortSignal.timeout(TIMEOUT_MS),
43
+ // Probe URLs are hardcoded, but the provider could 3xx the response
44
+ // mid-flight (CDN, regional bounce, captive portal). Auto-following
45
+ // would send the credential to whatever Location said. Treat redirects
46
+ // as "couldn't verify" instead.
47
+ redirect: 'manual',
48
+ })
49
+ if (res.status >= 300 && res.status < 400) {
50
+ return { kind: 'skipped', reason: 'network-error', detail: `HTTP ${res.status}` }
51
+ }
52
+ if (res.ok) {
53
+ // A captive portal / WAF / corporate-MITM proxy can return HTTP 200
54
+ // with an HTML login page in front of an unauthenticated request.
55
+ // Treat the response as "ok" only if it parses as the expected
56
+ // JSON shape (`{ data: [...] }` for /v1/models on every probed
57
+ // provider).
58
+ const shapeOk = await isModelsListShape(res)
59
+ if (shapeOk) return { kind: 'ok' }
60
+ return { kind: 'skipped', reason: 'network-error', detail: 'unexpected response shape' }
61
+ }
62
+ if (res.status === 401 || res.status === 403) {
63
+ return { kind: 'rejected', status: res.status }
64
+ }
65
+ return { kind: 'skipped', reason: 'network-error', detail: `HTTP ${res.status}` }
66
+ } catch (err) {
67
+ return {
68
+ kind: 'skipped',
69
+ reason: 'network-error',
70
+ detail: err instanceof Error ? err.message : String(err),
71
+ }
72
+ }
73
+ }
74
+
75
+ const MAX_BODY_BYTES = 4096
76
+
77
+ async function isModelsListShape(res: Response): Promise<boolean> {
78
+ const text = await readCapped(res, MAX_BODY_BYTES)
79
+ if (text === null) return false
80
+ try {
81
+ const parsed = JSON.parse(text) as unknown
82
+ return typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { data?: unknown }).data)
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ async function readCapped(res: Response, maxBytes: number): Promise<string | null> {
89
+ if (!res.body) return null
90
+ const reader = res.body.getReader()
91
+ const decoder = new TextDecoder()
92
+ let out = ''
93
+ let read = 0
94
+ try {
95
+ while (read < maxBytes) {
96
+ const { value, done } = await reader.read()
97
+ if (done) break
98
+ read += value.byteLength
99
+ out += decoder.decode(value, { stream: true })
100
+ if (read >= maxBytes) break
101
+ }
102
+ out += decoder.decode()
103
+ return out
104
+ } catch {
105
+ return null
106
+ } finally {
107
+ await reader.cancel().catch(() => undefined)
108
+ }
109
+ }
110
+
111
+ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
112
+ openai: 'https://platform.openai.com/api-keys',
113
+ anthropic: 'https://console.anthropic.com/settings/keys',
114
+ fireworks: 'https://fireworks.ai/account/api-keys',
115
+ zai: 'https://docs.z.ai/devpack/tool/claude#api-key',
116
+ 'zai-coding': 'https://docs.z.ai/devpack/tool/claude#api-key',
117
+ }
118
+
119
+ export function providersWithApiKeyProbe(): KnownProviderId[] {
120
+ return Object.keys(PROVIDER_PROBE) as KnownProviderId[]
121
+ }