typeclaw 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +2 -2
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/index.ts +9 -7
  4. package/src/agent/live-subagents.ts +0 -1
  5. package/src/agent/session-origin.ts +10 -0
  6. package/src/agent/subagent-completion-reminder.ts +4 -1
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/restart.ts +13 -2
  9. package/src/agent/tools/spawn-subagent.ts +0 -1
  10. package/src/agent/tools/subagent-output.ts +3 -51
  11. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  12. package/src/bundled-plugins/memory/index.ts +55 -25
  13. package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
  14. package/src/bundled-plugins/memory/migration.ts +21 -17
  15. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  16. package/src/bundled-plugins/security/index.ts +19 -17
  17. package/src/bundled-plugins/security/permissions.ts +9 -8
  18. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  19. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  20. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  21. package/src/channels/manager.ts +7 -0
  22. package/src/channels/router.ts +267 -14
  23. package/src/channels/schema.ts +22 -1
  24. package/src/cli/compose.ts +23 -2
  25. package/src/cli/cron.ts +1 -1
  26. package/src/cli/inspect.ts +105 -12
  27. package/src/cli/logs.ts +17 -2
  28. package/src/cli/role.ts +2 -2
  29. package/src/compose/logs.ts +8 -4
  30. package/src/config/config.ts +8 -0
  31. package/src/config/providers.ts +18 -0
  32. package/src/container/index.ts +1 -1
  33. package/src/container/logs.ts +38 -11
  34. package/src/cron/bridge.ts +25 -4
  35. package/src/hostd/daemon.ts +44 -24
  36. package/src/hostd/portbroker-manager.ts +19 -3
  37. package/src/init/dockerfile.ts +199 -4
  38. package/src/init/gitignore.ts +8 -0
  39. package/src/inspect/index.ts +42 -5
  40. package/src/inspect/live.ts +32 -1
  41. package/src/inspect/loop.ts +20 -0
  42. package/src/inspect/render.ts +32 -0
  43. package/src/inspect/replay.ts +14 -0
  44. package/src/inspect/types.ts +26 -0
  45. package/src/permissions/builtins.ts +29 -21
  46. package/src/permissions/permissions.ts +32 -5
  47. package/src/role-claim/code.ts +9 -9
  48. package/src/role-claim/controller.ts +3 -2
  49. package/src/role-claim/match-rule.ts +14 -19
  50. package/src/role-claim/pending.ts +2 -2
  51. package/src/run/index.ts +1 -0
  52. package/src/server/index.ts +59 -19
  53. package/src/shared/protocol.ts +30 -0
  54. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  55. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +144 -0
  56. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  57. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  58. package/src/skills/typeclaw-config/SKILL.md +39 -32
  59. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  60. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  61. package/src/test-helpers/wait-for.ts +15 -7
  62. package/typeclaw.schema.json +111 -10
package/src/cli/logs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { logs } from '@/container'
3
+ import { logs, parseTailValue } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
6
  import { c, errorLine } from './ui'
@@ -17,17 +17,32 @@ export const logsCommand = defineCommand({
17
17
  description: 'stream new log output as it arrives',
18
18
  default: false,
19
19
  },
20
+ tail: {
21
+ type: 'string',
22
+ alias: 'n',
23
+ description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
24
+ },
20
25
  },
