agent-relay-server 0.122.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +7 -8
  3. package/public/assets/{MessageBubble-DRdUD-kW.js → MessageBubble-BTeFExjj.js} +2 -2
  4. package/public/assets/{MessageBubble-DRdUD-kW.js.map → MessageBubble-BTeFExjj.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-siKMPCln.js → PlanGraphPanel-jU1DXg7w.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-siKMPCln.js.map → PlanGraphPanel-jU1DXg7w.js.map} +1 -1
  7. package/public/assets/{activity-D3MNcSaV.js → activity-DUig2iYW.js} +2 -2
  8. package/public/assets/{activity-D3MNcSaV.js.map → activity-DUig2iYW.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CKzttq6G.js → agent-profiles-D-oFmb6S.js} +2 -2
  10. package/public/assets/{agent-profiles-CKzttq6G.js.map → agent-profiles-D-oFmb6S.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-0ZJRP1Vy.js → agent-type-icon-Dan2l3Tm.js} +2 -2
  12. package/public/assets/{agent-type-icon-0ZJRP1Vy.js.map → agent-type-icon-Dan2l3Tm.js.map} +1 -1
  13. package/public/assets/{agents-CuVj_rfG.js → agents-BkC6mcTh.js} +2 -2
  14. package/public/assets/{agents-CuVj_rfG.js.map → agents-BkC6mcTh.js.map} +1 -1
  15. package/public/assets/{analytics-iob6C27d.js → analytics-C3Dlmqe9.js} +2 -2
  16. package/public/assets/{analytics-iob6C27d.js.map → analytics-C3Dlmqe9.js.map} +1 -1
  17. package/public/assets/{automation-B2ixxs4q.js → automation-AeMZ70WQ.js} +2 -2
  18. package/public/assets/{automation-B2ixxs4q.js.map → automation-AeMZ70WQ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CafBJWKo.js → branch-state-badge-CNW10MTC.js} +2 -2
  20. package/public/assets/{branch-state-badge-CafBJWKo.js.map → branch-state-badge-CNW10MTC.js.map} +1 -1
  21. package/public/assets/{channels-C-WsbL2H.js → channels-Bc_mEd56.js} +2 -2
  22. package/public/assets/{channels-C-WsbL2H.js.map → channels-Bc_mEd56.js.map} +1 -1
  23. package/public/assets/{chat-DeoVTVob.js → chat-BCNCuTgw.js} +2 -2
  24. package/public/assets/{chat-DeoVTVob.js.map → chat-BCNCuTgw.js.map} +1 -1
  25. package/public/assets/{connectors-C2Ac03LJ.js → connectors-h0d5Mc6e.js} +2 -2
  26. package/public/assets/{connectors-C2Ac03LJ.js.map → connectors-h0d5Mc6e.js.map} +1 -1
  27. package/public/assets/{display-CJzSoTMq.js → display-GFbGUG0O.js} +2 -2
  28. package/public/assets/{display-CJzSoTMq.js.map → display-GFbGUG0O.js.map} +1 -1
  29. package/public/assets/{formatted-body-35XBOY5L.js → formatted-body-CVr3Iev9.js} +3 -3
  30. package/public/assets/{formatted-body-35XBOY5L.js.map → formatted-body-CVr3Iev9.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-RmDxI5Ux.js → formatted-body-impl-Cq5SRGpm.js} +2 -2
  32. package/public/assets/{formatted-body-impl-RmDxI5Ux.js.map → formatted-body-impl-Cq5SRGpm.js.map} +1 -1
  33. package/public/assets/{index-D0xMQQb_.js → index--9UMzCFf.js} +6 -6
  34. package/public/assets/{index-D0xMQQb_.js.map → index--9UMzCFf.js.map} +1 -1
  35. package/public/assets/{insights-B643SFvm.js → insights-C8lIBXg3.js} +2 -2
  36. package/public/assets/{insights-B643SFvm.js.map → insights-C8lIBXg3.js.map} +1 -1
  37. package/public/assets/{integrations-hFIHeCF4.js → integrations-7OFsJtY7.js} +2 -2
  38. package/public/assets/{integrations-hFIHeCF4.js.map → integrations-7OFsJtY7.js.map} +1 -1
  39. package/public/assets/{maintenance-CURvdp_Z.js → maintenance-HDpg20OY.js} +2 -2
  40. package/public/assets/{maintenance-CURvdp_Z.js.map → maintenance-HDpg20OY.js.map} +1 -1
  41. package/public/assets/{managed-agents-DrZtdlSn.js → managed-agents-muCIMxng.js} +2 -2
  42. package/public/assets/{managed-agents-DrZtdlSn.js.map → managed-agents-muCIMxng.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-CVDO--ek.js → markdown-preview-impl-Cx7pCfA6.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-CVDO--ek.js.map → markdown-preview-impl-Cx7pCfA6.js.map} +1 -1
  45. package/public/assets/{memory-Bm7dt_Qf.js → memory-CTO1FfJ-.js} +2 -2
  46. package/public/assets/{memory-Bm7dt_Qf.js.map → memory-CTO1FfJ-.js.map} +1 -1
  47. package/public/assets/{messages-DTq4i2KZ.js → messages-RWSXG2UM.js} +2 -2
  48. package/public/assets/{messages-DTq4i2KZ.js.map → messages-RWSXG2UM.js.map} +1 -1
  49. package/public/assets/{orchestrators-Dra318Ap.js → orchestrators-5aiYw3yx.js} +2 -2
  50. package/public/assets/{orchestrators-Dra318Ap.js.map → orchestrators-5aiYw3yx.js.map} +1 -1
  51. package/public/assets/{overview-yr1Vmk64.js → overview-Du6XLt5l.js} +2 -2
  52. package/public/assets/{overview-yr1Vmk64.js.map → overview-Du6XLt5l.js.map} +1 -1
  53. package/public/assets/{pairs-mnlKDBGp.js → pairs-BDRnlEta.js} +2 -2
  54. package/public/assets/{pairs-mnlKDBGp.js.map → pairs-BDRnlEta.js.map} +1 -1
  55. package/public/assets/{projects-Dzdf-gkK.js → projects-DwsWE9es.js} +2 -2
  56. package/public/assets/{projects-Dzdf-gkK.js.map → projects-DwsWE9es.js.map} +1 -1
  57. package/public/assets/{prompt-settings-ByL8SNCi.js → prompt-settings-CaJX_LbA.js} +2 -2
  58. package/public/assets/{prompt-settings-ByL8SNCi.js.map → prompt-settings-CaJX_LbA.js.map} +1 -1
  59. package/public/assets/{registry-CXWyOtpr.js → registry-C-uIL0Nl.js} +2 -2
  60. package/public/assets/{registry-CXWyOtpr.js.map → registry-C-uIL0Nl.js.map} +1 -1
  61. package/public/assets/{security-0SBsc_4W.js → security-BpVrdYhJ.js} +2 -2
  62. package/public/assets/{security-0SBsc_4W.js.map → security-BpVrdYhJ.js.map} +1 -1
  63. package/public/assets/{select-Bw5z2KJ_.js → select-DallbU93.js} +2 -2
  64. package/public/assets/{select-Bw5z2KJ_.js.map → select-DallbU93.js.map} +1 -1
  65. package/public/assets/{settings-78Cyq_tA.js → settings-BUQirdyI.js} +2 -2
  66. package/public/assets/{settings-78Cyq_tA.js.map → settings-BUQirdyI.js.map} +1 -1
  67. package/public/assets/{store-BPrVuKiv.js → store-DMZSTozC.js} +2 -2
  68. package/public/assets/{store-BPrVuKiv.js.map → store-DMZSTozC.js.map} +1 -1
  69. package/public/assets/{tasks-Cg2MMeri.js → tasks-k8mQDfKy.js} +2 -2
  70. package/public/assets/{tasks-Cg2MMeri.js.map → tasks-k8mQDfKy.js.map} +1 -1
  71. package/public/assets/{teams-DWv6UwFV.js → teams-DI-Lhhbl.js} +2 -2
  72. package/public/assets/{teams-DWv6UwFV.js.map → teams-DI-Lhhbl.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-DBLDjbIg.js → terminal-viewer-impl-BL853Fpi.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-DBLDjbIg.js.map → terminal-viewer-impl-BL853Fpi.js.map} +1 -1
  75. package/public/assets/types-CRptbSYh.js +2 -0
  76. package/public/assets/types-CRptbSYh.js.map +1 -0
  77. package/public/assets/{user-avatar-BsOLyVJj.js → user-avatar-D9xyGi3u.js} +2 -2
  78. package/public/assets/{user-avatar-BsOLyVJj.js.map → user-avatar-D9xyGi3u.js.map} +1 -1
  79. package/public/assets/{work-queue-DmwvnF6F.js → work-queue-DrLIdvxB.js} +2 -2
  80. package/public/assets/{work-queue-DmwvnF6F.js.map → work-queue-DrLIdvxB.js.map} +1 -1
  81. package/public/assets/{workspaces-dIBivJN8.js → workspaces-CnNL6T8Q.js} +2 -2
  82. package/public/assets/{workspaces-dIBivJN8.js.map → workspaces-CnNL6T8Q.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/src/adapters/claude-delivery.ts +127 -0
  86. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  87. package/runner/src/adapters/claude-session-probe.ts +225 -0
  88. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  89. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  90. package/runner/src/adapters/claude-transcript.ts +414 -0
  91. package/runner/src/adapters/claude.ts +675 -0
  92. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  93. package/runner/src/adapters/codex-client.ts +279 -0
  94. package/runner/src/adapters/codex.ts +1345 -0
  95. package/runner/src/attachment-cache.ts +189 -0
  96. package/runner/src/busy-reconciler.ts +102 -0
  97. package/runner/src/claim-tracker.ts +140 -0
  98. package/runner/src/claude-prompt-gates.ts +268 -0
  99. package/runner/src/codex-version.ts +63 -0
  100. package/runner/src/config.ts +7 -0
  101. package/runner/src/context-probe-state.ts +6 -0
  102. package/runner/src/continuation-archive.ts +179 -0
  103. package/runner/src/control-server.ts +639 -0
  104. package/runner/src/index.ts +396 -0
  105. package/runner/src/launch-assembly.ts +674 -0
  106. package/runner/src/liveness.ts +50 -0
  107. package/runner/src/logger.ts +99 -0
  108. package/runner/src/mcp-outbox.ts +120 -0
  109. package/runner/src/message-body-config/config.ts +3 -0
  110. package/runner/src/message-body-config.ts +1 -0
  111. package/runner/src/native-self-resume.ts +202 -0
  112. package/runner/src/outbox.ts +342 -0
  113. package/runner/src/process-meta.ts +9 -0
  114. package/runner/src/profile-home.ts +260 -0
  115. package/runner/src/profile-projection.ts +324 -0
  116. package/runner/src/providers.ts +20 -0
  117. package/runner/src/provisioning.ts +368 -0
  118. package/runner/src/quota.ts +40 -0
  119. package/runner/src/rate-limit.ts +189 -0
  120. package/runner/src/relay-injection-events.ts +105 -0
  121. package/runner/src/relay-instructions.ts +60 -0
  122. package/runner/src/relay-mcp-proxy.ts +383 -0
  123. package/runner/src/relay-mcp.ts +156 -0
  124. package/runner/src/reply-obligation-cache.ts +136 -0
  125. package/runner/src/response-capture-report.ts +63 -0
  126. package/runner/src/runner-core.ts +2409 -0
  127. package/runner/src/runner-helpers.ts +435 -0
  128. package/runner/src/runner-insights.ts +105 -0
  129. package/runner/src/runner.ts +14 -0
  130. package/runner/src/session-destroy.ts +22 -0
  131. package/runner/src/session-insights.ts +118 -0
  132. package/runner/src/session-scratch.ts +271 -0
  133. package/runner/src/template-resolver.ts +29 -0
  134. package/runner/src/version.ts +44 -0
  135. package/src/config-store.ts +2 -0
  136. package/src/db/provisioning-registry.ts +40 -9
  137. package/src/db/provisioning-resolve.ts +66 -5
  138. package/src/gate-resolver.ts +7 -12
  139. package/src/routes/provisioning-registry.ts +1 -1
  140. package/src/services/spawn-agent.ts +1 -0
  141. package/public/assets/types-BeJUSfDj.js +0 -2
  142. package/public/assets/types-BeJUSfDj.js.map +0 -1
@@ -0,0 +1,156 @@
1
+ // Single home for the relay HTTP MCP endpoint descriptor + per-provider injection
2
+ // args. Both adapters (and Stage 2's proxy) import from here so the endpoint path,
3
+ // server name, and token-handling rules live in exactly one place.
4
+ //
5
+ // Token handling: the bearer token is NEVER placed in argv (it would leak via `ps`
6
+ // and the agent's own process inspection). Claude expands `${AGENT_RELAY_SESSION_TOKEN}`
7
+ // from the env at MCP-config parse time; Codex reads it from the named env var.
8
+ //
9
+ // Why a dedicated var and NOT `AGENT_RELAY_TOKEN`: a managed session inherits the
10
+ // runner's scoped, identity-bearing session token, but Claude Code applies a rig/user
11
+ // `settings.json` `env` block OVER the inherited env. A rig that sets
12
+ // `env.AGENT_RELAY_TOKEN` (e.g. to the admin token, for interactive CLI use) would
13
+ // clobber the scoped token at parse time, so the relay MCP connection authenticates as
14
+ // the `server` actor with no agent identity — `relay_whoami` returns null and
15
+ // `relay_send_message` demands a `from` it can't supply (#233). The runner exports the
16
+ // scoped token under this dedicated name that rigs don't set, so nothing on the host can
17
+ // hijack the managed agent's identity.
18
+ export const RELAY_MCP_SERVER_NAME = "agent-relay";
19
+ export const RELAY_MCP_PATH = "/api/mcp";
20
+ export const RELAY_MCP_TOKEN_ENV = "AGENT_RELAY_SESSION_TOKEN";
21
+
22
+ // #672 — shared host-listener MCP. The agent connects to ONE orchestrator-owned shared
23
+ // `callmux` listener via a per-agent `callmux bridge`, fronting the host's NON-IDENTITY MCP
24
+ // (tokenlean, github). The bridge is a stdio child (auto-reconnect + retry-once on a
25
+ // listener/downstream hiccup; `--cwd` injects `x-callmux-cwd` for per-worktree session-cwd
26
+ // isolation), so it rides the SAME provider injection as any provisioned stdio server. The
27
+ // identity-bearing relay endpoint is NEVER routed here — it stays per-agent on #215.
28
+ export const SHARED_MCP_SERVER_NAME = "callmux";
29
+ // callmux's documented manual-listener endpoint (`callmux --listen 4860`, Streamable HTTP at
30
+ // /mcp). In managed launches the orchestrator owns the real listener URL and passes it through
31
+ // AGENT_RELAY_SHARED_MCP_URL; this fallback is for direct runner/manual-listener use.
32
+ export const DEFAULT_SHARED_MCP_URL = "http://localhost:4860/mcp";
33
+ export const SHARED_MCP_URL_ENV = "AGENT_RELAY_SHARED_MCP_URL";
34
+
35
+ /** Resolve the shared-listener URL: explicit runner option → env → documented default. */
36
+ export function resolveSharedMcpUrl(explicit?: string): string {
37
+ return explicit ?? process.env[SHARED_MCP_URL_ENV] ?? DEFAULT_SHARED_MCP_URL;
38
+ }
39
+
40
+ export function relayMcpEndpoint(relayUrl: string): string {
41
+ return `${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`;
42
+ }
43
+
44
+ // Codex: `-c mcp_servers.<name>.*` overrides. `bearer_token_env_var` tells Codex to
45
+ // read the token from the env var itself → transport resolves to streamable_http.
46
+ // `endpoint` overrides the target URL (runner-local proxy, Stage 2 #215) — see above.
47
+ export function relayMcpCodexConfigArgs(relayUrl: string, endpoint?: string): string[] {
48
+ const key = `mcp_servers.${RELAY_MCP_SERVER_NAME}`;
49
+ return [
50
+ "-c",
51
+ `${key}.url=${tomlString(endpoint ?? relayMcpEndpoint(relayUrl))}`,
52
+ "-c",
53
+ `${key}.bearer_token_env_var=${tomlString(RELAY_MCP_TOKEN_ENV)}`,
54
+ ];
55
+ }
56
+
57
+ // Shared TOML string escaper for Codex `-c key=value` overrides. One home; codex.ts
58
+ // imports this rather than re-declaring it.
59
+ export function tomlString(value: string): string {
60
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
61
+ }
62
+
63
+ // #555 — compose the launch MCP set from (the relay endpoint, if relay.mcp) PLUS the
64
+ // profile's relay-provisioned servers, PLUS (#662) the host USER-scope servers for an
65
+ // `mcp.mode:"host"` profile. One `--mcp-config` JSON carries them all. `strict` adds
66
+ // `--strict-mcp-config` so ONLY these load — Claude reads no on-disk config, so the
67
+ // project-cwd `.mcp.json` is never discovered (its enable modal can't wedge a headless
68
+ // agent). Whatever the assembler decides host mode should keep is injected here explicitly;
69
+ // strict then preserves exactly that set and nothing else.
70
+ import { SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS, type ProvisioningMcpServer } from "agent-relay-sdk";
71
+
72
+ // The shared-listener bridge as a provider-neutral stdio server descriptor: a `callmux bridge`
73
+ // child fronting the host's shared listener, with per-agent `--cwd "$WORKTREE"` for session-cwd
74
+ // isolation (#672). It flows through the existing claude/codex stdio injection unchanged.
75
+ export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcpServer {
76
+ return {
77
+ command: "callmux",
78
+ args: ["bridge", "--url", url, "--cwd", cwd, "--call-timeout", String(SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS)],
79
+ };
80
+ }
81
+
82
+ function mergedConfigEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
83
+ if (!server.configEnv || Object.keys(server.configEnv).length === 0) return server;
84
+ return {
85
+ ...server,
86
+ env: { ...server.configEnv, ...(server.env ?? {}) },
87
+ };
88
+ }
89
+
90
+ function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
91
+ const merged = mergedConfigEnv(server);
92
+ const entry: Record<string, unknown> = {};
93
+ if (merged.type) entry.type = merged.type;
94
+ if (merged.command) entry.command = merged.command;
95
+ if (merged.args?.length) entry.args = merged.args;
96
+ if (merged.env && Object.keys(merged.env).length) entry.env = merged.env;
97
+ if (merged.url) entry.url = merged.url;
98
+ if (merged.headers && Object.keys(merged.headers).length) entry.headers = merged.headers;
99
+ return entry;
100
+ }
101
+
102
+ export function claudeMcpLaunchArgs(opts: {
103
+ relayUrl: string;
104
+ endpoint?: string;
105
+ includeRelay: boolean;
106
+ servers?: Record<string, ProvisioningMcpServer>;
107
+ /** #662 — host USER-scope MCP servers (`~/.claude.json` `mcpServers`), re-injected for
108
+ * `mcp.mode:"host"` so `--strict-mcp-config` PRESERVES them instead of dropping the
109
+ * ambient host MCP a host-base agent loaded before strict. Lowest precedence: the relay
110
+ * endpoint and relay-provisioned servers win on a name collision (relay decides). */
111
+ hostServers?: Record<string, ProvisioningMcpServer>;
112
+ strict?: boolean;
113
+ }): string[] {
114
+ const mcpServers: Record<string, unknown> = {};
115
+ // Host user-scope servers go in first so relay endpoint + provisioned servers below can
116
+ // override them by name (relay-owned config is authoritative over ambient host MCP).
117
+ for (const [name, server] of Object.entries(opts.hostServers ?? {})) {
118
+ if (name === RELAY_MCP_SERVER_NAME) continue;
119
+ const entry = claudeMcpServerEntry(server);
120
+ if (Object.keys(entry).length) mcpServers[name] = entry;
121
+ }
122
+ if (opts.includeRelay) {
123
+ mcpServers[RELAY_MCP_SERVER_NAME] = {
124
+ type: "http",
125
+ url: opts.endpoint ?? relayMcpEndpoint(opts.relayUrl),
126
+ headers: { Authorization: `Bearer \${${RELAY_MCP_TOKEN_ENV}}` },
127
+ };
128
+ }
129
+ for (const [name, server] of Object.entries(opts.servers ?? {})) {
130
+ // Never let a declared server shadow the relay endpoint (identity-bearing).
131
+ if (name === RELAY_MCP_SERVER_NAME) continue;
132
+ const entry = claudeMcpServerEntry(server);
133
+ if (Object.keys(entry).length) mcpServers[name] = entry;
134
+ }
135
+ // Nothing to inject and not enforcing strict isolation → don't pass an empty config.
136
+ if (Object.keys(mcpServers).length === 0 && !opts.strict) return [];
137
+ return ["--mcp-config", JSON.stringify({ mcpServers }), ...(opts.strict ? ["--strict-mcp-config"] : [])];
138
+ }
139
+
140
+ // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned
141
+ // server, the same shape relayMcpCodexConfigArgs uses for the relay endpoint.
142
+ export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
143
+ const args: string[] = [];
144
+ for (const [name, rawServer] of Object.entries(servers ?? {})) {
145
+ if (name === RELAY_MCP_SERVER_NAME) continue;
146
+ const server = mergedConfigEnv(rawServer);
147
+ const key = `mcp_servers.${name}`;
148
+ if (server.command) args.push("-c", `${key}.command=${tomlString(server.command)}`);
149
+ if (server.args?.length) args.push("-c", `${key}.args=[${server.args.map(tomlString).join(",")}]`);
150
+ if (server.url) args.push("-c", `${key}.url=${tomlString(server.url)}`);
151
+ for (const [envKey, envVal] of Object.entries(server.env ?? {})) {
152
+ args.push("-c", `${key}.env.${envKey}=${tomlString(envVal)}`);
153
+ }
154
+ }
155
+ return args;
156
+ }
@@ -0,0 +1,136 @@
1
+ import type { ReplyObligation } from "agent-relay-sdk";
2
+ import { errMessage } from "agent-relay-sdk";
3
+ import { logger } from "./logger";
4
+
5
+ // Phase 2 (#196) — the crux. The Claude Stop hook used to ask the server, synchronously
6
+ // and in the hot path, "does this agent owe a reply?" before clearing the turn. A slow
7
+ // server answer (the unindexed reply_to scan, #199) blew the hook's timeout and wedged the
8
+ // agent in `busy` forever. The fix: the hook asks the Runner, the Runner answers instantly
9
+ // from this local snapshot, and the snapshot is refreshed from the server only in the
10
+ // background — never on the path that ends a turn.
11
+ //
12
+ // Design rules:
13
+ // - `get()` is synchronous, never throws, never touches the network.
14
+ // - `refresh()` is the only thing that talks to the server; it coalesces concurrent calls
15
+ // and, on failure, keeps the last-known snapshot (stale-but-serving beats blocking).
16
+ // - A background interval keeps the snapshot warm; `markDirty()` requests an extra,
17
+ // debounced refresh when state likely just changed (a message arrived, a turn ended).
18
+
19
+ // #418 — the human `user` sink is NOT delivered via an explicit relay reply: the agent's
20
+ // turn is mirrored to the dashboard chat by the session-mirror lane (always, for managed
21
+ // agents). So an explicit `/reply` to `user` double-delivers — the operator sees the mirrored
22
+ // turn AND the explicit copy. Only agent↔agent / spawner obligations (#413) need an explicit
23
+ // reply to clear and reach their recipient. This is the single home for that distinction —
24
+ // both the Claude Stop-hook nag (control-server) and the non-claude re-injection (runner)
25
+ // gate on it, so the rule must not drift between them.
26
+ export function obligationRequiresExplicitReply(obligation: ReplyObligation): boolean {
27
+ if (obligation.source === "spawn-obligation") return false;
28
+ return obligation.from !== "user";
29
+ }
30
+
31
+ export function responseCaptureReplyRoute(
32
+ obligations: ReplyObligation[],
33
+ currentTurnStartedAt?: number,
34
+ opts: { suppressUser?: boolean } = {},
35
+ ): { replyToMessageId?: number; to: string } | null {
36
+ const eligible = [...obligations].reverse()
37
+ .filter((o) => currentTurnStartedAt === undefined || o.createdAt <= currentTurnStartedAt);
38
+ const explicit = eligible.find(obligationRequiresExplicitReply);
39
+ if (explicit) return { replyToMessageId: explicit.messageId, to: explicit.from };
40
+ const user = eligible.find((o) => o.from === "user");
41
+ if (!user) return { to: "user" };
42
+ return opts.suppressUser ? null : { replyToMessageId: user.messageId, to: "user" };
43
+ }
44
+
45
+ type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
46
+
47
+ interface ReplyObligationCacheOptions {
48
+ fetch: ReplyObligationFetch;
49
+ // Background freshness backstop. Default 10s — well under any turn cadence, cheap.
50
+ intervalMs?: number;
51
+ // Debounce window for markDirty()-triggered refreshes so a burst of events
52
+ // (e.g. a fan-out of messages) collapses into one server round-trip.
53
+ dirtyDebounceMs?: number;
54
+ }
55
+
56
+ const DEFAULT_INTERVAL_MS = 10_000;
57
+ const DEFAULT_DIRTY_DEBOUNCE_MS = 400;
58
+
59
+ export class ReplyObligationCache {
60
+ private readonly fetch: ReplyObligationFetch;
61
+ private readonly intervalMs: number;
62
+ private readonly dirtyDebounceMs: number;
63
+
64
+ private snapshot: ReplyObligation[] = [];
65
+ private lastRefreshedAt = 0;
66
+ private inFlight: Promise<void> | null = null;
67
+ private intervalTimer?: ReturnType<typeof setInterval>;
68
+ private dirtyTimer?: ReturnType<typeof setTimeout>;
69
+ private stopped = false;
70
+
71
+ constructor(options: ReplyObligationCacheOptions) {
72
+ this.fetch = options.fetch;
73
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
74
+ this.dirtyDebounceMs = options.dirtyDebounceMs ?? DEFAULT_DIRTY_DEBOUNCE_MS;
75
+ }
76
+
77
+ // Synchronous, hot-path-safe read. Returns a copy so callers can't mutate the snapshot.
78
+ get(): ReplyObligation[] {
79
+ return this.snapshot.slice();
80
+ }
81
+
82
+ getLastRefreshedAt(): number {
83
+ return this.lastRefreshedAt;
84
+ }
85
+
86
+ // Begin the background freshness loop and prime the first snapshot immediately.
87
+ start(): void {
88
+ if (this.intervalTimer || this.stopped) return;
89
+ void this.refresh();
90
+ this.intervalTimer = setInterval(() => { void this.refresh(); }, this.intervalMs);
91
+ // Don't keep the process alive solely for cache refreshes.
92
+ this.intervalTimer.unref?.();
93
+ }
94
+
95
+ stop(): void {
96
+ this.stopped = true;
97
+ if (this.intervalTimer) clearInterval(this.intervalTimer);
98
+ this.intervalTimer = undefined;
99
+ if (this.dirtyTimer) clearTimeout(this.dirtyTimer);
100
+ this.dirtyTimer = undefined;
101
+ }
102
+
103
+ // Request a refresh because state likely changed (message arrived / turn ended).
104
+ // Debounced so a burst collapses into a single server round-trip.
105
+ markDirty(): void {
106
+ if (this.stopped || this.dirtyTimer) return;
107
+ this.dirtyTimer = setTimeout(() => {
108
+ this.dirtyTimer = undefined;
109
+ void this.refresh();
110
+ }, this.dirtyDebounceMs);
111
+ this.dirtyTimer.unref?.();
112
+ }
113
+
114
+ // Fetch from the server and replace the snapshot. Coalesces concurrent callers onto a
115
+ // single in-flight request. Never rejects — a failed fetch leaves the prior snapshot in
116
+ // place (the hook keeps getting an answer even while the server is down).
117
+ refresh(): Promise<void> {
118
+ if (this.stopped) return Promise.resolve();
119
+ if (this.inFlight) return this.inFlight;
120
+ this.inFlight = this.doRefresh().finally(() => { this.inFlight = null; });
121
+ return this.inFlight;
122
+ }
123
+
124
+ private async doRefresh(): Promise<void> {
125
+ try {
126
+ const obligations = await this.fetch();
127
+ if (this.stopped) return;
128
+ this.snapshot = Array.isArray(obligations) ? obligations : [];
129
+ this.lastRefreshedAt = Date.now();
130
+ } catch (error) {
131
+ // Server-down is a non-event: keep serving the last snapshot. Debug, not error —
132
+ // this is expected during outages and must not spam the log.
133
+ logger.debug("obligation-cache", `refresh failed, serving cached snapshot (${this.snapshot.length}): ${errMessage(error)}`);
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,63 @@
1
+ import type { MessageSessionMeta, SendMessageInput } from "agent-relay-sdk";
2
+
3
+ interface ReportOutbox {
4
+ enqueue(record: { kind: string; idempotencyKey?: string; payload: SendMessageInput }): void;
5
+ }
6
+
7
+ type SessionPublisher = (input: {
8
+ from: string;
9
+ to: string;
10
+ body: string;
11
+ session: MessageSessionMeta;
12
+ replyTo?: number;
13
+ }) => void | Promise<void>;
14
+
15
+ function finalSession(session: MessageSessionMeta): MessageSessionMeta {
16
+ return { ...session, final: true };
17
+ }
18
+
19
+ export async function publishCapturedResponse(input: {
20
+ publishSessionEvent: SessionPublisher;
21
+ outbox: ReportOutbox;
22
+ provider: string;
23
+ from: string;
24
+ to: string;
25
+ body: string;
26
+ session: MessageSessionMeta;
27
+ replyTo?: number;
28
+ }): Promise<void> {
29
+ await input.publishSessionEvent({
30
+ from: input.from,
31
+ to: input.to,
32
+ body: input.body,
33
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
34
+ session: finalSession(input.session),
35
+ });
36
+ enqueueCapturedResponseReport(input);
37
+ }
38
+
39
+ export function enqueueCapturedResponseReport(input: {
40
+ outbox: ReportOutbox;
41
+ provider: string;
42
+ from: string;
43
+ to: string;
44
+ body: string;
45
+ session: MessageSessionMeta;
46
+ replyTo?: number;
47
+ }): void {
48
+ if (!input.replyTo || input.to === "user") return;
49
+ const turnId = input.session.turnId ?? "";
50
+ input.outbox.enqueue({
51
+ kind: "session-message",
52
+ idempotencyKey: `response-report:${input.from}:${input.replyTo}:${turnId}`,
53
+ payload: {
54
+ from: input.from,
55
+ to: input.to,
56
+ replyTo: input.replyTo,
57
+ kind: "chat",
58
+ body: input.body,
59
+ replyExpected: false,
60
+ payload: { responseCapture: { provider: input.provider, ...finalSession(input.session) } },
61
+ },
62
+ });
63
+ }