typeclaw 0.37.3 → 0.37.4

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.
@@ -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) => {
@@ -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
  })
@@ -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
+ }
package/src/tui/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  formatUserPromptHistory,
24
24
  withTimestamp,
25
25
  } from './format'
26
+ import { armTerminalGuard } from './terminal-guard'
26
27
  import { colors, editorTheme, markdownTheme } from './theme'
27
28
 
28
29
  export type ClientFactory = (url: string) => Promise<Client>
@@ -103,6 +104,10 @@ export function createTui({
103
104
  const status = new Text(colors.dim(`connecting to ${displayUrl}...`), 0, 0)
104
105
  tui.addChild(status)
105
106
  tui.start()
107
+ // Armed once the terminal is in raw mode + Kitty kbd protocol: restores the
108
+ // terminal on an abnormal exit (SSH SIGHUP, kill, crash) that never reaches
109
+ // tui.stop(), so the shell isn't left echoing Kitty key-release events.
110
+ const guard = armTerminalGuard()
106
111
  tui.requestRender()
107
112
 
108
113
  // Pre-handshake failures resolve 'connectFailed' (not throw): the standalone
@@ -113,6 +118,7 @@ export function createTui({
113
118
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
114
119
  tui.requestRender()
115
120
  tui.stop()
121
+ guard.disarm()
116
122
  exit(1)
117
123
  return null
118
124
  })
@@ -124,6 +130,7 @@ export function createTui({
124
130
  tui.requestRender()
125
131
  client.close()
126
132
  tui.stop()
133
+ guard.disarm()
127
134
  exit(1)
128
135
  return null
129
136
  })
@@ -418,21 +425,32 @@ export function createTui({
418
425
  // Settle BEFORE closing the client: client.close() fires onClose, which
419
426
  // settles 'lostConnection'. settle() is idempotent, so the first call wins —
420
427
  // settling the deliberate outcome first keeps the later onClose a no-op.
421
- const teardown = (): void => {
422
- stopAllLoaders()
423
- tui.stop()
424
- client.close()
428
+ // drainInput() runs before stop() so late Kitty key-release bytes (the keys
429
+ // that triggered the exit) are swallowed instead of leaking to the shell
430
+ // over a slow SSH link. The promise is memoized so repeated callers (a
431
+ // fire-and-forget detach/exit plus the end-of-run await) share ONE teardown
432
+ // and the end-of-run await truly blocks until draining finishes — otherwise
433
+ // the next clack picker could start reading stdin mid-drain.
434
+ let teardownPromise: Promise<void> | null = null
435
+ const teardown = (): Promise<void> => {
436
+ teardownPromise ??= (async () => {
437
+ stopAllLoaders()
438
+ await terminal.drainInput().catch(() => {})
439
+ tui.stop()
440
+ client.close()
441
+ guard.disarm()
442
+ })()
443
+ return teardownPromise
425
444
  }
426
445
 
427
446
  const exitWith = (code: number): void => {
428
447
  settle({ reason: 'exit', exitCode: code })
429
- teardown()
430
- exit(code)
448
+ void teardown().finally(() => exit(code))
431
449
  }
432
450
 
433
451
  const detach = (): void => {
434
452
  settle({ reason: 'detach' })
435
- teardown()
453
+ void teardown()
436
454
  }
437
455
 
438
456
  // Ctrl+C exits the client. In raw mode the kernel does NOT generate SIGINT,
@@ -490,7 +508,7 @@ export function createTui({
490
508
  }
491
509
 
492
510
  const result = await outcome
493
- tui.stop()
511
+ await teardown()
494
512
  return result
495
513
  }
496
514
 
@@ -0,0 +1,139 @@
1
+ import { writeSync } from 'node:fs'
2
+
3
+ // Mirrors pi-tui ProcessTerminal.stop()'s resets so an abnormal exit (SSH
4
+ // SIGHUP, a frozen TUI the user kills, a crash, or a Bun process.exit() that
5
+ // drops pi-tui's buffered stop() writes) can't leave the parent shell with the
6
+ // Kitty keyboard protocol still on. While on, the terminal emits CSI-u
7
+ // key-RELEASE events (`;1:3u`) for every keystroke, corrupting the shell. Order
8
+ // matches stop(): pop Kitty kbd protocol, disable xterm modifyOtherKeys, disable
9
+ // bracketed paste, end synchronized output, show cursor.
10
+ export const RESTORE_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?2026l\x1b[?25h'
11
+
12
+ const STDOUT_FD = 1
13
+
14
+ const SCOPED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
15
+ export type ScopedSignal = (typeof SCOPED_SIGNALS)[number]
16
+
17
+ const SIGNAL_EXIT_CODE: Record<ScopedSignal, number> = {
18
+ SIGINT: 130,
19
+ SIGTERM: 143,
20
+ SIGHUP: 129,
21
+ }
22
+
23
+ export type TerminalGuardDeps = {
24
+ isTty: boolean
25
+ writeStdout: (seq: string) => void
26
+ setRawMode: (mode: boolean) => void
27
+ on: (event: string, handler: (...args: unknown[]) => void) => void
28
+ off: (event: string, handler: (...args: unknown[]) => void) => void
29
+ killSelf: (signal: ScopedSignal) => void
30
+ exit: (code: number) => void
31
+ }
32
+
33
+ export type TerminalGuard = {
34
+ arm: () => void
35
+ disarm: () => void
36
+ }
37
+
38
+ export function createTerminalGuard(deps: TerminalGuardDeps): TerminalGuard {
39
+ let armed = 0
40
+ let exitInstalled = false
41
+ let signalHandlers: Map<ScopedSignal, (...args: unknown[]) => void> | null = null
42
+
43
+ const restore = (): void => {
44
+ if (!deps.isTty) return
45
+ deps.writeStdout(RESTORE_SEQUENCE)
46
+ deps.setRawMode(false)
47
+ }
48
+
49
+ const onExit = (): void => {
50
+ restore()
51
+ }
52
+
53
+ const installSignalHandlers = (): void => {
54
+ if (signalHandlers !== null) return
55
+ const handlers = new Map<ScopedSignal, (...args: unknown[]) => void>()
56
+ for (const sig of SCOPED_SIGNALS) {
57
+ const handler = (): void => {
58
+ // Restore the terminal, then let the signal terminate with default
59
+ // semantics. Remove our handlers first so the re-raised signal isn't
60
+ // swallowed by this same handler; fall back to an explicit exit if the
61
+ // re-raise somehow doesn't terminate.
62
+ removeSignalHandlers()
63
+ restore()
64
+ deps.killSelf(sig)
65
+ deps.exit(SIGNAL_EXIT_CODE[sig])
66
+ }
67
+ deps.on(sig, handler)
68
+ handlers.set(sig, handler)
69
+ }
70
+ signalHandlers = handlers
71
+ }
72
+
73
+ const removeSignalHandlers = (): void => {
74
+ if (signalHandlers === null) return
75
+ for (const [sig, handler] of signalHandlers) deps.off(sig, handler)
76
+ signalHandlers = null
77
+ }
78
+
79
+ return {
80
+ arm: () => {
81
+ // A non-TTY process (piped/redirected, or the test runner) has no terminal
82
+ // state to protect, so the guard stays fully inert — it never touches the
83
+ // real process signal/exit handlers.
84
+ if (!deps.isTty) return
85
+ armed += 1
86
+ if (!exitInstalled) {
87
+ deps.on('exit', onExit)
88
+ exitInstalled = true
89
+ }
90
+ installSignalHandlers()
91
+ },
92
+ disarm: () => {
93
+ if (!deps.isTty) return
94
+ if (armed > 0) armed -= 1
95
+ if (armed === 0) removeSignalHandlers()
96
+ },
97
+ }
98
+ }
99
+
100
+ let defaultGuard: TerminalGuard | null = null
101
+
102
+ function getDefaultGuard(): TerminalGuard {
103
+ defaultGuard ??= createTerminalGuard({
104
+ isTty: Boolean(process.stdout.isTTY),
105
+ writeStdout: (seq) => {
106
+ try {
107
+ writeSync(STDOUT_FD, seq)
108
+ } catch {
109
+ /* fd closed mid-teardown */
110
+ }
111
+ },
112
+ setRawMode: (mode) => {
113
+ try {
114
+ process.stdin.setRawMode?.(mode)
115
+ } catch {
116
+ /* terminal already torn down */
117
+ }
118
+ },
119
+ on: (event, handler) => {
120
+ process.on(event as NodeJS.Signals, handler)
121
+ },
122
+ off: (event, handler) => {
123
+ process.off(event as NodeJS.Signals, handler)
124
+ },
125
+ killSelf: (signal) => {
126
+ process.kill(process.pid, signal)
127
+ },
128
+ exit: (code) => {
129
+ process.exit(code)
130
+ },
131
+ })
132
+ return defaultGuard
133
+ }
134
+
135
+ export function armTerminalGuard(): TerminalGuard {
136
+ const guard = getDefaultGuard()
137
+ guard.arm()
138
+ return guard
139
+ }
@@ -1669,7 +1669,7 @@
1669
1669
  "default": {
1670
1670
  "enabled": true,
1671
1671
  "imageMaxBytes": 262144,
1672
- "textMaxBytes": 65536,
1672
+ "textMaxBytes": 32768,
1673
1673
  "exemptTools": []
1674
1674
  },
1675
1675
  "type": "object",
@@ -1685,7 +1685,7 @@
1685
1685
  "maximum": 9007199254740991
1686
1686
  },
1687
1687
  "textMaxBytes": {
1688
- "default": 65536,
1688
+ "default": 32768,
1689
1689
  "type": "integer",
1690
1690
  "minimum": 1024,
1691
1691
  "maximum": 9007199254740991