switchroom 0.13.9 → 0.13.11

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 (29) hide show
  1. package/dist/cli/switchroom.js +38 -14
  2. package/dist/host-control/main.js +222 -7
  3. package/examples/switchroom.yaml +25 -7
  4. package/package.json +1 -1
  5. package/profiles/_shared/telegram-style.md.hbs +1 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +23 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +540 -147
  8. package/telegram-plugin/dist/server.js +23 -4
  9. package/telegram-plugin/gateway/config-approval-handler.test.ts +246 -0
  10. package/telegram-plugin/gateway/config-approval-handler.ts +284 -0
  11. package/telegram-plugin/gateway/gateway.ts +218 -25
  12. package/telegram-plugin/gateway/ipc-protocol.ts +72 -2
  13. package/telegram-plugin/gateway/ipc-server.ts +101 -0
  14. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +185 -0
  15. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +69 -0
  16. package/telegram-plugin/model-unavailable.ts +11 -1
  17. package/telegram-plugin/operator-events.fixtures.json +14 -24
  18. package/telegram-plugin/operator-events.ts +11 -2
  19. package/telegram-plugin/session-tail.ts +71 -4
  20. package/telegram-plugin/subagent-watcher.ts +39 -0
  21. package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
  22. package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
  23. package/telegram-plugin/tests/operator-events.test.ts +14 -7
  24. package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
  25. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +105 -0
  26. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +61 -0
  27. package/telegram-plugin/tests/subagent-watcher.test.ts +67 -1
  28. package/telegram-plugin/uat/scenarios/jtbd-subagent-handback-dm.test.ts +95 -0
  29. package/profiles/default/CLAUDE.md +0 -193
@@ -281,6 +281,7 @@ import {
281
281
  buildVaultSaveFailedInbound,
282
282
  buildVaultSaveDiscardedInbound,
283
283
  } from './vault-grant-inbound-builders.js'
284
+ import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
284
285
  import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
