agent-relay-server 0.121.5 → 0.122.1

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 (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.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/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -0,0 +1,118 @@
1
+ // Provider-agnostic core for the #184 context-gathering signal (epic #183).
2
+ //
3
+ // The transcript *format* is provider-specific (Claude JSONL, Codex app-server items,
4
+ // future providers), so each adapter normalizes its session into the same `SessionEvent`
5
+ // stream via `collectSessionEvents`. Everything downstream — the gathering/action
6
+ // classifier and the ratio math — lives here once and is shared, so a tool reclassified
7
+ // for one provider is reclassified for all, and a new provider only implements the
8
+ // normalization.
9
+ //
10
+ // The classifier is model-free and runs in the runner, so it costs zero agent tokens and
11
+ // the agent can't game it.
12
+
13
+ // A normalized, ordered session event. Order is significant: `leadingGather` counts the
14
+ // run of gathering tools before the first action.
15
+ export type SessionEvent =
16
+ // A tool invocation. Gathering-vs-action is decided here by `isGatheringTool(name)`.
17
+ | { type: "tool"; name: string }
18
+ // A failed tool result (paired outcome proxy — failures/workarounds the agent hit).
19
+ | { type: "tool_error" }
20
+ // A real user prompt (paired outcome proxy — more back-and-forth ~ clarification/correction).
21
+ | { type: "user_prompt" }
22
+ // A substantive assistant turn (one that produced text or a tool call).
23
+ | { type: "turn" };
24
+
25
+ // Tools that acquire context without changing anything. Anything not matched here is
26
+ // treated as an action (mutation, execution, or a delegation/direction decision) — Bash
27
+ // counts as an action because it executes (a conservative, documented choice for v0;
28
+ // `cat`/`ls` via Bash are misclassified, refine later if the data warrants it).
29
+ const GATHERING_TOOLS = new Set([
30
+ "Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch",
31
+ ]);
32
+ const GATHERING_NAME = /(?:^|[._-])(read|get|list|search|grep|glob|find|fetch|query|browse|view|show|cat|status|inspect|lookup|symbols|snippet)/i;
33
+
34
+ export function isGatheringTool(name: string): boolean {
35
+ if (GATHERING_TOOLS.has(name)) return true;
36
+ // MCP / custom tools: classify by name shape (e.g. mcp__callmux__searxng_web_search).
37
+ return GATHERING_NAME.test(name);
38
+ }
39
+
40
+ interface ContextRatioMetric {
41
+ /** Session-wide gathering fraction: gatheringCalls / totalToolCalls. The headline metric. */
42
+ ratio: number;
43
+ gatheringCalls: number;
44
+ actionCalls: number;
45
+ totalToolCalls: number;
46
+ /** Consecutive gathering calls before the first action — the "read N files before moving" signal. */
47
+ leadingGather: number;
48
+ /** Substantive assistant turns (turns that produced text or a tool call). */
49
+ turns: number;
50
+ }
51
+
52
+ interface SessionOutcomeProxy {
53
+ /** Real user prompts in the session — more back-and-forth ~ more clarification/correction. */
54
+ userPrompts: number;
55
+ /** tool_result blocks flagged is_error — failures/workarounds the agent hit. */
56
+ toolErrors: number;
57
+ }
58
+
59
+ export interface SessionAnalysis {
60
+ metric: ContextRatioMetric;
61
+ outcome: SessionOutcomeProxy;
62
+ }
63
+
64
+ /**
65
+ * Reduce a normalized event stream to the context-gathering ratio plus paired outcome
66
+ * proxies. Returns null when there's nothing substantive to measure (no tool calls) —
67
+ * trivial segments have nothing to learn from and shouldn't pollute the baselines.
68
+ *
69
+ * Per-segment by construction: callers pass only the events since the last capture
70
+ * boundary (compact/clear/restart/shutdown), so each result describes one work chunk.
71
+ */
72
+ export function computeContextRatio(events: SessionEvent[]): SessionAnalysis | null {
73
+ let gatheringCalls = 0;
74
+ let actionCalls = 0;
75
+ let leadingGather = 0;
76
+ let sawAction = false;
77
+ let userPrompts = 0;
78
+ let toolErrors = 0;
79
+ let turns = 0;
80
+
81
+ for (const event of events) {
82
+ switch (event.type) {
83
+ case "user_prompt":
84
+ userPrompts++;
85
+ break;
86
+ case "tool_error":
87
+ toolErrors++;
88
+ break;
89
+ case "turn":
90
+ turns++;
91
+ break;
92
+ case "tool":
93
+ if (isGatheringTool(event.name)) {
94
+ gatheringCalls++;
95
+ if (!sawAction) leadingGather++;
96
+ } else {
97
+ actionCalls++;
98
+ sawAction = true;
99
+ }
100
+ break;
101
+ }
102
+ }
103
+
104
+ const totalToolCalls = gatheringCalls + actionCalls;
105
+ if (totalToolCalls === 0) return null;
106
+
107
+ return {
108
+ metric: {
109
+ ratio: gatheringCalls / totalToolCalls,
110
+ gatheringCalls,
111
+ actionCalls,
112
+ totalToolCalls,
113
+ leadingGather,
114
+ turns,
115
+ },
116
+ outcome: { userPrompts, toolErrors },
117
+ };
118
+ }
@@ -0,0 +1,271 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
+
7
+ const SCRATCH_DIR_NAME = ".agent-relay";
8
+ // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
9
+ // base, matching git's gitignore semantics.
10
+ const EXCLUDE_ENTRY = "/.agent-relay/";
11
+ const LAUNCHER_DIR = join(tmpdir(), `agent-relay-launchers-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
12
+ const DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS = 24 * 60 * 60 * 1000;
13
+
14
+ export interface SessionScratchLayout {
15
+ baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
16
+ rootDir: string; // <base>/.agent-relay
17
+ sessionsDir: string; // <base>/.agent-relay/sessions
18
+ sessionDir: string; // <base>/.agent-relay/sessions/<id>
19
+ tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
20
+ replyFile: string; // <tmp>/reply.md
21
+ }
22
+
23
+ interface SessionScratchTarget {
24
+ agentId: string;
25
+ cwd: string;
26
+ // Orchestrator base dir, used only when cwd is not writable. NEVER home — a
27
+ // home fallback would place scratch above the orchestrator base dir and break
28
+ // cwd containment.
29
+ fallbackBaseDir?: string;
30
+ }
31
+
32
+ interface SessionLauncherTarget {
33
+ launcherScript?: string;
34
+ launcherDir?: string;
35
+ tmuxSession?: string;
36
+ }
37
+
38
+ function isWritableDir(dir: string): boolean {
39
+ try {
40
+ if (!statSync(dir).isDirectory()) return false;
41
+ accessSync(dir, constants.W_OK);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
49
+ // writable dir. Returns cwd as a last resort so callers can still attempt mkdir
50
+ // (and surface the real error) rather than silently picking an unrelated path.
51
+ export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
52
+ if (isWritableDir(cwd)) return cwd;
53
+ if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
54
+ return cwd;
55
+ }
56
+
57
+ function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
58
+ const rootDir = join(baseDir, SCRATCH_DIR_NAME);
59
+ const sessionsDir = join(rootDir, "sessions");
60
+ const sessionDir = join(sessionsDir, agentId);
61
+ const tmpDir = join(sessionDir, "tmp");
62
+ return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
63
+ }
64
+
65
+ // Resolve git's exclude file for baseDir, correctly handling plain repos,
66
+ // worktrees, and submodules (git rev-parse returns the shared commondir's
67
+ // info/exclude). Returns null when baseDir is not inside a git work tree.
68
+ function gitExcludePath(baseDir: string): string | null {
69
+ try {
70
+ const out = execFileSync("git", ["-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ }).trim();
74
+ if (!out) return null;
75
+ return isAbsolute(out) ? out : resolve(baseDir, out);
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
82
+ function appendIgnoreEntry(file: string): void {
83
+ let existing = "";
84
+ try {
85
+ existing = readFileSync(file, "utf8");
86
+ } catch {}
87
+ const wanted = EXCLUDE_ENTRY.trim();
88
+ if (existing.split("\n").some((line) => line.trim() === wanted)) return;
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
91
+ appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
92
+ }
93
+
94
+ // Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
95
+ // local, uncommitted ignore list) so no tracked file is ever modified; falls
96
+ // back to .gitignore only when baseDir is not a git repo. Returns which file was
97
+ // used (for logging/tests).
98
+ export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
99
+ const excludePath = gitExcludePath(baseDir);
100
+ if (excludePath) {
101
+ appendIgnoreEntry(excludePath);
102
+ return "exclude";
103
+ }
104
+ appendIgnoreEntry(join(baseDir, ".gitignore"));
105
+ return "gitignore";
106
+ }
107
+
108
+ // SessionStart: create the session tmp dir and ensure local ignore. Dir
109
+ // creation is the load-bearing part; ignore setup is best-effort.
110
+ export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
111
+ const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
112
+ const layout = sessionScratchLayout(base, target.agentId);
113
+ mkdirSync(layout.tmpDir, { recursive: true });
114
+ try {
115
+ ensureScratchIgnored(base);
116
+ } catch {
117
+ // best-effort: a missing ignore entry never pollutes git because the dir is
118
+ // already created and the entry is the only thing at risk.
119
+ }
120
+ return layout;
121
+ }
122
+
123
+ function resolveLauncherDir(launcherDir?: string): string {
124
+ return launcherDir ?? LAUNCHER_DIR;
125
+ }
126
+
127
+ export function launcherScriptPathForSession(sessionName: string, launcherDir?: string): string {
128
+ const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
129
+ return join(resolveLauncherDir(launcherDir), `${sanitized}.sh`);
130
+ }
131
+
132
+ function dedupeBases(bases: Array<string | undefined>): string[] {
133
+ const seen = new Set<string>();
134
+ const out: string[] = [];
135
+ for (const b of bases) {
136
+ if (!b || seen.has(b)) continue;
137
+ seen.add(b);
138
+ out.push(b);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function dedupePaths(paths: Array<string | undefined>): string[] {
144
+ const seen = new Set<string>();
145
+ const out: string[] = [];
146
+ for (const path of paths) {
147
+ if (!path || seen.has(path)) continue;
148
+ seen.add(path);
149
+ out.push(path);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // SessionEnd: remove this session's dir from every candidate base.
155
+ export function reapSessionScratch(target: SessionScratchTarget): void {
156
+ for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
157
+ const { sessionDir } = sessionScratchLayout(base, target.agentId);
158
+ try {
159
+ rmSync(sessionDir, { recursive: true, force: true });
160
+ } catch {}
161
+ }
162
+ }
163
+
164
+ // Claude headless sessions externalize their provider launch command into a
165
+ // per-session script under /tmp. Remove it on runner shutdown alongside the
166
+ // scratch dir reap to avoid leaking one file per spawn.
167
+ export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
168
+ const removed: string[] = [];
169
+ for (const path of dedupePaths([
170
+ target.launcherScript,
171
+ target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
172
+ ])) {
173
+ try {
174
+ const exists = statSync(path).isFile();
175
+ rmSync(path, { force: true });
176
+ if (exists) removed.push(path);
177
+ } catch {}
178
+ }
179
+ return removed;
180
+ }
181
+
182
+ interface SweepOptions {
183
+ cwd: string;
184
+ fallbackBaseDir?: string;
185
+ // Agent ids to keep (currently-known agents + self). Any session dir whose id
186
+ // is not in this set AND older than minAgeMs is removed.
187
+ keepAgentIds: Set<string>;
188
+ now: number;
189
+ minAgeMs?: number;
190
+ }
191
+
192
+ interface LauncherSweepOptions {
193
+ // Agent ids to keep (currently-known agents + self). Launcher scripts embed
194
+ // AGENT_RELAY_ID, so a stale sweep can safely remove only scripts whose agent
195
+ // is no longer live.
196
+ keepAgentIds: Set<string>;
197
+ launcherDir?: string;
198
+ now: number;
199
+ minAgeMs?: number;
200
+ }
201
+
202
+ function maybeShellUnquote(value: string): string {
203
+ const trimmed = value.trim();
204
+ if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) return trimmed;
205
+ return trimmed.slice(1, -1).replace(/'\\''/g, "'");
206
+ }
207
+
208
+ function launcherAgentId(scriptPath: string): string | null {
209
+ try {
210
+ const match = readFileSync(scriptPath, "utf8").match(/^export AGENT_RELAY_ID=(.+)$/m);
211
+ if (!match) return null;
212
+ const agentId = maybeShellUnquote(match[1] ?? "");
213
+ return agentId || null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ // Periodic sweep of leftover session dirs from agents no longer known to the
220
+ // relay. The minAgeMs grace avoids racing a peer that just created its dir but
221
+ // has not yet appeared in the agent list.
222
+ export function sweepStaleSessions(opts: SweepOptions): string[] {
223
+ const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
224
+ const removed: string[] = [];
225
+ for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
226
+ const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
227
+ let ids: string[];
228
+ try {
229
+ ids = readdirSync(sessionsDir);
230
+ } catch {
231
+ continue;
232
+ }
233
+ for (const id of ids) {
234
+ if (opts.keepAgentIds.has(id)) continue;
235
+ const dir = join(sessionsDir, id);
236
+ try {
237
+ if (opts.now - statSync(dir).mtimeMs < minAge) continue;
238
+ rmSync(dir, { recursive: true, force: true });
239
+ removed.push(dir);
240
+ } catch {}
241
+ }
242
+ }
243
+ return removed;
244
+ }
245
+
246
+ // Periodic sweep of leaked per-spawn launcher scripts. Old scripts whose agent
247
+ // id is no longer live are safe to prune; old unparsable files are treated as
248
+ // stale too so the next run clears historical buildup without manual cleanup.
249
+ export function sweepStaleLaunchers(opts: LauncherSweepOptions): string[] {
250
+ const minAge = opts.minAgeMs ?? DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS;
251
+ const removed: string[] = [];
252
+ const launcherDir = resolveLauncherDir(opts.launcherDir);
253
+ let entries: string[];
254
+ try {
255
+ entries = readdirSync(launcherDir);
256
+ } catch {
257
+ return removed;
258
+ }
259
+ for (const entry of entries) {
260
+ const scriptPath = join(launcherDir, entry);
261
+ try {
262
+ if (!statSync(scriptPath).isFile()) continue;
263
+ if (opts.now - statSync(scriptPath).mtimeMs < minAge) continue;
264
+ const agentId = launcherAgentId(scriptPath);
265
+ if (agentId && opts.keepAgentIds.has(agentId)) continue;
266
+ rmSync(scriptPath, { force: true });
267
+ removed.push(scriptPath);
268
+ } catch {}
269
+ }
270
+ return removed;
271
+ }
@@ -0,0 +1,29 @@
1
+ import type { RelayHttpClient } from "agent-relay-sdk";
2
+ import { RUNNER_PROMPT_TEMPLATE_DEFAULTS } from "agent-relay-sdk/prompt-template-defaults";
3
+
4
+ const RUNNER_DEFAULTS = new Map<string, string>(RUNNER_PROMPT_TEMPLATE_DEFAULTS.map((template) => [template.slug, template.defaultTemplate]));
5
+ const templateCache = new Map<string, string>();
6
+
7
+ function renderValue(value: string | undefined): string {
8
+ return value ?? "";
9
+ }
10
+
11
+ function applyVariables(template: string, vars: Record<string, string>): string {
12
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => renderValue(vars[key]));
13
+ }
14
+
15
+ export async function initializeRunnerTemplates(http: RelayHttpClient): Promise<void> {
16
+ try {
17
+ const templates = await http.listPromptTemplates();
18
+ templateCache.clear();
19
+ for (const template of templates) templateCache.set(template.slug, template.template);
20
+ } catch {
21
+ templateCache.clear();
22
+ }
23
+ }
24
+
25
+ export function renderRunnerTemplate(slug: string, vars: Record<string, string> = {}): string {
26
+ const template = templateCache.get(slug) ?? RUNNER_DEFAULTS.get(slug);
27
+ if (template === undefined) throw new Error(`unknown runner prompt template slug: ${slug}`);
28
+ return applyVariables(template, vars);
29
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CONTRACT_VERSIONS } from "agent-relay-sdk";
5
+ import { getManifest } from "agent-relay-providers";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
9
+
10
+ export const PACKAGE_NAME = pkg.name || "agent-relay-runner";
11
+ export const VERSION = pkg.version || "0.0.0";
12
+ // Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
13
+ export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
14
+ export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
15
+
16
+ export const CONTRACTS = {
17
+ runnerProtocol: RUNNER_PROTOCOL_VERSION,
18
+ providerPluginProtocol: PROVIDER_PLUGIN_PROTOCOL_VERSION,
19
+ } as const;
20
+
21
+ export const RUNTIME_CAPABILITIES = {
22
+ relayCommandBus: true,
23
+ managedRegistration: true,
24
+ lifecycleCommands: true,
25
+ claimRenewal: true,
26
+ deliveryRetry: true,
27
+ } as const;
28
+
29
+ export function runtimeMetadata(provider?: string) {
30
+ const manifest = provider ? getManifest(provider) : undefined;
31
+ return {
32
+ package: {
33
+ name: PACKAGE_NAME,
34
+ version: VERSION,
35
+ },
36
+ contracts: CONTRACTS,
37
+ capabilities: {
38
+ ...RUNTIME_CAPABILITIES,
39
+ ...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
40
+ ...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
41
+ },
42
+ };
43
+ }
@@ -9,6 +9,7 @@
9
9
  import { isRecord } from "agent-relay-sdk";
10
10
  import { attachBranchState } from "./agent-branch-state";
11
11
  import { deriveAgentBusyHealth } from "./agent-busy-health";
12
+ import { attachCommandActivity } from "./agent-command-activity";
12
13
  import { attachIssueRepo } from "./agent-issue-repo";
13
14
  import type { AgentCard } from "./types";
14
15
 
@@ -28,8 +29,11 @@ const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
28
29
  /** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
29
30
  export function projectAgentCard(agent: AgentCard): AgentCard {
30
31
  const projected = attachIssueRepo(attachBranchState(agent));
32
+ // busy-health derives from the RAW provider status, so read it before the #1004 command-busy
33
+ // overlay — a running background command must not make the agent look stuck-busy.
31
34
  const busyHealth = deriveAgentBusyHealth(projected);
32
- return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
35
+ const enriched = busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
36
+ return attachCommandActivity(enriched);
33
37
  }
34
38
 
35
39
  function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
@@ -0,0 +1,39 @@
1
+ // #1004 — the single home for the running-command → busy DISPLAY rule.
2
+ //
3
+ // A background command scheduled via relay_schedule_timer runs on the creating agent's behalf
4
+ // (on an orchestrator). The provider reports that agent `idle` the instant its own turn ends,
5
+ // so the still-running command is invisible on the dashboard — it reads idle while a real shell
6
+ // command (e.g. a release) executes. Reflect it as `busy` for the command's lifetime.
7
+ //
8
+ // Every agent-card serialization boundary routes through here — the SSE projection
9
+ // (agent-card-projection.ts) AND the REST reads (routes/agents.ts) — mirroring how
10
+ // attachBranchState/attachIssueRepo enrich at those same boundaries, so live ticks and polls
11
+ // agree. A prompt-wakeup timer (no command) is a plain future alarm and never triggers this.
12
+ //
13
+ // Override only a resting `idle`/`online`: never mask a genuine offline/stale/error, and never
14
+ // fabricate work the provider itself already reports (busy stays busy). The busy-ness is derived
15
+ // live from the timer/command rows (the source of truth via agentHasRunningCommandTimer), not a
16
+ // duplicated status flag — so it self-heals when the command settles and can't leak a
17
+ // permanently-busy agent. It is a display overlay only: the stored provider status the reconcilers
18
+ // read (getAgent/listAgents) is untouched, so stuck-busy health and lifecycle gating are unaffected.
19
+ import { agentHasRunningCommandTimer, agentsWithRunningCommandTimers } from "./db";
20
+ import type { AgentCard } from "./types";
21
+
22
+ function applyCommandBusy(agent: AgentCard, running: boolean): AgentCard {
23
+ if (!running) return agent;
24
+ if (agent.status !== "idle" && agent.status !== "online") return agent;
25
+ return { ...agent, status: "busy" };
26
+ }
27
+
28
+ /** Overlay running-command busy on a single card (SSE projection + REST get-by-id). */
29
+ export function attachCommandActivity(agent: AgentCard): AgentCard {
30
+ return applyCommandBusy(agent, agentHasRunningCommandTimer(agent.id));
31
+ }
32
+
33
+ /** Batch variant for list endpoints: one query, then map (REST get-many). */
34
+ export function attachCommandActivities(agents: AgentCard[]): AgentCard[] {
35
+ if (agents.length === 0) return agents;
36
+ const running = agentsWithRunningCommandTimers();
37
+ if (running.size === 0) return agents;
38
+ return agents.map((agent) => applyCommandBusy(agent, running.has(agent.id)));
39
+ }
@@ -202,7 +202,7 @@ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: n
202
202
  automationTargetNeverReady: true,
203
203
  agentId,
204
204
  },
205
- });
205
+ }, { privileged: true });
206
206
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
207
207
  updateRun(run.id, {
208
208
  status: "failed",
@@ -248,7 +248,7 @@ function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number):
248
248
  maxRuntimeMs: budget.maxRuntimeMs,
249
249
  deadlineAt: deadline,
250
250
  },
251
- });
251
+ }, { privileged: true });
252
252
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
253
253
  const command = requestOnDemandAgentShutdown(run, updatedTask, now, "runtime-budget-timeout");
254
254
  updateRun(run.id, {
@@ -150,7 +150,9 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
150
150
  }
151
151
 
152
152
  if (action === "cleanup-stale") {
153
- const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, ...(idExplicit && id ? { workspaceId: id } : {}) });
153
+ // #965 --force discards an abandoned offline-owner worktree even when its git state is a
154
+ // conflict/unlanded (the land-safety gate is right for live work, wrong for a dead branch).
155
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, force, ...(idExplicit && id ? { workspaceId: id } : {}) });
154
156
  console.log(JSON.stringify(result, null, 2));
155
157
  return;
156
158
  }
package/src/config.ts CHANGED
@@ -65,7 +65,16 @@ export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STAR
65
65
  export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
66
66
  export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
67
67
  export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
68
- export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES", 60);
68
+ // #1055 invariant: retain STRICTLY MORE than one reaper interval of lag history. The
69
+ // stale/bus reaper reads the cumulative lag grace once per REAP_INTERVAL_MS; if the ring
70
+ // held only one interval's worth (the old 60×1s == 60s == REAP_INTERVAL coincidence),
71
+ // the oldest stall samples could rotate out the instant before the first post-recovery
72
+ // reaper run and collapse the grace exactly when it's needed. Keep a half-interval margin
73
+ // (1.5× reaper interval) so recent overload always survives to the next reaper tick.
74
+ export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt(
75
+ "AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES",
76
+ Math.ceil((REAP_INTERVAL_MS / EVENT_LOOP_LAG_SAMPLE_MS) * 1.5),
77
+ );
69
78
  export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
70
79
  export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
71
80
  // #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended