switchroom 0.13.8 → 0.13.10

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.
@@ -281,6 +281,7 @@ import {
281
281
  buildVaultSaveFailedInbound,
282
282
  buildVaultSaveDiscardedInbound,
283
283
  } from './vault-grant-inbound-builders.js'
284
+ import { buildSubagentHandbackInbound } from './subagent-handback-inbound-builder.js'
284
285
  import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
285
286
  import type {
286
287
  ToolCallMessage,
@@ -3438,11 +3439,15 @@ const ipcServer: IpcServer = createIpcServer({
3438
3439
  },
3439
3440
 
3440
3441
  onClientDisconnected(client: IpcClient) {
3441
- process.stderr.write(`telegram gateway: bridge disconnected agent=${client.agentName}\n`)
3442
- // Phase 2b shadow: ONLY emit bridgeDown for the REAL bridge sidecar
3443
- // (matching the bridgeUp gate above). Anonymous IPC clients
3444
- // disconnect frequently those are not bridge flaps.
3442
+ // ONLY log "bridge disconnected" + emit bridgeDown for the REAL
3443
+ // bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
3444
+ // clients e.g. recall.py's one-shot legacy update_placeholder
3445
+ // handshakeconnect and disconnect constantly with agentName=null;
3446
+ // logging those as "bridge disconnected — agent=null" reads as a
3447
+ // bridge flap in the supervisor log when nothing flapped. The
3448
+ // anonymous disconnect is still traced by disconnect-flush.ts.
3445
3449
  if (client.agentName != null) {
3450
+ process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
3446
3451
  shadowEmit({ kind: 'bridgeDown', at: Date.now() })
3447
3452
  }
3448
3453
 
@@ -3768,6 +3773,102 @@ const ipcServer: IpcServer = createIpcServer({
3768
3773
  })
3769
3774
  },
3770
3775
 
3776
+ /**
3777
+ * #1623 — hostd-initiated config-edit approval card. hostd posts a
3778
+ * `request_config_approval` message; we render the card via
3779
+ * `handleRequestConfigApproval`, awaiting the operator tap (or
3780
+ * timeout) before sending `config_approval_resolved` back. After
3781
+ * hostd's apply attempt completes it posts
3782
+ * `request_config_finalize` to flip the card to the terminal
3783
+ * outcome.
3784
+ */
3785
+ async onRequestConfigApproval(client: IpcClient, msg) {
3786
+ const { handleRequestConfigApproval } = await import(
3787
+ './config-approval-handler.js'
3788
+ )
3789
+ const { InlineKeyboard } = await import('grammy')
3790
+ await handleRequestConfigApproval(client, msg, {
3791
+ agentName: getMyAgentName(),
3792
+ loadTargetChat: () => {
3793
+ const access = loadAccess()
3794
+ const operator = access.allowFrom[0]
3795
+ if (operator === undefined) return null
3796
+ return { chatId: operator }
3797
+ },
3798
+ buildKeyboard: (requestId) =>
3799
+ new InlineKeyboard()
3800
+ .text('✅ Approve', `cfg:${requestId}:approve`)
3801
+ .text('🚫 Deny', `cfg:${requestId}:deny`),
3802
+ postCard: async (args) => {
3803
+ try {
3804
+ const sent = await robustApiCall(
3805
+ () =>
3806
+ bot.api.sendMessage(args.chatId, args.text, {
3807
+ parse_mode: 'HTML',
3808
+ ...(args.threadId !== undefined
3809
+ ? { message_thread_id: args.threadId }
3810
+ : {}),
3811
+ reply_markup: args.replyMarkup as never,
3812
+ }),
3813
+ {
3814
+ chat_id: String(args.chatId),
3815
+ verb: 'config-approval-card',
3816
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
3817
+ },
3818
+ )
3819
+ return { messageId: (sent as { message_id: number }).message_id }
3820
+ } catch (err) {
3821
+ process.stderr.write(
3822
+ `telegram gateway: config-approval postCard failed: ${(err as Error).message}\n`,
3823
+ )
3824
+ return null
3825
+ }
3826
+ },
3827
+ editCard: async (args) => {
3828
+ try {
3829
+ await robustApiCall(
3830
+ () =>
3831
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
3832
+ parse_mode: 'HTML',
3833
+ }),
3834
+ { chat_id: String(args.chatId), verb: 'config-approval-edit' },
3835
+ )
3836
+ } catch (err) {
3837
+ process.stderr.write(
3838
+ `telegram gateway: config-approval editCard failed: ${(err as Error).message}\n`,
3839
+ )
3840
+ }
3841
+ },
3842
+ log: (m) =>
3843
+ process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
3844
+ })
3845
+ },
3846
+
3847
+ async onRequestConfigFinalize(client: IpcClient, msg) {
3848
+ const { handleRequestConfigFinalize } = await import(
3849
+ './config-approval-handler.js'
3850
+ )
3851
+ await handleRequestConfigFinalize(client, msg, {
3852
+ editCard: async (args) => {
3853
+ try {
3854
+ await robustApiCall(
3855
+ () =>
3856
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
3857
+ parse_mode: 'HTML',
3858
+ }),
3859
+ { chat_id: String(args.chatId), verb: 'config-approval-finalize' },
3860
+ )
3861
+ } catch (err) {
3862
+ process.stderr.write(
3863
+ `telegram gateway: config-finalize editCard failed: ${(err as Error).message}\n`,
3864
+ )
3865
+ }
3866
+ },
3867
+ log: (m) =>
3868
+ process.stderr.write(`telegram gateway: config-finalize — ${m}\n`),
3869
+ })
3870
+ },
3871
+
3771
3872
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
3772
3873
  const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
