typeclaw 0.37.3 → 0.37.5

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 (58) hide show
  1. package/README.md +69 -46
  2. package/package.json +1 -1
  3. package/src/agent/compaction.ts +24 -15
  4. package/src/agent/doctor.ts +6 -1
  5. package/src/agent/session-origin.ts +101 -173
  6. package/src/agent/subagents.ts +146 -14
  7. package/src/agent/system-prompt.ts +46 -48
  8. package/src/agent/todo/scope.ts +4 -2
  9. package/src/agent/tools/channel-reply.ts +7 -9
  10. package/src/bundled-plugins/memory/index.ts +33 -33
  11. package/src/bundled-plugins/memory/load-memory.ts +92 -35
  12. package/src/bundled-plugins/memory/slug.ts +19 -0
  13. package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
  14. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  15. package/src/bundled-plugins/tool-result-cap/README.md +7 -7
  16. package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
  17. package/src/channels/adapters/discord-bot.ts +11 -4
  18. package/src/channels/adapters/github/inbound.ts +68 -43
  19. package/src/channels/adapters/github/index.ts +57 -9
  20. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  21. package/src/channels/adapters/kakaotalk.ts +5 -1
  22. package/src/channels/adapters/mention-hints.ts +75 -0
  23. package/src/channels/adapters/slack-bot.ts +8 -2
  24. package/src/channels/continuation-willingness.ts +216 -68
  25. package/src/channels/router.ts +149 -15
  26. package/src/cli/dreams.ts +2 -2
  27. package/src/cli/init.ts +41 -7
  28. package/src/cli/inspect.ts +2 -2
  29. package/src/cli/logs.ts +2 -2
  30. package/src/cli/qr.ts +4 -3
  31. package/src/cli/require-agent-dir.ts +31 -0
  32. package/src/cli/shell.ts +2 -2
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +8 -4
  36. package/src/container/shared.ts +18 -0
  37. package/src/container/start.ts +1 -1
  38. package/src/doctor/checks.ts +145 -2
  39. package/src/hostd/client.ts +48 -52
  40. package/src/hostd/daemon.ts +82 -39
  41. package/src/hostd/paths.ts +22 -2
  42. package/src/hostd/spawn.ts +7 -0
  43. package/src/hostd/tailscale.ts +12 -1
  44. package/src/init/index.ts +35 -8
  45. package/src/init/kakaotalk-auth.ts +2 -2
  46. package/src/init/packagejson.ts +2 -2
  47. package/src/init/run-bun-install.ts +71 -37
  48. package/src/inspect/transcript-view.ts +15 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/portbroker/hostd-client.ts +32 -6
  51. package/src/sandbox/session-tmp.ts +6 -1
  52. package/src/secrets/export-claude-credentials-file.ts +2 -2
  53. package/src/shared/index.ts +4 -0
  54. package/src/shared/platform.ts +11 -0
  55. package/src/shared/wsl.ts +139 -0
  56. package/src/tui/index.ts +26 -8
  57. package/src/tui/terminal-guard.ts +139 -0
  58. package/typeclaw.schema.json +2 -2
package/src/init/index.ts CHANGED
@@ -20,7 +20,14 @@ import {
20
20
  type KnownProviderId,
21
21
  type ModelRef,
22
22
  } from '@/config/providers'
23
- import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
23
+ import {
24
+ checkDockerAvailable,
25
+ type DockerAvailability,
26
+ type DockerExec,
27
+ start,
28
+ type StartResult,
29
+ stop,
30
+ } from '@/container'
24
31
  import { commitSystemFile } from '@/git/system-commit'
25
32
  import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
26
33
  import { hostLocaleIsCjk } from '@/shared/host-locale'
@@ -376,10 +383,12 @@ export async function runInit({
376
383
  emit({ step: 'install', phase: 'start' })
377
384
  const install = await installRunner(cwd)
378
385
  emit({ step: 'install', phase: 'done', result: install })
386
+ if (!install.ok) throw new Error(`Dependency install failed: ${install.reason}`)
379
387
 
380
388
  emit({ step: 'dockerfile', phase: 'start' })
381
389
  const docker = await writeDockerAssets(cwd)
382
390
  emit({ step: 'dockerfile', phase: 'done', result: docker })
391
+ if (!docker.ok) throw new Error(`Dockerfile generation failed: ${docker.reason}`)
383
392
 
384
393
  emit({ step: 'git', phase: 'start' })
385
394
  const git = await initGitRepo(cwd)
@@ -417,6 +426,7 @@ export async function defaultRunHatching({
417
426
  tui: tuiFactory = createTui,
418
427
  waitForAgent: waitForAgentFn = waitForAgent,
419
428
  runClaim = defaultRunClaim,
429
+ stopContainer = stop,
420
430
  }: {
421
431
  cwd: string
422
432
  port: number
@@ -426,14 +436,17 @@ export async function defaultRunHatching({
426
436
  tui?: typeof createTui
427
437
  waitForAgent?: typeof waitForAgent
428
438
  runClaim?: ClaimRunner
439
+ stopContainer?: typeof stop
429
440
  }): Promise<HatchingResult> {
441
+ let launch: Extract<StartResult, { ok: true }> | null = null
430
442
  try {
431
- const launch = await startContainer({
443
+ const startResult = await startContainer({
432
444
  cwd,
433
445
  preferredHostPort: port,
434
446
  ...(cliEntry !== undefined ? { cliEntry } : {}),
435
447
  })
436
- if (!launch.ok) return { ok: false, reason: launch.reason }
448
+ if (!startResult.ok) return { ok: false, reason: startResult.reason }
449
+ launch = startResult
437
450
 
438
451
  // start() may have allocated a different host port (the preferred one was
439
452
  // bound). Use the actually-published port for the TUI handshake instead of
@@ -455,6 +468,9 @@ export async function defaultRunHatching({
455
468
  await tui.run()
456
469
  return { ok: true }
457
470
  } catch (error) {
471
+ if (launch !== null && !launch.alreadyRunning) {
472
+ await stopContainer({ cwd }).catch(() => {})
473
+ }
458
474
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
459
475
  }
460
476
  }
@@ -709,7 +725,7 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
709
725
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
710
726
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
711
727
 
712
- if (existsSync(join(cwd, '.git'))) return { ok: true, skipped: true }
728
+ const hasGit = existsSync(join(cwd, '.git'))
713
729
 
714
730
  // Author the initial commit as TypeClaw itself. The agent is still unnamed
715
731
  // (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
@@ -724,10 +740,21 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
724
740
  }
725
741
 
726
742
  try {
727
- const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
728
- if ((await init.exited) !== 0) {
729
- const stderr = await new Response(init.stderr).text()
730
- return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
743
+ if (hasGit) {
744
+ const head = bun.spawn({
745
+ cmd: ['git', 'rev-parse', '--verify', 'HEAD'],
746
+ cwd,
747
+ env,
748
+ stdout: 'pipe',
749
+ stderr: 'pipe',
750
+ })
751
+ if ((await head.exited) === 0) return { ok: true, skipped: true }
752
+ } else {
753
+ const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
754
+ if ((await init.exited) !== 0) {
755
+ const stderr = await new Response(init.stderr).text()
756
+ return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
757
+ }
731
758
  }
732
759
 
733
760
  const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
@@ -1,4 +1,4 @@
1
- import { join, resolve } from 'node:path'
1
+ import { join, posix, resolve } from 'node:path'
2
2
 
3
3
  import { loginFlow as upstreamLoginFlow } from 'agent-messenger/kakaotalk'
4
4
 
@@ -34,7 +34,7 @@ export type LoginFlowOptions = Parameters<LoginFlowFn>[0]
34
34
  export type LoginFlowResult = Awaited<ReturnType<LoginFlowFn>>
35
35
 
36
36
  export function kakaotalkConfigDir(agentDir: string): string {
37
- return join(agentDir, 'workspace', '.agent-messenger')
37
+ return posix.join(agentDir, 'workspace', '.agent-messenger')
38
38
  }
39
39
 
40
40
  export function kakaotalkSecretsPath(agentDir: string): string {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
3
+ import { join, posix } from 'node:path'
4
4
 
5
5
  import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
6
6
 
@@ -33,7 +33,7 @@ export async function refreshPackageJson(cwd: string): Promise<PackageJsonRefres
33
33
  if (updated) changed.push(PACKAGE_FILE)
34
34
  }
35
35
 
36
- const gitkeepRel = join(PACKAGES_DIR, GITKEEP_FILE)
36
+ const gitkeepRel = posix.join(PACKAGES_DIR, GITKEEP_FILE)
37
37
  const gitkeepPath = join(cwd, gitkeepRel)
38
38
  if (!existsSync(gitkeepPath)) {
39
39
  await mkdir(join(cwd, PACKAGES_DIR), { recursive: true })
@@ -1,5 +1,7 @@
1
1
  export type InstallResult = { ok: true } | { ok: false; reason: string }
2
2
 
3
+ const INSTALL_TIMEOUT_MS = 300_000
4
+
3
5
  export type InstallRunnerOptions = {
4
6
  // Append `--force` to the bun install argv to bypass the cache for
5
7
  // `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
@@ -7,6 +9,8 @@ export type InstallRunnerOptions = {
7
9
  // locally-linked typeclaw never propagate into <agent>/node_modules until
8
10
  // either the typeclaw version is bumped or the install is forced.
9
11
  force?: boolean
12
+ timeoutMs?: number
13
+ spawn?: typeof Bun.spawn
10
14
  }
11
15
 
12
16
  // Signature for the function `runInit` uses to materialize the agent folder's
@@ -19,31 +23,29 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
19
23
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
20
24
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
21
25
  try {
22
- const proc = bun.spawn({
23
- // `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
24
- // (the default since 1.3.0). When any single package fetch fails — 401,
25
- // SHA-512 mismatch, transient registry 5xx, the kind of flake that's
26
- // routine on GitHub Actions shared-IP runners the isolated linker
27
- // hangs the process indefinitely instead of erroring out
28
- // (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
29
- // ~500 transitive packages with no lockfile, so the odds of triggering
30
- // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
31
- // before 1.3 slightly slower for huge monorepos, indistinguishable
32
- // for an agent folder, and not affected by the bug.
33
- //
34
- // `--force` is conditional: it bypasses the package cache so file:/link:
35
- // deps re-copy their current on-disk source into node_modules. Bun's
36
- // file-dep cache is keyed on name+version, so without --force, edits to
37
- // a `file:..` typeclaw never reach the container after the first install.
26
+ // `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
27
+ // (the default since 1.3.0). When any single package fetch fails — 401,
28
+ // SHA-512 mismatch, transient registry 5xx, the kind of flake that's
29
+ // routine on GitHub Actions shared-IP runners the isolated linker
30
+ // hangs the process indefinitely instead of erroring out
31
+ // (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
32
+ // ~500 transitive packages with no lockfile, so the odds of triggering
33
+ // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
34
+ // before 1.3 slightly slower for huge monorepos, indistinguishable
35
+ // for an agent folder, and not affected by the bug.
36
+ //
37
+ // `--force` is conditional: it bypasses the package cache so file:/link:
38
+ // deps re-copy their current on-disk source into node_modules. Bun's
39
+ // file-dep cache is keyed on name+version, so without --force, edits to
40
+ // a `file:..` typeclaw never reach the container after the first install.
41
+ return await runTimedBunProcess({
38
42
  cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
39
43
  cwd,
40
- stdout: 'pipe',
41
- stderr: 'pipe',
44
+ timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
45
+ spawn: opts?.spawn ?? bun.spawn,
46
+ timeoutReason: (seconds) => `bun install timed out after ${seconds}s`,
47
+ failureReason: (code, stderr) => `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
42
48
  })
43
- const code = await proc.exited
44
- if (code === 0) return { ok: true }
45
- const stderr = await new Response(proc.stderr).text()
46
- return { ok: false, reason: `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
47
49
  } catch (error) {
48
50
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
49
51
  }
@@ -55,30 +57,62 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
55
57
  // install` would no-op when the existing lockfile entry already satisfies
56
58
  // an in-range spec — which is the exact regression auto-upgrade exists to
57
59
  // prevent).
58
- export type UpdateRunner = (cwd: string, pkg: string) => Promise<InstallResult>
60
+ export type UpdateRunnerOptions = Pick<InstallRunnerOptions, 'timeoutMs' | 'spawn'>
59
61
 
60
- export async function runBunUpdate(cwd: string, pkg: string): Promise<InstallResult> {
62
+ export type UpdateRunner = (cwd: string, pkg: string, opts?: UpdateRunnerOptions) => Promise<InstallResult>
63
+
64
+ export async function runBunUpdate(cwd: string, pkg: string, opts?: UpdateRunnerOptions): Promise<InstallResult> {
61
65
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
62
66
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
63
67
  try {
64
- const proc = bun.spawn({
65
- // `bun update <pkg> --latest` re-resolves <pkg> against the registry,
66
- // capped by the spec in package.json. For a caret/tilde range this
67
- // pulls the highest in-range version (the case `bun install` won't
68
- // upgrade because the lockfile already satisfies the spec). For an
69
- // exact pin it's effectively a force re-fetch of that exact version.
70
- // `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
71
- // runBunInstall above.
68
+ // `bun update <pkg> --latest` re-resolves <pkg> against the registry,
69
+ // capped by the spec in package.json. For a caret/tilde range this
70
+ // pulls the highest in-range version (the case `bun install` won't
71
+ // upgrade because the lockfile already satisfies the spec). For an
72
+ // exact pin it's effectively a force re-fetch of that exact version.
73
+ // `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
74
+ // runBunInstall above.
75
+ return await runTimedBunProcess({
72
76
  cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
73
77
  cwd,
74
- stdout: 'pipe',
75
- stderr: 'pipe',
78
+ timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
79
+ spawn: opts?.spawn ?? bun.spawn,
80
+ timeoutReason: (seconds) => `bun update ${pkg} timed out after ${seconds}s`,
81
+ failureReason: (code, stderr) => `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
76
82
  })
83
+ } catch (error) {
84
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
85
+ }
86
+ }
87
+
88
+ async function runTimedBunProcess({
89
+ cmd,
90
+ cwd,
91
+ timeoutMs,
92
+ spawn,
93
+ timeoutReason,
94
+ failureReason,
95
+ }: {
96
+ cmd: string[]
97
+ cwd: string
98
+ timeoutMs: number
99
+ spawn: typeof Bun.spawn
100
+ timeoutReason: (seconds: number) => string
101
+ failureReason: (code: number, stderr: string) => string
102
+ }): Promise<InstallResult> {
103
+ const proc = spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
104
+ let timedOut = false
105
+ const timeout = setTimeout(() => {
106
+ timedOut = true
107
+ proc.kill('SIGKILL')
108
+ }, timeoutMs)
109
+ try {
77
110
  const code = await proc.exited
111
+ if (timedOut) return { ok: false, reason: timeoutReason(timeoutMs / 1000) }
78
112
  if (code === 0) return { ok: true }
79
113
  const stderr = await new Response(proc.stderr).text()
80
- return { ok: false, reason: `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
81
- } catch (error) {
82
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
114
+ return { ok: false, reason: failureReason(code, stderr) }
115
+ } finally {
116
+ clearTimeout(timeout)
83
117
  }
84
118
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from '@mariozechner/pi-tui'
11
11
 
12
12
  import { formatToolEnd, formatToolStart, formatUserPromptHistory } from '@/tui/format'
13
+ import { armTerminalGuard } from '@/tui/terminal-guard'
13
14
  import { colors, markdownTheme } from '@/tui/theme'
14
15
 
15
16
  import { streamSessionEvents, type LiveSourceFactory, type StreamPhase } from './index'
@@ -46,6 +47,9 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
46
47
  tui.addChild(new Text(header(opts.summary), 0, 0))
47
48
  tui.addChild(status)
48
49
  tui.start()
50
+ // Restores the terminal on an abnormal exit (SSH SIGHUP, kill, crash) that
51
+ // never reaches tui.stop(), so the shell isn't left in Kitty kbd mode.
52
+ const guard = armTerminalGuard()
49
53
  tui.requestRender()
50
54
 
51
55
  // The status line is pinned last (no editor to pin, unlike createTui). Each
@@ -71,12 +75,21 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
71
75
  const outcome = new Promise<TranscriptViewOutcome>((resolve) => {
72
76
  settle = resolve
73
77
  })
78
+ // drainInput() before stop() swallows late Kitty key-release bytes so they
79
+ // don't leak to the shell over a slow SSH link; disarm() drops the abnormal-
80
+ // exit guard now that a clean teardown ran.
74
81
  const finish = (reason: TranscriptViewOutcome['reason']): void => {
75
82
  if (settle === null) return
76
83
  const fn = settle
77
84
  settle = null
78
- tui.stop()
79
- fn({ reason })
85
+ void terminal
86
+ .drainInput()
87
+ .catch(() => {})
88
+ .then(() => {
89
+ tui.stop()
90
+ guard.disarm()
91
+ fn({ reason })
92
+ })
80
93
  }
81
94
 
82
95
  tui.addInputListener((data) => {
@@ -61,8 +61,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
61
61
  if (!existsSync(resolved)) {
62
62
  throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
63
63
  }
64
- const url = pathToFileURL(resolved).href
65
- const mod = (await import(url)) as { default?: unknown }
64
+ const mod = (await import(toModuleSpecifier(resolved))) as { default?: unknown }
66
65
  const defined = expectDefined(mod, entry)
67
66
  const name = basename(resolved).replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
68
67
  return { name, version: undefined, source: entry, defined }
@@ -108,10 +107,10 @@ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin>
108
107
  // located on disk; the else branch lets Bun's resolver read `exports` maps.
109
108
  let importTarget: string
110
109
  if (entryPath !== null) {
111
- importTarget = pathToFileURL(entryPath).href
110
+ importTarget = toModuleSpecifier(entryPath)
112
111
  } else {
113
112
  try {
114
- importTarget = Bun.resolveSync(packageName, agentDir)
113
+ importTarget = toModuleSpecifier(Bun.resolveSync(packageName, agentDir))
115
114
  } catch (err) {
116
115
  throw new PluginNotFoundError(entry, `cannot resolve plugin "${entry}": ${describeError(err)}`, { cause: err })
117
116
  }
@@ -151,6 +150,10 @@ function describeError(err: unknown): string {
151
150
  return err instanceof Error ? err.message : String(err)
152
151
  }
153
152
 
153
+ function toModuleSpecifier(target: string): string {
154
+ return isAbsolute(target) ? pathToFileURL(target).href : target
155
+ }
156
+
154
157
  function findPackageJson(entry: string, agentDir: string): string | null {
155
158
  const PACKAGE_JSON = 'package.json'
156
159
  let cur = agentDir
@@ -30,7 +30,8 @@ export type BrokerOptions = {
30
30
  // and reports the reason so hostd can GC the stale registration.
31
31
  onFatalAuthFailure?: (reason: string) => void
32
32
  onLog?: (msg: string) => void
33
- connectWs?: (url: string) => Promise<WsClient>
33
+ connectWs?: (url: string, timeoutMs: number) => Promise<WsClient>
34
+ connectTimeoutMs?: number
34
35
  listenHost?: ListenHostFn
35
36
  reconnectDelaysMs?: number[]
36
37
  hostBindAddr?: string
@@ -71,6 +72,7 @@ export type Broker = {
71
72
 
72
73
  const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 4_000, 10_000]
73
74
  const DEFAULT_HOST_BIND = '127.0.0.1'
75
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
74
76
 
75
77
  // broker-hello-nack reasons emitted by container-server.ts when authentication
76
78
  // fails. These are immutable for a broker instance's lifetime, so reconnecting
@@ -82,6 +84,7 @@ export function createBroker(opts: BrokerOptions): Broker {
82
84
  const reconnectDelays = opts.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS
83
85
  const hostBind = opts.hostBindAddr ?? DEFAULT_HOST_BIND
84
86
  const connectWs = opts.connectWs ?? defaultConnectWs
87
+ const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
85
88
  const listenHost = opts.listenHost ?? defaultListenHost
86
89
 
87
90
  type ForwarderState = {
@@ -437,7 +440,7 @@ export function createBroker(opts: BrokerOptions): Broker {
437
440
  const url = `ws://127.0.0.1:${hostPort}/portbroker`
438
441
  let client: WsClient
439
442
  try {
440
- client = await connectWs(url)
443
+ client = await connectWs(url, connectTimeoutMs)
441
444
  } catch (err) {
442
445
  log(`ws connect ${url}: ${err instanceof Error ? err.message : String(err)}`)
443
446
  scheduleReconnect()
@@ -504,12 +507,31 @@ export function createBroker(opts: BrokerOptions): Broker {
504
507
  }
505
508
  }
506
509
 
507
- async function defaultConnectWs(url: string): Promise<WsClient> {
510
+ async function defaultConnectWs(url: string, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS): Promise<WsClient> {
508
511
  return new Promise((resolve, reject) => {
509
512
  const ws = new WebSocket(url)
510
- let resolved = false
513
+ let settled = false
511
514
  const messageCbs: Array<(msg: ContainerToHostd) => void> = []
512
515
  const closeCbs: Array<() => void> = []
516
+ let connectTimeout: ReturnType<typeof setTimeout> | null = null
517
+ const clearConnectTimeout = (): void => {
518
+ if (connectTimeout !== null) {
519
+ clearTimeout(connectTimeout)
520
+ connectTimeout = null
521
+ }
522
+ }
523
+ const failConnect = (err: Error): void => {
524
+ if (settled) return
525
+ settled = true
526
+ clearConnectTimeout()
527
+ reject(err)
528
+ }
529
+ connectTimeout = setTimeout(() => {
530
+ failConnect(new Error(`ws connect timeout after ${timeoutMs}ms to ${url}`))
531
+ try {
532
+ ws.close()
533
+ } catch {}
534
+ }, timeoutMs)
513
535
  const client: WsClient = {
514
536
  send: (msg) => {
515
537
  try {
@@ -529,7 +551,9 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
529
551
  },
530
552
  }
531
553
  ws.onopen = (): void => {
532
- resolved = true
554
+ if (settled) return
555
+ settled = true
556
+ clearConnectTimeout()
533
557
  resolve(client)
534
558
  }
535
559
  ws.onmessage = (ev: MessageEvent): void => {
@@ -542,9 +566,11 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
542
566
  for (const cb of messageCbs) cb(msg)
543
567
  }
544
568
  ws.onerror = (): void => {
545
- if (!resolved) reject(new Error(`ws error connecting to ${url}`))
569
+ failConnect(new Error(`ws error connecting to ${url}`))
546
570
  }
547
571
  ws.onclose = (): void => {
572
+ if (!settled) failConnect(new Error(`ws closed connecting to ${url}`))
573
+ else clearConnectTimeout()
548
574
  for (const cb of closeCbs) cb()
549
575
  }
550
576
  })
@@ -1,5 +1,10 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
- import { isAbsolute, join, relative, resolve } from 'node:path'
2
+ import { posix } from 'node:path'
3
+
4
+ // Container-only code over the POSIX `/tmp`; pinned to `path.posix` so the test
5
+ // suite produces the same backing paths on a win32 runner (default `node:path`
6
+ // would yield `\tmp\…` and diverge from the Linux runtime).
7
+ const { isAbsolute, join, relative, resolve } = posix
3
8
 
4
9
  // Per-session scratch lives on the REAL container /tmp, namespaced by session id.
5
10
  // It sits OUTSIDE the agent folder on purpose: the agent folder's `sessions/` is
@@ -9,7 +9,7 @@ import {
9
9
  writeFileSync,
10
10
  } from 'node:fs'
11
11
  import { homedir } from 'node:os'
12
- import { dirname, isAbsolute, join, resolve } from 'node:path'
12
+ import { dirname, isAbsolute, join, posix, resolve } from 'node:path'
13
13
 
14
14
  import { decodeClaudeAccessTokenExpiryMs, emitClaudeCredentialsJson } from './claude-credentials-json'
15
15
  import type { ProviderCredential, Providers } from './schema'
@@ -19,7 +19,7 @@ const FILE_MODE = 0o600
19
19
  const DIR_MODE = 0o700
20
20
  export const CLAUDE_CREDENTIALS_FILE_NAME = '.credentials.json'
21
21
  export const CLAUDE_DEFAULT_CONFIG_DIR_NAME = '.claude'
22
- export const CLAUDE_CREDENTIALS_RELATIVE_PATH = join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
22
+ export const CLAUDE_CREDENTIALS_RELATIVE_PATH = posix.join(CLAUDE_DEFAULT_CONFIG_DIR_NAME, CLAUDE_CREDENTIALS_FILE_NAME)
23
23
 
24
24
  export type ExportClaudeCredentialsFileResult =
25
25
  | { action: 'skipped'; reason: SkipReason }
@@ -27,3 +27,7 @@ export {
27
27
  } from './protocol'
28
28
 
29
29
  export { formatLocalDate, formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from './local-time'
30
+
31
+ export { detectWsl, isWindowsDriveMount, type WslInfo, type WslVersion } from './wsl'
32
+
33
+ export { isMacOS, isWindows } from './platform'
@@ -0,0 +1,11 @@
1
+ // Centralized seam so host-stage macOS/Linux/Windows branches stay greppable as
2
+ // native-Windows support lands; the default arg lets tests pass a platform
3
+ // without mutating the read-only `process.platform`.
4
+
5
+ export function isWindows(platform: NodeJS.Platform = process.platform): boolean {
6
+ return platform === 'win32'
7
+ }
8
+
9
+ export function isMacOS(platform: NodeJS.Platform = process.platform): boolean {
10
+ return platform === 'darwin'
11
+ }
@@ -0,0 +1,139 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { release } from 'node:os'
3
+
4
+ // Detection of WSL (Windows Subsystem for Linux) and Windows-drive mounts.
5
+ //
6
+ // Why this matters for typeclaw: inside WSL the kernel is real Linux, so the
7
+ // host stage runs unchanged — EXCEPT when files live on a Windows-drive mount
8
+ // (DrvFs on WSL1, 9p on WSL2, e.g. `/mnt/c/...`). On those mounts POSIX
9
+ // permissions are NOT enforced: `chmod 0o600` silently succeeds but leaves the
10
+ // file world-readable. typeclaw stores encryption keys and API tokens with
11
+ // mode 0600 (src/secrets/*, src/hostd/paths.ts), so a secrets file on `/mnt/c`
12
+ // loses its confidentiality guarantee. The doctor surfaces this as a warning.
13
+
14
+ export type WslVersion = 1 | 2
15
+
16
+ export type WslInfo = {
17
+ isWsl: boolean
18
+ // null when not WSL, or WSL but the version can't be determined (e.g. a
19
+ // custom kernel detected only via the binfmt/`/run/WSL` artifacts).
20
+ version: WslVersion | null
21
+ }
22
+
23
+ // Injectable so the pure detection logic is unit-testable without a real
24
+ // `/proc` or `/etc/wsl.conf`; production uses the real-filesystem defaults.
25
+ export type WslProbes = {
26
+ platform: NodeJS.Platform
27
+ kernelRelease: () => string
28
+ readFile: (path: string) => string | null
29
+ fileExists: (path: string) => boolean
30
+ env: NodeJS.ProcessEnv
31
+ }
32
+
33
+ function safeReadFile(path: string): string | null {
34
+ try {
35
+ return readFileSync(path, 'utf8')
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ const defaultProbes: WslProbes = {
42
+ platform: process.platform,
43
+ kernelRelease: release,
44
+ readFile: safeReadFile,
45
+ fileExists: existsSync,
46
+ env: process.env,
47
+ }
48
+
49
+ // Mirrors the is-wsl@3 cascade: os.release() → /proc/version → WSL runtime
50
+ // artifacts. Matching is case-insensitive because WSL1 stamps `Microsoft`
51
+ // (capital M) and WSL2 stamps `microsoft` (lowercase) into the kernel strings.
52
+ // A user-compiled WSL2 kernel can omit the string entirely, which is why the
53
+ // binfmt/`/run/WSL` artifacts are the final fallback.
54
+ export function detectWslWith(probes: WslProbes): WslInfo {
55
+ if (probes.platform !== 'linux') return { isWsl: false, version: null }
56
+
57
+ const kernelRelease = probes.kernelRelease().toLowerCase()
58
+ const procVersion = (probes.readFile('/proc/version') ?? '').toLowerCase()
59
+
60
+ const kernelSaysMicrosoft = kernelRelease.includes('microsoft')
61
+ const procSaysMicrosoft = procVersion.includes('microsoft')
62
+ const hasArtifacts = probes.fileExists('/proc/sys/fs/binfmt_misc/WSLInterop') || probes.fileExists('/run/WSL')
63
+
64
+ if (!kernelSaysMicrosoft && !procSaysMicrosoft && !hasArtifacts) {
65
+ return { isWsl: false, version: null }
66
+ }
67
+
68
+ return { isWsl: true, version: resolveWslVersion({ kernelRelease, procVersion, env: probes.env }) }
69
+ }
70
+
71
+ // WSL_INTEROP is present only under WSL2 (Microsoft's own recommendation in
72
+ // microsoft/WSL#4555). It can be stripped by `sudo`, so the kernel-string
73
+ // heuristics back it up: WSL2 kernels are `*-microsoft-standard*` / contain
74
+ // `wsl2`; WSL1 kernels are the older `*-Microsoft` form without "standard".
75
+ function resolveWslVersion(args: {
76
+ kernelRelease: string
77
+ procVersion: string
78
+ env: NodeJS.ProcessEnv
79
+ }): WslVersion | null {
80
+ if (args.env.WSL_INTEROP !== undefined && args.env.WSL_INTEROP !== '') return 2
81
+
82
+ const haystack = `${args.kernelRelease} ${args.procVersion}`
83
+ if (haystack.includes('wsl2') || haystack.includes('microsoft-standard')) return 2
84
+
85
+ // Older WSL1 kernels say `microsoft` but never `standard`/`wsl2`. If we only
86
+ // know it's WSL from the kernel/proc string (not the artifacts), call it v1.
87
+ if (args.kernelRelease.includes('microsoft') || args.procVersion.includes('microsoft')) return 1
88
+
89
+ return null
90
+ }
91
+
92
+ export function detectWsl(): WslInfo {
93
+ return detectWslWith(defaultProbes)
94
+ }
95
+
96
+ // The WSL automount root defaults to `/mnt/` but can be remapped via
97
+ // `/etc/wsl.conf` ([automount] root = ...). Returns the configured root with a
98
+ // guaranteed trailing slash, or `/mnt/` when unset/unreadable.
99
+ export function readAutomountRootWith(probes: Pick<WslProbes, 'readFile'>): string {
100
+ const conf = probes.readFile('/etc/wsl.conf')
101
+ const parsed = conf === null ? null : parseAutomountRoot(conf)
102
+ const root = parsed ?? '/mnt/'
103
+ return root.endsWith('/') ? root : `${root}/`
104
+ }
105
+
106
+ function parseAutomountRoot(content: string): string | null {
107
+ let inAutomountSection = false
108
+ for (const rawLine of content.split('\n')) {
109
+ const line = rawLine.trim()
110
+ if (line.length === 0 || line.startsWith('#') || line.startsWith(';')) continue
111
+ const section = /^\[(?<name>[^\]]+)\]$/.exec(line)
112
+ if (section) {
113
+ inAutomountSection = section.groups?.name?.trim().toLowerCase() === 'automount'
114
+ continue
115
+ }
116
+ if (!inAutomountSection) continue
117
+ const match = /^root\s*=\s*(?<value>"[^"]*"|'[^']*'|[^#;]*)/.exec(line)
118
+ const raw = match?.groups?.value
119
+ if (raw === undefined) continue
120
+ const value = raw.trim().replace(/^["']|["']$/g, '')
121
+ if (value.length > 0) return value
122
+ }
123
+ return null
124
+ }
125
+
126
+ // True when `path` lives under the WSL Windows-drive automount (e.g.
127
+ // `/mnt/c/...`), where Unix permissions are not enforced. The drive component
128
+ // must be a single ASCII letter so `/mnt/wsl/...` (WSLg, not a Windows drive)
129
+ // is correctly excluded.
130
+ export function isWindowsDriveMountWith(path: string, probes: Pick<WslProbes, 'readFile'>): boolean {
131
+ const root = readAutomountRootWith(probes)
132
+ if (!path.startsWith(root)) return false
133
+ const rest = path.slice(root.length)
134
+ return /^[A-Za-z](?:\/|$)/.test(rest)
135
+ }
136
+
137
+ export function isWindowsDriveMount(path: string): boolean {
138
+ return isWindowsDriveMountWith(path, defaultProbes)
139
+ }