typeclaw 0.8.0 → 0.9.1

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 (96) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +75 -15
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/agent/tools/channel-reply.ts +47 -7
  12. package/src/agent/tools/channel-send.ts +43 -11
  13. package/src/agent/tools/runtime-notice.ts +41 -0
  14. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  15. package/src/bundled-plugins/guard/index.ts +14 -1
  16. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  17. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  18. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  20. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  21. package/src/bundled-plugins/guard/policy.ts +7 -0
  22. package/src/bundled-plugins/memory/README.md +76 -62
  23. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  24. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  25. package/src/bundled-plugins/memory/citations.ts +19 -8
  26. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  27. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  28. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  29. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  30. package/src/bundled-plugins/memory/index.ts +257 -16
  31. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  32. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  33. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  34. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  35. package/src/bundled-plugins/memory/memory-retrieval.ts +111 -0
  36. package/src/bundled-plugins/memory/migration.ts +353 -1
  37. package/src/bundled-plugins/memory/paths.ts +42 -0
  38. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  39. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  40. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  41. package/src/bundled-plugins/memory/slug.ts +59 -0
  42. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  43. package/src/bundled-plugins/memory/strength.ts +3 -3
  44. package/src/bundled-plugins/memory/topics.ts +70 -16
  45. package/src/bundled-plugins/security/index.ts +24 -0
  46. package/src/bundled-plugins/security/permissions.ts +4 -0
  47. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  48. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  49. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  50. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  51. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  52. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  53. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  54. package/src/channels/adapters/kakaotalk.ts +65 -38
  55. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  56. package/src/channels/index.ts +5 -0
  57. package/src/channels/router.ts +320 -22
  58. package/src/channels/subagent-completion-bridge.ts +84 -0
  59. package/src/cli/builtins.ts +1 -0
  60. package/src/cli/index.ts +1 -0
  61. package/src/cli/init.ts +122 -14
  62. package/src/cli/inspect.ts +151 -0
  63. package/src/cron/consumer.ts +1 -1
  64. package/src/init/dockerfile.ts +268 -4
  65. package/src/init/hatching.ts +5 -6
  66. package/src/init/kakaotalk-auth.ts +6 -47
  67. package/src/init/validate-api-key.ts +121 -0
  68. package/src/inspect/index.ts +213 -0
  69. package/src/inspect/label.ts +50 -0
  70. package/src/inspect/live.ts +221 -0
  71. package/src/inspect/render.ts +163 -0
  72. package/src/inspect/replay.ts +295 -0
  73. package/src/inspect/session-list.ts +160 -0
  74. package/src/inspect/types.ts +110 -0
  75. package/src/plugin/hooks.ts +23 -1
  76. package/src/plugin/index.ts +2 -0
  77. package/src/plugin/manager.ts +1 -1
  78. package/src/plugin/registry.ts +1 -1
  79. package/src/plugin/types.ts +10 -0
  80. package/src/run/channel-session-factory.ts +7 -1
  81. package/src/run/index.ts +103 -21
  82. package/src/secrets/kakao-renewal.ts +3 -47
  83. package/src/server/index.ts +241 -60
  84. package/src/shared/index.ts +3 -0
  85. package/src/shared/protocol.ts +49 -0
  86. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  87. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  88. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  89. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  90. package/src/skills/typeclaw-config/SKILL.md +1 -1
  91. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  92. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  93. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  94. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  95. package/src/test-helpers/wait-for.ts +7 -1
  96. package/typeclaw.schema.json +15 -1
@@ -6,33 +6,8 @@ import type { InboundMessage } from '@/channels/types'
6
6
 
7
7
  import { slackTsToMillis } from './slack-bot-time'
8
8
 
9
- // Upstream's `SlackSocketModeMessageEvent` carries `[key: string]: unknown`
10
- // for fields it does not type explicitly. Three of those untyped fields are
11
- // load-bearing for this adapter:
12
- // - `parent_user_id`: set on every reply within a thread; identifies the
13
- // author of the message the thread is rooted at. Used to decide whether
14
- // a reply targets the bot, another human, or an unknown parent.
15
- // - `client_msg_id`: client-generated UUID on user-authored messages,
16
- // stable across Slack-side resends of the same gesture. Primary dedupe
17
- // key for the "one user action surfaces as two events" case.
18
- // - `files`: attachments delivered inline on the same message event (Slack
19
- // does not fire a separate file_share for messages we receive).
20
- // Typing them here (rather than reading them via `as` casts at every call
21
- // site) keeps the classifier readable and makes it the single source of
22
- // truth for "what Slack actually sends" — anything else reading these
23
- // fields imports `SlackInboundMessageEvent` from this module.
24
- export type SlackInboundMessageEvent = SlackSocketModeMessageEvent & {
25
- parent_user_id?: string
26
- client_msg_id?: string
27
- files?: SlackFile[]
28
- }
29
-
30
- // `app_mention` envelopes do not always carry `client_msg_id`, but typing
31
- // it keeps the promotion to a message-shaped event lossless if Slack
32
- // starts sending it. Same reasoning as `SlackInboundMessageEvent` above.
33
- export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent & {
34
- client_msg_id?: string
35
- }
9
+ export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
10
+ export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent
36
11
 
