typeclaw 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +213 -32
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +25 -7
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
|
|
2
|
+
// Owns the AbortController lifecycle and the bare-ESC debounce timer,
|
|
3
|
+
// independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
|
|
4
|
+
// Extracted for testability: the lifecycle bug we want to pin is "armForStream's
|
|
5
|
+
// signal must remain valid across pause()/resume() cycles" — verifying that without
|
|
6
|
+
// a real TTY requires this seam.
|
|
7
|
+
|
|
8
|
+
export type EscChunkResult = { sigint: boolean }
|
|
9
|
+
|
|
10
|
+
export type EscController = {
|
|
11
|
+
armForStream: () => AbortSignal
|
|
12
|
+
onChunk: (chunk: Buffer) => EscChunkResult
|
|
13
|
+
clearPending: () => void
|
|
14
|
+
dispose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
18
|
+
let currentCtrl: AbortController | null = null
|
|
19
|
+
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
20
|
+
|
|
21
|
+
const clearPending = (): void => {
|
|
22
|
+
if (pendingEsc !== null) {
|
|
23
|
+
clearTimeout(pendingEsc)
|
|
24
|
+
pendingEsc = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
armForStream: () => {
|
|
30
|
+
clearPending()
|
|
31
|
+
currentCtrl = new AbortController()
|
|
32
|
+
return currentCtrl.signal
|
|
33
|
+
},
|
|
34
|
+
onChunk: (chunk) => {
|
|
35
|
+
if (chunk.length === 0) return { sigint: false }
|
|
36
|
+
if (chunk[0] === 0x03) {
|
|
37
|
+
// Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
|
|
38
|
+
// SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
|
|
39
|
+
// we deliberately keep the AbortController lifecycle separate from SIGINT.
|
|
40
|
+
return { sigint: true }
|
|
41
|
+
}
|
|
42
|
+
if (chunk.length === 1 && chunk[0] === 0x1b) {
|
|
43
|
+
// Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
|
|
44
|
+
// sequences from arrow keys, mouse, paste) cancels the pending fire.
|
|
45
|
+
// Snapshot currentCtrl so a late-firing timer can't abort a controller
|
|
46
|
+
// created by a subsequent armForStream() call.
|
|
47
|
+
clearPending()
|
|
48
|
+
const ctrl = currentCtrl
|
|
49
|
+
pendingEsc = setTimeout(() => {
|
|
50
|
+
pendingEsc = null
|
|
51
|
+
ctrl?.abort()
|
|
52
|
+
}, debounceMs)
|
|
53
|
+
return { sigint: false }
|
|
54
|
+
}
|
|
55
|
+
// Any other byte arriving within the ESC window is the second byte of a CSI
|
|
56
|
+
// sequence; cancel the pending abort.
|
|
57
|
+
clearPending()
|
|
58
|
+
return { sigint: false }
|
|
59
|
+
},
|
|
60
|
+
clearPending,
|
|
61
|
+
dispose: () => {
|
|
62
|
+
clearPending()
|
|
63
|
+
currentCtrl = null
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { findAgentDir } from '@/init'
|
|
|
5
5
|
import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
|
+
import { createEscController } from './inspect-controller'
|
|
8
9
|
import { cancel, c, errorLine, isCancel } from './ui'
|
|
9
10
|
|
|
10
11
|
const ESC_LISTEN_DELAY_MS = 50
|
|
@@ -55,9 +56,9 @@ export const inspectCommand = defineCommand({
|
|
|
55
56
|
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
57
|
json: isJson,
|
|
57
58
|
color,
|
|
58
|
-
selectSession: (sessions) => {
|
|
59
|
+
selectSession: (sessions, selectOpts) => {
|
|
59
60
|
escListener?.pause()
|
|
60
|
-
return clackSelect(sessions).finally(() => {
|
|
61
|
+
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
61
62
|
escListener?.resume()
|
|
62
63
|
})
|
|
63
64
|
},
|
|
@@ -124,29 +125,12 @@ function createEscListener(): EscListener | null {
|
|
|
124
125
|
const stdin = process.stdin
|
|
125
126
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
128
|
+
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
129
129
|
let active = false
|
|
130
130
|
|
|
131
131
|
const onData = (chunk: Buffer): void => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (first === 0x03) {
|
|
135
|
-
process.kill(process.pid, 'SIGINT')
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
if (chunk.length === 1 && first === 0x1b) {
|
|
139
|
-
if (pendingEsc !== null) clearTimeout(pendingEsc)
|
|
140
|
-
pendingEsc = setTimeout(() => {
|
|
141
|
-
pendingEsc = null
|
|
142
|
-
currentCtrl?.abort()
|
|
143
|
-
}, ESC_LISTEN_DELAY_MS)
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
if (pendingEsc !== null) {
|
|
147
|
-
clearTimeout(pendingEsc)
|
|
148
|
-
pendingEsc = null
|
|
149
|
-
}
|
|
132
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
133
|
+
if (sigint) process.kill(process.pid, 'SIGINT')
|
|
150
134
|
}
|
|
151
135
|
|
|
152
136
|
const start = (): void => {
|
|
@@ -166,27 +150,28 @@ function createEscListener(): EscListener | null {
|
|
|
166
150
|
/* terminal already torn down */
|
|
167
151
|
}
|
|
168
152
|
stdin.pause()
|
|
169
|
-
|
|
170
|
-
clearTimeout(pendingEsc)
|
|
171
|
-
pendingEsc = null
|
|
172
|
-
}
|
|
153
|
+
ctrl.clearPending()
|
|
173
154
|
}
|
|
174
155
|
|
|
175
156
|
return {
|
|
176
157
|
armForStream: () => {
|
|
177
|
-
|
|
158
|
+
const signal = ctrl.armForStream()
|
|
178
159
|
start()
|
|
179
|
-
return
|
|
160
|
+
return signal
|
|
180
161
|
},
|
|
181
162
|
pause: () => {
|
|
182
163
|
stop()
|
|
183
164
|
},
|
|
184
165
|
resume: () => {
|
|
185
|
-
|
|
166
|
+
// Resume the listener WITHOUT replacing the AbortController.
|
|
167
|
+
// The signal returned by armForStream() is held by the live source
|
|
168
|
+
// through streamSession's combinedSignal; replacing the controller
|
|
169
|
+
// here would orphan that signal so a subsequent ESC press could
|
|
170
|
+
// not abort the live tail.
|
|
186
171
|
start()
|
|
187
172
|
},
|
|
188
173
|
stop: () => {
|
|
189
|
-
|
|
174
|
+
ctrl.dispose()
|
|
190
175
|
stop()
|
|
191
176
|
},
|
|
192
177
|
}
|
|
@@ -204,8 +189,15 @@ function useColor(): boolean {
|
|
|
204
189
|
return Boolean(process.stdout.isTTY)
|
|
205
190
|
}
|
|
206
191
|
|
|
207
|
-
async function clackSelect(
|
|
192
|
+
async function clackSelect(
|
|
193
|
+
sessions: SessionSummary[],
|
|
194
|
+
initialSessionId: string | undefined,
|
|
195
|
+
): Promise<SessionSummary | null> {
|
|
208
196
|
const { select } = await import('@clack/prompts')
|
|
197
|
+
const preferred =
|
|
198
|
+
initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
|
|
199
|
+
? initialSessionId
|
|
200
|
+
: sessions[0]?.sessionId
|
|
209
201
|
const picked = await select<string>({
|
|
210
202
|
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
211
203
|
options: sessions.map((s) => ({
|
|
@@ -213,7 +205,7 @@ async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary |
|
|
|
213
205
|
label: formatRowLabel(s),
|
|
214
206
|
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
215
207
|
})),
|
|
216
|
-
initialValue:
|
|
208
|
+
initialValue: preferred,
|
|
217
209
|
})
|
|
218
210
|
if (isCancel(picked)) {
|
|
219
211
|
cancel('Cancelled.')
|
package/src/cli/run.ts
CHANGED
|
@@ -49,21 +49,40 @@ export const run = defineCommand({
|
|
|
49
49
|
initialPrompt: args.prompt,
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
const exit = (code: number): void => {
|
|
53
|
+
process.exit(code)
|
|
54
|
+
}
|
|
55
|
+
const onSignal = (): void => {
|
|
56
|
+
void shutdown({ stop, exit })
|
|
55
57
|
}
|
|
56
58
|
process.once('SIGINT', onSignal)
|
|
57
59
|
process.once('SIGTERM', onSignal)
|
|
58
60
|
|
|
59
61
|
if (tuiPromise) {
|
|
60
62
|
await tuiPromise
|
|
61
|
-
stop
|
|
62
|
-
process.exit(0)
|
|
63
|
+
await shutdown({ stop, exit })
|
|
63
64
|
}
|
|
64
65
|
},
|
|
65
66
|
})
|
|
66
67
|
|
|
68
|
+
// Awaits `stop()` BEFORE exiting so async teardown side-effects (channel
|
|
69
|
+
// adapter teardown, in particular GitHub webhook deregistration) actually
|
|
70
|
+
// complete. The previous code called `stop()` without awaiting and then
|
|
71
|
+
// `process.exit(0)` synchronously, so the in-process DELETE /repos/.../hooks/
|
|
72
|
+
// requests never went out and webhooks survived `typeclaw stop` (which
|
|
73
|
+
// `docker stop`s the container → SIGTERM).
|
|
74
|
+
export async function shutdown(deps: {
|
|
75
|
+
stop: () => void | Promise<void>
|
|
76
|
+
exit: (code: number) => void
|
|
77
|
+
}): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
await deps.stop()
|
|
80
|
+
deps.exit(0)
|
|
81
|
+
} catch {
|
|
82
|
+
deps.exit(1)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
function resolveAttachTui({
|
|
68
87
|
tui,
|
|
69
88
|
noTui,
|
package/src/cli/tui.ts
CHANGED
|
@@ -25,16 +25,40 @@ export const tui = defineCommand({
|
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
async run({ args }) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
|
|
29
|
+
|
|
30
|
+
let initialPrompt: string | undefined = args.prompt
|
|
31
|
+
let attempt = 0
|
|
32
|
+
const RECONNECT_MAX_ATTEMPTS = 30
|
|
33
|
+
const RECONNECT_BACKOFF_MS = 1_000
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
const url = await resolveUrl()
|
|
37
|
+
const tui = createTui({
|
|
38
|
+
url,
|
|
39
|
+
...(initialPrompt !== undefined ? { initialPrompt } : {}),
|
|
40
|
+
expectedVersion: CLI_VERSION,
|
|
41
|
+
onVersionMismatch: (info) => {
|
|
42
|
+
process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
const outcome = await tui.run()
|
|
46
|
+
if (!outcome.lostConnection) return
|
|
47
|
+
// The TUI lost its WS post-handshake (container restart, network blip,
|
|
48
|
+
// hostd hiccup). Re-resolve the URL because the host port can change
|
|
49
|
+
// across container lifecycles (see resolveHostPort), then reconnect.
|
|
50
|
+
// The initial prompt is intentionally cleared after the first cycle:
|
|
51
|
+
// on a reconnect, the agent is resuming the same session — replaying
|
|
52
|
+
// the prompt would re-send it to the LLM.
|
|
53
|
+
initialPrompt = undefined
|
|
54
|
+
attempt += 1
|
|
55
|
+
if (attempt > RECONNECT_MAX_ATTEMPTS) {
|
|
56
|
+
console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
|
|
61
|
+
}
|
|
38
62
|
},
|
|
39
63
|
})
|
|
40
64
|
|