typeclaw 0.25.0 → 0.27.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/session-origin.ts +36 -5
  3. package/src/agent/subagent-completion-reminder.ts +16 -1
  4. package/src/agent/tools/channel-react.ts +11 -4
  5. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  6. package/src/channels/adapters/discord-bot-classify.ts +3 -0
  7. package/src/channels/adapters/discord-bot-reactions.ts +164 -0
  8. package/src/channels/adapters/discord-bot.ts +23 -0
  9. package/src/channels/adapters/github/inbound.ts +60 -13
  10. package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
  11. package/src/channels/adapters/slack-bot-classify.ts +2 -0
  12. package/src/channels/adapters/slack-bot-reactions.ts +167 -0
  13. package/src/channels/adapters/slack-bot.ts +24 -0
  14. package/src/channels/router.ts +191 -7
  15. package/src/channels/schema.ts +41 -0
  16. package/src/cli/inspect.ts +216 -36
  17. package/src/cli/logs.ts +15 -0
  18. package/src/cli/tui.ts +33 -39
  19. package/src/compose/logs.ts +1 -1
  20. package/src/config/config.ts +43 -2
  21. package/src/container/logs.ts +70 -22
  22. package/src/init/index.ts +3 -3
  23. package/src/inspect/index.ts +128 -42
  24. package/src/inspect/item-list.ts +44 -0
  25. package/src/inspect/item.ts +17 -0
  26. package/src/inspect/label.ts +1 -1
  27. package/src/inspect/logs-item.ts +79 -0
  28. package/src/inspect/loop.ts +74 -3
  29. package/src/inspect/open-item.ts +100 -0
  30. package/src/inspect/preview.ts +106 -0
  31. package/src/inspect/session-list.ts +15 -3
  32. package/src/inspect/transcript-view.ts +182 -0
  33. package/src/inspect/tui-item.ts +97 -0
  34. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  35. package/src/tui/index.ts +72 -32
  36. package/typeclaw.schema.json +1 -0
package/src/tui/index.ts CHANGED
@@ -50,13 +50,21 @@ export type TuiOptions = {
50
50
  onVersionMismatch?: (info: VersionMismatch) => void
51
51
  }
52
52
 
53
- // Outcome of a single `run()` cycle. The CLI's reconnect loop reads this to
54
- // decide whether to spin again or exit. `lostConnection` is true when the
55
- // WS closed AFTER the connected handshake without a deliberate /quit or
56
- // Ctrl+C — exactly the case a self-restart produces, and the only one
57
- // where a fresh connect can recover the session. Quit / Ctrl+C / pre-
58
- // handshake errors all resolve with `lostConnection: false`.
59
- export type TuiRunOutcome = { lostConnection: boolean }
53
+ // Outcome of a single `run()` cycle.
54
+ // - 'detach': idle Esc return to the session-viewer list. Closing the WS
55
+ // ends the server-side AgentSession (accepted; the list re-shows it as a
56
+ // read-only transcript).
57
+ // - 'exit': deliberate /quit or Ctrl+C terminate the client.
58
+ // - 'lostConnection': WS closed AFTER the handshake without a deliberate
59
+ // quit/detach exactly the self-restart case, and the only one where a
60
+ // fresh connect can recover the session.
61
+ // - 'connectFailed': pre-handshake connect/handshake error.
62
+ // The CLI reconnect loop spins only on 'lostConnection'.
63
+ export type TuiRunResult =
64
+ | { reason: 'detach' }
65
+ | { reason: 'exit'; exitCode: number }
66
+ | { reason: 'lostConnection' }
67
+ | { reason: 'connectFailed' }
60
68
 