37
12
  export type InboundDropReason =
38
13
  | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
@@ -9,6 +9,11 @@ export {
9
9
  type CreateSessionForChannel,
10
10
  } from './router'
11
11
  export { createChannelsReloadable } from './reloadable'
12
+ export {
13
+ createSubagentCompletionBridge,
14
+ type SubagentCompletionBridge,
15
+ type SubagentCompletionBridgeOptions,
16
+ } from './subagent-completion-bridge'
12
17
  export {
13
18
  channelsSchema,
14
19
  ADAPTER_IDS,
@@ -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,
@@ -387,6 +405,21 @@ export type ChannelRouter = {
387
405
  // slack-bot-classify.ts. Read live so a reload of `alias` propagates
388
406
  // to adapters without a restart.
389
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' }
390
423
  stop: () => Promise<void>
391
424
  liveCount: () => number
392
425
  __testing?: {
@@ -396,6 +429,34 @@ export type ChannelRouter = {
396
429
  isTypingActive: (key: ChannelKey) => boolean
397
430
  stopTyping: (key: ChannelKey) => Promise<void>
398
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
399
460
  }
400
461
  }
401
462
 
@@ -800,6 +861,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
800
861
  resolvedNames,
801
862
  originRef,
802
863
  promptQueue: [],
864
+ pendingSystemReminders: [],
803
865
  contextBuffer: [],
804
866
  draining: false,
805
867
  debounceTimer: null,
@@ -811,7 +873,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
811
873
  firstUnprocessedAt: 0,
812
874
  currentTurnAuthorId: null,
813
875
  currentTurnAuthorIds: new Set(),
814
- 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,
815
888
  consecutiveAborts: 0,
816
889
  consecutiveSends: new Map(),
817
890
  lastSentText: new Map(),
@@ -1026,12 +1099,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1026
1099
  }
1027
1100
  }
1028
1101
 
1029
- const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
1102
+ const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
1030
1103
  if (!live.hooks) return