21
26
  async run({ args }) {
22
27
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
23
28
 
29
+ let tail: string | undefined
30
+ if (args.tail !== undefined) {
31
+ const parsed = parseTailValue(args.tail)
32
+ if (!parsed.ok) {
33
+ console.error(errorLine(parsed.reason))
34
+ process.exit(2)
35
+ }
36
+ tail = parsed.value
37
+ }
38
+
24
39
  if (args.follow) {
25
40
  console.log(c.cyan('Streaming container logs...'))
26
41
  } else {
27
42
  console.log(c.dim('Showing container logs.'))
28
43
  }
29
44
 
30
- const result = await logs({ cwd, follow: args.follow })
45
+ const result = await logs({ cwd, follow: args.follow, tail })
31
46
  if (!result.ok) {
32
47
  console.error(errorLine(result.reason))
33
48
  process.exit(1)
package/src/cli/role.ts CHANGED
@@ -11,7 +11,7 @@ const claimSub = defineCommand({
11
11
  meta: {
12
12
  name: 'claim',
13
13
  description:
14
- 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you DM that code back to the bot to prove control of the channel account.',
14
+ 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you send that code back to the bot from any chat (DM, group, channel) to prove control of the channel account. The resulting match rule grants the role to your author identity across every chat on that platform.',
15
15
  },
16
16
  args: {
17
17
  as: {
@@ -57,7 +57,7 @@ const claimSub = defineCommand({
57
57
  s.stop('Ready.')
58
58
  const expiresInSec = Math.max(0, Math.round((payload.expiresAt - Date.now()) / 1000))
59
59
  const lines = [
60
- `Send this message to your bot as a DM:`,
60
+ `Send this message to your bot from any chat (DM, group, or channel):`,
61
61
  '',
62
62
  ` ${c.bold(payload.code)}`,
63
63
  '',
@@ -1,4 +1,4 @@
1
- import { containerExists } from '@/container'
1
+ import { buildDockerLogsCmd, containerExists } from '@/container'
2
2
  import { supportsColor } from '@/container/log-colors'
3
3
  import { makeLogTimestampReformatter, type TimestampReformatter } from '@/container/log-timestamps'
4
4
  import { getBun } from '@/container/shared'
@@ -8,6 +8,7 @@ import { discoverAgents, type AgentEntry } from './discover'
8
8
  export type ComposeLogsOptions = {
9
9
  rootCwd: string
10
10
  follow: boolean
11
+ tail?: string
11
12
  out?: NodeJS.WritableStream
12
13
  err?: NodeJS.WritableStream
13
14
  signal?: AbortSignal
@@ -66,6 +67,7 @@ export function makeLinePrefixer(
66
67
  export async function composeLogs({
67
68
  rootCwd,
68
69
  follow,
70
+ tail,
69
71
  out = process.stdout,
70
72
  err = process.stderr,
71
73
  signal,
@@ -93,9 +95,11 @@ export async function composeLogs({
93
95
  const useColor = supportsColor(out)
94
96
 
95
97
  const procs = attached.map((agent) => {
96
- const cmd = follow
97
- ? ['docker', 'logs', '--timestamps', '-f', agent.containerName]
98
- : ['docker', 'logs', '--timestamps', agent.containerName]
98
+ const cmd = buildDockerLogsCmd({
99
+ containerName: agent.containerName,
100
+ follow,
101
+ ...(tail !== undefined ? { tail } : {}),
102
+ })
99
103
  const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
100
104
  return { agent, proc }
101
105
  })
@@ -121,6 +121,14 @@ const dockerfileObjectSchema = z.object({
121
121
  // time, not via version pins like apt. Default `false`; the bundled
122
122
  // `typeclaw-claude-code` skill prompts the user to opt in.
123
123
  claudeCode: z.boolean().default(false),
124
+ // `codexCli` is boolean-only (not an apt feature toggle): the upstream
125
+ // installer is the npm package `@openai/codex` which we install globally
126
+ // via `bun install -g`. Default `false`; the bundled `typeclaw-codex-cli`
127
+ // skill prompts the user to opt in. Mirrors the `claudeCode` toggle for
128
+ // OpenAI's Codex CLI (https://github.com/openai/codex) — same shape, same
129
+ // restart-required semantics, separate hook scripts (Codex uses
130
+ // hooks.json with a different event matcher than Claude Code).
131
+ codexCli: z.boolean().default(false),
124
132
  append: z.array(dockerfileLineSchema).default([]),
125
133
  })
126
134
 
@@ -465,6 +465,24 @@ export function providerForModelRef(ref: KnownModelRef): KnownProviderId {
465
465
  throw new Error(`Unknown provider in model ref: ${ref}`)
466
466
  }
467
467
 
468
+ // Per-provider default for pi-coding-agent's `thinkingLevel` knob. Returning
469
+ // `undefined` defers to the SDK default (`medium`); returning a level pins it
470
+ // to that value at session-creation time.
471
+ //
472
+ // OpenAI-family providers (`openai`, `openai-codex`) pin to `low`: GPT-5.x at
473
+ // `medium` pads reasoning tokens on routine tool-driven turns (code edits,
474
+ // channel replies, cron prompts) with no observable quality delta on this
475
+ // codebase's workloads. Applies to every session that resolves to a GPT model
476
+ // regardless of profile, so the saving is uniform.
477
+ //
478
+ // Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
479
+ // SDK default.
480
+ export function defaultThinkingLevelForRef(ref: KnownModelRef): 'low' | undefined {
481
+ const providerId = providerForModelRef(ref)
482
+ if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
483
+ return undefined
484
+ }
485
+
468
486
  // `as const satisfies` narrows each entry's `auth` to a tuple of its specific
469
487
  // literal values, which makes `provider.auth.includes('oauth')` fail to
470
488
  // compile on api-key-only entries (because TS thinks the array can never
@@ -1,4 +1,4 @@
1
- export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
1
+ export { buildDockerLogsCmd, logs, parseTailValue, planLogs, type LogsPlan, type LogsResult } from './logs'
2
2
  export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
3
3
  export {
4
4
  requireContainerRunning,
@@ -5,6 +5,7 @@ import { containerExists, containerNameFromCwd, getBun } from './shared'
5
5
  export type LogsPlan = {
6
6
  containerName: string
7
7
  follow: boolean
8
+ tail?: string
8
9
  }
9
10
 
10
11
  export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
@@ -12,6 +13,10 @@ export type LogsResult = { ok: true; containerName: string; exitCode: number } |
12
13
  export type LogsOptions = {
13
14
  cwd: string
14
15
  follow: boolean
16
+ // Forwarded to `docker logs --tail <value>`. Accepts a non-negative
17
+ // integer string or the sentinel `"all"`. When undefined, no `--tail`
18
+ // arg is added and docker's default ("all") applies.
19
+ tail?: string
15
20
  out?: NodeJS.WritableStream
16
21
  err?: NodeJS.WritableStream
17
22
  signal?: AbortSignal
@@ -23,6 +28,7 @@ export type LogsOptions = {
23
28
  export async function logs({
24
29
  cwd,
25
30
  follow,
31
+ tail,
26
32
  out = process.stdout,
27
33
  err = process.stderr,
28
34
  signal,
@@ -31,18 +37,14 @@ export async function logs({
31
37
  const bun = getBun()
32
38
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
33
39
 
34
- const { containerName } = planLogs(cwd, { follow })
40
+ const plan = planLogs(cwd, { follow, tail })
35
41
 
36
42
  try {
37
- if (!(await containerExists(containerName))) {
38
- return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
43
+ if (!(await containerExists(plan.containerName))) {
44
+ return { ok: false, reason: `Container ${plan.containerName} not found. Run \`typeclaw start\` first.` }
39
45
  }
40
46
 
41
- const cmd = ['docker', 'logs', '--timestamps']
42
- if (follow) cmd.push('-f')
43
- cmd.push(containerName)
44
-
45
- const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
47
+ const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdout: 'pipe', stderr: 'pipe' })
46
48
 
47
49
  const onAbort = (): void => {
48
50
  try {
@@ -62,14 +64,39 @@ export async function logs({
62
64
  const exitCode = await proc.exited
63
65
  signal?.removeEventListener('abort', onAbort)
64
66
 
65
- return { ok: true, containerName, exitCode }
67
+ return { ok: true, containerName: plan.containerName, exitCode }
66
68
  } catch (error) {
67
69
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
68
70
  }
69
71
  }
70
72
 
71
- export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
72
- return { containerName: containerNameFromCwd(cwd), follow }
73
+ export function planLogs(cwd: string, { follow, tail }: { follow: boolean; tail?: string }): LogsPlan {
74
+ return { containerName: containerNameFromCwd(cwd), follow, ...(tail !== undefined ? { tail } : {}) }
75
+ }
76
+
77
+ // Validate user-supplied `--tail` value. Mirrors `docker logs --tail`'s
78
+ // accepted shape: either the sentinel `"all"` (case-insensitive) or a
79
+ // non-negative integer.
80
+ export function parseTailValue(raw: string): { ok: true; value: string } | { ok: false; reason: string } {
81
+ const trimmed = raw.trim()
82
+ if (trimmed.length === 0) return { ok: false, reason: '--tail requires a value (a non-negative integer or "all")' }
83
+ if (trimmed.toLowerCase() === 'all') return { ok: true, value: 'all' }
84
+ // Reject leading +, leading zeros (other than "0"), signs, decimals, and
85
+ // scientific notation up front so the user gets a clear error instead of
86
+ // docker's terse "invalid value" later.
87
+ if (!/^(?:0|[1-9]\d*)$/.test(trimmed)) {
88
+ return { ok: false, reason: `--tail expects a non-negative integer or "all", got ${JSON.stringify(raw)}` }
89
+ }
90
+ return { ok: true, value: trimmed }
91
+ }
92
+
93
+ // Exported so `compose/logs.ts` builds the exact same `docker logs` argv shape.
94
+ export function buildDockerLogsCmd(plan: LogsPlan): string[] {
95
+ const cmd = ['docker', 'logs', '--timestamps']
96
+ if (plan.tail !== undefined) cmd.push('--tail', plan.tail)
97
+ if (plan.follow) cmd.push('-f')
98
+ cmd.push(plan.containerName)
99
+ return cmd
73
100
  }
74
101
 
75
102
  // Exported for `compose/logs.ts` so the multi-agent path reuses the same
@@ -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
- const port = await resolveHostPort({ cwd: opts.cwd })
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)
@@ -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
- apply(parsed)
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(input: PortbrokerStartInput) {
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
- void existing.stop().catch(() => {})
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
- void existingTailscale.stopAll().catch(() => {})
51
+ try {
52
+ await existingTailscale.stopAll()
53
+ } catch {}
38
54
  }
39
55
  const tailscale = createTailscaleServeManager({
40
56
  containerName: input.containerName,