3773
3874
  ? msg.inbound.meta.prompt_key
@@ -12716,6 +12817,55 @@ bot.on('callback_query:data', async ctx => {
12716
12817
  return
12717
12818
  }
12718
12819
 
12820
+ // #1623: cfg:<requestId>:<approve|deny> — hostd config-edit
12821
+ // approval card. Resolves the pending approval (gateway-side
12822
+ // in-memory map), sends `config_approval_resolved` back to hostd
12823
+ // over the original IPC connection, and edits the card to the
12824
+ // interim "Applying…" / "Denied" state. Same operator-allowlist
12825
+ // gate as every other callback family.
12826
+ if (data.startsWith('cfg:')) {
12827
+ const access = loadAccess()
12828
+ const senderId = String(ctx.from?.id ?? '')
12829
+ if (!access.allowFrom.includes(senderId)) {
12830
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
12831
+ return
12832
+ }
12833
+ const { parseConfigApprovalCallback, resolvePendingConfigApproval } =
12834
+ await import('./config-approval-handler.js')
12835
+ const parsed = parseConfigApprovalCallback(data)
12836
+ if (parsed === null) {
12837
+ await ctx.answerCallbackQuery({ text: 'Malformed callback.' })
12838
+ return
12839
+ }
12840
+ const resolved = await resolvePendingConfigApproval(
12841
+ parsed.requestId,
12842
+ parsed.choice,
12843
+ {
12844
+ editCard: async (args) => {
12845
+ try {
12846
+ await robustApiCall(() =>
12847
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
12848
+ parse_mode: 'HTML',
12849
+ }),
12850
+ )
12851
+ } catch {
12852
+ /* best effort */
12853
+ }
12854
+ },
12855
+ log: (m) =>
12856
+ process.stderr.write(`telegram gateway: config-approval cb — ${m}\n`),
12857
+ },
12858
+ )
12859
+ await ctx.answerCallbackQuery({
12860
+ text: resolved
12861
+ ? parsed.choice === 'approve'
12862
+ ? 'Approving…'
12863
+ : 'Denied'
12864
+ : 'Already resolved.',
12865
+ })
12866
+ return
12867
+ }
12868
+
12719
12869
  // RFC E §4.1: drvpick:<verb>:<agent>[:<...>] — folder-picker card taps.
