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.
- package/README.md +69 -46
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -15
- package/src/agent/session-origin.ts +101 -173
- package/src/agent/system-prompt.ts +46 -48
- package/src/bundled-plugins/memory/index.ts +24 -27
- package/src/bundled-plugins/memory/load-memory.ts +78 -35
- package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
- package/src/bundled-plugins/tool-result-cap/README.md +7 -7
- package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
- package/src/channels/adapters/discord-bot.ts +11 -4
- package/src/channels/adapters/mention-hints.ts +58 -0
- package/src/channels/adapters/slack-bot.ts +8 -2
- package/src/channels/continuation-willingness.ts +216 -68
- package/src/channels/router.ts +29 -3
- package/src/cli/init.ts +41 -7
- package/src/cli/qr.ts +4 -3
- package/src/cli/ui.ts +8 -4
- package/src/doctor/checks.ts +145 -2
- package/src/hostd/tailscale.ts +12 -1
- package/src/init/index.ts +35 -8
- package/src/init/run-bun-install.ts +71 -37
- package/src/inspect/transcript-view.ts +15 -2
- package/src/portbroker/hostd-client.ts +32 -6
- package/src/shared/index.ts +4 -0
- package/src/shared/platform.ts +11 -0
- package/src/shared/wsl.ts +139 -0
- package/src/tui/index.ts +26 -8
- package/src/tui/terminal-guard.ts +139 -0
- package/typeclaw.schema.json +2 -2
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/typeclaw.schema.json
CHANGED
|
@@ -1669,7 +1669,7 @@
|
|
|
1669
1669
|
"default": {
|
|
1670
1670
|
"enabled": true,
|
|
1671
1671
|
"imageMaxBytes": 262144,
|
|
1672
|
-
"textMaxBytes":
|
|
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":
|
|
1688
|
+
"default": 32768,
|
|
1689
1689
|
"type": "integer",
|
|
1690
1690
|
"minimum": 1024,
|
|
1691
1691
|
"maximum": 9007199254740991
|