1031
1104
  try {
1032
1105
  await live.hooks.runSessionTurnStart({
1033
1106
  sessionId: live.sessionId,
1034
1107
  agentDir: options.agentDir,
1108
+ userPrompt,
1035
1109
  origin: buildLiveOrigin(live),
1036
1110
  })
1037
1111
  } catch (err) {
@@ -1082,6 +1156,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1082
1156
  live.debounceTimer = null
1083
1157
  live.firstUnprocessedAt = 0
1084
1158
  live.promptQueue.length = 0
1159
+ live.pendingSystemReminders.length = 0
1085
1160
  await stopTypingHeartbeat(live)
1086
1161
  try {
1087
1162
  await live.session.abort()
@@ -1095,7 +1170,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1095
1170
  if (live.draining || live.destroyed) return
1096
1171
  live.draining = true
1097
1172
  try {
1098
- while (live.promptQueue.length > 0 && !live.destroyed) {
1173
+ while ((live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) && !live.destroyed) {
1099
1174
  live.typingTimedOut = false
1100
1175
  // Heartbeat must run during generation as well as during debounce.
1101
1176
  // Because new inbounds during a turn just push into promptQueue
@@ -1104,13 +1179,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1104
1179
  startTypingHeartbeat(live)
1105
1180
  const batch = live.promptQueue.splice(0, live.promptQueue.length)
1106
1181
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1107
- 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
+ })
1108
1187
 
1109
- live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
1110
- live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1111
1188
  if (batch.length > 0) {
1189
+ live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1190
+ live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1112
1191
  live.consecutiveSends.clear()
1113
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)
1114
1208
  }
1115
1209
 
1116
1210
  // Update the live origin holder so this turn's tool.before events
@@ -1127,7 +1221,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1127
1221
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1128
1222
  const promptStart = now()
1129
1223
  const successfulSendsBeforePrompt = live.successfulChannelSends
1130
- await fireSessionTurnStart(live)
1224
+ await fireSessionTurnStart(live, text)
1131
1225
  try {
1132
1226
  await live.session.prompt(text)
1133
1227
  await validateChannelTurn(live, successfulSendsBeforePrompt)
@@ -1142,6 +1236,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1142
1236
  }
1143
1237
  await fireSessionIdle(live)
1144
1238
  live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
1239
+ if (live.currentTurnAuthorId !== null) {
1240
+ live.lastTurnAuthorId = live.currentTurnAuthorId
1241
+ }
1145
1242
  }
1146
1243
  } finally {
1147
1244
  live.draining = false
@@ -1645,8 +1742,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1645
1742
  const assistantText = latestAssistantText(live.session)
1646
1743
  if (assistantText === null) return
1647
1744
 
1648
- if (isNoReplySignal(assistantText)) {
1649
- logger.info(`[channels] ${live.keyId} no_reply`)
1745
+ if (endsWithNoReplySignal(assistantText)) {
1746
+ const leakedReasoning = !isNoReplySignal(assistantText)
1747
+ logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
1650
1748
  return
1651
1749
  }
1652
1750
 
@@ -1657,6 +1755,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1657
1755
  return
1658
1756
  }
1659
1757
 
1758
+ if (isLikelyKimiChannelToolLeak(assistantText)) {
1759
+ logger.warn(`[channels] ${live.keyId}: suppressed kimi_tool_call_leak text_len=${assistantText.length}`)
1760
+ return
1761
+ }
1762
+
1660
1763
  logger.warn(
1661
1764
  `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
1662
1765
  )
@@ -1743,6 +1846,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1743
1846
  if (live.destroyed) continue
1744
1847
  if (live.draining) continue
1745
1848
  if (live.promptQueue.length > 0) continue
1849
+ // pendingSystemReminders is checked alongside promptQueue because both
1850
+ // represent pending work that drain() will process. Today's only
1851
+ // populator (injectSubagentCompletionReminder) also fires drain()
1852
+ // synchronously, which sets draining=true and shadows this guard via
1853
+ // the line above — but the guard exists to keep the invariant honest
1854
+ // for any future caller that queues a reminder without immediately
1855
+ // waking the drain loop.
1856
+ if (live.pendingSystemReminders.length > 0) continue
1746
1857
  if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
1747
1858
  victims.push(live)
1748
1859
  }
@@ -1812,6 +1923,45 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1812
1923
  return { kind: 'unknown-command', name: lowered }
1813
1924
  }
1814
1925
 
1926
+ const injectSubagentCompletionReminder = (args: {
1927
+ parentSessionId: string
1928
+ subagent: string
1929
+ taskId: string
1930
+ ok: boolean
1931
+ durationMs: number
1932
+ error?: string
1933
+ }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
1934
+ for (const live of liveSessions.values()) {
1935
+ if (live.destroyed) continue
1936
+ if (live.sessionId !== args.parentSessionId) continue
1937
+ const text = renderSubagentCompletionReminder({
1938
+ subagent: args.subagent,
1939
+ taskId: args.taskId,
1940
+ ok: args.ok,
1941
+ durationMs: args.durationMs,
1942
+ ...(args.error !== undefined ? { error: args.error } : {}),
1943
+ channel: true,
1944
+ })
1945
+ live.pendingSystemReminders.push(text)
1946
+ logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
1947
+ // Wake the drain loop. If a turn is already in flight, the wakeup is
1948
+ // a no-op because drain() will pick up the reminder on its next
1949
+ // iteration (it now gates on promptQueue OR pendingSystemReminders).
1950
+ // If the session is idle, fire drain() immediately rather than going
1951
+ // through the debounce path — the reminder is not a user inbound,
1952
+ // so the "coalesce nearby inbounds" rationale for debouncing does
1953
+ // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
1954
+ // semantics: the channel router doesn't have a `delivery: interrupt`
1955
+ // mechanism (no in-flight abort during a turn), but firing drain()
1956
+ // immediately is the equivalent for an idle session.
1957
+ if (!live.draining) {
1958
+ void drain(live)
1959
+ }
1960
+ return { kind: 'delivered', keyId: live.keyId }
1961
+ }
1962
+ return { kind: 'no-live-session' }
1963
+ }
1964
+
1815
1965
  return {
1816
1966
  route,
1817
1967
  send,
@@ -1833,6 +1983,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1833
1983
  fetchAttachment,
1834
1984
  executeCommand,
1835
1985
  getSelfAliases: computeSelfAliases,
1986
+ injectSubagentCompletionReminder,
1836
1987
  stop,
1837
1988
  liveCount: () => liveSessions.size,
1838
1989
  __testing: {
@@ -1876,6 +2027,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1876
2027
  await stopTypingHeartbeat(live)
1877
2028
  },
1878
2029
  runIdleGc,
2030
+ getLiveOriginSnapshot: (key: ChannelKey) => {
2031
+ const live = liveSessions.get(channelKeyId(key))
2032
+ const origin = live?.originRef.current
2033
+ if (origin === undefined) return undefined
2034
+ return { ...origin }
2035
+ },
2036
+ getLiveAuthorState: (key: ChannelKey) => {
2037
+ const live = liveSessions.get(channelKeyId(key))
2038
+ if (live === undefined) return undefined
2039
+ return {
2040
+ currentTurnAuthorId: live.currentTurnAuthorId,
2041
+ currentTurnAuthorIds: Array.from(live.currentTurnAuthorIds),
2042
+ lastTurnAuthorId: live.lastTurnAuthorId,
2043
+ lastTurnAuthorIds: Array.from(live.lastTurnAuthorIds),
2044
+ }
2045
+ },
1879
2046
  },
1880
2047
  }
1881
2048
  }
@@ -1883,27 +2050,50 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1883
2050
  function composeTurnPrompt(
1884
2051
  observed: readonly ObservedInbound[],
1885
2052
  batch: readonly QueuedInbound[],
1886
- state: { loopGuardActive: boolean } = { loopGuardActive: false },
2053
+ state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
1887
2054
  ): string {
1888
2055
  const parts: string[] = []
2056
+ // System reminders (subagent-completion wakeups today) lead the turn body
2057
+ // because they are typically what triggered the drain — when the prompt
2058
+ // queue is empty and the only thing in this iteration is a reminder, the
2059
+ // model needs to see the reminder before any optional context. The
2060
+ // reminder block is self-fenced by its <system-reminder> tags, so no
2061
+ // extra framing is needed and the model already learns this shape from
2062
+ // the TUI path; channel sessions see the same tags.
2063
+ if (state.systemReminders && state.systemReminders.length > 0) {
2064
+ for (const reminder of state.systemReminders) {
2065
+ parts.push(reminder)
2066
+ }
2067
+ parts.push('')
2068
+ }
1889
2069
  // Loop-guard notice lives in the user-turn text (recomposed every drain)
1890
2070
  // rather than in the system prompt so it does not invalidate the
1891
2071
  // prompt-prefix cache. The cached prefix covers system + tools + earlier
1892
2072
  // turns; the current user-turn suffix is non-cacheable by design, so
1893
2073
  // adding a section here is cache-neutral.
1894
2074
  //
1895
- // SYSTEM MESSAGE convention: any runtime-injected block in the user turn
1896
- // that is NOT from a chat participant must use the
1897
- // `**[SYSTEM MESSAGE — not from a human]**` framing fenced by horizontal
1898
- // rules (`---`). This is structurally distinct from the H2 sections used
1899
- // for actual conversation content (`## Recent context`,
2075
+ // SYSTEM MESSAGE convention: any runtime-injected block in the user
2076
+ // turn that is NOT from a chat participant MUST use the
2077
+ // `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
2078
+ // horizontal rules (`---`) the loop-guard block below is the
2079
+ // canonical example. This is structurally distinct from the H2
2080
+ // sections used for actual conversation content (`## Recent context`,
1900
2081
  // `## Current message`). Without the fencing, models — especially
1901
2082
  // persona-rich ones like Kimi — read the heading as a human-authored
1902
2083
  // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
1903
- // bracketed marker plus the explicit "Do not acknowledge or reply to this
1904
- // notice" line is the trust boundary that prevents this. New runtime
1905
- // notices (rate-limit, schema-mismatch, abort signals, etc.) MUST follow
1906
- // this same convention so models learn the pattern.
2084
+ // bracketed marker plus the explicit "Do not acknowledge or reply to
2085
+ // this notice" line is the trust boundary that prevents this. New
2086
+ // runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
2087
+ // MUST follow this convention.
2088
+ //
2089
+ // ONE narrow exception exists: subagent-completion reminders use
2090
+ // `<system-reminder>...</system-reminder>` tags (prepended above) for
2091
+ // parity with the TUI path's identical tagging (see
2092
+ // `renderSubagentCompletionReminder` in
2093
+ // `src/agent/subagent-completion-reminder.ts`) so the model sees the
2094
+ // same shape across origins. The exception is scoped to that single
2095
+ // case: do NOT extend it to new notice types. Anything that is not
2096
+ // a true subagent-style completion ping uses framing 1.
1907
2097
  if (state.loopGuardActive) {
1908
2098
  parts.push(
1909
2099
  '---',
@@ -1930,10 +2120,23 @@ function composeTurnPrompt(
1930
2120
  parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
1931
2121
  }
1932
2122
  parts.push('')
1933
- parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
1934
2123
  }
1935
- for (const b of batch) {
1936
- parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2124
+ // Only emit the `## Current message(s)` header when there is at least one
2125
+ // queued inbound to live under it. A reminder-only wakeup (subagent
2126
+ // completion firing while the prompt queue is empty) used to print the
2127
+ // header with zero lines underneath; persona-rich models read the empty
2128
+ // header as "there must be a current message addressed to me" and
2129
+ // hallucinated content to reply to. The header is now batch-gated; the
2130
+ // reminder block above and any observed context still render normally.
2131
+ if (batch.length > 0) {
2132
+ if (observed.length > 0) {
2133
+ parts.push(
2134
+ batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)',
2135
+ )
2136
+ }
2137
+ for (const b of batch) {
2138
+ parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2139
+ }
1937
2140
  }
1938
2141
  return parts.join('\n')
1939
2142
  }
@@ -2133,6 +2336,45 @@ export function isNoReplySignal(text: string): boolean {
2133
2336
  return false
2134
2337
  }
2135
2338
 
2339
+ // Looser sibling of isNoReplySignal, used ONLY by validateChannelTurn's
2340
+ // recovery path. Catches leaked-reasoning turns where the model produced
2341
+ // prose and then ended with the silent-turn token, e.g.
2342
+ // "The user is laughing. ... I'll end with NO_REPLY.NO_REPLY"
2343
+ // Today those fall through to recovery and the entire reasoning paragraph
2344
+ // gets posted to the channel — the worst-possible outcome, since the leaked
2345
+ // prose is itself an admission that the model intended to stay silent.
2346
+ //
2347
+ // NOT shared with channel_send / channel_reply misuse guards: those need
2348
+ // strict literal match so a legitimate message like "set NO_REPLY=true in
2349
+ // the env" isn't rejected as a misuse of the silent-turn signal. Recovery
2350
+ // is a different question — by the time we get here the model already
2351
+ // failed to call the tool, and "ends in NO_REPLY" is strong evidence of
2352
+ // intent to stay silent, not of intent to send those bytes.
2353
+ //
2354
+ // Matches (returns true):
2355
+ // "NO_REPLY" (strict)
2356
+ // "(NO_REPLY)" (strict, parenthesized)
2357
+ // "... I'll end with NO_REPLY" (trailing token after whitespace)
2358
+ // "... end with NO_REPLY." (+ sentence punctuation)
2359
+ // "... end with NO_REPLY.NO_REPLY" (model-doubled terminator, glued)
2360
+ // "... and stop. (NO_REPLY)" (parenthesized at end)
2361
+ // Does not match (returns false):
2362
+ // "NO_REPLY means do nothing" (token at start, prose after)
2363
+ // "the env var is NO_REPLY_MODE" (substring, not whole token)
2364
+ // "no reply needed" (case-sensitive on purpose)
2365
+ export function endsWithNoReplySignal(text: string): boolean {
2366
+ if (isNoReplySignal(text)) return true
2367
+ const trimmed = text.trim()
2368
+ if (trimmed === '') return false
2369
+ // Strip trailing sentence punctuation / closing brackets / whitespace, then
2370
+ // check the last whitespace-or-punctuation-separated token. The leading
2371
+ // boundary in the regex (`[\s.!?([]`) treats `.NO_REPLY` as a separate
2372
+ // token from the preceding sentence, which covers the model-doubled
2373
+ // `...NO_REPLY.NO_REPLY` shape.
2374
+ const tail = trimmed.replace(/[.!?)\]\s]+$/, '')
2375
+ return /(?:^|[\s.!?([])\(?NO_REPLY\)?$/.test(tail)
2376
+ }
2377
+
2136
2378
  // Detects the upstream "empty response" debug sentinel: when the LLM ends a
2137
2379
  // turn with only a `thinking` block, some provider SDK paths (observed
2138
2380
  // against claude-opus-4-5 via pi-ai) fabricate a single text block whose
@@ -2158,6 +2400,62 @@ export function isUpstreamEmptyResponseSentinel(text: string): boolean {
2158
2400
  return trimmed.includes("'stop_reason'")
2159
2401
  }
2160
2402
 
2403
+ // Detects any Kimi-family tool-call delimiter token. Kimi-family deployments
2404
+ // emit tool calls inline in their native chat template using these tokens:
2405
+ //
2406
+ // <|tool_calls_section_begin|>
2407
+ // <|tool_call_begin|>functions.<name>:<idx><|tool_call_argument_begin|>{...}<|tool_call_end|>
2408
+ // <|tool_calls_section_end|>
2409
+ //
2410
+ // (Source: https://github.com/MoonshotAI/Kimi-K2/blob/1b4022b/docs/tool_call_guidance.md;
2411
+ // the documented set is exactly five tokens — the section begin/end markers,
2412
+ // the per-call begin/end markers, and the argument-begin separator. There is
2413
+ // no `<|tool_call_argument_end|>`: arguments terminate at `<|tool_call_end|>`.)
2414
+ //
2415
+ // Production inference servers are expected to parse this format server-side
2416
+ // and translate it into OpenAI-shaped `choice.delta.tool_calls`. When the
2417
+ // translation breaks (observed against Fireworks' `kimi-k2p6-turbo` router on
2418
+ // 2026-05-24; vLLM had a similar class of leak fixed in
2419
+ // https://github.com/vllm-project/vllm/pull/38579), the raw tokens flow
2420
+ // through `choice.delta.content` instead. pi-ai's `openai-completions`
2421
+ // provider is vendor-neutral and has no Kimi-specific parser, so they land
2422
+ // verbatim in the assistant message's text content with `stopReason: 'stop'`.
2423
+ //
2424
+ // Used as a defense-in-depth check at the `channel_send` / `channel_reply`
2425
+ // tool boundary so a model that somehow passes raw delimiter text as the
2426
+ // message body is denied. NOT used directly by the recovery path in
2427
+ // `validateChannelTurn` — see `isLikelyKimiChannelToolLeak` below.
2428
+ const KIMI_TOOL_DELIMITER_RE = /<\|tool_calls_section_(?:begin|end)\|>|<\|tool_call_(?:begin|end|argument_begin)\|>/
2429
+
2430
+ export function containsKimiToolDelimiter(text: string): boolean {
2431
+ return KIMI_TOOL_DELIMITER_RE.test(text)
2432
+ }
2433
+
2434
+ // Narrower predicate used by `validateChannelTurn` to decide whether to
2435
+ // suppress recovery of assistant text. Requires BOTH:
2436
+ // (1) at least one Kimi tool-call delimiter token, AND
2437
+ // (2) a recognizable channel-tool-call identifier (`channel_reply:N` or
2438
+ // `channel_send:N`, with or without the `functions.` prefix).
2439
+ //
2440
+ // The two-signal rule narrows the false-positive surface to "the model was
2441
+ // trying to call a channel tool and the upstream parser failed". Bare-text
2442
+ // discussion of the Kimi protocol — e.g. the agent answering "explain Kimi's
2443
+ // tool-call format" with documentation-style prose containing `<|tool_call_begin|>`
2444
+ // — does NOT trigger suppression and reaches the user normally. The leak shape
2445
+ // observed in production (`channel_reply:0<|tool_call_argument_begin|>{...}<|tool_calls_section_end|>`)
2446
+ // satisfies both conditions trivially.
2447
+ //
2448
+ // The tool-name regex deliberately stays loose on the index suffix
2449
+ // (`channel_reply:0` / `channel_reply:1` / `channel_send:0` / ...): every
2450
+ // observed leak uses the canonical `functions.<name>:<idx>` shape, but partial
2451
+ // parsers may strip the `functions.` prefix before the leak surfaces.
2452
+ const KIMI_CHANNEL_TOOL_ID_RE = /(?:functions\.)?channel_(?:reply|send):\d+/
2453
+
2454
+ export function isLikelyKimiChannelToolLeak(text: string): boolean {
2455
+ if (!containsKimiToolDelimiter(text)) return false
2456
+ return KIMI_CHANNEL_TOOL_ID_RE.test(text)
2457
+ }
2458
+
2161
2459
  function describe(err: unknown): string {
2162
2460
  return err instanceof Error ? err.message : String(err)
2163
2461
  }
@@ -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',