typeclaw 0.8.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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
package/src/init/dockerfile.ts
CHANGED
|
@@ -474,6 +474,257 @@ const CLAUDE_CODE_ONBOARDING_SEED = JSON.stringify({
|
|
|
474
474
|
numStartups: 1,
|
|
475
475
|
})
|
|
476
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
|
+
|
|
477
728
|
function renderClaudeCodeInstallLayer(enabled: boolean): string {
|
|
478
729
|
if (!enabled) return ''
|
|
479
730
|
return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
|
|
@@ -481,13 +732,26 @@ function renderClaudeCodeInstallLayer(enabled: boolean): string {
|
|
|
481
732
|
# documents the auth + usage flow. Pre-seed ~/.claude.json so the first
|
|
482
733
|
# launch skips the TTY-only theme picker; see CLAUDE_CODE_ONBOARDING_SEED
|
|
483
734
|
# above for the rationale and what the seed deliberately does NOT cover.
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
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.
|
|
488
745
|
RUN curl -fsSL https://claude.ai/install.sh | bash \\
|
|
489
746
|
&& ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
|
|
490
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" \\
|
|
491
755
|
&& printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
|
|
492
756
|
}
|
|
493
757
|
|
package/src/init/hatching.ts
CHANGED
|
@@ -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
|
|
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
|
|
45
|
-
2.
|
|
46
|
-
3.
|
|
47
|
-
4.
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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 ??
|
|
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
|
+
}
|