typeclaw 0.5.1 → 0.6.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 +4 -0
- package/package.json +1 -1
- package/src/agent/index.ts +80 -8
- package/src/agent/live-subagents.ts +215 -0
- package/src/agent/plugin-tools.ts +60 -20
- package/src/agent/session-origin.ts +15 -0
- package/src/agent/subagents.ts +140 -3
- package/src/agent/system-prompt.ts +40 -0
- package/src/agent/tools/channel-reply.ts +24 -1
- package/src/agent/tools/channel-send.ts +26 -1
- package/src/agent/tools/spawn-subagent.ts +283 -0
- package/src/agent/tools/subagent-cancel.ts +96 -0
- package/src/agent/tools/subagent-output.ts +192 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
- package/src/bundled-plugins/explorer/explorer.ts +103 -0
- package/src/bundled-plugins/explorer/index.ts +11 -0
- package/src/bundled-plugins/guard/index.ts +12 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
- package/src/bundled-plugins/guard/policy.ts +1 -0
- package/src/bundled-plugins/operator/index.ts +11 -0
- package/src/bundled-plugins/operator/operator.ts +76 -0
- package/src/bundled-plugins/scout/index.ts +11 -0
- package/src/bundled-plugins/scout/scout.ts +94 -0
- package/src/channels/router.ts +32 -0
- package/src/config/config.ts +45 -12
- package/src/config/index.ts +3 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- package/src/permissions/builtins.ts +23 -2
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/types.ts +15 -22
- package/src/run/bundled-plugins.ts +6 -0
- package/src/run/channel-session-factory.ts +19 -0
- package/src/run/index.ts +56 -6
- package/src/server/index.ts +103 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
- package/src/skills/typeclaw-config/SKILL.md +29 -26
- package/typeclaw.schema.json +6 -0
package/src/run/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import { createSession, createSessionWithDispose } from '@/agent'
|
|
4
|
+
import { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
4
5
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
5
6
|
import {
|
|
6
7
|
createSubagentConsumer,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
type Subagent as InternalSubagent,
|
|
10
11
|
type SubagentConsumer,
|
|
11
12
|
type SubagentRegistry,
|
|
13
|
+
type SubagentShared,
|
|
12
14
|
} from '@/agent/subagents'
|
|
13
15
|
import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
14
16
|
import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
|
|
@@ -176,6 +178,8 @@ export async function startAgent({
|
|
|
176
178
|
},
|
|
177
179
|
})
|
|
178
180
|
|
|
181
|
+
const liveSubagentRegistry = new LiveSubagentRegistry()
|
|
182
|
+
|
|
179
183
|
const channelManager = createChannelManagerFor({
|
|
180
184
|
agentDir: cwd,
|
|
181
185
|
channelsConfigRef: () => getConfig().channels,
|
|
@@ -191,6 +195,9 @@ export async function startAgent({
|
|
|
191
195
|
getChannelRouter: () => channelManager.router,
|
|
192
196
|
rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
|
|
193
197
|
permissions: pluginsLoaded.permissions,
|
|
198
|
+
liveSubagentRegistry,
|
|
199
|
+
subagentRegistry: pluginRuntime.get().subagents,
|
|
200
|
+
getCreateSessionForSubagent: () => createSessionForSubagent,
|
|
194
201
|
...containerNameOpt,
|
|
195
202
|
...runtimeVersionOpt,
|
|
196
203
|
}),
|
|
@@ -347,6 +354,9 @@ export async function startAgent({
|
|
|
347
354
|
},
|
|
348
355
|
}
|
|
349
356
|
: {}),
|
|
357
|
+
liveSubagentRegistry,
|
|
358
|
+
subagentRegistry: pluginRuntime.get().subagents,
|
|
359
|
+
createSessionForSubagent,
|
|
350
360
|
...containerNameOpt,
|
|
351
361
|
...runtimeVersionOpt,
|
|
352
362
|
})
|
|
@@ -465,6 +475,8 @@ export async function startAgent({
|
|
|
465
475
|
claimController,
|
|
466
476
|
commandRunnerFactory,
|
|
467
477
|
tunnelManager,
|
|
478
|
+
liveSubagentRegistry,
|
|
479
|
+
createSessionForSubagent,
|
|
468
480
|
...containerNameOpt,
|
|
469
481
|
...runtimeVersionOpt,
|
|
470
482
|
...tuiTokenOpt,
|
|
@@ -593,7 +605,15 @@ function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFa
|
|
|
593
605
|
return ({ file, onFire }) => createScheduler({ jobs: [...file.jobs, ...internalJobs()], onFire })
|
|
594
606
|
}
|
|
595
607
|
|
|
596
|
-
|
|
608
|
+
// Exported for the regression test in `merge-subagents.test.ts`. The shim
|
|
609
|
+
// layer between the plugin-author-facing `Subagent` (`@/plugin/types`) and
|
|
610
|
+
// the runtime-internal `Subagent` (`@/agent/subagents`) is the load-bearing
|
|
611
|
+
// translation point for visibility, payload-schema, and permission gating —
|
|
612
|
+
// fields that flow through the `SubagentRegistry` without going through the
|
|
613
|
+
// `pluginSubagentByShim` recovery path. Previous regressions silently
|
|
614
|
+
// dropped fields here, hiding every public bundled subagent (scout,
|
|
615
|
+
// explorer, operator) from the `spawn_subagent` tool surface.
|
|
616
|
+
export function mergeSubagents(pluginRegistry: PluginRegistry): {
|
|
597
617
|
registry: SubagentRegistry
|
|
598
618
|
pluginSubagentByShim: WeakMap<InternalSubagent<any>, PluginSubagentEntry>
|
|
599
619
|
pluginSubagentByName: Map<string, PluginSubagentEntry>
|
|
@@ -620,10 +640,40 @@ function mergeSubagents(pluginRegistry: PluginRegistry): {
|
|
|
620
640
|
return { registry: merged, pluginSubagentByShim, pluginSubagentByName }
|
|
621
641
|
}
|
|
622
642
|
|
|
643
|
+
// Compile-time proof that every plugin-only key on `@/plugin`'s `Subagent`
|
|
644
|
+
// (i.e. every key NOT inherited from `SubagentShared`) has been classified
|
|
645
|
+
// for the shim. When a future maintainer introduces a new field on plugin-side
|
|
646
|
+
// `Subagent` that isn't on `SubagentShared`, the `satisfies` clause on
|
|
647
|
+
// `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` below fails at compile time until the
|
|
648
|
+
// new key is listed there — and the destructuring in `pluginSubagentShim`
|
|
649
|
+
// is updated to discard it. Without this guard, the shim's rest-spread
|
|
650
|
+
// would silently leak future plugin-only fields into the internal registry —
|
|
651
|
+
// the opposite-direction drift from the bug this PR fixes for shared fields.
|
|
652
|
+
type PluginOnlySubagentKeys = Exclude<keyof import('@/plugin').Subagent<any>, keyof SubagentShared<any>>
|
|
653
|
+
const PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM = {
|
|
654
|
+
tools: true,
|
|
655
|
+
customTools: true,
|
|
656
|
+
inFlightKey: true,
|
|
657
|
+
} satisfies Record<PluginOnlySubagentKeys, true>
|
|
658
|
+
// Reference the table so it's not dead code. The value is a runtime no-op;
|
|
659
|
+
// the load-bearing work is the `satisfies` clause above which forces
|
|
660
|
+
// exhaustive classification of plugin-only keys at compile time.
|
|
661
|
+
void PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM
|
|
662
|
+
|
|
623
663
|
function pluginSubagentShim(subagent: import('@/plugin').Subagent<any>): InternalSubagent<any> {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
664
|
+
// The two diverging fields (`tools` is `BuiltinToolRef[]` plugin-side vs
|
|
665
|
+
// `AgentSessionTools` internal-side; `customTools` similarly differs) are
|
|
666
|
+
// resolved later in `createSessionForSubagent` via the
|
|
667
|
+
// `pluginSubagentByShim` lookup, which recovers the original plugin
|
|
668
|
+
// reference. `inFlightKey` is consumed only by the SubagentConsumer via
|
|
669
|
+
// `pluginSubagentByName`, not through this shim's registry path. Every
|
|
670
|
+
// other plugin-side field lives on `SubagentShared` and is structurally
|
|
671
|
+
// assignable to the internal `Subagent`, so a rest-spread carries them
|
|
672
|
+
// verbatim — including `visibility` and `requiresSpecificPermission`,
|
|
673
|
+
// whose silent drop in the previous shim made every plugin-contributed
|
|
674
|
+
// public subagent (scout, explorer, operator) invisible to the
|
|
675
|
+
// `spawn_subagent` tool. The list of keys removed here is enforced
|
|
676
|
+
// exhaustive at compile time by `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` above.
|
|
677
|
+
const { tools: _tools, customTools: _customTools, inFlightKey: _inFlightKey, ...shared } = subagent
|
|
678
|
+
return shared
|
|
629
679
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
type CreateSessionResult,
|
|
8
8
|
} from '@/agent'
|
|
9
9
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
10
|
+
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
10
11
|
import { detectProviderError } from '@/agent/provider-error'
|
|
11
12
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
13
|
+
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
12
14
|
import type { ChannelRouter } from '@/channels/router'
|
|
13
15
|
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
14
16
|
import type { HookBus } from '@/plugin'
|
|
@@ -79,6 +81,27 @@ export type ServerOptions = {
|
|
|
79
81
|
// without it the four `exec_command`-family messages are answered with
|
|
80
82
|
// `command_error` so the host CLI sees a clean failure.
|
|
81
83
|
commandRunnerFactory?: (outbound: CommandOutbound) => CommandRunner
|
|
84
|
+
// Subagent orchestration plumbing for TUI sessions. Both fields must be
|
|
85
|
+
// present together for the spawn_subagent / subagent_output / subagent_cancel
|
|
86
|
+
// tools to surface; `createSession` gates registration on all three of
|
|
87
|
+
// (liveSubagentRegistry, subagentRegistry, createSessionForSubagent), and
|
|
88
|
+
// we derive subagentRegistry per WS connection from the same `pluginRuntime`
|
|
89
|
+
// snapshot that already feeds `plugins.registry` — so a reload landing
|
|
90
|
+
// mid-connection keeps using the snapshot the session opened with, matching
|
|
91
|
+
// the existing per-session lifecycle invariant.
|
|
92
|
+
//
|
|
93
|
+
// `createSessionForSubagent` is passed eagerly (not late-bound) because the
|
|
94
|
+
// TUI server is constructed AFTER the channel manager in `startAgent`,
|
|
95
|
+
// breaking the construction cycle that forces the channel session factory's
|
|
96
|
+
// `getCreateSessionForSubagent` late-binding.
|
|
97
|
+
//
|
|
98
|
+
// Channel and cron sessions get the same plumbing through
|
|
99
|
+
// `buildChannelSessionFactory` / `createSessionForCron` (see src/run/). The
|
|
100
|
+
// three top-level callers must stay aligned; otherwise the agent's tool
|
|
101
|
+
// surface diverges across origin kinds — exactly the gap PR #281 flagged
|
|
102
|
+
// as out-of-scope follow-up.
|
|
103
|
+
liveSubagentRegistry?: LiveSubagentRegistry
|
|
104
|
+
createSessionForSubagent?: CreateSessionForSubagent
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
const consoleLogger: ServerLogger = {
|
|
@@ -176,6 +199,8 @@ export function createServer({
|
|
|
176
199
|
logger = consoleLogger,
|
|
177
200
|
claimController,
|
|
178
201
|
commandRunnerFactory,
|
|
202
|
+
liveSubagentRegistry,
|
|
203
|
+
createSessionForSubagent,
|
|
179
204
|
}: ServerOptions) {
|
|
180
205
|
const sessionStates = new WeakMap<Ws, SessionState>()
|
|
181
206
|
const callIdToWs = new Map<string, AnyOwnerWs>()
|
|
@@ -351,6 +376,15 @@ export function createServer({
|
|
|
351
376
|
}
|
|
352
377
|
: undefined
|
|
353
378
|
const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
|
|
379
|
+
// Derive subagentRegistry from the same runtimeSnapshot that
|
|
380
|
+
// populates `plugins.registry`. createSession gates the orchestration
|
|
381
|
+
// tools on (liveRegistry, subagentRegistry, createSessionForSubagent,
|
|
382
|
+
// agentDir) being all-present; threading the registry alongside the
|
|
383
|
+
// two server-owned fields gives the gate a complete tuple for TUI
|
|
384
|
+
// sessions whenever the host plumbed in plugin runtime + subagent
|
|
385
|
+
// wiring (production), while keeping every existing test that omits
|
|
386
|
+
// either side at exactly its current tool surface.
|
|
387
|
+
const subagentRegistry = runtimeSnapshot?.subagents
|
|
354
388
|
const result = await createSession({
|
|
355
389
|
reloadRegistry,
|
|
356
390
|
sessionManager,
|
|
@@ -360,6 +394,9 @@ export function createServer({
|
|
|
360
394
|
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
361
395
|
...(containerName !== undefined ? { containerName } : {}),
|
|
362
396
|
...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
|
|
397
|
+
...(liveSubagentRegistry !== undefined ? { liveSubagentRegistry } : {}),
|
|
398
|
+
...(subagentRegistry !== undefined ? { subagentRegistry } : {}),
|
|
399
|
+
...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
|
|
363
400
|
})
|
|
364
401
|
const session = 'session' in result ? result.session : result
|
|
365
402
|
const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
|
|
@@ -392,6 +429,7 @@ export function createServer({
|
|
|
392
429
|
)
|
|
393
430
|
|
|
394
431
|
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
432
|
+
routeSubagentCompletionReminder(state, msg, stream)
|
|
395
433
|
const payload: ServerMessage = {
|
|
396
434
|
type: 'notification',
|
|
397
435
|
payload: msg.payload,
|
|
@@ -712,6 +750,71 @@ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, s
|
|
|
712
750
|
send(ws, { type: 'error', message: detected.message })
|
|
713
751
|
}
|
|
714
752
|
|
|
753
|
+
function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage, stream: Stream): void {
|
|
754
|
+
const payload = msg.payload
|
|
755
|
+
if (payload === null || typeof payload !== 'object') return
|
|
756
|
+
const p = payload as {
|
|
757
|
+
kind?: unknown
|
|
758
|
+
taskId?: unknown
|
|
759
|
+
subagent?: unknown
|
|
760
|
+
parentSessionId?: unknown
|
|
761
|
+
ok?: unknown
|
|
762
|
+
durationMs?: unknown
|
|
763
|
+
error?: unknown
|
|
764
|
+
}
|
|
765
|
+
if (p.kind !== 'subagent.completed') return
|
|
766
|
+
if (typeof p.parentSessionId !== 'string' || p.parentSessionId !== state.sessionFileId) return
|
|
767
|
+
|
|
768
|
+
const subagent = typeof p.subagent === 'string' ? p.subagent : 'subagent'
|
|
769
|
+
const taskId = typeof p.taskId === 'string' ? p.taskId : '<unknown>'
|
|
770
|
+
const ok = p.ok === true
|
|
771
|
+
const durationMs = typeof p.durationMs === 'number' ? p.durationMs : 0
|
|
772
|
+
const error = typeof p.error === 'string' ? p.error : undefined
|
|
773
|
+
|
|
774
|
+
const idle = state.drainQueue.length === 0 && !state.draining
|
|
775
|
+
const delivery = idle ? 'interrupt' : 'queue'
|
|
776
|
+
const text = renderCompletionReminder({ subagent, taskId, ok, durationMs, error })
|
|
777
|
+
stream.publish({
|
|
778
|
+
target: { kind: 'session', sessionId: state.sessionFileId },
|
|
779
|
+
payload: { kind: 'prompt', text, delivery },
|
|
780
|
+
meta: { source: 'subagent-completion' },
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function renderCompletionReminder(args: {
|
|
785
|
+
subagent: string
|
|
786
|
+
taskId: string
|
|
787
|
+
ok: boolean
|
|
788
|
+
durationMs: number
|
|
789
|
+
error?: string
|
|
790
|
+
}): string {
|
|
791
|
+
const durationStr = formatReminderDuration(args.durationMs)
|
|
792
|
+
if (args.ok) {
|
|
793
|
+
return (
|
|
794
|
+
`<system-reminder>\n` +
|
|
795
|
+
`Subagent \`${args.subagent}\` (${args.taskId}) completed in ${durationStr}. ` +
|
|
796
|
+
`Use subagent_output to fetch the result.\n` +
|
|
797
|
+
`</system-reminder>`
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
const err = args.error ?? 'unknown error'
|
|
801
|
+
return (
|
|
802
|
+
`<system-reminder>\n` +
|
|
803
|
+
`Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
|
|
804
|
+
`Use subagent_output to inspect.\n` +
|
|
805
|
+
`</system-reminder>`
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function formatReminderDuration(ms: number): string {
|
|
810
|
+
if (ms < 1000) return `${ms}ms`
|
|
811
|
+
const totalSec = Math.floor(ms / 1000)
|
|
812
|
+
if (totalSec < 60) return `${totalSec}s`
|
|
813
|
+
const min = Math.floor(totalSec / 60)
|
|
814
|
+
const sec = totalSec % 60
|
|
815
|
+
return `${min}m${sec}s`
|
|
816
|
+
}
|
|
817
|
+
|
|
715
818
|
function enqueuePrompt(
|
|
716
819
|
ws: Ws,
|
|
717
820
|
state: SessionState,
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-claude-code
|
|
3
|
+
description: Use this skill whenever you decide to delegate substantial coding or code-analysis work to Claude Code (Anthropic's official coding-agent CLI). Triggers include "use Claude Code", "ask Claude Code", "delegate to claude", "claude cli", "have claude do it", any task where you want a more capable agent than yourself, and any time you're about to run `claude` from a shell. Read it before you spawn the CLI — Claude Code is a TTY-only TUI in interactive mode (you must drive it through tmux, not pipes), it operates inside a dedicated `git worktree` checkout under `/tmp/` so its commits never pollute the agent folder, and you detect "turn done" through a `Stop` hook that writes a sentinel file. Skipping this skill means you'll either fall back to `claude -p` (which strips plan mode and sub-agents), let claude mutate the live agent checkout (which loses you the rollback safety), or try to parse the TUI buffer with capture-pane heuristics (fragile, version-locked).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-claude-code
|
|
7
|
+
|
|
8
|
+
You can delegate work to Claude Code, Anthropic's official coding agent. The agent runs as an interactive TUI: it plans, uses sub-agents, edits files, runs tools — the full loop. You drive it through tmux because your own process has no TTY, you isolate it in a dedicated `git worktree` so its experiments never touch the live agent checkout, and you detect "turn done" through a `Stop` hook that writes a sentinel file (not by parsing the TUI buffer).
|
|
9
|
+
|
|
10
|
+
This skill is for the case where Claude Code is the right tool: hard architecture work, multi-file refactors, deep code analysis, a second-opinion read on something you wrote. It is **not** for trivial edits — the round-trip cost (worktree setup + process spawn + auth check + TUI init + at least one full Claude turn) is 15–45 seconds and several thousand tokens of someone else's context window. Do trivial edits yourself.
|
|
11
|
+
|
|
12
|
+
## When to delegate to Claude Code
|
|
13
|
+
|
|
14
|
+
Use Claude Code for:
|
|
15
|
+
|
|
16
|
+
- **Multi-file refactors** that need a holistic plan before any edit lands.
|
|
17
|
+
- **Code analysis** the user wants done thoroughly — "review this module", "find the bug in this 800-line file", "explain why X is slow".
|
|
18
|
+
- **Implementations you're unsure about** where a more capable model would catch issues you'd miss.
|
|
19
|
+
- **A second pair of eyes** on a design you've already drafted, especially when the user asks for one.
|
|
20
|
+
|
|
21
|
+
Do **not** use Claude Code for:
|
|
22
|
+
|
|
23
|
+
- One-line edits, typo fixes, single-function tweaks.
|
|
24
|
+
- Anything where the user is watching your tool calls and wants to see each step — Claude's intermediate output is captured but not streamed back to the user.
|
|
25
|
+
- Tasks that depend on context you haven't extracted yet. Claude won't have repo-wide context either; you have to brief it explicitly.
|
|
26
|
+
|
|
27
|
+
## First-time auth (interactive)
|
|
28
|
+
|
|
29
|
+
If `claude` is installed but no credential is set up, you have to broker the auth flow yourself. The user is talking to you through the TUI (or a channel); you walk them through one of two paths.
|
|
30
|
+
|
|
31
|
+
**Decision rule, top to bottom:**
|
|
32
|
+
|
|
33
|
+
1. **Already authenticated?** Run `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='` — if either is present, skip auth entirely.
|
|
34
|
+
2. **User has an Anthropic Console workspace** (API billing, no subscription) → API key path.
|
|
35
|
+
3. **User has a Claude Pro/Max/Team/Enterprise subscription** → OAuth token path.
|
|
36
|
+
4. **User is unsure** → ask which kind of Claude account they have. Both paths are now equally low-friction (one user action each — paste an API key, or run one command on their machine and paste the result), so the old "prefer API key when unsure" bias is gone. Pick by account shape, not by flow complexity.
|
|
37
|
+
|
|
38
|
+
Both paths converge on the same final steps: read `.env`, merge one new `KEY=value` line, write back with the `nonWorkspaceWrite` guard ack, verify, and prompt the user to restart the container. Only the credential differs.
|
|
39
|
+
|
|
40
|
+
### API key path
|
|
41
|
+
|
|
42
|
+
1. Ask the user: "Paste your Anthropic API key (starts with `sk-ant-`) — or say 'cancel' to use OAuth instead."
|
|
43
|
+
2. **Validate** the pasted value before writing: `/^sk-ant-[A-Za-z0-9_-]{20,}$/`. If it doesn't match, refuse and ask again — neither the guard nor the restart tool catches a malformed token.
|
|
44
|
+
3. **Read** the existing `.env` first (if any). Parse it into a key→value map so you don't clobber unrelated entries.
|
|
45
|
+
4. **Reconstruct** the full `.env` content with `ANTHROPIC_API_KEY=<value>` added or replaced.
|
|
46
|
+
5. **Write** with `acknowledgeGuards: { nonWorkspaceWrite: true }`. `.env` is in the `nonWorkspaceWrite` guard's deny set; the call fails without the ack flag.
|
|
47
|
+
6. **Verify** by re-reading the file.
|
|
48
|
+
7. **Ask the user**: "Auth is on disk. The container needs to restart to load it (TUI will briefly disconnect). May I restart now, or do you have other changes to make first?"
|
|
49
|
+
8. On yes → call the `restart` tool. On no → tell them to run `typeclaw restart` themselves when ready.
|
|
50
|
+
|
|
51
|
+
### OAuth path
|
|
52
|
+
|
|
53
|
+
The OAuth flow runs **on the user's own machine**, not inside the container. The user generates a long-lived `CLAUDE_CODE_OAUTH_TOKEN` with `claude setup-token` on whatever local machine they're already authenticated on, copies the printed token, and pastes it back to you. You write it to `.env` exactly like the API key path.
|
|
54
|
+
|
|
55
|
+
Why this works: `claude setup-token` is Anthropic's documented path for "CI pipelines, scripts, or other environments where interactive browser login isn't available" ([code.claude.com/docs/en/authentication](https://code.claude.com/docs/en/authentication)). A typeclaw container is exactly that environment. The token is one-year-lived, authenticates against the user's Claude subscription, and is scoped to inference only — it can't establish Remote Control sessions or otherwise act outside of `claude` CLI calls.
|
|
56
|
+
|
|
57
|
+
Do **not** run `claude setup-token` inside the container. The container has no browser, no display, and (for remote-host typeclaw deployments) is on a different machine from the user's browser anyway. The user's local machine already has `claude` installed for them to be a subscriber in the first place — they're the right place to run the one-off `setup-token` command.
|
|
58
|
+
|
|
59
|
+
1. Confirm with the user: "Do you have the `claude` CLI installed on your local machine and are you signed in to it with your Claude Pro/Max/Team/Enterprise account? If not, install it from claude.com/code and `claude login` first."
|
|
60
|
+
2. Once they confirm, instruct them: "Run `claude setup-token` on your machine. It opens a browser, you authorize, and the terminal prints a long token (looks like `sk-ant-oat01-...` or similar). Copy that token and paste it back to me. The token is long-lived (one year) and authenticates against your Claude subscription — keep it private."
|
|
61
|
+
3. When they paste, **validate** before writing: `/^[A-Za-z0-9_-]{30,}$/`. Strip surrounding whitespace first. If it doesn't match (too short, contains slashes, looks like a URL or a sentence), refuse and ask again — the user may have pasted a partial copy or the wrong line.
|
|
62
|
+
4. **Read** the existing `.env` first. Parse it into a key→value map.
|
|
63
|
+
5. **Reconstruct** the full `.env` content with `CLAUDE_CODE_OAUTH_TOKEN=<value>` added or replaced.
|
|
64
|
+
6. **Write** with `acknowledgeGuards: { nonWorkspaceWrite: true }`.
|
|
65
|
+
7. **Verify** by re-reading the file.
|
|
66
|
+
8. **Ask before restart** (same prompt as the API key path).
|
|
67
|
+
9. On yes → call the `restart` tool. On no → `typeclaw restart` themselves when ready.
|
|
68
|
+
|
|
69
|
+
The full validation rules, the failure modes on the user's side (their `claude` CLI is signed out, their `setup-token` command 401s, their subscription is expired), and the rationale for not doing the OAuth dance in-container are in `references/auth-flow.md`.
|
|
70
|
+
|
|
71
|
+
### Cost-cap warning
|
|
72
|
+
|
|
73
|
+
Interactive-mode Claude Code has **no built-in spend cap** — `--max-budget-usd` only works in `-p` mode, which is not what we use here. If the user is on the API-key path, recommend setting a workspace spend limit in the Anthropic Console; that's the only safety net. If they're on OAuth (subscription), usage is bounded by the subscription's monthly Agent SDK credit pool. Tell them once before the first delegation so it's not a surprise.
|
|
74
|
+
|
|
75
|
+
## Prerequisites
|
|
76
|
+
|
|
77
|
+
Before you spawn `claude` for any real work:
|
|
78
|
+
|
|
79
|
+
- **`docker.file.claudeCode: true`** in `typeclaw.json`. Verify with `which claude`; if missing, the toggle isn't on. Tell the user to enable it and `typeclaw start --build`.
|
|
80
|
+
- **`docker.file.tmux: true`** (default `true`, but check). Verify with `which tmux`.
|
|
81
|
+
- **Auth set up** — see above. Verify with `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='`.
|
|
82
|
+
- **Agent folder is a git repo.** Verify with `git -C /agent rev-parse --is-inside-work-tree`. The worktree model below requires it. If the user's agent folder somehow isn't a repo (rare — `typeclaw init` scaffolds one), tell them to `git init && git add -A && git commit -m "initial"` first.
|
|
83
|
+
- **No uncommitted changes that you care about.** `git -C /agent status --porcelain` should be clean, or you should be willing to set the working tree aside before delegating. The worktree is a separate checkout, so claude can't see your uncommitted changes — meaning claude operates on the last committed state. If the user wants claude to work with in-progress edits, commit them first (even on a WIP branch).
|
|
84
|
+
|
|
85
|
+
If any prerequisite is missing, stop and surface the gap to the user. Do not try to install `claude` yourself in the running container — the install belongs in the Dockerfile layer, not at runtime.
|
|
86
|
+
|
|
87
|
+
## Create the worktree
|
|
88
|
+
|
|
89
|
+
Each delegation runs inside a dedicated `git worktree` checkout under `/tmp/`. This is the load-bearing isolation that makes the rest of the skill safe:
|
|
90
|
+
|
|
91
|
+
- **Claude can edit, commit, reset, run tests** — none of it touches the agent folder's live working tree or its main branch pointer.
|
|
92
|
+
- **You get perfect introspection.** `git diff` between claude's branch and your main checkout shows exactly what claude changed; `git log` shows how it got there.
|
|
93
|
+
- **Cleanup is bounded.** When you're done, you remove the worktree and its branch; nothing persists on disk except deliberately cherry-picked commits.
|
|
94
|
+
- **The agent folder's `git status` stays clean during delegation** — the user can keep working on their own checkout while claude operates in parallel.
|
|
95
|
+
|
|
96
|
+
### Setup
|
|
97
|
+
|
|
98
|
+
Pick a task id (short hex string or `verb-noun` like `refactor-auth`) and create the worktree:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
git -C /agent worktree add -b cc-<task-id> /tmp/cc-<task-id> HEAD
|
|
102
|
+
cd /tmp/cc-<task-id>
|
|
103
|
+
mkdir -p .claude
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This creates:
|
|
107
|
+
|
|
108
|
+
- A new branch `cc-<task-id>` rooted at the agent folder's current `HEAD`.
|
|
109
|
+
- A new working tree at `/tmp/cc-<task-id>/` containing every file from that commit.
|
|
110
|
+
- An entry in `/agent/.git/worktrees/cc-<task-id>/` that ties the two together.
|
|
111
|
+
|
|
112
|
+
The worktree shares the agent folder's `.git` directory but has its own `HEAD`, index, and working tree. Branch state lives in `/agent/.git/refs/heads/cc-<task-id>` regardless of where the worktree itself lives on disk.
|
|
113
|
+
|
|
114
|
+
Inside `/tmp/cc-<task-id>/`, write the per-task hook config (see "The Stop hook" below):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
/tmp/cc-<task-id>/
|
|
118
|
+
├── .claude/
|
|
119
|
+
│ └── settings.json # registers the Stop hook
|
|
120
|
+
├── hook-on-stop.sh # the hook script, chmod +x
|
|
121
|
+
├── sentinel.json # written by the hook (does not exist yet)
|
|
122
|
+
└── .done # flag file (does not exist yet)
|
|
123
|
+
└── ... # plus every file from the agent folder's HEAD
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Why `/tmp/`, not `workspace/`?
|
|
127
|
+
|
|
128
|
+
`workspace/` is the agent folder's gitignored scratch zone — fine for one-off scripts. But a `git worktree` is a _checkout_, not scratch: it carries an index, refs in `/agent/.git/worktrees/`, and (briefly) shares working-tree state with the main checkout. Putting it under `workspace/` would mean the agent folder contains a worktree of itself, which works mechanically but is recursive and confusing (nested worktrees? infinite recursion if claude does `git status`?). `/tmp/cc-<id>/` keeps the worktree clearly outside the agent folder. It's also genuinely ephemeral — `/tmp/` is tmpfs-ish, survives container life but never enters git history or backups.
|
|
129
|
+
|
|
130
|
+
## The Stop hook
|
|
131
|
+
|
|
132
|
+
Claude Code fires a `Stop` hook every time it finishes responding — turn-end, not session-end. The hook runs an arbitrary shell command with the lifecycle event payload (JSON) on stdin. We use this as the done-signal: the hook writes the payload to `sentinel.json` and `touch`es `.done`, and your polling loop watches for `.done`.
|
|
133
|
+
|
|
134
|
+
Minimum `/tmp/cc-<id>/.claude/settings.json`:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"hooks": {
|
|
139
|
+
"Stop": [
|
|
140
|
+
{
|
|
141
|
+
"matcher": "*",
|
|
142
|
+
"hooks": [{ "type": "command", "command": "./hook-on-stop.sh" }]
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Minimum `/tmp/cc-<id>/hook-on-stop.sh` (chmod +x):
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
#!/bin/sh
|
|
153
|
+
# stdin carries the Stop event JSON; transcript_path points at the JSONL.
|
|
154
|
+
cat > sentinel.json.tmp
|
|
155
|
+
mv sentinel.json.tmp sentinel.json
|
|
156
|
+
touch .done
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The temp-file-then-rename keeps the read side from ever seeing a partial sentinel. The full schema of the Stop event (every field Claude Code populates, including `last_assistant_message` and `transcript_path`) is in `references/stop-hook.md`.
|
|
160
|
+
|
|
161
|
+
## Driving the session
|
|
162
|
+
|
|
163
|
+
The minimum protocol — translate to your actual tool calls:
|
|
164
|
+
|
|
165
|
+
1. Create the worktree, write the hook config (above).
|
|
166
|
+
2. `tmux new-session -d -s cc-<id> -c /tmp/cc-<id> claude`.
|
|
167
|
+
3. Wait ~3 seconds for the TUI to initialize.
|
|
168
|
+
4. `tmux send-keys -t cc-<id> "<your prompt>" Enter`.
|
|
169
|
+
5. **Poll** for `/tmp/cc-<id>/.done` in a 500ms-cadence loop with a wall-clock budget (default 10 minutes). On every iteration, also check `tmux has-session -t cc-<id>` — if the session died, claude crashed or auth failed.
|
|
170
|
+
6. When `.done` exists: `rm .done`, read `sentinel.json`, examine `last_assistant_message`.
|
|
171
|
+
7. Decide using the multi-turn loop below.
|
|
172
|
+
8. When done: `tmux send-keys -t cc-<id> "/exit" Enter && sleep 1 && tmux kill-session -t cc-<id>`.
|
|
173
|
+
|
|
174
|
+
The full polling implementation, the ANSI-handling rules for `capture-pane` fallbacks, and the "tmux session died unexpectedly" recovery path are in `references/tmux-driving.md`.
|
|
175
|
+
|
|
176
|
+
## The multi-turn decision loop
|
|
177
|
+
|
|
178
|
+
`Stop` fires every turn — including turns where claude paused to ask you a question, not just turns where claude finished the task. After every Stop sentinel, read `last_assistant_message` and decide:
|
|
179
|
+
|
|
180
|
+
- **Ends with a question mark, or contains "Do you want me to", "Should I", "Could you clarify"** → claude is asking a clarifying question. Compose an answer from the original task brief and `send-keys` it back. Reset the loop: `rm .done`, poll again.
|
|
181
|
+
- **Mentions a permission-style ask** ("May I run `<command>`?", "Allow me to edit `<file>`?") → answer per the task's safety constraints. If the constraint is unclear, abort with `/exit` and surface to the user — never invent a yes/no on the user's behalf for an unbounded operation.
|
|
182
|
+
- **Looks like a final result** (code block + summary, or "Done.", "Here's the result.", "I've finished") → capture and `/exit`.
|
|
183
|
+
- **Looks like a status update mid-tool-use** ("Let me check…", "Reading the file now…") → this is a spurious Stop (a Claude turn-boundary that isn't real task progress). Just `rm .done` and keep polling.
|
|
184
|
+
|
|
185
|
+
**Hard turn cap: 8 turns per delegation.** Beyond that, either the task is too complex to delegate cleanly or claude is stuck in a loop. Abort with `/exit`, capture what you have, surface to the user with: "Claude took 8 turns without finishing — here's what it produced, what do you want to do?"
|
|
186
|
+
|
|
187
|
+
This loop is the most failure-prone part of the skill. If you find yourself uncertain whether a message is a question or a result, **default to surfacing to the user**, not to guessing. Wrong answers compound across turns.
|
|
188
|
+
|
|
189
|
+
## Capturing the output
|
|
190
|
+
|
|
191
|
+
Four sources, in order of preference:
|
|
192
|
+
|
|
193
|
+
1. **`git diff /agent main..cc-<id>`** (run from `/agent`, or use the explicit worktree path). This is the killer feature of the worktree model — the exact set of changes claude made, branch-vs-branch. Use this for code-change tasks.
|
|
194
|
+
2. **`git log cc-<id> --oneline main..cc-<id>`** for how claude got there (the sequence of commits). Useful when claude broke a refactor into steps you want to attribute or cherry-pick.
|
|
195
|
+
3. **`sentinel.json` from the final turn** (`last_assistant_message`). The narrative summary claude gave you. Use this for analysis tasks where the answer is prose, not code.
|
|
196
|
+
4. **The JSONL transcript** at `transcript_path` in the sentinel. The complete conversation including intermediate tool calls. Use when the diff/log aren't enough and you need to see how claude reasoned. Schema in `references/stop-hook.md`.
|
|
197
|
+
|
|
198
|
+
For code-change tasks, the canonical pattern is:
|
|
199
|
+
|
|
200
|
+
1. Read `last_assistant_message` for the summary.
|
|
201
|
+
2. Run `git diff main..cc-<id> -- <files>` to see the actual changes.
|
|
202
|
+
3. Decide: are these changes good? If yes, either `git cherry-pick <commits>` onto the agent folder's branch OR copy the changes manually into the main checkout and commit there with proper attribution (per `typeclaw-git`).
|
|
203
|
+
4. Throw away the `cc-<id>` branch.
|
|
204
|
+
|
|
205
|
+
Never paste Claude's output verbatim into your reply or a commit message. Summarize, attribute ("Claude Code's analysis: ..."), and stay accountable for the work. You delegated up; you didn't outsource ownership.
|
|
206
|
+
|
|
207
|
+
## Cleanup discipline
|
|
208
|
+
|
|
209
|
+
Cleanup is git-aware: a worktree isn't just a directory. Three steps, in order:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
tmux kill-session -t cc-<id> 2>/dev/null || true
|
|
213
|
+
git -C /agent worktree remove --force /tmp/cc-<id>
|
|
214
|
+
git -C /agent branch -D cc-<id>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **`tmux kill-session`** first because claude might still be holding files open. `|| true` because a clean `/exit` already killed the session.
|
|
218
|
+
- **`git worktree remove --force`** because the working tree may have dirty files (the sentinel, the hook script, claude's in-progress edits). `--force` skips the "uncommitted changes" check; this is correct here because we're explicitly discarding the worktree.
|
|
219
|
+
- **`git branch -D cc-<id>`** to delete the branch ref. Without this, `cc-<id>` lingers in `git branch -a` indefinitely. `-D` (capital) because `cc-<id>` is unmerged into anything you care about.
|
|
220
|
+
|
|
221
|
+
Always do all three, including on failure paths. Orphan worktrees:
|
|
222
|
+
|
|
223
|
+
- Show up in `git worktree list` forever.
|
|
224
|
+
- Cause `git status` in the agent folder to mention "another worktree exists at /tmp/cc-<id>" if you `cd` somewhere related.
|
|
225
|
+
- Make the next delegation with the same task-id fail with "branch already exists".
|
|
226
|
+
|
|
227
|
+
Before starting a new delegation, check for orphans:
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
git -C /agent worktree list | grep cc-
|
|
231
|
+
tmux ls 2>/dev/null | grep '^cc-'
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Kill anything you find first.
|
|
235
|
+
|
|
236
|
+
## When not to delegate
|
|
237
|
+
|
|
238
|
+
A re-statement, because this is where the skill is most often misused:
|
|
239
|
+
|
|
240
|
+
- **Trivial edits**: the round-trip cost dominates. Do it yourself.
|
|
241
|
+
- **Tasks needing live user visibility**: claude's tool calls don't stream back through TypeClaw. The user sees a long pause, not progress. Use your own tools.
|
|
242
|
+
- **Tasks where you don't have the context to brief claude**: spend tokens narrowing the problem first. A vague delegation produces a vague result.
|
|
243
|
+
- **Anything secret beyond `ANTHROPIC_API_KEY`**: claude only sees the prompt you send it and the files in its worktree (which is everything at `HEAD`). Don't try to pass secrets through the prompt — they'll land in claude's transcript and in your sentinel.
|
|
244
|
+
|
|
245
|
+
## Things you must not do
|
|
246
|
+
|
|
247
|
+
- **Do not use `claude -p` for delegation work.** The headless print mode strips plan mode, sub-agents, and the agent loop. The whole reason to delegate up is the loop. If you find yourself reaching for `-p`, the right answer is probably "do it yourself".
|
|
248
|
+
- **Do not run `claude` directly inside `/agent`.** Always inside `/tmp/cc-<id>/`. Running claude in the agent folder lets it mutate the live working tree and break the user's session in flight.
|
|
249
|
+
- **Do not skip the worktree.** Even for short delegations, the worktree is what gives you the `git diff` introspection and the rollback safety. Skipping it because "this one's small" is the path to claude accidentally committing on the wrong branch.
|
|
250
|
+
- **Do not share a tmux session across two delegated tasks.** Each task needs its own worktree, its own session, and its own `.claude/settings.json`. Sharing corrupts the sentinel state and crosses transcripts.
|
|
251
|
+
- **Do not leave a tmux session, worktree, or branch alive after capturing the result.** All three need explicit teardown. Reusing them defeats the per-task isolation that makes the Stop hook reliable.
|
|
252
|
+
- **Do not push claude's branch to a remote.** `cc-<id>` is throwaway. If something useful happened, cherry-pick onto a real branch first; don't push the experimental branch directly.
|
|
253
|
+
- **Do not merge claude's branch into main without reviewing the diff.** The `git diff main..cc-<id>` is your review surface. Skipping the diff and merging blindly means you don't actually know what shipped.
|
|
254
|
+
- **Do not commit `/tmp/cc-<id>/` artifacts back to the agent folder.** The sentinel, the hook script, the captured pane content are scratch — they live in `/tmp/`, they die with `worktree remove`.
|
|
255
|
+
- **Do not paste Claude's output verbatim into a commit message or a user reply.** Summarize and attribute. You're accountable for the work you ship.
|
|
256
|
+
- **Do not put `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` in `typeclaw.json`, in a prompt, or in any committed file.** They live in `.env`, which is gitignored. Period.
|
|
257
|
+
- **Do not poll the JSONL transcript directly as the done-signal.** The JSONL has documented race conditions (the file can be stale when `Stop` fires, or occasionally missing entirely). The sentinel is the reliable signal; the JSONL is for content, not lifecycle.
|
|
258
|
+
- **Do not write to `.env` without `acknowledgeGuards: { nonWorkspaceWrite: true }`.** The guard will refuse, the agent loop will retry the same broken write, and you'll waste tokens fighting the guard. The ack is required every write, not just the first one.
|
|
259
|
+
- **Do not edit `.env` with the `edit` tool's patch semantics.** Use read-modify-write: read the whole file, reconstruct the new content, write the whole file. `.env` is a flat KV store; a fragile `oldText` match could corrupt unrelated lines.
|
|
260
|
+
- **Do not run `claude setup-token` inside the container.** It's a TUI OAuth flow that wants a browser. The container has no display, no browser, and is often on a different machine from the user anyway. Always have the user run `setup-token` on their own machine and paste the resulting token back; never spawn it in tmux on this side.
|
|
261
|
+
- **Do not echo, log, or transcribe the pasted `CLAUDE_CODE_OAUTH_TOKEN` value back to the user, into a sentinel, into a commit message, or into any message you send.** It's a one-year credential. Confirm receipt with "got it, validating" — never with the token itself.
|
|
262
|
+
- **Do not invent answers to Claude's clarifying questions.** If you can't derive the answer from the original task brief, surface the question to the user. Wrong answers compound across multi-turn delegations.
|
|
263
|
+
- **Do not exceed 8 turns per delegation.** Abort, capture what you have, surface. Long delegations almost always mean the task wasn't shaped right.
|
|
264
|
+
- **Do not assume `claude` exists.** If `which claude` returns empty, the `docker.file.claudeCode` toggle isn't on. Tell the user, don't try to install it yourself.
|
|
265
|
+
|
|
266
|
+
## Cross-references
|
|
267
|
+
|
|
268
|
+
- **`references/auth-flow.md`** — both auth paths in detail: the API-key recap, the OAuth user-machine flow (what to tell the user, what their `claude setup-token` output looks like, validation rules), and the failure-mode catalogue (expired subscription, wrong account, malformed paste).
|
|
269
|
+
- **`references/tmux-driving.md`** — full polling implementation, ANSI handling, session-died recovery, the `capture-pane` fallback details, the worktree-is-not-scratch distinction.
|
|
270
|
+
- **`references/stop-hook.md`** — complete `Stop` event JSON schema, `SubagentStop` differences, transcript JSONL schema (unofficial but reverse-engineered), documented race conditions to handle.
|
|
271
|
+
- **`typeclaw-config`** — the `docker.file.claudeCode` toggle that gates the install.
|
|
272
|
+
- **`typeclaw-git`** — commit discipline for any cherry-picks or hand-copies from claude's worktree back into the agent folder.
|
|
273
|
+
- **`typeclaw-monorepo`** — the `workspace/` vs `packages/` distinction (this skill uses `/tmp/`, not `workspace/`, for reasons explained above).
|