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.
- package/docs/openapi.json +1 -1
- package/package.json +7 -8
- package/public/assets/{MessageBubble-DRdUD-kW.js → MessageBubble-BTeFExjj.js} +2 -2
- package/public/assets/{MessageBubble-DRdUD-kW.js.map → MessageBubble-BTeFExjj.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-siKMPCln.js → PlanGraphPanel-jU1DXg7w.js} +2 -2
- package/public/assets/{PlanGraphPanel-siKMPCln.js.map → PlanGraphPanel-jU1DXg7w.js.map} +1 -1
- package/public/assets/{activity-D3MNcSaV.js → activity-DUig2iYW.js} +2 -2
- package/public/assets/{activity-D3MNcSaV.js.map → activity-DUig2iYW.js.map} +1 -1
- package/public/assets/{agent-profiles-CKzttq6G.js → agent-profiles-D-oFmb6S.js} +2 -2
- package/public/assets/{agent-profiles-CKzttq6G.js.map → agent-profiles-D-oFmb6S.js.map} +1 -1
- package/public/assets/{agent-type-icon-0ZJRP1Vy.js → agent-type-icon-Dan2l3Tm.js} +2 -2
- package/public/assets/{agent-type-icon-0ZJRP1Vy.js.map → agent-type-icon-Dan2l3Tm.js.map} +1 -1
- package/public/assets/{agents-CuVj_rfG.js → agents-BkC6mcTh.js} +2 -2
- package/public/assets/{agents-CuVj_rfG.js.map → agents-BkC6mcTh.js.map} +1 -1
- package/public/assets/{analytics-iob6C27d.js → analytics-C3Dlmqe9.js} +2 -2
- package/public/assets/{analytics-iob6C27d.js.map → analytics-C3Dlmqe9.js.map} +1 -1
- package/public/assets/{automation-B2ixxs4q.js → automation-AeMZ70WQ.js} +2 -2
- package/public/assets/{automation-B2ixxs4q.js.map → automation-AeMZ70WQ.js.map} +1 -1
- package/public/assets/{branch-state-badge-CafBJWKo.js → branch-state-badge-CNW10MTC.js} +2 -2
- package/public/assets/{branch-state-badge-CafBJWKo.js.map → branch-state-badge-CNW10MTC.js.map} +1 -1
- package/public/assets/{channels-C-WsbL2H.js → channels-Bc_mEd56.js} +2 -2
- package/public/assets/{channels-C-WsbL2H.js.map → channels-Bc_mEd56.js.map} +1 -1
- package/public/assets/{chat-DeoVTVob.js → chat-BCNCuTgw.js} +2 -2
- package/public/assets/{chat-DeoVTVob.js.map → chat-BCNCuTgw.js.map} +1 -1
- package/public/assets/{connectors-C2Ac03LJ.js → connectors-h0d5Mc6e.js} +2 -2
- package/public/assets/{connectors-C2Ac03LJ.js.map → connectors-h0d5Mc6e.js.map} +1 -1
- package/public/assets/{display-CJzSoTMq.js → display-GFbGUG0O.js} +2 -2
- package/public/assets/{display-CJzSoTMq.js.map → display-GFbGUG0O.js.map} +1 -1
- package/public/assets/{formatted-body-35XBOY5L.js → formatted-body-CVr3Iev9.js} +3 -3
- package/public/assets/{formatted-body-35XBOY5L.js.map → formatted-body-CVr3Iev9.js.map} +1 -1
- package/public/assets/{formatted-body-impl-RmDxI5Ux.js → formatted-body-impl-Cq5SRGpm.js} +2 -2
- package/public/assets/{formatted-body-impl-RmDxI5Ux.js.map → formatted-body-impl-Cq5SRGpm.js.map} +1 -1
- package/public/assets/{index-D0xMQQb_.js → index--9UMzCFf.js} +6 -6
- package/public/assets/{index-D0xMQQb_.js.map → index--9UMzCFf.js.map} +1 -1
- package/public/assets/{insights-B643SFvm.js → insights-C8lIBXg3.js} +2 -2
- package/public/assets/{insights-B643SFvm.js.map → insights-C8lIBXg3.js.map} +1 -1
- package/public/assets/{integrations-hFIHeCF4.js → integrations-7OFsJtY7.js} +2 -2
- package/public/assets/{integrations-hFIHeCF4.js.map → integrations-7OFsJtY7.js.map} +1 -1
- package/public/assets/{maintenance-CURvdp_Z.js → maintenance-HDpg20OY.js} +2 -2
- package/public/assets/{maintenance-CURvdp_Z.js.map → maintenance-HDpg20OY.js.map} +1 -1
- package/public/assets/{managed-agents-DrZtdlSn.js → managed-agents-muCIMxng.js} +2 -2
- package/public/assets/{managed-agents-DrZtdlSn.js.map → managed-agents-muCIMxng.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CVDO--ek.js → markdown-preview-impl-Cx7pCfA6.js} +2 -2
- package/public/assets/{markdown-preview-impl-CVDO--ek.js.map → markdown-preview-impl-Cx7pCfA6.js.map} +1 -1
- package/public/assets/{memory-Bm7dt_Qf.js → memory-CTO1FfJ-.js} +2 -2
- package/public/assets/{memory-Bm7dt_Qf.js.map → memory-CTO1FfJ-.js.map} +1 -1
- package/public/assets/{messages-DTq4i2KZ.js → messages-RWSXG2UM.js} +2 -2
- package/public/assets/{messages-DTq4i2KZ.js.map → messages-RWSXG2UM.js.map} +1 -1
- package/public/assets/{orchestrators-Dra318Ap.js → orchestrators-5aiYw3yx.js} +2 -2
- package/public/assets/{orchestrators-Dra318Ap.js.map → orchestrators-5aiYw3yx.js.map} +1 -1
- package/public/assets/{overview-yr1Vmk64.js → overview-Du6XLt5l.js} +2 -2
- package/public/assets/{overview-yr1Vmk64.js.map → overview-Du6XLt5l.js.map} +1 -1
- package/public/assets/{pairs-mnlKDBGp.js → pairs-BDRnlEta.js} +2 -2
- package/public/assets/{pairs-mnlKDBGp.js.map → pairs-BDRnlEta.js.map} +1 -1
- package/public/assets/{projects-Dzdf-gkK.js → projects-DwsWE9es.js} +2 -2
- package/public/assets/{projects-Dzdf-gkK.js.map → projects-DwsWE9es.js.map} +1 -1
- package/public/assets/{prompt-settings-ByL8SNCi.js → prompt-settings-CaJX_LbA.js} +2 -2
- package/public/assets/{prompt-settings-ByL8SNCi.js.map → prompt-settings-CaJX_LbA.js.map} +1 -1
- package/public/assets/{registry-CXWyOtpr.js → registry-C-uIL0Nl.js} +2 -2
- package/public/assets/{registry-CXWyOtpr.js.map → registry-C-uIL0Nl.js.map} +1 -1
- package/public/assets/{security-0SBsc_4W.js → security-BpVrdYhJ.js} +2 -2
- package/public/assets/{security-0SBsc_4W.js.map → security-BpVrdYhJ.js.map} +1 -1
- package/public/assets/{select-Bw5z2KJ_.js → select-DallbU93.js} +2 -2
- package/public/assets/{select-Bw5z2KJ_.js.map → select-DallbU93.js.map} +1 -1
- package/public/assets/{settings-78Cyq_tA.js → settings-BUQirdyI.js} +2 -2
- package/public/assets/{settings-78Cyq_tA.js.map → settings-BUQirdyI.js.map} +1 -1
- package/public/assets/{store-BPrVuKiv.js → store-DMZSTozC.js} +2 -2
- package/public/assets/{store-BPrVuKiv.js.map → store-DMZSTozC.js.map} +1 -1
- package/public/assets/{tasks-Cg2MMeri.js → tasks-k8mQDfKy.js} +2 -2
- package/public/assets/{tasks-Cg2MMeri.js.map → tasks-k8mQDfKy.js.map} +1 -1
- package/public/assets/{teams-DWv6UwFV.js → teams-DI-Lhhbl.js} +2 -2
- package/public/assets/{teams-DWv6UwFV.js.map → teams-DI-Lhhbl.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DBLDjbIg.js → terminal-viewer-impl-BL853Fpi.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DBLDjbIg.js.map → terminal-viewer-impl-BL853Fpi.js.map} +1 -1
- package/public/assets/types-CRptbSYh.js +2 -0
- package/public/assets/types-CRptbSYh.js.map +1 -0
- package/public/assets/{user-avatar-BsOLyVJj.js → user-avatar-D9xyGi3u.js} +2 -2
- package/public/assets/{user-avatar-BsOLyVJj.js.map → user-avatar-D9xyGi3u.js.map} +1 -1
- package/public/assets/{work-queue-DmwvnF6F.js → work-queue-DrLIdvxB.js} +2 -2
- package/public/assets/{work-queue-DmwvnF6F.js.map → work-queue-DrLIdvxB.js.map} +1 -1
- package/public/assets/{workspaces-dIBivJN8.js → workspaces-CnNL6T8Q.js} +2 -2
- package/public/assets/{workspaces-dIBivJN8.js.map → workspaces-CnNL6T8Q.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +675 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/codex-version.ts +63 -0
- package/runner/src/config.ts +7 -0
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +674 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +324 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +368 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +105 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +44 -0
- package/src/config-store.ts +2 -0
- package/src/db/provisioning-registry.ts +40 -9
- package/src/db/provisioning-resolve.ts +66 -5
- package/src/gate-resolver.ts +7 -12
- package/src/routes/provisioning-registry.ts +1 -1
- package/src/services/spawn-agent.ts +1 -0
- package/public/assets/types-BeJUSfDj.js +0 -2
- 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
|
+
}
|