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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -4
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  14. package/src/channels/adapters/github/auth-app.ts +53 -9
  15. package/src/channels/adapters/github/auth-pat.ts +4 -1
  16. package/src/channels/adapters/github/auth.ts +10 -0
  17. package/src/channels/adapters/github/event-permissions.ts +83 -0
  18. package/src/channels/adapters/github/inbound.ts +126 -1
  19. package/src/channels/adapters/github/index.ts +60 -66
  20. package/src/channels/adapters/github/outbound.ts +65 -17
  21. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  22. package/src/channels/adapters/github/team-membership.ts +56 -0
  23. package/src/channels/router.ts +213 -32
  24. package/src/channels/schema.ts +8 -7
  25. package/src/channels/types.ts +1 -1
  26. package/src/cli/channel.ts +135 -38
  27. package/src/cli/init.ts +133 -86
  28. package/src/cli/inspect-controller.ts +66 -0
  29. package/src/cli/inspect.ts +24 -32
  30. package/src/cli/run.ts +24 -5
  31. package/src/cli/tui.ts +34 -10
  32. package/src/cli/tunnel.ts +453 -14
  33. package/src/config/config.ts +35 -7
  34. package/src/config/providers.ts +64 -56
  35. package/src/init/env-file.ts +66 -0
  36. package/src/init/hatching.ts +32 -5
  37. package/src/init/index.ts +131 -39
  38. package/src/init/validate-api-key.ts +31 -0
  39. package/src/inspect/index.ts +5 -1
  40. package/src/inspect/loop.ts +12 -1
  41. package/src/inspect/replay.ts +15 -1
  42. package/src/run/codex-fetch-observer.ts +377 -0
  43. package/src/run/index.ts +12 -2
  44. package/src/server/index.ts +59 -1
  45. package/src/shared/protocol.ts +1 -1
  46. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  47. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  48. package/src/tui/index.ts +17 -5
  49. package/src/tunnels/index.ts +1 -0
  50. package/src/tunnels/manager.ts +18 -0
  51. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  52. package/src/tunnels/types.ts +17 -1
  53. 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
+ }
@@ -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
- let currentCtrl: AbortController | null = null
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
- if (chunk.length === 0) return
133
- const first = chunk[0]
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
- if (pendingEsc !== null) {
170
- clearTimeout(pendingEsc)
171
- pendingEsc = null
172
- }
153
+ ctrl.clearPending()
173
154
  }
174
155
 
175
156
  return {
176
157
  armForStream: () => {
177
- currentCtrl = new AbortController()
158
+ const signal = ctrl.armForStream()
178
159
  start()
179
- return currentCtrl.signal
160
+ return signal
180
161
  },
181
162
  pause: () => {
182
163
  stop()
183
164
  },
184
165
  resume: () => {
185
- currentCtrl = new AbortController()
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
- currentCtrl = null
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(sessions: SessionSummary[]): Promise<SessionSummary | null> {
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: sessions[0]?.sessionId,
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 onSignal = () => {
53
- stop()
54
- process.exit(0)
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 url = args.url ?? (await defaultUrl())
29
- const tui = createTui({
30
- url,
31
- ...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
32
- expectedVersion: CLI_VERSION,
33
- onVersionMismatch: (info) => {
34
- process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
35
- },
36
- })
37
- await tui.run()
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