typeclaw 0.7.0 → 0.9.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 (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
@@ -6,6 +6,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
6
6
  import { createSession, type AgentSession } from '@/agent'
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
+ import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
9
10
  import { createCommandRegistry } from '@/commands'
10
11
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
11
12
  import type { HookBus } from '@/plugin'
@@ -254,6 +255,14 @@ type LiveSession = {
254
255
  currentTurnAuthorId: string | null
255
256
  currentTurnAuthorIds: Set<string>
256
257
  lastTurnAuthorIds: Set<string>
258
+ // Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
259
+ // prior batch), preserved across the drain finally-block which resets
260
+ // currentTurnAuthorId to null. Read by the reminder-only branch in
261
+ // drain() so a system-reminder wakeup carries the same author the prior
262
+ // turn's tool.before saw — matching "last speaker" semantics (not "first
263
+ // inserted into Set"), so a multi-author prior turn like alice→bob
264
+ // restores `bob`, the same identity normal turns would have used.
265
+ lastTurnAuthorId: string | null
257
266
  consecutiveAborts: number
258
267
  // Per-(chat:thread) count of bot messages sent without intervening user
259
268
  // input being rendered into the model's context. Reset at the top of each
@@ -261,6 +270,15 @@ type LiveSession = {
261
270
  // about to be shown to the model). channel_send reads this BEFORE calling
262
271
  // router.send so the hint reflects the position of the about-to-happen send
263
272
  // (n-th in a row), nudging the model to yield without forcing it to.
273
+ // Queue of `<system-reminder>...</system-reminder>` strings to prepend
274
+ // into the next turn's user-message body. Populated by
275
+ // `injectSubagentCompletionReminder` (and any future system-injected
276
+ // wakeups) so a backgrounded subagent's completion can wake a channel
277
+ // session that has no pending user inbounds. Drained at the top of
278
+ // every `drain()` iteration alongside the regular promptQueue batch;
279
+ // the drain loop's run condition checks BOTH queues so a system
280
+ // reminder alone is enough to trigger a turn.
281
+ pendingSystemReminders: string[]
264
282
  consecutiveSends: Map<string, number>
265
283
  // Per-(chat:thread) text of the last reserved bot send. Set
266
284
  // SYNCHRONOUSLY inside router.send before the outbound callback awaits,
@@ -297,9 +315,30 @@ type LiveSession = {
297
315
  unsubProviderErrors: (() => void) | null
298
316
  }
299
317
 
318
+ // `event` is null for command invocations that originated outside the inbound
319
+ // pipeline (e.g. Discord native slash commands fired from listener.on
320
+ // ('interaction_create')). Handlers that need a real inbound — for some
321
+ // future hypothetical command like `/quote` — must guard on event !== null
322
+ // instead of assuming it.
300
323
  type ChannelCommandContext = {
301
324
  live: LiveSession
302
- event: InboundMessage
325
+ event: InboundMessage | null
326
+ }
327
+
328
+ export type ExecuteCommandResult =
329
+ | { kind: 'handled'; name: string }
330
+ | { kind: 'unknown-command'; name: string }
331
+ | { kind: 'no-live-session' }
332
+ | { kind: 'permission-denied' }
333
+ | { kind: 'ambiguous'; matchCount: number }
334
+
335
+ // Identifies who invoked an adapter-driven command. Required so the router
336
+ // can run the same channel.respond permission gate the text-prefix command
337
+ // path runs (isChannelRespondDenied in route()). Without it, a guest user
338
+ // in a public Slack channel could /stop an owner-created session that
339
+ // happened to be live, bypassing role gating entirely.
340
+ export type ExecuteCommandOptions = {
341
+ invokerId: string
303
342
  }
304
343
 
305
344
  export type SendSource = 'tool' | 'system'
@@ -345,11 +384,42 @@ export type ChannelRouter = {
345
384
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
346
385
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
347
386
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
387
+ // Execute a command by name against an existing live session, bypassing
388
+ // the inbound classifier, engagement gate, debounce, and prompt queue.
389
+ // Used by adapters that receive commands through a native surface
390
+ // (Discord application-command interactions) rather than text. Gates
391
+ // the invoker on channel.respond — same permission gate the text-prefix
392
+ // command path runs — so a guest user cannot abort an owner's session
393
+ // by clicking the slash-command picker. Adapters MUST forward the
394
+ // invoker's platform-specific user id; without it the gate cannot
395
+ // identify the actor and resolves to 'guest' which denies. Returns:
396
+ // - handled: command ran
397
+ // - permission-denied: invoker lacks channel.respond
398
+ // - no-live-session: channel has no active session
399
+ // - ambiguous: multiple thread-keyed sessions in same chat (Slack);
400
+ // caller should refuse to act rather than abort an arbitrary one
401
+ // - unknown-command: name is not registered
402
+ executeCommand: (key: ChannelKey, name: string, options: ExecuteCommandOptions) => Promise<ExecuteCommandResult>
348
403
  // Lowered self-aliases (configured + implicit dir-name). Adapters use
349
404
  // this to anchor outbound threading on alias-only inbounds — see
350
405
  // slack-bot-classify.ts. Read live so a reload of `alias` propagates
351
406
  // to adapters without a restart.
352
407
  getSelfAliases: () => readonly string[]
408
+ // Inject a `<system-reminder>` block addressed to a live channel session
409
+ // identified by `parentSessionId`. The reminder is rendered into the
410
+ // next turn's user-message body and triggers a drain even if the
411
+ // promptQueue is empty. Returns `delivered` when a matching live
412
+ // session was found and the reminder was queued, `no-live-session`
413
+ // otherwise. Used by the subagent-completion bridge in
414
+ // src/run/index.ts; safe for tests to call directly via a fake router.
415
+ injectSubagentCompletionReminder: (args: {
416
+ parentSessionId: string
417
+ subagent: string
418
+ taskId: string
419
+ ok: boolean
420
+ durationMs: number
421
+ error?: string
422
+ }) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
353
423
  stop: () => Promise<void>
354
424
  liveCount: () => number
355
425
  __testing?: {
@@ -359,6 +429,34 @@ export type ChannelRouter = {
359
429
  isTypingActive: (key: ChannelKey) => boolean
360
430
  stopTyping: (key: ChannelKey) => Promise<void>
361
431
  runIdleGc: () => Promise<void>
432
+ // Returns the seeded author state on the live session matching
433
+ // `key`, or undefined when no live session exists. Tests use this
434
+ // to pin the symmetric-seeding invariant between `lastTurnAuthorId`
435
+ // (string) and `lastTurnAuthorIds` (Set) at session creation —
436
+ // observable directly here rather than via a downstream sticky-
437
+ // credit grant test that would need to coordinate with multiple
438
+ // subsystems.
439
+ getLiveAuthorState: (key: ChannelKey) =>
440
+ | {
441
+ currentTurnAuthorId: string | null
442
+ currentTurnAuthorIds: readonly string[]
443
+ lastTurnAuthorId: string | null
444
+ lastTurnAuthorIds: readonly string[]
445
+ }
446
+ | undefined
447
+ // Returns a shallow copy of `live.originRef.current` for the live
448
+ // session matching `key`, or undefined when no live session exists.
449
+ // Exists so tests can assert on the per-turn origin that tool.before
450
+ // consumers would see — the origin is normally only observable
451
+ // indirectly via in-flight tool calls, which the fake session doesn't
452
+ // execute. The shallow copy detaches the top-level fields from
453
+ // `originRef` so a later turn replacing `originRef.current` doesn't
454
+ // change a captured assertion. Nested fields (`participants`,
455
+ // `membership`) are still shared by reference; in practice
456
+ // `updateParticipants` returns a fresh array rather than mutating in
457
+ // place, so observed snapshots are stable for the assertions tests
458
+ // make today. NOT a public router method.
459
+ getLiveOriginSnapshot: (key: ChannelKey) => SessionOrigin | undefined
362
460
  }
363
461
  }
364
462
 
@@ -763,6 +861,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
763
861
  resolvedNames,
764
862
  originRef,
765
863
  promptQueue: [],
864
+ pendingSystemReminders: [],
766
865
  contextBuffer: [],
767
866
  draining: false,
768
867
  debounceTimer: null,
@@ -774,7 +873,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
774
873
  firstUnprocessedAt: 0,
775
874
  currentTurnAuthorId: null,
776
875
  currentTurnAuthorIds: new Set(),
777
- lastTurnAuthorIds: new Set(),
876
+ // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
877
+ // origin) and `lastTurnAuthorIds` (Set, used by
878
+ // `grantStickyForReplyTargets` as the fallback when
879
+ // `currentTurnAuthorIds` is empty) are seeded TOGETHER from
880
+ // `triggeringAuthorId`. Seeding only the string would leave the
881
+ // Set empty for the cold-start reminder-only path, which is
882
+ // observable when the agent replies during that turn — `send()`
883
+ // would compute an empty `targetIds` and silently drop the
884
+ // sticky-credit grant for the seeded author. The two fields must
885
+ // stay in sync, so they are written in the same statement.
886
+ lastTurnAuthorIds: triggeringAuthorId !== undefined ? new Set([triggeringAuthorId]) : new Set(),
887
+ lastTurnAuthorId: triggeringAuthorId ?? null,
778
888
  consecutiveAborts: 0,
779
889
  consecutiveSends: new Map(),
780
890
  lastSentText: new Map(),
@@ -989,12 +1099,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
989
1099
  }
990
1100
  }
991
1101
 
992
- const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
1102
+ const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
993
1103
  if (!live.hooks) return
994
1104
  try {
995
1105
  await live.hooks.runSessionTurnStart({
996
1106
  sessionId: live.sessionId,
997
1107
  agentDir: options.agentDir,
1108
+ userPrompt,
998
1109
  origin: buildLiveOrigin(live),
999
1110
  })
1000
1111
  } catch (err) {
@@ -1045,6 +1156,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1045
1156
  live.debounceTimer = null
1046
1157
  live.firstUnprocessedAt = 0
1047
1158
  live.promptQueue.length = 0
1159
+ live.pendingSystemReminders.length = 0
1048
1160
  await stopTypingHeartbeat(live)
1049
1161
  try {
1050
1162
  await live.session.abort()
@@ -1058,7 +1170,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1058
1170
  if (live.draining || live.destroyed) return
1059
1171
  live.draining = true
1060
1172
  try {
1061
- while (live.promptQueue.length > 0 && !live.destroyed) {
1173
+ while ((live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) && !live.destroyed) {
1062
1174
  live.typingTimedOut = false
1063
1175
  // Heartbeat must run during generation as well as during debounce.
1064
1176
  // Because new inbounds during a turn just push into promptQueue
@@ -1067,13 +1179,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1067
1179
  startTypingHeartbeat(live)
1068
1180
  const batch = live.promptQueue.splice(0, live.promptQueue.length)
1069
1181
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1070
- const text = composeTurnPrompt(observed, batch, { loopGuardActive: live.loopGuardActive })
1182
+ const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1183
+ const text = composeTurnPrompt(observed, batch, {
1184
+ loopGuardActive: live.loopGuardActive,
1185
+ systemReminders: reminders,
1186
+ })
1071
1187
 
1072
- live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
1073
- live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1074
1188
  if (batch.length > 0) {
1189
+ live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1190
+ live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1075
1191
  live.consecutiveSends.clear()
1076
1192
  live.lastSentText.clear()
1193
+ } else if (live.lastTurnAuthorId !== null) {
1194
+ // Reminder-only turn (batch.length === 0, reminders.length > 0):
1195
+ // restore the author identity from the prior turn so author-
1196
+ // scoped role resolution still works on this turn. The drain
1197
+ // finally-block clears `currentTurnAuthorId` between turns, so a
1198
+ // reminder arriving while the session is idle would otherwise
1199
+ // strip `lastInboundAuthorId` from the tool.before origin and
1200
+ // demote roles like `slack:T0/C0 author:U_OWNER` to whichever
1201
+ // non-author rule matches — silently breaking the channel_reply
1202
+ // that the reminder is asking the agent to send. `lastTurnAuthorId`
1203
+ // tracks the LAST speaker of the prior batch (matching normal-
1204
+ // turn `batch[batch.length - 1]!.authorId` semantics) so a multi-
1205
+ // author prior turn like alice→bob restores `bob`, not alice.
1206
+ live.currentTurnAuthorId = live.lastTurnAuthorId
1207
+ live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
1077
1208
  }
1078
1209
 
1079
1210
  // Update the live origin holder so this turn's tool.before events
@@ -1090,7 +1221,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1090
1221
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1091
1222
  const promptStart = now()
1092
1223
  const successfulSendsBeforePrompt = live.successfulChannelSends
1093
- await fireSessionTurnStart(live)
1224
+ await fireSessionTurnStart(live, text)
1094
1225
  try {
1095
1226
  await live.session.prompt(text)
1096
1227
  await validateChannelTurn(live, successfulSendsBeforePrompt)
@@ -1105,6 +1236,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1105
1236
  }
1106
1237
  await fireSessionIdle(live)
1107
1238
  live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
1239
+ if (live.currentTurnAuthorId !== null) {
1240
+ live.lastTurnAuthorId = live.currentTurnAuthorId
1241
+ }
1108
1242
  }
1109
1243
  } finally {
1110
1244
  live.draining = false
@@ -1706,6 +1840,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1706
1840
  if (live.destroyed) continue
1707
1841
  if (live.draining) continue
1708
1842
  if (live.promptQueue.length > 0) continue
1843
+ // pendingSystemReminders is checked alongside promptQueue because both
1844
+ // represent pending work that drain() will process. Today's only
1845
+ // populator (injectSubagentCompletionReminder) also fires drain()
1846
+ // synchronously, which sets draining=true and shadows this guard via
1847
+ // the line above — but the guard exists to keep the invariant honest
1848
+ // for any future caller that queues a reminder without immediately
1849
+ // waking the drain loop.
1850
+ if (live.pendingSystemReminders.length > 0) continue
1709
1851
  if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
1710
1852
  victims.push(live)
1711
1853
  }
@@ -1733,6 +1875,87 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1733
1875
  }
1734
1876
  }
1735
1877
 
1878
+ const executeCommand = async (
1879
+ key: ChannelKey,
1880
+ name: string,
1881
+ options: ExecuteCommandOptions,
1882
+ ): Promise<ExecuteCommandResult> => {
1883
+ const lowered = name.toLowerCase()
1884
+ if (!commands.has(lowered)) {
1885
+ return { kind: 'unknown-command', name: lowered }
1886
+ }
1887
+ // Permission gate runs BEFORE the live-session lookup so a guest user
1888
+ // invoking /stop on a non-existent session gets 'permission-denied'
1889
+ // (consistent answer regardless of session state) rather than leaking
1890
+ // session presence via the 'no-live-session' vs 'permission-denied'
1891
+ // distinction.
1892
+ const partial: SessionOrigin = {
1893
+ kind: 'channel',
1894
+ adapter: key.adapter,
1895
+ workspace: key.workspace,
1896
+ chat: key.chat,
1897
+ thread: key.thread,
1898
+ lastInboundAuthorId: options.invokerId,
1899
+ }
1900
+ if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
1901
+ return { kind: 'permission-denied' }
1902
+ }
1903
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
1904
+ if (resolved.kind === 'none') {
1905
+ return { kind: 'no-live-session' }
1906
+ }
1907
+ if (resolved.kind === 'ambiguous') {
1908
+ return { kind: 'ambiguous', matchCount: resolved.count }
1909
+ }
1910
+ const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
1911
+ if (result.kind === 'handled') {
1912
+ return { kind: 'handled', name: result.name }
1913
+ }
1914
+ // commands.execute can only return not-command (impossible — we pass a
1915
+ // leading slash), unknown-command (impossible — we just checked has()),
1916
+ // or handled. Any other outcome is a bug.
1917
+ return { kind: 'unknown-command', name: lowered }
1918
+ }
1919
+
1920
+ const injectSubagentCompletionReminder = (args: {
1921
+ parentSessionId: string
1922
+ subagent: string
1923
+ taskId: string
1924
+ ok: boolean
1925
+ durationMs: number
1926
+ error?: string
1927
+ }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
1928
+ for (const live of liveSessions.values()) {
1929
+ if (live.destroyed) continue
1930
+ if (live.sessionId !== args.parentSessionId) continue
1931
+ const text = renderSubagentCompletionReminder({
1932
+ subagent: args.subagent,
1933
+ taskId: args.taskId,
1934
+ ok: args.ok,
1935
+ durationMs: args.durationMs,
1936
+ ...(args.error !== undefined ? { error: args.error } : {}),
1937
+ channel: true,
1938
+ })
1939
+ live.pendingSystemReminders.push(text)
1940
+ logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
1941
+ // Wake the drain loop. If a turn is already in flight, the wakeup is
1942
+ // a no-op because drain() will pick up the reminder on its next
1943
+ // iteration (it now gates on promptQueue OR pendingSystemReminders).
1944
+ // If the session is idle, fire drain() immediately rather than going
1945
+ // through the debounce path — the reminder is not a user inbound,
1946
+ // so the "coalesce nearby inbounds" rationale for debouncing does
1947
+ // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
1948
+ // semantics: the channel router doesn't have a `delivery: interrupt`
1949
+ // mechanism (no in-flight abort during a turn), but firing drain()
1950
+ // immediately is the equivalent for an idle session.
1951
+ if (!live.draining) {
1952
+ void drain(live)
1953
+ }
1954
+ return { kind: 'delivered', keyId: live.keyId }
1955
+ }
1956
+ return { kind: 'no-live-session' }
1957
+ }
1958
+
1736
1959
  return {
1737
1960
  route,
1738
1961
  send,
@@ -1752,7 +1975,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1752
1975
  registerFetchAttachment,
1753
1976
  unregisterFetchAttachment,
1754
1977
  fetchAttachment,
1978
+ executeCommand,
1755
1979
  getSelfAliases: computeSelfAliases,
1980
+ injectSubagentCompletionReminder,
1756
1981
  stop,
1757
1982
  liveCount: () => liveSessions.size,
1758
1983
  __testing: {
@@ -1796,6 +2021,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1796
2021
  await stopTypingHeartbeat(live)
1797
2022
  },
1798
2023
  runIdleGc,
2024
+ getLiveOriginSnapshot: (key: ChannelKey) => {
2025
+ const live = liveSessions.get(channelKeyId(key))
2026
+ const origin = live?.originRef.current
2027
+ if (origin === undefined) return undefined
2028
+ return { ...origin }
2029
+ },
2030
+ getLiveAuthorState: (key: ChannelKey) => {
2031
+ const live = liveSessions.get(channelKeyId(key))
2032
+ if (live === undefined) return undefined
2033
+ return {
2034
+ currentTurnAuthorId: live.currentTurnAuthorId,
2035
+ currentTurnAuthorIds: Array.from(live.currentTurnAuthorIds),
2036
+ lastTurnAuthorId: live.lastTurnAuthorId,
2037
+ lastTurnAuthorIds: Array.from(live.lastTurnAuthorIds),
2038
+ }
2039
+ },
1799
2040
  },
1800
2041
  }
1801
2042
  }