285
286
  import type {
286
287
  ToolCallMessage,
@@ -2711,8 +2712,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
2711
2712
  // Card text branches on the AND. wouldFireFleetAutoFallback is a
2712
2713
  // pure read of the dedup state; calling fireFleetAutoFallback only
2713
2714
  // when both are true keeps the card honest.
2714
- const isAutoKind =
2715
- modelUnavailable.kind === 'quota_exhausted' || modelUnavailable.kind === 'overload'
2715
+ // Only a genuine quota / usage-limit hit is addressable by fleet
2716
+ // auto-fallback (swap to an account that still has runway). An
2717
+ // `overload` is transient Anthropic SERVER-side capacity pressure —
2718
+ // every account is equally affected, so failing over does nothing;
2719
+ // it just produces a self-cancelling "probed healthy / Stale event?"
2720
+ // loop on every 529. Overload is handled by Claude Code's own
2721
+ // internal retry, not by switching accounts.
2722
+ const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
2716
2723
  const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
2717
2724
  process.stderr.write(
2718
2725
  `telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
@@ -3438,11 +3445,15 @@ const ipcServer: IpcServer = createIpcServer({
3438
3445
  },
3439
3446
 
3440
3447
  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.
3448
+ // ONLY log "bridge disconnected" + emit bridgeDown for the REAL
3449
+ // bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
3450
+ // clients e.g. recall.py's one-shot legacy update_placeholder
3451
+ // handshakeconnect and disconnect constantly with agentName=null;
3452
+ // logging those as "bridge disconnected — agent=null" reads as a
3453
+ // bridge flap in the supervisor log when nothing flapped. The
3454
+ // anonymous disconnect is still traced by disconnect-flush.ts.
3445
3455
  if (client.agentName != null) {
3456
+ process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
3446
3457
  shadowEmit({ kind: 'bridgeDown', at: Date.now() })
3447
3458
  }
3448
3459
 
@@ -3768,6 +3779,102 @@ const ipcServer: IpcServer = createIpcServer({
3768
3779
  })
3769
3780
  },
3770
3781
 
3782
+ /**
3783
+ * #1623 — hostd-initiated config-edit approval card. hostd posts a
3784
+ * `request_config_approval` message; we render the card via
3785
+ * `handleRequestConfigApproval`, awaiting the operator tap (or
3786
+ * timeout) before sending `config_approval_resolved` back. After
3787
+ * hostd's apply attempt completes it posts
3788
+ * `request_config_finalize` to flip the card to the terminal
3789
+ * outcome.
3790
+ */
3791
+ async onRequestConfigApproval(client: IpcClient, msg) {
3792
+ const { handleRequestConfigApproval } = await import(
3793
+ './config-approval-handler.js'
3794
+ )
3795
+ const { InlineKeyboard } = await import('grammy')
3796
+ await handleRequestConfigApproval(client, msg, {
3797
+ agentName: getMyAgentName(),
3798
+ loadTargetChat: () => {
3799
+ const access = loadAccess()
3800
+ const operator = access.allowFrom[0]
3801
+ if (operator === undefined) return null
3802
+ return { chatId: operator }
3803
+ },
3804
+ buildKeyboard: (requestId) =>
3805
+ new InlineKeyboard()
3806
+ .text('✅ Approve', `cfg:${requestId}:approve`)
3807
+ .text('🚫 Deny', `cfg:${requestId}:deny`),
3808
+ postCard: async (args) => {
3809
+ try {
3810
+ const sent = await robustApiCall(
3811
+ () =>
3812
+ bot.api.sendMessage(args.chatId, args.text, {
3813
+ parse_mode: 'HTML',
3814
+ ...(args.threadId !== undefined
3815
+ ? { message_thread_id: args.threadId }
3816
+ : {}),
3817
+ reply_markup: args.replyMarkup as never,
3818
+ }),
3819
+ {
3820
+ chat_id: String(args.chatId),
3821
+ verb: 'config-approval-card',
3822
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
3823
+ },
3824
+ )
3825
+ return { messageId: (sent as { message_id: number }).message_id }
3826
+ } catch (err) {
3827
+ process.stderr.write(
3828
+ `telegram gateway: config-approval postCard failed: ${(err as Error).message}\n`,
3829
+ )
3830
+ return null
3831
+ }
3832
+ },
3833
+ editCard: async (args) => {
3834
+ try {
3835
+ await robustApiCall(
3836
+ () =>
3837
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
3838
+ parse_mode: 'HTML',
3839
+ }),
3840
+ { chat_id: String(args.chatId), verb: 'config-approval-edit' },
3841
+ )
3842
+ } catch (err) {
3843
+ process.stderr.write(
3844
+ `telegram gateway: config-approval editCard failed: ${(err as Error).message}\n`,
3845
+ )
3846
+ }
3847
+ },
3848
+ log: (m) =>
3849
+ process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
3850
+ })
3851
+ },
3852
+
3853
+ async onRequestConfigFinalize(client: IpcClient, msg) {
3854
+ const { handleRequestConfigFinalize } = await import(
3855
+ './config-approval-handler.js'
3856
+ )
3857
+ await handleRequestConfigFinalize(client, msg, {
3858
+ editCard: async (args) => {
3859
+ try {
3860
+ await robustApiCall(
3861
+ () =>
3862
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
3863
+ parse_mode: 'HTML',
3864
+ }),
3865
+ { chat_id: String(args.chatId), verb: 'config-approval-finalize' },
3866
+ )
3867
+ } catch (err) {
3868
+ process.stderr.write(
3869
+ `telegram gateway: config-finalize editCard failed: ${(err as Error).message}\n`,
3870
+ )
3871
+ }
3872
+ },
3873
+ log: (m) =>
3874
+ process.stderr.write(`telegram gateway: config-finalize — ${m}\n`),
3875
+ })
3876
+ },
3877
+
3771
3878
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
3772
3879
  const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
3773
3880
  ? msg.inbound.meta.prompt_key
@@ -12716,6 +12823,55 @@ bot.on('callback_query:data', async ctx => {
12716
12823
  return
12717
12824
  }
12718
12825
 
12826
+ // #1623: cfg:<requestId>:<approve|deny> — hostd config-edit
12827
+ // approval card. Resolves the pending approval (gateway-side
12828
+ // in-memory map), sends `config_approval_resolved` back to hostd
12829
+ // over the original IPC connection, and edits the card to the
12830
+ // interim "Applying…" / "Denied" state. Same operator-allowlist
12831
+ // gate as every other callback family.
12832
+ if (data.startsWith('cfg:')) {
12833
+ const access = loadAccess()
12834
+ const senderId = String(ctx.from?.id ?? '')
12835
+ if (!access.allowFrom.includes(senderId)) {
12836
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
12837
+ return
12838
+ }
12839
+ const { parseConfigApprovalCallback, resolvePendingConfigApproval } =
12840
+ await import('./config-approval-handler.js')
12841
+ const parsed = parseConfigApprovalCallback(data)
12842
+ if (parsed === null) {
12843
+ await ctx.answerCallbackQuery({ text: 'Malformed callback.' })
12844
+ return
12845
+ }
12846
+ const resolved = await resolvePendingConfigApproval(
12847
+ parsed.requestId,
12848
+ parsed.choice,
12849
+ {
12850
+ editCard: async (args) => {
12851
+ try {
12852
+ await robustApiCall(() =>
12853
+ bot.api.editMessageText(args.chatId, args.messageId, args.text, {
12854
+ parse_mode: 'HTML',
12855
+ }),
12856
+ )
12857
+ } catch {
12858
+ /* best effort */
12859
+ }
12860
+ },
12861
+ log: (m) =>
12862
+ process.stderr.write(`telegram gateway: config-approval cb — ${m}\n`),
12863
+ },
12864
+ )
12865
+ await ctx.answerCallbackQuery({
12866
+ text: resolved
12867
+ ? parsed.choice === 'approve'
12868
+ ? 'Approving…'
12869
+ : 'Denied'
12870
+ : 'Already resolved.',
12871
+ })
12872
+ return
12873
+ }
12874
+
12719
12875
  // RFC E §4.1: drvpick:<verb>:<agent>[:<...>] — folder-picker card taps.
12720
12876
  // open / enter / back / refresh re-render the card in place;
12721
12877
  // grant writes an allow_always kernel decision at
@@ -14897,25 +15053,40 @@ void (async () => {
14897
15053
  )
14898
15054
  }
14899
15055
  },
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 = ''
14906
- let chatId = ''
15056
+ // conversational-pacing beat 4 — the handback. A foreground
15057
+ // sub-agent hands its result straight back as the Task tool
15058
+ // result, inside the parent's own turn; the model sees it
15059
+ // in-context. A *background* sub-agent does not it
15060
+ // finishes decoupled from any turn boundary, with the parent
15061
+ // typically idle and no turn to receive the result. Without
15062
+ // a nudge the user never hears back until they send the next
15063
+ // message themselves. So: when a background sub-agent
15064
+ // terminates, wake the agent with a `subagent_handback`
15065
+ // inbound carrying the worker's result text, and let it
15066
+ // synthesise a user-facing handback in its own voice.
15067
+ //
15068
+ // Gated to background completions: foreground sub-agents
15069
+ // need nothing here, and 'orphan' is a stale historical-at-
15070
+ // boot row, not a fresh completion the user is waiting on.
15071
+ onFinish: ({ agentId, outcome, description, resultText }) => {
15072
+ // IO: resolve the fleet chat id and the background flag.
15073
+ // The DECISION (gating + inbound build) is delegated to
15074
+ // the pure `decideSubagentHandback` so it is unit-tested
15075
+ // independent of the gateway — see
15076
+ // `subagent-handback-decision.test.ts`.
15077
+ let fleetChatId = ''
14907
15078
  let isBackground = false
14908
15079
  try {
14909
15080
  const fleets = progressDriver?.peekAllFleets() ?? []
14910
15081
  for (const f of fleets) {
14911
15082
  if (f.fleet.has(agentId)) {
14912
- parentTurnKey = f.turnKey
14913
- chatId = f.chatId ?? ''
15083
+ fleetChatId = f.chatId ?? ''
14914
15084
  break
14915
15085
  }
14916
15086
  }
14917
15087
  } catch {
14918
- // peek failures are non-fatal — we still emit the event.
15088
+ // peek failures are non-fatal — fall through to the
15089
+ // owner-chat fallback inside decideSubagentHandback.
14919
15090
  }
14920
15091
  if (turnsDb != null) {
14921
15092
  try {
@@ -14925,15 +15096,37 @@ void (async () => {
14925
15096
  if (row != null) isBackground = row.background === 1
14926
15097
  } catch { /* best-effort */ }
14927
15098
  }
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
15099
+
15100
+ const decision = decideSubagentHandback({
15101
+ handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
15102
+ outcome,
15103
+ isBackground,
15104
+ fleetChatId,
15105
+ // Owner-chat fallback: if the progress-driver fleet
15106
+ // entry was already cleaned up, route to the owner
15107
+ // chat. Every switchroom fleet agent is DM-shaped, so
15108
+ // allowFrom[0] is the conversation that dispatched.
15109
+ ownerChatId: loadAccess().allowFrom[0] ?? '',
15110
+ taskDescription: description,
15111
+ resultText,
15112
+ })
15113
+ if (!decision.deliver) {
15114
+ if (decision.reason === 'no-chat') {
15115
+ process.stderr.write(
15116
+ `telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
15117
+ )
15118
+ }
15119
+ return
15120
+ }
15121
+
15122
+ // Deliver via pendingInboundBuffer + the idle-drain tick.
15123
+ // The drain only releases at an idle prompt (no active
15124
+ // turn), so the handback always lands as a clean fresh
15125
+ // turn and never races a turn-in-flight composer (#1556).
15126
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
15127
+ process.stderr.write(
15128
+ `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
15129
+ )
14937
15130
  },
14938
15131
  })
14939
15132
  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