12720
12870
  // open / enter / back / refresh re-render the card in place;
12721
12871
  // grant writes an allow_always kernel decision at
@@ -14897,25 +15047,38 @@ void (async () => {
14897
15047
  )
14898
15048
  }
14899
15049
  },
14900
- // #card-audit-log: symmetric sub_agent_finished surface.
14901
- // The driver's per-chat shadow knows the parent turnKey and
14902
- // the registry DB carries the background flag — combine them
14903
- // into a single audit-log line for retrospective debugging.
14904
- onFinish: ({ agentId, outcome, toolCount, durationMs }) => {
14905
- let parentTurnKey = ''
15050
+ // conversational-pacing beat 4 — the handback. A foreground
15051
+ // sub-agent hands its result straight back as the Task tool
15052
+ // result, inside the parent's own turn; the model sees it
15053
+ // in-context. A *background* sub-agent does not it
15054
+ // finishes decoupled from any turn boundary, with the parent
15055
+ // typically idle and no turn to receive the result. Without
15056
+ // a nudge the user never hears back until they send the next
15057
+ // message themselves. So: when a background sub-agent
15058
+ // terminates, wake the agent with a `subagent_handback`
15059
+ // inbound carrying the worker's result text, and let it
15060
+ // synthesise a user-facing handback in its own voice.
15061
+ //
15062
+ // Gated to background completions: foreground sub-agents
15063
+ // need nothing here, and 'orphan' is a stale historical-at-
15064
+ // boot row, not a fresh completion the user is waiting on.
15065
+ onFinish: ({ agentId, outcome, description, resultText }) => {
15066
+ if (process.env.SWITCHROOM_SUBAGENT_HANDBACK === '0') return
15067
+ if (outcome !== 'completed' && outcome !== 'failed') return
15068
+
14906
15069
  let chatId = ''
14907
15070
  let isBackground = false
14908
15071
  try {
14909
15072
  const fleets = progressDriver?.peekAllFleets() ?? []
14910
15073
  for (const f of fleets) {
14911
15074
  if (f.fleet.has(agentId)) {
14912
- parentTurnKey = f.turnKey
14913
15075
  chatId = f.chatId ?? ''
14914
15076
  break
14915
15077
  }
14916
15078
  }
14917
15079
  } catch {
14918
- // peek failures are non-fatal — we still emit the event.
15080
+ // peek failures are non-fatal — fall through to the
15081
+ // owner-chat fallback below.
14919
15082
  }
14920
15083
  if (turnsDb != null) {
14921
15084
  try {
@@ -14925,15 +15088,37 @@ void (async () => {
14925
15088
  if (row != null) isBackground = row.background === 1
14926
15089
  } catch { /* best-effort */ }
14927
15090
  }
14928
- // #1122 PR3: card-event-log emission removed with the
14929
- // progress card. Sub-agent completion is now visible
14930
- // via the agent's own chat narration.
14931
- const finalOutcome: 'completed' | 'orphan' | 'background' =
14932
- isBackground ? 'background' : (outcome === 'completed' ? 'completed' : 'orphan')
14933
- void finalOutcome
14934
- void parentTurnKey
14935
- void durationMs
14936
- void toolCount
15091
+ if (!isBackground) return
15092
+
15093
+ // chatId fallback: if the progress-driver fleet entry was
15094
+ // already cleaned up by the time onFinish fires, route to
15095
+ // the owner chat. Every switchroom fleet agent is
15096
+ // DM-shaped, so allowFrom[0] is the conversation that
15097
+ // dispatched the work.
15098
+ const handbackChatId = chatId || (loadAccess().allowFrom[0] ?? '')
15099
+ if (!handbackChatId) {
15100
+ process.stderr.write(
15101
+ `telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
15102
+ )
15103
+ return
15104
+ }
15105
+
15106
+ const inbound = buildSubagentHandbackInbound({
15107
+ ctx: {
15108
+ chatId: String(handbackChatId),
15109
+ taskDescription: description,
15110
+ resultText,
15111
+ outcome,
15112
+ },
15113
+ })
15114
+ // Deliver via pendingInboundBuffer + the idle-drain tick.
15115
+ // The drain only releases at an idle prompt (no active
15116
+ // turn), so the handback always lands as a clean fresh
15117
+ // turn and never races a turn-in-flight composer (#1556).
15118
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', inbound)
15119
+ process.stderr.write(
15120
+ `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${handbackChatId} resultChars=${resultText.length}\n`,
15121
+ )
14937
15122
  },
14938
15123
  })
14939
15124
  process.stderr.write('telegram gateway: subagent-watcher active\n')
@@ -93,13 +93,32 @@ export interface DriveApprovalPostedEvent {
93
93
  reason?: string;
94
94
  }
95
95
 
96
+ /**
97
+ * hostd config-edit approval — sent by the gateway back to hostd
98
+ * after the operator taps Approve/Deny on a `request_config_approval`
99
+ * card (or the 10-minute timeout elapses).
100
+ *
101
+ * The gateway is the source of truth for the verdict; hostd treats
102
+ * this as a one-shot reply per `requestId`. Subsequent taps on the
103
+ * same card are ignored at the callback dispatcher (#1623, RFC §3.4).
104
+ */
105
+ export interface ConfigApprovalResolvedEvent {
106
+ type: "config_approval_resolved";
107
+ /** Echoes the requestId from the originating request_config_approval. */
108
+ requestId: string;
109
+ verdict: "approve" | "deny" | "timeout";
110
+ /** Diagnostic detail when present (currently unused; reserved). */
111
+ reason?: string;
112
+ }
113
+
96
114
  export type GatewayToClient =
97
115
  | InboundMessage
98
116
  | PermissionEvent
99
117
  | StatusEvent
100
118
  | ToolCallResult
101
119
  | ScheduleRestartResult
102
- | DriveApprovalPostedEvent;
120
+ | DriveApprovalPostedEvent
121
+ | ConfigApprovalResolvedEvent;
103
122
 
104
123
  // === Bridge (Client) -> Gateway messages ===
105
124
 
@@ -278,6 +297,55 @@ export interface RequestDriveApprovalMessage {
278
297
  ttlMs?: number;
279
298
  }
280
299
 
300
+ /**
301
+ * hostd config-edit approval — sent by hostd to the caller agent's
302
+ * gateway to render an approval card with the full unified diff in
303
+ * the operator's primary chat. The gateway:
304
+ *
305
+ * 1. Posts a Telegram card with [✅ Approve] [🚫 Deny] buttons
306
+ * using callback_data `cfg:<requestId>:approve` / `:deny`.
307
+ * 2. Tracks the pending request in-memory (no SQLite).
308
+ * 3. On button tap (or 10-minute timeout) sends a single
309
+ * `config_approval_resolved` event back over the same
310
+ * connection.
311
+ * 4. After hostd reports the apply outcome via
312
+ * `request_config_finalize`, edits the card body to the final
313
+ * state ("✅ applied" / "⚠️ reconcile failed; rolled back" /
314
+ * "🚫 denied" / "⏱ expired").
315
+ *
316
+ * Trust model: same as request_drive_approval — the gateway socket
317
+ * lives inside the agent container, only that-UID processes can
318
+ * connect. hostd reaches it via the per-agent state-dir bind mount
319
+ * (`<state-dir>/gateway.sock`).
320
+ */
321
+ export interface RequestConfigApprovalMessage {
322
+ type: "request_config_approval";
323
+ /** Hostd-generated stable id (8-hex). Echoed in resolved/finalize. */
324
+ requestId: string;
325
+ /** Name of the admin agent that called config_propose_edit. */
326
+ agentName: string;
327
+ /** Operator-visible justification (≤500 chars). */
328
+ reason: string;
329
+ /** Full unified diff to render in a code block on the card. */
330
+ unifiedDiff: string;
331
+ /** Card timeout in milliseconds (gateway-enforced). */
332
+ timeoutMs: number;
333
+ }
334
+
335
+ /**
336
+ * Sent by hostd after the apply attempt completes (success OR
337
+ * rollback) so the gateway can edit the approval card body to a
338
+ * terminal state. Idempotent: if the card was already edited (e.g.
339
+ * by the timeout path), the second edit is a best-effort no-op.
340
+ */
341
+ export interface RequestConfigFinalizeMessage {
342
+ type: "request_config_finalize";
343
+ requestId: string;
344
+ outcome: "applied" | "reconcile_failed_rolled_back";
345
+ /** Optional short diagnostic appended to the card body. */
346
+ detail?: string;
347
+ }
348
+
281
349
  export type ClientToGateway =
282
350
  | RegisterMessage
283
351
  | ToolCallMessage
@@ -289,4 +357,6 @@ export type ClientToGateway =
289
357
  | PtyPartialForward
290
358
  | UpdatePlaceholderMessage
291
359
  | InjectInboundMessage
292
- | RequestDriveApprovalMessage;
360
+ | RequestDriveApprovalMessage
361
+ | RequestConfigApprovalMessage
362
+ | RequestConfigFinalizeMessage;
@@ -8,6 +8,8 @@ import type {
8
8
  PermissionRequestForward,
9
9
  PtyPartialForward,
10
10
  RegisterMessage,
11
+ RequestConfigApprovalMessage,
12
+ RequestConfigFinalizeMessage,
11
13
  RequestDriveApprovalMessage,
12
14
  ScheduleRestartMessage,
13
15
  SessionEventForward,
@@ -53,6 +55,26 @@ export interface IpcServerOptions {
53
55
  client: IpcClient,
54
56
  msg: RequestDriveApprovalMessage,
55
57
  ) => Promise<void>;
58
+ /**
59
+ * #1623 — hostd-initiated config-edit approval card. Handler posts
60
+ * a Telegram card with [✅ Approve] [🚫 Deny] buttons, tracks the
61
+ * pending request in-memory, and sends `config_approval_resolved`
62
+ * back over the same connection when the operator taps or the
63
+ * timeout fires. Optional: gateways without the hostd integration
64
+ * configured ignore these messages.
65
+ */
66
+ onRequestConfigApproval?: (
67
+ client: IpcClient,
68
+ msg: RequestConfigApprovalMessage,
69
+ ) => Promise<void>;
70
+ /**
71
+ * #1623 — hostd-initiated terminal card edit after apply completed
72
+ * (success OR rolled-back). Best-effort; no reply expected.
73
+ */
74
+ onRequestConfigFinalize?: (
75
+ client: IpcClient,
76
+ msg: RequestConfigFinalizeMessage,
77
+ ) => Promise<void>;
56
78
  log?: (msg: string) => void;
57
79
  /**
58
80
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -205,6 +227,35 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
205
227
  && typeof inb.meta === "object"
206
228
  && inb.meta !== null;
207
229
  }
230
+ case "request_config_approval": {
231
+ // #1623 — hostd-initiated config-edit approval card. Wire shape
232
+ // only; the handler module validates the diff content.
233
+ if (typeof m.requestId !== "string"
234
+ || (m.requestId as string).length === 0
235
+ || (m.requestId as string).length > 64) return false;
236
+ if (typeof m.agentName !== "string"
237
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
238
+ if (typeof m.reason !== "string"
239
+ || (m.reason as string).length === 0
240
+ || (m.reason as string).length > 500) return false;
241
+ if (typeof m.unifiedDiff !== "string"
242
+ || (m.unifiedDiff as string).length === 0) return false;
243
+ if (typeof m.timeoutMs !== "number"
244
+ || !Number.isFinite(m.timeoutMs)
245
+ || (m.timeoutMs as number) <= 0) return false;
246
+ return true;
247
+ }
248
+ case "request_config_finalize": {
249
+ if (typeof m.requestId !== "string"
250
+ || (m.requestId as string).length === 0
251
+ || (m.requestId as string).length > 64) return false;
252
+ if (m.outcome !== "applied"
253
+ && m.outcome !== "reconcile_failed_rolled_back") return false;
254
+ if (m.detail !== undefined
255
+ && (typeof m.detail !== "string"
256
+ || (m.detail as string).length > 500)) return false;
257
+ return true;
258
+ }
208
259
  case "request_drive_approval": {
209
260
  // RFC E §4.2 Cut 2. Validate the wire-shaped fields the
210
261
  // gateway will route on; the inner `preview` is treated as
@@ -241,6 +292,8 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
241
292
  onPtyPartial,
242
293
  onInjectInbound,
243
294
  onRequestDriveApproval,
295
+ onRequestConfigApproval,
296
+ onRequestConfigFinalize,
244
297
  log = () => {},
245
298
  heartbeatTimeoutMs = 30_000,
246
299
  } = options;
@@ -383,6 +436,54 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
383
436
  }
384
437
  }
385
438
  break;
439
+ case "request_config_approval":
440
+ if (onRequestConfigApproval) {
441
+ onRequestConfigApproval(
442
+ client,
443
+ msg as RequestConfigApprovalMessage,
444
+ ).catch((err) => {
445
+ log(
446
+ `request_config_approval handler threw (client=${client.id}): ${(err as Error).message}`,
447
+ );
448
+ try {
449
+ client.send({
450
+ type: "config_approval_resolved",
451
+ requestId: (msg as RequestConfigApprovalMessage).requestId,
452
+ verdict: "deny",
453
+ reason: `gateway handler error: ${(err as Error).message}`,
454
+ });
455
+ } catch {
456
+ /* best effort */
457
+ }
458
+ });
459
+ } else {
460
+ // Fail closed — hostd treats this as deny so the apply path
461
+ // never runs without an operator-attested approval card.
462
+ try {
463
+ client.send({
464
+ type: "config_approval_resolved",
465
+ requestId: (msg as RequestConfigApprovalMessage).requestId,
466
+ verdict: "deny",
467
+ reason: "gateway not configured for config-edit approval",
468
+ });
469
+ } catch {
470
+ /* best effort */
471
+ }
472
+ }
473
+ break;
474
+ case "request_config_finalize":
475
+ if (onRequestConfigFinalize) {
476
+ onRequestConfigFinalize(
477
+ client,
478
+ msg as RequestConfigFinalizeMessage,
479
+ ).catch((err) => {
480
+ log(
481
+ `request_config_finalize handler threw (client=${client.id}): ${(err as Error).message}`,
482
+ );
483
+ });
484
+ }
485
+ // No reply expected.
486
+ break;
386
487
  case "update_placeholder":
387
488
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
388
489
  // Soft-accepted so recall.py keeps working without modifying
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure builder for the synthetic `subagent_handback` inbound the gateway
3
+ * injects when a *background* sub-agent (worker / researcher) finishes.
4
+ *
5
+ * Why this exists (conversational-pacing beat 4 — the handback):
6
+ * A foreground sub-agent hands its result straight back as the `Task`
7
+ * tool result, in the parent's own turn — the model sees it in-context.
8
+ * A background sub-agent does not: it finishes decoupled from any turn
9
+ * boundary, and when it completes the parent agent is typically idle
10
+ * with no turn in flight to receive the result. Claude Code surfaces a
11
+ * background result only on the parent's *next* turn — for a Telegram
12
+ * agent that means the user must send another message before they ever
13
+ * hear back. The agent never proactively says "the worker's done".
14
+ *
15
+ * This builder produces the InboundMessage that closes that gap. The
16
+ * gateway's subagent-watcher `onFinish` callback (which already knows
17
+ * the moment a background sub-agent terminates) feeds the worker's
18
+ * result text in here; the gateway delivers the envelope through the
19
+ * same idle-drain path cron and vault-grant wake-ups use. The model
20
+ * wakes, sees `<channel source="subagent_handback">`, and synthesises a
21
+ * user-facing handback in its own voice — beat 4 made deterministic.
22
+ *
23
+ * Shape contract (mirrors `vault-grant-inbound-builders.ts`): the
24
+ * `meta.source` string is load-bearing — the MCP channel notification
25
+ * wraps it as `<channel source="subagent_handback">`. A regression that
26
+ * changes the source string or drops a meta field silently breaks the
27
+ * wake-up. Pinned by `subagent-handback-inbound-builder.test.ts`.
28
+ */
29
+
30
+ import type { InboundMessage } from './ipc-protocol.js'
31
+
32
+ /** Cap on the worker result text carried in the inbound. The model
33
+ * synthesises a fresh handback from it — the full transcript is never
34
+ * needed, and an unbounded paste bloats the parent's context. */
35
+ export const HANDBACK_RESULT_MAX = 3000
36
+ /** Cap on the dispatch-time task description echoed back for context. */
37
+ export const HANDBACK_DESC_MAX = 200
38
+
39
+ export interface SubagentHandbackContext {
40
+ /** Telegram chat the work was dispatched from — the synthesized
41
+ * handback turn lands here so it stays with the conversation. */
42
+ chatId: string
43
+ /** Dispatch-time task description (the sub-agent's `description`). */
44
+ taskDescription: string
45
+ /** The worker's final result text — its last narrative emission
46
+ * before terminating. May be empty if the watcher never observed a
47
+ * text line (rare: a worker that only ran tools then exited). */
48
+ resultText: string
49
+ /** Terminal outcome as classified by the watcher. */
50
+ outcome: 'completed' | 'failed'
51
+ }
52
+
53
+ function truncate(s: string, max: number): string {
54
+ const t = s.trim()
55
+ return t.length > max ? t.slice(0, max) + '…' : t
56
+ }
57
+
58
+ /**
59
+ * Build the synthetic InboundMessage for a finished background
60
+ * sub-agent. Deterministic under a fixed `nowMs` for tests.
61
+ */
62
+ export function buildSubagentHandbackInbound(opts: {
63
+ ctx: SubagentHandbackContext
64
+ nowMs?: number
65
+ }): InboundMessage {
66
+ const ts = opts.nowMs ?? Date.now()
67
+ const desc = truncate(opts.ctx.taskDescription, HANDBACK_DESC_MAX) || '(no description)'
68
+ const result = truncate(opts.ctx.resultText, HANDBACK_RESULT_MAX)
69
+
70
+ const text =
71
+ opts.ctx.outcome === 'failed'
72
+ ? `🤝 A background worker you dispatched has FAILED.\n\n` +
73
+ `Task: ${desc}\n\n` +
74
+ (result ? `What it reported before failing:\n${result}\n\n` : '') +
75
+ `This is beat 4 — the handback. Tell the user plainly that the ` +
76
+ `delegated work did not complete, what is known, and your ` +
77
+ `recommended next step — one \`reply\` in your own voice. Do not ` +
78
+ `stay silent.`
79
+ : `🤝 A background worker you dispatched has finished.\n\n` +
80
+ `Task: ${desc}\n\n` +
81
+ (result
82
+ ? `What the worker reported:\n${result}\n\n`
83
+ : `The worker left no summary text.\n\n`) +
84
+ `This is beat 4 — the handback. Synthesise this for the user ` +
85
+ `now: one \`reply\` in your own voice covering what the worker ` +
86
+ `found and your recommended next step. Do NOT paste the raw ` +
87
+ `report and do NOT stay silent — the user dispatched this and ` +
88
+ `is waiting to hear back.`
89
+
90
+ return {
91
+ type: 'inbound',
92
+ chatId: opts.ctx.chatId,
93
+ messageId: ts, // synthetic — no Telegram message id exists
94
+ user: 'subagent-watcher',
95
+ userId: 0,
96
+ ts,
97
+ text,
98
+ meta: {
99
+ source: 'subagent_handback',
100
+ outcome: opts.ctx.outcome,
101
+ },
102
+ }
103
+ }