61
69
  export function createTui({
62
70
  url,
@@ -68,7 +76,7 @@ export function createTui({
68
76
  expectedVersion,
69
77
  onVersionMismatch,
70
78
  }: TuiOptions) {
71
- async function run(): Promise<TuiRunOutcome> {
79
+ async function run(): Promise<TuiRunResult> {
72
80
  const terminal = createTerminal()
73
81
  const tui = new TUI(terminal)
74
82
  const displayUrl = redactUrl(url)
@@ -78,13 +86,19 @@ export function createTui({
78
86
  tui.start()
79
87
  tui.requestRender()
80
88
 
81
- const client = await createClient(url).catch((err) => {
89
+ // Pre-handshake failures resolve 'connectFailed' (not throw): the standalone
90
+ // CLI injects exit=process.exit so exit(1) ends the process and the return is
91
+ // moot; the viewer injects a no-op exit so run() resolves cleanly and the
92
+ // caller maps connectFailed into an error result instead of an uncaught reject.
93
+ const maybeClient = await createClient(url).catch((err) => {
82
94
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
83
95
  tui.requestRender()
84
96
  tui.stop()
85
97
  exit(1)
86
- throw err
98
+ return null
87
99
  })
100
+ if (maybeClient === null) return { reason: 'connectFailed' }
101
+ const client = maybeClient
88
102
 
89
103
  const handshake = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
90
104
  status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
@@ -92,10 +106,10 @@ export function createTui({
92
106
  client.close()
93
107
  tui.stop()
94
108
  exit(1)
95
- throw err
109
+ return null
96
110
  })
111
+ if (handshake === null) return { reason: 'connectFailed' }
97
112
 
98
- let userInitiatedShutdown = false
99
113
  const { sessionId, serverVersion } = handshake
100
114
  status.setText(colors.dim(`session: ${sessionId}`))
101
115
  tui.requestRender()
@@ -231,12 +245,23 @@ export function createTui({
231
245
  }
232
246
  })
233
247
 
234
- const closed = new Promise<boolean>((resolve) => {
235
- client.onClose(() => {
236
- appendHistory(new Text(colors.dim('disconnected'), 0, 0))
237
- tui.requestRender()
238
- resolve(!userInitiatedShutdown)
239
- })
248
+ let settleOutcome: ((result: TuiRunResult) => void) | null = null
249
+ const outcome = new Promise<TuiRunResult>((resolve) => {
250
+ settleOutcome = resolve
251
+ })
252
+ const settle = (result: TuiRunResult): void => {
253
+ if (settleOutcome === null) return
254
+ const fn = settleOutcome
255
+ settleOutcome = null
256
+ fn(result)
257
+ }
258
+
259
+ client.onClose(() => {
260
+ appendHistory(new Text(colors.dim('disconnected'), 0, 0))
261
+ tui.requestRender()
262
+ // A user-initiated detach/exit already closed the WS deliberately and
263
+ // settled the outcome; onClose then fires but must not override it.
264
+ settle({ reason: 'lostConnection' })
240
265
  })
241
266
 
242
267
  function send(text: string): Promise<void> {
@@ -249,7 +274,7 @@ export function createTui({
249
274
 
250
275
  function runTuiCommand(command: TuiCommandName): boolean {
251
276
  if (command === 'quit') {
252
- shutdown(0)
277
+ exitWith(0)
253
278
  return true
254
279
  }
255
280
  if (command === 'reload') {
@@ -266,29 +291,44 @@ export function createTui({
266
291
  return true
267
292
  }
268
293
 
269
- // Esc aborts an in-flight reply. The Editor does not bind Esc, so a
270
- // top-level input listener can intercept it without fighting the editor.
294
+ // Esc means "abort the in-flight reply" while a turn is generating, and
295
+ // "detach back to the session list" when idle. The Editor does not bind
296
+ // Esc, so a top-level listener intercepts it without fighting the editor.
271
297
  tui.addInputListener((data) => {
272
- if (matchesKey(data, Key.escape) && replyInFlight) {
298
+ if (!matchesKey(data, Key.escape)) return undefined
299
+ if (replyInFlight) {
273
300
  client.send({ type: 'abort' })
274
301
  return { consume: true }
275
302
  }
276
- return undefined
303
+ detach()
304
+ return { consume: true }
277
305
  })
278
306
 
279
- const shutdown = (code: number) => {
280
- userInitiatedShutdown = true
307
+ // Settle BEFORE closing the client: client.close() fires onClose, which
308
+ // settles 'lostConnection'. settle() is idempotent, so the first call wins —
309
+ // settling the deliberate outcome first keeps the later onClose a no-op.
310
+ const teardown = (): void => {
281
311
  tui.stop()
282
312
  client.close()
313
+ }
314
+
315
+ const exitWith = (code: number): void => {
316
+ settle({ reason: 'exit', exitCode: code })
317
+ teardown()
283
318
  exit(code)
284
319
  }
285
320
 
286
- // Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
287
- // so we must intercept the \x03 byte ourselves. The Editor would otherwise
288
- // swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
321
+ const detach = (): void => {
322
+ settle({ reason: 'detach' })
323
+ teardown()
324
+ }
325
+
326
+ // Ctrl+C exits the client. In raw mode the kernel does NOT generate SIGINT,
327
+ // so we intercept the \x03 byte ourselves; the Editor would otherwise
328
+ // swallow it. teardown() restores raw-mode/cursor/echo before we settle.
289
329
  tui.addInputListener((data) => {
290
330
  if (matchesKey(data, Key.ctrl('c'))) {
291
- shutdown(0)
331
+ exitWith(0)
292
332
  return { consume: true }
293
333
  }
294
334
  return undefined
@@ -330,15 +370,15 @@ export function createTui({
330
370
  const command = parseBareTuiCommand(initialPrompt)
331
371
  if (command !== null) {
332
372
  runTuiCommand(command)
333
- if (command === 'quit') return { lostConnection: false }
373
+ if (command === 'quit') return { reason: 'exit', exitCode: 0 }
334
374
  } else {
335
375
  await send(initialPrompt)
336
376
  }
337
377
  }
338
378
 
339
- const lostConnection = await closed
379
+ const result = await outcome
340
380
  tui.stop()
341
- return { lostConnection }
381
+ return result
342
382
  }
343
383
 
344
384
  return { run }
@@ -529,6 +529,7 @@
529
529
  "discussion_comment.created",
530
530
  "issues.opened",
531
531
  "pull_request.opened",
532
+ "pull_request.ready_for_review",
532
533
  "pull_request.review_requested",
533
534
  "pull_request.review_request_removed",
534
535
  "discussion.created",