switchroom 0.13.9 → 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.
- package/dist/cli/switchroom.js +38 -14
- package/dist/host-control/main.js +222 -7
- package/examples/switchroom.yaml +25 -7
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +514 -143
- package/telegram-plugin/gateway/config-approval-handler.test.ts +246 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +284 -0
- package/telegram-plugin/gateway/gateway.ts +206 -21
- package/telegram-plugin/gateway/ipc-protocol.ts +72 -2
- package/telegram-plugin/gateway/ipc-server.ts +101 -0
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +103 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +69 -0
- package/telegram-plugin/subagent-watcher.ts +39 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +105 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +61 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +67 -1
- package/telegram-plugin/uat/scenarios/jtbd-subagent-handback-dm.test.ts +95 -0
- 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 { 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
|
-
|
|
3442
|
-
//
|
|
3443
|
-
//
|
|
3444
|
-
//
|
|
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
|
+
// handshake — connect 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
|
-
//
|
|
14901
|
-
//
|
|
14902
|
-
// the
|
|
14903
|
-
//
|
|
14904
|
-
|
|
14905
|
-
|
|
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 —
|
|
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
|
-
|
|
14929
|
-
|
|
14930
|
-
//
|
|
14931
|
-
|
|
14932
|
-
|
|
14933
|
-
|
|
14934
|
-
|
|
14935
|
-
|
|
14936
|
-
|
|
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
|
+
}
|