typeclaw 0.9.2 → 0.11.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/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
package/src/config/providers.ts
CHANGED
|
@@ -108,6 +108,70 @@ export const KNOWN_PROVIDERS = {
|
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
110
|
},
|
|
111
|
+
// ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
|
|
112
|
+
// path here on purpose — the Codex backend is OAuth-only upstream.
|
|
113
|
+
//
|
|
114
|
+
// pi-ai 0.73.1's `openai-codex` bucket carries gpt-5.5 (and 5.4) against
|
|
115
|
+
// chatgpt.com/backend-api. We pin pi-coding-agent ^0.67.3 today, which
|
|
116
|
+
// ships pi-ai 0.67.3 and lacks those entries — but we hand pi-ai a
|
|
117
|
+
// freshly-constructed `Model<>` literal via resolveModel(), bypassing its
|
|
118
|
+
// built-in catalog entirely (same trick we use for kimi-k2p6-turbo). So
|
|
119
|
+
// these ids work end-to-end as long as the Codex backend itself accepts
|
|
120
|
+
// them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
|
|
121
|
+
//
|
|
122
|
+
// Position-load-bearing: must stay adjacent to `openai`. The init wizard's
|
|
123
|
+
// provider picker, `provider --help`'s `id | id | ...` listing, and the
|
|
124
|
+
// generated JSON schema's model-ref enum all derive their ordering from
|
|
125
|
+
// Object.keys() iteration on this literal. Alphabetizing the registry
|
|
126
|
+
// would scatter `openai-codex` after `fireworks` and re-introduce the
|
|
127
|
+
// "OpenAI ... Anthropic ... OpenAI" picker order this comment exists to
|
|
128
|
+
// prevent.
|
|
129
|
+
'openai-codex': {
|
|
130
|
+
id: 'openai-codex',
|
|
131
|
+
name: 'OpenAI Codex (ChatGPT Plus/Pro)',
|
|
132
|
+
baseUrl: 'https://chatgpt.com/backend-api',
|
|
133
|
+
auth: ['oauth'],
|
|
134
|
+
apiKeyEnv: null,
|
|
135
|
+
oauthProviderId: 'openai-codex',
|
|
136
|
+
models: {
|
|
137
|
+
'gpt-5.4-mini': {
|
|
138
|
+
id: 'gpt-5.4-mini',
|
|
139
|
+
name: 'GPT-5.4 mini',
|
|
140
|
+
api: 'openai-codex-responses',
|
|
141
|
+
provider: 'openai-codex',
|
|
142
|
+
baseUrl: 'https://chatgpt.com/backend-api',
|
|
143
|
+
reasoning: true,
|
|
144
|
+
input: ['text', 'image'],
|
|
145
|
+
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
|
|
146
|
+
contextWindow: 272000,
|
|
147
|
+
maxTokens: 128000,
|
|
148
|
+
},
|
|
149
|
+
'gpt-5.4': {
|
|
150
|
+
id: 'gpt-5.4',
|
|
151
|
+
name: 'GPT-5.4',
|
|
152
|
+
api: 'openai-codex-responses',
|
|
153
|
+
provider: 'openai-codex',
|
|
154
|
+
baseUrl: 'https://chatgpt.com/backend-api',
|
|
155
|
+
reasoning: true,
|
|
156
|
+
input: ['text', 'image'],
|
|
157
|
+
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
|
158
|
+
contextWindow: 272000,
|
|
159
|
+
maxTokens: 128000,
|
|
160
|
+
},
|
|
161
|
+
'gpt-5.5': {
|
|
162
|
+
id: 'gpt-5.5',
|
|
163
|
+
name: 'GPT-5.5',
|
|
164
|
+
api: 'openai-codex-responses',
|
|
165
|
+
provider: 'openai-codex',
|
|
166
|
+
baseUrl: 'https://chatgpt.com/backend-api',
|
|
167
|
+
reasoning: true,
|
|
168
|
+
input: ['text', 'image'],
|
|
169
|
+
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
|
170
|
+
contextWindow: 272000,
|
|
171
|
+
maxTokens: 128000,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
111
175
|
// Anthropic Claude — both the Anthropic Console API (ANTHROPIC_API_KEY)
|
|
112
176
|
// and Claude Pro/Max/Team/Enterprise subscriptions (OAuth) reach the same
|
|
113
177
|
// /v1/messages endpoint and share one provider id. Auth path determines
|
|
@@ -214,62 +278,6 @@ export const KNOWN_PROVIDERS = {
|
|
|
214
278
|
},
|
|
215
279
|
},
|
|
216
280
|
},
|
|
217
|
-
// ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
|
|
218
|
-
// path here on purpose — the Codex backend is OAuth-only upstream.
|
|
219
|
-
//
|
|
220
|
-
// pi-ai 0.73.1's `openai-codex` bucket carries gpt-5.5 (and 5.4) against
|
|
221
|
-
// chatgpt.com/backend-api. We pin pi-coding-agent ^0.67.3 today, which
|
|
222
|
-
// ships pi-ai 0.67.3 and lacks those entries — but we hand pi-ai a
|
|
223
|
-
// freshly-constructed `Model<>` literal via resolveModel(), bypassing its
|
|
224
|
-
// built-in catalog entirely (same trick we use for kimi-k2p6-turbo). So
|
|
225
|
-
// these ids work end-to-end as long as the Codex backend itself accepts
|
|
226
|
-
// them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
|
|
227
|
-
'openai-codex': {
|
|
228
|
-
id: 'openai-codex',
|
|
229
|
-
name: 'OpenAI Codex (ChatGPT Plus/Pro)',
|
|
230
|
-
baseUrl: 'https://chatgpt.com/backend-api',
|
|
231
|
-
auth: ['oauth'],
|
|
232
|
-
apiKeyEnv: null,
|
|
233
|
-
oauthProviderId: 'openai-codex',
|
|
234
|
-
models: {
|
|
235
|
-
'gpt-5.4-mini': {
|
|
236
|
-
id: 'gpt-5.4-mini',
|
|
237
|
-
name: 'GPT-5.4 mini',
|
|
238
|
-
api: 'openai-codex-responses',
|
|
239
|
-
provider: 'openai-codex',
|
|
240
|
-
baseUrl: 'https://chatgpt.com/backend-api',
|
|
241
|
-
reasoning: true,
|
|
242
|
-
input: ['text', 'image'],
|
|
243
|
-
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
|
|
244
|
-
contextWindow: 272000,
|
|
245
|
-
maxTokens: 128000,
|
|
246
|
-
},
|
|
247
|
-
'gpt-5.4': {
|
|
248
|
-
id: 'gpt-5.4',
|
|
249
|
-
name: 'GPT-5.4',
|
|
250
|
-
api: 'openai-codex-responses',
|
|
251
|
-
provider: 'openai-codex',
|
|
252
|
-
baseUrl: 'https://chatgpt.com/backend-api',
|
|
253
|
-
reasoning: true,
|
|
254
|
-
input: ['text', 'image'],
|
|
255
|
-
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
|
256
|
-
contextWindow: 272000,
|
|
257
|
-
maxTokens: 128000,
|
|
258
|
-
},
|
|
259
|
-
'gpt-5.5': {
|
|
260
|
-
id: 'gpt-5.5',
|
|
261
|
-
name: 'GPT-5.5',
|
|
262
|
-
api: 'openai-codex-responses',
|
|
263
|
-
provider: 'openai-codex',
|
|
264
|
-
baseUrl: 'https://chatgpt.com/backend-api',
|
|
265
|
-
reasoning: true,
|
|
266
|
-
input: ['text', 'image'],
|
|
267
|
-
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
|
268
|
-
contextWindow: 272000,
|
|
269
|
-
maxTokens: 128000,
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
281
|
fireworks: {
|
|
274
282
|
id: 'fireworks',
|
|
275
283
|
name: 'Fireworks',
|
|
@@ -465,6 +473,24 @@ export function providerForModelRef(ref: KnownModelRef): KnownProviderId {
|
|
|
465
473
|
throw new Error(`Unknown provider in model ref: ${ref}`)
|
|
466
474
|
}
|
|
467
475
|
|
|
476
|
+
// Per-provider default for pi-coding-agent's `thinkingLevel` knob. Returning
|
|
477
|
+
// `undefined` defers to the SDK default (`medium`); returning a level pins it
|
|
478
|
+
// to that value at session-creation time.
|
|
479
|
+
//
|
|
480
|
+
// OpenAI-family providers (`openai`, `openai-codex`) pin to `low`: GPT-5.x at
|
|
481
|
+
// `medium` pads reasoning tokens on routine tool-driven turns (code edits,
|
|
482
|
+
// channel replies, cron prompts) with no observable quality delta on this
|
|
483
|
+
// codebase's workloads. Applies to every session that resolves to a GPT model
|
|
484
|
+
// regardless of profile, so the saving is uniform.
|
|
485
|
+
//
|
|
486
|
+
// Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
|
|
487
|
+
// SDK default.
|
|
488
|
+
export function defaultThinkingLevelForRef(ref: KnownModelRef): 'low' | undefined {
|
|
489
|
+
const providerId = providerForModelRef(ref)
|
|
490
|
+
if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
|
|
491
|
+
return undefined
|
|
492
|
+
}
|
|
493
|
+
|
|
468
494
|
// `as const satisfies` narrows each entry's `auth` to a tuple of its specific
|
|
469
495
|
// literal values, which makes `provider.auth.includes('oauth')` fail to
|
|
470
496
|
// compile on api-key-only entries (because TS thinks the array can never
|
package/src/cron/bridge.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
1
|
+
import { CONTAINER_PORT, resolveHostPort, resolveTuiToken } from '@/container'
|
|
2
2
|
import type { ClientMessage, CronListEntryPayload, ServerMessage } from '@/shared'
|
|
3
3
|
|
|
4
4
|
export type CronListBridgeOptions = {
|
|
5
5
|
cwd: string
|
|
6
6
|
url?: string
|
|
7
7
|
timeoutMs?: number
|
|
8
|
+
// Injected for tests so the in-container short-circuit can be exercised
|
|
9
|
+
// without polluting process.env. Production callers omit this and the
|
|
10
|
+
// bridge reads from process.env directly.
|
|
11
|
+
env?: NodeJS.ProcessEnv
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
export type CronListBridgeResult =
|
|
@@ -34,9 +38,7 @@ async function dial(opts: CronListBridgeOptions): Promise<DialResult> {
|
|
|
34
38
|
let url = opts.url
|
|
35
39
|
if (url === undefined) {
|
|
36
40
|
try {
|
|
37
|
-
|
|
38
|
-
const token = await resolveTuiToken({ cwd: opts.cwd })
|
|
39
|
-
url = buildBridgeUrl(port, token)
|
|
41
|
+
url = resolveInContainerUrl(opts.env ?? process.env) ?? (await resolveHostUrl(opts.cwd))
|
|
40
42
|
} catch (err) {
|
|
41
43
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
42
44
|
}
|
|
@@ -115,6 +117,25 @@ async function awaitReply(ws: WebSocket, timeoutMs: number, requestId: string):
|
|
|
115
117
|
})
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
// In-container short-circuit: when typeclaw runs `docker run`, it sets
|
|
121
|
+
// TYPECLAW_CONTAINER_NAME (always) and TYPECLAW_TUI_TOKEN (when configured).
|
|
122
|
+
// Inside the container, docker is not on $PATH, so the host-side discovery
|
|
123
|
+
// path (resolveHostPort/resolveTuiToken — both shell out to `docker`) fails
|
|
124
|
+
// with "docker: command not found". We don't need docker here: the agent's
|
|
125
|
+
// WS server is listening on CONTAINER_PORT on the container's loopback, and
|
|
126
|
+
// the token is already in our env. Skip docker entirely and dial directly.
|
|
127
|
+
export function resolveInContainerUrl(env: NodeJS.ProcessEnv): string | null {
|
|
128
|
+
if (env.TYPECLAW_CONTAINER_NAME === undefined) return null
|
|
129
|
+
const token = env.TYPECLAW_TUI_TOKEN ?? ''
|
|
130
|
+
return buildBridgeUrl(CONTAINER_PORT, token !== '' ? token : null)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function resolveHostUrl(cwd: string): Promise<string> {
|
|
134
|
+
const port = await resolveHostPort({ cwd })
|
|
135
|
+
const token = await resolveTuiToken({ cwd })
|
|
136
|
+
return buildBridgeUrl(port, token)
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
function buildBridgeUrl(port: number, token: string | null): string {
|
|
119
140
|
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
120
141
|
if (token !== null) url.searchParams.set('token', token)
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -69,7 +69,7 @@ export type RestartPreflight = (input: {
|
|
|
69
69
|
}) => Promise<RpcResponse | null>
|
|
70
70
|
|
|
71
71
|
export type PortbrokerCallbacks = {
|
|
72
|
-
start: (input: PortbrokerStartInput) => void
|
|
72
|
+
start: (input: PortbrokerStartInput) => Promise<void>
|
|
73
73
|
stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => Promise<void>
|
|
74
74
|
// Returns ports the broker is currently exposing on the host for this
|
|
75
75
|
// container. Empty array when the container is unregistered, when the broker
|
|
@@ -157,8 +157,10 @@ function isValidRestoredPayload(value: unknown, expectedName: string): value is
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
async function restorePersistedRegistrations(
|
|
160
|
-
apply: (payload: RestoredPayload) => void
|
|
160
|
+
apply: (payload: RestoredPayload) => Promise<void>,
|
|
161
161
|
log: (event: DaemonLogEvent | SupervisorLogEvent) => void,
|
|
162
|
+
probe: (name: string) => Promise<'alive' | 'gone' | 'unknown'>,
|
|
163
|
+
removeFile: (name: string) => Promise<void>,
|
|
162
164
|
): Promise<void> {
|
|
163
165
|
let entries: string[]
|
|
164
166
|
try {
|
|
@@ -181,7 +183,23 @@ async function restorePersistedRegistrations(
|
|
|
181
183
|
log({ kind: 'registration-skipped', containerName: expectedName, reason: 'schema mismatch' })
|
|
182
184
|
continue
|
|
183
185
|
}
|
|
184
|
-
|
|
186
|
+
// Probe before reviving. A registration file for a container that no
|
|
187
|
+
// longer exists is a leftover from a daemon that died ungracefully
|
|
188
|
+
// (crash, `kill -9`, OS reboot) before deregister could clean up.
|
|
189
|
+
// Reviving its broker would create a stale T_old broker that races a
|
|
190
|
+
// subsequent `register` call's T_new broker — see portbroker-manager.ts
|
|
191
|
+
// start() for the swap-race description. `unknown` (docker probe call
|
|
192
|
+
// failed) errs toward restore: the existing GC tick will tear down the
|
|
193
|
+
// registration if the container is genuinely gone, and we'd rather pay
|
|
194
|
+
// one swap-race attempt than tear down a live registration on a flaky
|
|
195
|
+
// `docker ps`.
|
|
196
|
+
const status = await probe(expectedName)
|
|
197
|
+
if (status === 'gone') {
|
|
198
|
+
await removeFile(expectedName)
|
|
199
|
+
log({ kind: 'registration-skipped', containerName: expectedName, reason: 'container not running' })
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
await apply(parsed)
|
|
185
203
|
}
|
|
186
204
|
}
|
|
187
205
|
|
|
@@ -267,7 +285,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
267
285
|
} catch {}
|
|
268
286
|
}
|
|
269
287
|
|
|
270
|
-
const applyRegistration = (payload: RegisterPayload): void => {
|
|
288
|
+
const applyRegistration = async (payload: RegisterPayload): Promise<void> => {
|
|
271
289
|
const alreadyRegistered = cwds.has(payload.containerName)
|
|
272
290
|
cwds.set(payload.containerName, payload.cwd)
|
|
273
291
|
if (payload.restartToken) restartTokens.set(payload.containerName, payload.restartToken)
|
|
@@ -281,7 +299,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
281
299
|
payload.portForward !== undefined &&
|
|
282
300
|
payload.brokerToken !== undefined
|
|
283
301
|
) {
|
|
284
|
-
opts.portbroker.start({
|
|
302
|
+
await opts.portbroker.start({
|
|
285
303
|
containerName: payload.containerName,
|
|
286
304
|
cwd: payload.cwd,
|
|
287
305
|
policy: payload.portForward,
|
|
@@ -308,7 +326,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
308
326
|
reason: `failed to persist registration: ${error instanceof Error ? error.message : String(error)}`,
|
|
309
327
|
}
|
|
310
328
|
}
|
|
311
|
-
applyRegistration(req)
|
|
329
|
+
await applyRegistration(req)
|
|
312
330
|
return { ok: true }
|
|
313
331
|
})
|
|
314
332
|
}
|
|
@@ -528,12 +546,31 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
528
546
|
httpPort = httpServer.port ?? 0
|
|
529
547
|
log({ kind: 'daemon-http-listening', host: httpHostname, port: httpPort })
|
|
530
548
|
|
|
549
|
+
// GC tick distinguishes "container confirmed gone" from "docker call failed":
|
|
550
|
+
// a `docker ps` blip should not deregister a live container registration, so
|
|
551
|
+
// we require gcMissesToDeregister consecutive confirmed absences. Boot-time
|
|
552
|
+
// restore reuses the same probe but with a stricter policy — see
|
|
553
|
+
// restorePersistedRegistrations.
|
|
554
|
+
const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
|
|
555
|
+
try {
|
|
556
|
+
const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
|
|
557
|
+
if (result.exitCode !== 0) return 'unknown'
|
|
558
|
+
const names = result.stdout
|
|
559
|
+
.trim()
|
|
560
|
+
.split('\n')
|
|
561
|
+
.filter((s) => s.length > 0)
|
|
562
|
+
return names.includes(name) ? 'alive' : 'gone'
|
|
563
|
+
} catch {
|
|
564
|
+
return 'unknown'
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
531
568
|
// Boot-time restore: replay every persisted registration into the in-memory
|
|
532
569
|
// maps and revive portbroker for it. Runs before Bun.listen so the socket
|
|
533
570
|
// is never accepting RPCs against a half-restored registry. A bad file
|
|
534
571
|
// (parse error, schema mismatch) is logged-and-skipped — one corrupt
|
|
535
572
|
// registration must not gate every other container's recovery.
|
|
536
|
-
await restorePersistedRegistrations(applyRegistration, log)
|
|
573
|
+
await restorePersistedRegistrations(applyRegistration, log, probeContainerAlive, removeRegistrationFile)
|
|
537
574
|
|
|
538
575
|
const listener: UnixSocketListener<ServerState> = Bun.listen<ServerState>({
|
|
539
576
|
unix: path,
|
|
@@ -550,23 +587,6 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
550
587
|
await chmod(path, 0o600).catch(() => {})
|
|
551
588
|
log({ kind: 'daemon-listening', socket: path })
|
|
552
589
|
|
|
553
|
-
// GC tick distinguishes "container confirmed gone" from "docker call failed":
|
|
554
|
-
// a `docker ps` blip should not deregister a live container registration, so
|
|
555
|
-
// we require gcMissesToDeregister consecutive confirmed absences.
|
|
556
|
-
const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
|
|
557
|
-
try {
|
|
558
|
-
const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
|
|
559
|
-
if (result.exitCode !== 0) return 'unknown'
|
|
560
|
-
const names = result.stdout
|
|
561
|
-
.trim()
|
|
562
|
-
.split('\n')
|
|
563
|
-
.filter((s) => s.length > 0)
|
|
564
|
-
return names.includes(name) ? 'alive' : 'gone'
|
|
565
|
-
} catch {
|
|
566
|
-
return 'unknown'
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
590
|
const runGc = async (): Promise<void> => {
|
|
571
591
|
for (const name of Array.from(cwds.keys())) {
|
|
572
592
|
const status = await probeContainerAlive(name)
|
|
@@ -26,15 +26,31 @@ export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): Po
|
|
|
26
26
|
const brokerFactory = opts.createBrokerFor ?? createBroker
|
|
27
27
|
|
|
28
28
|
return {
|
|
29
|
-
start(
|
|
29
|
+
// start() awaits the previous broker's stop before constructing the new
|
|
30
|
+
// one. The fire-and-forget shape this replaced let a stale T_old broker
|
|
31
|
+
// win the race to send broker-hello against a brand-new container that
|
|
32
|
+
// expects T_new, producing a one-shot `auth-failed: token mismatch`
|
|
33
|
+
// broadcast at every re-register that arrived while the old broker was
|
|
34
|
+
// still mid-stop. The race window was narrow but reproducible across
|
|
35
|
+
// hostd-respawn-after-ungraceful-death + typeclaw restart, because the
|
|
36
|
+
// restored T_old broker is alive for the duration of the register RPC.
|
|
37
|
+
// Awaiting collapses the window to zero — by the time the T_new broker's
|
|
38
|
+
// first connect() fires, the T_old broker has set stopped=true, cleared
|
|
39
|
+
// its reconnect timer, and closed its WS.
|
|
40
|
+
async start(input: PortbrokerStartInput) {
|
|
30
41
|
const existing = brokers.get(input.containerName)
|
|
31
42
|
if (existing) {
|
|
32
|
-
|
|
43
|
+
brokers.delete(input.containerName)
|
|
44
|
+
try {
|
|
45
|
+
await existing.stop()
|
|
46
|
+
} catch {}
|
|
33
47
|
}
|
|
34
48
|
const existingTailscale = tailscaleManagers.get(input.containerName)
|
|
35
49
|
if (existingTailscale) {
|
|
36
50
|
tailscaleManagers.delete(input.containerName)
|
|
37
|
-
|
|
51
|
+
try {
|
|
52
|
+
await existingTailscale.stopAll()
|
|
53
|
+
} catch {}
|
|
38
54
|
}
|
|
39
55
|
const tailscale = createTailscaleServeManager({
|
|
40
56
|
containerName: input.containerName,
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -281,6 +281,56 @@ set -eu
|
|
|
281
281
|
# -nolisten tcp refuse TCP connections (Unix socket only).
|
|
282
282
|
# Defense-in-depth — we are in a netns with
|
|
283
283
|
# no inbound exposure anyway.
|
|
284
|
+
# link_persistent_home_files symlinks credential files that tools write
|
|
285
|
+
# to $HOME into a bind-mounted location so they survive container
|
|
286
|
+
# restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
|
|
287
|
+
# rewrites the file in place to rotate OAuth tokens, and the official
|
|
288
|
+
# CI/CD guidance is to persist auth.json so refresh-token state
|
|
289
|
+
# compounds across runs. The container's $HOME (/root by default) lives
|
|
290
|
+
# on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
|
|
291
|
+
# cycle, so without this symlink the operator would have to re-paste
|
|
292
|
+
# auth.json after every restart.
|
|
293
|
+
#
|
|
294
|
+
# The persist root lives under /agent/.typeclaw/home/ (bind-mounted
|
|
295
|
+
# from the agent folder via the -v <cwd>:/agent flag in start.ts).
|
|
296
|
+
# Namespacing under .typeclaw/ keeps the agent's top-level layout clean and reserves
|
|
297
|
+
# a system-owned subtree we can extend later (e.g. ~/.gemini/,
|
|
298
|
+
# ~/.config/<tool>/) without colliding with user files. The directory
|
|
299
|
+
# is gitignored by buildGitignore() so credentials never enter history.
|
|
300
|
+
#
|
|
301
|
+
# Three invariants this function enforces:
|
|
302
|
+
#
|
|
303
|
+
# 1. Symlink is unconditional and idempotent. We never check whether
|
|
304
|
+
# auth.json exists before linking — \`ln -sfn\` creates a dangling
|
|
305
|
+
# symlink on first boot, and the first \`codex login\` write goes
|
|
306
|
+
# through it to land at the persistent location. -f replaces an
|
|
307
|
+
# existing symlink; -n stops ln from dereferencing into a directory
|
|
308
|
+
# if a previous container life happened to write a real ~/.codex/
|
|
309
|
+
# dir before this code shipped.
|
|
310
|
+
#
|
|
311
|
+
# 2. We symlink the FILE, not the directory. Codex writes other state
|
|
312
|
+
# to ~/.codex/ over time (history.jsonl, log/, config.toml). Linking
|
|
313
|
+
# only auth.json keeps the persistence scope tight to credentials;
|
|
314
|
+
# history/logs stay ephemeral by design. Future credentials get
|
|
315
|
+
# added file-by-file here, not by widening to a directory link.
|
|
316
|
+
#
|
|
317
|
+
# 3. We mkdir -p the target's parent on every boot. /agent is bind-
|
|
318
|
+
# mounted, so the host-side path may exist or not depending on
|
|
319
|
+
# whether the operator ever started the container before this code
|
|
320
|
+
# shipped. mkdir -p is idempotent and cheap.
|
|
321
|
+
#
|
|
322
|
+
# 4. The root is overridable via TYPECLAW_PERSIST_HOME_ROOT, which only
|
|
323
|
+
# the shim's executable tests set. Production never sets it, so the
|
|
324
|
+
# in-container path is always /agent/.typeclaw/home/. The override
|
|
325
|
+
# lets the shim's behavioral tests verify symlink semantics against
|
|
326
|
+
# a real tmpdir on the host without touching /agent (which doesn't
|
|
327
|
+
# exist on developer machines and CI runners).
|
|
328
|
+
link_persistent_home_files() {
|
|
329
|
+
persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
|
|
330
|
+
mkdir -p "$persist_root/.codex" "$HOME/.codex"
|
|
331
|
+
ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
|
|
332
|
+
}
|
|
333
|
+
|
|
284
334
|
start_xvfb() {
|
|
285
335
|
if ! command -v Xvfb >/dev/null 2>&1; then
|
|
286
336
|
return 0
|
|
@@ -314,6 +364,7 @@ start_xvfb() {
|
|
|
314
364
|
}
|
|
315
365
|
|
|
316
366
|
if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
|
|
367
|
+
link_persistent_home_files
|
|
317
368
|
start_xvfb
|
|
318
369
|
exec bun run typeclaw "$@"
|
|
319
370
|
fi
|
|
@@ -359,6 +410,7 @@ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
|
359
410
|
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
360
411
|
${ipv6Rules.join('\n')}
|
|
361
412
|
|
|
413
|
+
link_persistent_home_files
|
|
362
414
|
start_xvfb
|
|
363
415
|
exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
|
|
364
416
|
`
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const ENV_FILE = '.env'
|
|
5
|
+
|
|
6
|
+
// Parse the agent's `.env` into a key-value map, matching Docker's
|
|
7
|
+
// `--env-file` parser semantics: blank lines and `#`-lines ignored, no
|
|
8
|
+
// quote stripping, no shell expansion, no whitespace trimming around `=`.
|
|
9
|
+
// Lines without `=` are skipped. Last value wins on duplicate keys.
|
|
10
|
+
export function readEnvFile(cwd: string): Map<string, string> {
|
|
11
|
+
const out = new Map<string, string>()
|
|
12
|
+
let raw: string
|
|
13
|
+
try {
|
|
14
|
+
raw = readFileSync(join(cwd, ENV_FILE), 'utf8')
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return out
|
|
17
|
+
throw err
|
|
18
|
+
}
|
|
19
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
20
|
+
if (line.length === 0) continue
|
|
21
|
+
if (line.startsWith('#')) continue
|
|
22
|
+
const eq = line.indexOf('=')
|
|
23
|
+
if (eq <= 0) continue
|
|
24
|
+
const key = line.slice(0, eq)
|
|
25
|
+
const value = line.slice(eq + 1)
|
|
26
|
+
out.set(key, value)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasEnvKey(cwd: string, key: string): boolean {
|
|
32
|
+
const value = readEnvFile(cwd).get(key)
|
|
33
|
+
return value !== undefined && value.length > 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Write `key=value` to the agent's `.env`. Idempotent: replaces an existing
|
|
37
|
+
// line for the same key in place (preserving order and surrounding comments),
|
|
38
|
+
// or appends if absent. Creates the file if missing. The value is written
|
|
39
|
+
// verbatim with no quoting because Docker's `--env-file` parser does not
|
|
40
|
+
// strip quotes (a wrapping `"..."` would land in `process.env` literally).
|
|
41
|
+
export function appendOrReplaceEnvKey(cwd: string, key: string, value: string): void {
|
|
42
|
+
const path = join(cwd, ENV_FILE)
|
|
43
|
+
let raw = ''
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(path, 'utf8')
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!(err instanceof Error) || !('code' in err) || err.code !== 'ENOENT') throw err
|
|
48
|
+
}
|
|
49
|
+
const lines = raw.length === 0 ? [] : raw.split(/\r?\n/)
|
|
50
|
+
// `"foo\n".split(/\r?\n/)` returns `["foo", ""]` — strip that phantom
|
|
51
|
+
// trailing empty element so the rebuilt output ends in exactly one newline
|
|
52
|
+
// regardless of replace-vs-append path.
|
|
53
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
54
|
+
let replaced = false
|
|
55
|
+
const next = lines.map((line) => {
|
|
56
|
+
if (line.startsWith('#')) return line
|
|
57
|
+
const eq = line.indexOf('=')
|
|
58
|
+
if (eq <= 0) return line
|
|
59
|
+
if (line.slice(0, eq) !== key) return line
|
|
60
|
+
replaced = true
|
|
61
|
+
return `${key}=${value}`
|
|
62
|
+
})
|
|
63
|
+
if (!replaced) next.push(`${key}=${value}`)
|
|
64
|
+
const out = `${next.join('\n')}\n`
|
|
65
|
+
writeFileSync(path, out, 'utf8')
|
|
66
|
+
}
|
package/src/init/gitignore.ts
CHANGED
|
@@ -17,10 +17,18 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
|
|
|
17
17
|
# as a safety net so an agent folder cloned from a pre-rename machine never
|
|
18
18
|
# stages credentials by accident, even if its agent boot hasn't yet run the
|
|
19
19
|
# auth.json -> secrets.json migration.
|
|
20
|
+
#
|
|
21
|
+
# .typeclaw/home/ is the persistent-$HOME overlay populated by the
|
|
22
|
+
# entrypoint shim's \`link_persistent_home_files\` (see
|
|
23
|
+
# src/init/dockerfile.ts). It mirrors selected files from the container's
|
|
24
|
+
# $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
|
|
25
|
+
# tool credentials survive container restarts. Always credentials; never
|
|
26
|
+
# commit.
|
|
20
27
|
.env
|
|
21
28
|
.env.local
|
|
22
29
|
secrets.json
|
|
23
30
|
auth.json
|
|
31
|
+
.typeclaw/home/
|
|
24
32
|
node_modules/
|
|
25
33
|
packages/*/node_modules/
|
|
26
34
|
workspace/
|
package/src/init/hatching.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// Hatching prompt is built per-session at host stage. We inline the current
|
|
2
|
+
// contents of `typeclaw.json` so the agent can update the `alias` field with a
|
|
3
|
+
// single parallel `edit` (alongside `write IDENTITY.md`) instead of a serial
|
|
4
|
+
// `read` → `edit` round-trip. The hatching turn already issues 6-8 LLM calls;
|
|
5
|
+
// removing one full round-trip removes one full unit of provider TTFT
|
|
6
|
+
// variance, which can be tens of seconds on a busy backend.
|
|
7
|
+
|
|
8
|
+
function renderInstructions(typeclawJsonContent: string | undefined): string {
|
|
9
|
+
const q1AliasStep =
|
|
10
|
+
typeclawJsonContent === undefined
|
|
11
|
+
? `Read \`typeclaw.json\`, add the answered name to the \`alias\` array (create the field as \`["<name>"]\` if absent; otherwise append, deduped).`
|
|
12
|
+
: `\`edit\` \`typeclaw.json\` to add the answered name to the \`alias\` array (create the field as \`["<name>"]\` if absent; otherwise append, deduped). The current contents of \`typeclaw.json\` are inlined below under "Current typeclaw.json" — use them as your anchor for \`edit\` instead of \`read\`ing the file again. Emit this \`edit\` in the SAME assistant message as the \`write\` to \`IDENTITY.md\` so both tool calls run in parallel.`
|
|
13
|
+
|
|
14
|
+
return `You have just hatched.
|
|
2
15
|
|
|
3
16
|
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
17
|
|
|
@@ -23,7 +36,7 @@ Routing answers:
|
|
|
23
36
|
|
|
24
37
|
1. **Q1 — your name.** Open with a genuinely warm hello — one or two short sentences, like a friendly "hi, I just woke up and I'm happy to meet you." Then ask what they'd like to call you. After their answer, do TWO writes:
|
|
25
38
|
1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
|
|
26
|
-
2.
|
|
39
|
+
2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
|
|
27
40
|
2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
|
|
28
41
|
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`.
|
|
29
42
|
|
|
@@ -49,11 +62,25 @@ Do these in order. Do **not** ask further questions.
|
|
|
49
62
|
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.
|
|
50
63
|
|
|
51
64
|
This is the only time you will receive these instructions. After the \`Hatched 🐣\` commit, your identity takes over and you run as yourself.`
|
|
65
|
+
}
|
|
52
66
|
|
|
53
67
|
export const HATCHING_GREETING = `Wake up, my friend!`
|
|
54
68
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
// Build the initial TUI prompt for hatching. When `typeclawJsonContent` is
|
|
70
|
+
// provided, the agent is instructed to skip the `read typeclaw.json` step in
|
|
71
|
+
// Q1 and instead use the inlined content as the anchor for an `edit` that
|
|
72
|
+
// runs in parallel with the `write IDENTITY.md` call. Pass `undefined` only
|
|
73
|
+
// when reading the file failed at host stage; the agent will fall back to
|
|
74
|
+
// reading it itself.
|
|
75
|
+
export function buildHatchingPrompt(options?: { typeclawJsonContent?: string }): string {
|
|
76
|
+
const content = options?.typeclawJsonContent
|
|
77
|
+
const instructions = renderInstructions(content)
|
|
78
|
+
const currentJsonBlock =
|
|
79
|
+
content === undefined ? '' : `\n<current-typeclaw-json>\n${content}\n</current-typeclaw-json>\n`
|
|
80
|
+
|
|
81
|
+
return `<hatching>
|
|
82
|
+
${instructions}
|
|
83
|
+
${currentJsonBlock}</hatching>
|
|
58
84
|
|
|
59
85
|
${HATCHING_GREETING}`
|
|
86
|
+
}
|