@@ -1803,27 +2044,50 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1803
2044
  function composeTurnPrompt(
1804
2045
  observed: readonly ObservedInbound[],
1805
2046
  batch: readonly QueuedInbound[],
1806
- state: { loopGuardActive: boolean } = { loopGuardActive: false },
2047
+ state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
1807
2048
  ): string {
1808
2049
  const parts: string[] = []
2050
+ // System reminders (subagent-completion wakeups today) lead the turn body
2051
+ // because they are typically what triggered the drain — when the prompt
2052
+ // queue is empty and the only thing in this iteration is a reminder, the
2053
+ // model needs to see the reminder before any optional context. The
2054
+ // reminder block is self-fenced by its <system-reminder> tags, so no
2055
+ // extra framing is needed and the model already learns this shape from
2056
+ // the TUI path; channel sessions see the same tags.
2057
+ if (state.systemReminders && state.systemReminders.length > 0) {
2058
+ for (const reminder of state.systemReminders) {
2059
+ parts.push(reminder)
2060
+ }
2061
+ parts.push('')
2062
+ }
1809
2063
  // Loop-guard notice lives in the user-turn text (recomposed every drain)
1810
2064
  // rather than in the system prompt so it does not invalidate the
1811
2065
  // prompt-prefix cache. The cached prefix covers system + tools + earlier
1812
2066
  // turns; the current user-turn suffix is non-cacheable by design, so
1813
2067
  // adding a section here is cache-neutral.
1814
2068
  //
1815
- // SYSTEM MESSAGE convention: any runtime-injected block in the user turn
1816
- // that is NOT from a chat participant must use the
1817
- // `**[SYSTEM MESSAGE — not from a human]**` framing fenced by horizontal
1818
- // rules (`---`). This is structurally distinct from the H2 sections used
1819
- // for actual conversation content (`## Recent context`,
2069
+ // SYSTEM MESSAGE convention: any runtime-injected block in the user
2070
+ // turn that is NOT from a chat participant MUST use the
2071
+ // `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
2072
+ // horizontal rules (`---`) the loop-guard block below is the
2073
+ // canonical example. This is structurally distinct from the H2
2074
+ // sections used for actual conversation content (`## Recent context`,
1820
2075
  // `## Current message`). Without the fencing, models — especially
1821
2076
  // persona-rich ones like Kimi — read the heading as a human-authored
1822
2077
  // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
1823
- // bracketed marker plus the explicit "Do not acknowledge or reply to this
1824
- // notice" line is the trust boundary that prevents this. New runtime
1825
- // notices (rate-limit, schema-mismatch, abort signals, etc.) MUST follow
1826
- // this same convention so models learn the pattern.
2078
+ // bracketed marker plus the explicit "Do not acknowledge or reply to
2079
+ // this notice" line is the trust boundary that prevents this. New
2080
+ // runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
2081
+ // MUST follow this convention.
2082
+ //
2083
+ // ONE narrow exception exists: subagent-completion reminders use
2084
+ // `<system-reminder>...</system-reminder>` tags (prepended above) for
2085
+ // parity with the TUI path's identical tagging (see
2086
+ // `renderSubagentCompletionReminder` in
2087
+ // `src/agent/subagent-completion-reminder.ts`) so the model sees the
2088
+ // same shape across origins. The exception is scoped to that single
2089
+ // case: do NOT extend it to new notice types. Anything that is not
2090
+ // a true subagent-style completion ping uses framing 1.
1827
2091
  if (state.loopGuardActive) {
1828
2092
  parts.push(
1829
2093
  '---',
@@ -1912,6 +2176,52 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
1912
2176
  return `${chat}:${thread ?? ''}`
1913
2177
  }
1914
2178
 
2179
+ export type ResolveLiveSessionResult =
2180
+ | { kind: 'found'; session: LiveSession }
2181
+ | { kind: 'none' }
2182
+ | { kind: 'ambiguous'; count: number }
2183
+
2184
+ // Lookup policy for adapter-driven commands. Exact-key match always wins.
2185
+ // On miss, fall back to (adapter, workspace, chat) without thread — but
2186
+ // only when EXACTLY ONE non-destroyed candidate exists. Ambiguous matches
2187
+ // return 'ambiguous' so the caller can refuse to act rather than abort an
2188
+ // arbitrary session.
2189
+ //
2190
+ // Why the fallback: Slack slash commands carry channel_id but no thread_ts,
2191
+ // so a slash invocation from a thread-keyed live session would otherwise
2192
+ // report no-live-session. Discord doesn't hit this — Discord treats threads
2193
+ // as channels, so the exact-key path already resolves.
2194
+ //
2195
+ // Why ambiguity-rejection: "first match wins" map-iteration semantics would
2196
+ // abort an arbitrary thread when multiple thread-keyed sessions coexist in
2197
+ // one channel (plausible on Slack: bot mentioned in multiple threads). The
2198
+ // user's slash command picker doesn't know about threads; we don't know
2199
+ // which they meant; refusing is safer than guessing.
2200
+ export function resolveLiveSessionForCommand(
2201
+ liveSessions: ReadonlyMap<string, LiveSession>,
2202
+ key: ChannelKey,
2203
+ ): ResolveLiveSessionResult {
2204
+ const exact = liveSessions.get(channelKeyId(key))
2205
+ if (exact && !exact.destroyed) return { kind: 'found', session: exact }
2206
+
2207
+ const matches: LiveSession[] = []
2208
+ for (const candidate of liveSessions.values()) {
2209
+ if (candidate.destroyed) continue
2210
+ if (
2211
+ candidate.key.adapter === key.adapter &&
2212
+ candidate.key.workspace === key.workspace &&
2213
+ candidate.key.chat === key.chat
2214
+ ) {
2215
+ matches.push(candidate)
2216
+ if (matches.length > 1) {
2217
+ return { kind: 'ambiguous', count: matches.length }
2218
+ }
2219
+ }
2220
+ }
2221
+ if (matches.length === 1) return { kind: 'found', session: matches[0]! }
2222
+ return { kind: 'none' }
2223
+ }
2224
+
1915
2225
  function normalizeSendText(text: string | undefined): string | undefined {
1916
2226
  if (text === undefined) return undefined
1917
2227
  if (text === '') return undefined
@@ -0,0 +1,84 @@
1
+ import { parseSubagentCompletedPayload } from '@/agent/subagent-completion-reminder'
2
+ import type { Stream } from '@/stream'
3
+
4
+ import type { ChannelRouter } from './router'
5
+
6
+ export type SubagentCompletionBridgeLogger = {
7
+ info: (msg: string) => void
8
+ warn: (msg: string) => void
9
+ }
10
+
11
+ export type SubagentCompletionBridgeOptions = {
12
+ stream: Stream
13
+ router: Pick<ChannelRouter, 'injectSubagentCompletionReminder'>
14
+ logger?: SubagentCompletionBridgeLogger
15
+ }
16
+
17
+ export type SubagentCompletionBridge = {
18
+ stop: () => void
19
+ }
20
+
21
+ const consoleLogger: SubagentCompletionBridgeLogger = {
22
+ info: (msg) => console.log(msg),
23
+ warn: (msg) => console.warn(msg),
24
+ }
25
+
26
+ // Bridges `subagent.completed` broadcasts on the in-process Stream into a
27
+ // channel router call so the channel session that spawned the subagent
28
+ // gets woken up with a `<system-reminder>` when the subagent finishes.
29
+ //
30
+ // Two-bridges-for-two-surfaces design (matches the TUI side at
31
+ // src/server/index.ts `routeSubagentCompletionReminder`):
32
+ //
33
+ // - TUI sessions: the WS server subscribes to broadcasts on the same
34
+ // stream and re-publishes the reminder as `target: { kind: 'session' }`
35
+ // so the per-session drain loop in the server picks it up. Lookup is
36
+ // by sessionId (which is `state.sessionFileId`).
37
+ //
38
+ // - Channel sessions: this bridge subscribes and calls
39
+ // `router.injectSubagentCompletionReminder` because the channel router
40
+ // owns its own per-key drain loop and doesn't use the stream's
41
+ // session-keyed target.
42
+ //
43
+ // `parentSessionId` matching is the same on both sides: when a channel
44
+ // session spawns a subagent via `spawn_subagent`, the tool captures
45
+ // `sessionManager.getSessionId()` and publishes it as the broadcast's
46
+ // `parentSessionId`. That id is exactly what the router stores on each
47
+ // `LiveSession`, so the lookup is O(N) over live sessions with N small
48
+ // (one per active conversation).
49
+ //
50
+ // On `no-live-session`, we silently drop. Three observable paths reach
51
+ // this branch in production:
52
+ //
53
+ // - The parent session was GC'd by the idle-eviction tick
54
+ // (SESSION_IDLE_MS) while the subagent was running.
55
+ // - The parent session rolled over (SESSION_FRESHNESS_TTL_MS) when a
56
+ // new inbound arrived during a long-running subagent — the channel
57
+ // conversation continues on the new sessionId, but the broadcast
58
+ // still carries the old one.
59
+ // - The parent was a TUI session (the TUI bridge in
60
+ // src/server/index.ts handles it).
61
+ //
62
+ // The right fix for the first two paths is for the broadcast to carry
63
+ // the channel-key coordinate `{ adapter, workspace, chat, thread }` so
64
+ // the bridge can fall back to "any live session for the same channel
65
+ // key" when the exact sessionId no longer matches. That requires
66
+ // extending the broadcast payload (consumed by TUI and channel paths)
67
+ // and gating spawn_subagent to capture the origin coordinates — both
68
+ // non-trivial. Deferred until we see this drop pattern in production
69
+ // logs; the info log line below makes the case diagnosable from logs
70
+ // alone.
71
+ export function createSubagentCompletionBridge(options: SubagentCompletionBridgeOptions): SubagentCompletionBridge {
72
+ const logger = options.logger ?? consoleLogger
73
+ const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
74
+ const parsed = parseSubagentCompletedPayload(msg.payload)
75
+ if (parsed === null) return
76
+ const result = options.router.injectSubagentCompletionReminder(parsed)
77
+ if (result.kind === 'no-live-session') {
78
+ logger.info(
79
+ `[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}`,
80
+ )
81
+ }
82
+ })
83
+ return { stop: unsubscribe }
84
+ }
@@ -12,6 +12,7 @@ export const BUILTIN_COMMAND_NAMES = [
12
12
  'status',
13
13
  'reload',
14
14
  'logs',
15
+ 'inspect',
15
16
  'shell',
16
17
  'compose',
17
18
  'channel',
package/src/cli/index.ts CHANGED
@@ -22,6 +22,7 @@ const main = defineCommand({
22
22
  status: () => import('./status').then((m) => m.statusCommand),
23
23
  reload: () => import('./reload').then((m) => m.reload),
24
24
  logs: () => import('./logs').then((m) => m.logsCommand),
25
+ inspect: () => import('./inspect').then((m) => m.inspectCommand),
25
26
  shell: () => import('./shell').then((m) => m.shellCommand),
26
27
  compose: () => import('./compose').then((m) => m.composeCommand),
27
28
  channel: () => import('./channel').then((m) => m.channelCommand),