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,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,44 @@
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
+ ...(manifest?.provisioning?.bundledHooks?.length ? { bundledHooks: true } : {}),
42
+ },
43
+ };
44
+ }
@@ -335,6 +335,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Omit<
335
335
  },
336
336
  hooks: {
337
337
  mode: input.hooks?.mode ?? (isolated ? "none" : "host"),
338
+ ...(input.hooks?.refs?.length ? { refs: input.hooks.refs } : {}),
338
339
  },
339
340
  permissions: {
340
341
  mode: input.permissions?.mode,
@@ -419,6 +420,7 @@ function validateAgentProfile(key: string, value: unknown, opts: { allowBuiltIn?
419
420
  },
420
421
  hooks: {
421
422
  mode: hooks.mode === undefined ? defaults.hooks.mode : cleanEnum(hooks.mode, "hooks.mode", VALID_PROFILE_CATEGORY_MODES),
423
+ refs: cleanAgentProfileAssetArray(hooks.refs, "hooks.refs"),
422
424
  },
423
425
  permissions: {
424
426
  mode: permissions.mode === undefined || permissions.mode === null
@@ -9,6 +9,7 @@ import { parseJson } from "../utils";
9
9
  import { errMessage, isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
10
10
  import type {
11
11
  AgentProfileProvider,
12
+ HookAssetDefinition,
12
13
  McpAssetDefinition,
13
14
  PluginAssetDefinition,
14
15
  ProvisioningCapability,
@@ -26,7 +27,7 @@ import type {
26
27
  // the launch. #557 (per-profile assembly) is meant to be a thin unifier on top, so
27
28
  // this stays the single home for asset definition + resolution — not three modules.
28
29
 
29
- const PROVISIONING_ASSET_KINDS = ["skill", "mcp", "plugin"] as const;
30
+ const PROVISIONING_ASSET_KINDS = ["skill", "mcp", "plugin", "hook"] as const;
30
31
  const PROVISIONING_PROVIDERS = [...SPAWN_PROVIDERS, "any"];
31
32
  const GITHUB_API = "https://api.github.com";
32
33
  const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
@@ -213,6 +214,10 @@ function assertDefinition(kind: ProvisioningAssetKind, definition: ResolvedAsset
213
214
  const metadata = isRecord(def.metadata) ? def.metadata : {};
214
215
  if (isRecord(metadata.callmuxServer)) assertCallmuxServerPlaceholders(metadata.callmuxServer as ProvisioningMcpServer, def.name);
215
216
  }
217
+ if (kind === "hook") {
218
+ const def = definition as HookAssetDefinition;
219
+ if (!isRecord(def.hooks) || !Object.keys(def.hooks).length) throw new ValidationError("hook definition requires a hooks object");
220
+ }
216
221
  if ((kind === "skill" || kind === "plugin")) {
217
222
  const def = definition as SkillAssetDefinition | PluginAssetDefinition;
218
223
  if (!def.files?.length && !def.dir) throw new ValidationError(`${kind} definition requires files or dir`);
@@ -470,6 +475,24 @@ function mcpDefinitionFromFiles(name: string, files: VendoredFile[], mcpServerNa
470
475
  return { kind: "mcp", name, server: coerceMcpServer(parsed) };
471
476
  }
472
477
 
478
+ function hookDefinitionFromFiles(name: string, files: Array<{ path: string; content: string }>): HookAssetDefinition {
479
+ const preferred = files.find((file) => file.path === "hooks.json")
480
+ ?? files.find((file) => file.path.endsWith("/hooks.json"))
481
+ ?? files.find((file) => file.path === `${name}.json`);
482
+ if (!preferred) throw new ValidationError("hook imports require hooks.json");
483
+ let parsed: Record<string, unknown>;
484
+ try {
485
+ const value = JSON.parse(preferred.content);
486
+ if (!isRecord(value)) throw new Error("not an object");
487
+ parsed = value;
488
+ } catch {
489
+ throw new ValidationError(`${preferred.path} must be valid JSON object`);
490
+ }
491
+ const hooks = isRecord(parsed.hooks) ? parsed.hooks : parsed;
492
+ if (!isRecord(hooks) || !Object.keys(hooks).length) throw new ValidationError("hook import requires a non-empty hooks object");
493
+ return { kind: "hook", name, hooks: hooks as HookAssetDefinition["hooks"], files: files.map(({ path, content }) => ({ path, content })) };
494
+ }
495
+
473
496
  function definitionFromVendoredFiles(input: ImportProvisioningAssetFromGitHubInput, files: VendoredFile[], provider: AgentProfileProvider): ResolvedAssetDefinition {
474
497
  if (input.kind === "skill") {
475
498
  assertRootSkill(files);
@@ -479,6 +502,7 @@ function definitionFromVendoredFiles(input: ImportProvisioningAssetFromGitHubInp
479
502
  assertPlugin(files, provider);
480
503
  return { kind: "plugin", name: input.name, files: files.map(({ path, content }) => ({ path, content })) };
481
504
  }
505
+ if (input.kind === "hook") return hookDefinitionFromFiles(input.name, files);
482
506
  return mcpDefinitionFromFiles(input.name, files, input.mcpServerName);
483
507
  }
484
508
 
@@ -529,7 +553,7 @@ function updateBundledAssetMetadata(asset: ProvisioningAsset, root: string, file
529
553
  }
530
554
 
531
555
  function upsertBundledProvisioningAsset(input: {
532
- kind: "skill" | "plugin";
556
+ kind: "skill" | "plugin" | "hook";
533
557
  name: string;
534
558
  provider: AgentProfileProvider;
535
559
  files: BundledFile[];
@@ -538,7 +562,10 @@ function upsertBundledProvisioningAsset(input: {
538
562
  description: string;
539
563
  }): void {
540
564
  if (!input.files.length) return;
541
- const definition = { kind: input.kind, name: input.name, files: input.files.map(({ path, content }) => ({ path, content })) } as SkillAssetDefinition | PluginAssetDefinition;
565
+ const files = input.files.map(({ path, content }) => ({ path, content }));
566
+ const definition = input.kind === "hook"
567
+ ? hookDefinitionFromFiles(input.name, input.files)
568
+ : { kind: input.kind, name: input.name, files } as SkillAssetDefinition | PluginAssetDefinition;
542
569
  assertDefinition(input.kind, definition);
543
570
  const asset = upsertProvisioningAsset({ kind: input.kind, name: input.name, provider: input.provider, definition });
544
571
  updateBundledAssetMetadata(asset, input.root, input.files, input.title, input.description);
@@ -552,12 +579,16 @@ export function seedBundledProvisioningAssets(): void {
552
579
  const plugin = provisioning.bundledPlugin;
553
580
  upsertBundledProvisioningAsset({ kind: "plugin", name: plugin.ref, provider, root: plugin.dir, files: readBundledFiles(resolve(import.meta.dir, "../../", plugin.dir), { omitFiles: plugin.omitFiles, fileOverrides: plugin.fileOverrides }), title: plugin.title, description: plugin.description });
554
581
  }
555
- if (!provisioning.bundledSkills) continue;
556
- const skills = provisioning.bundledSkills;
557
- const skillsDir = resolve(import.meta.dir, "../../", skills.dir);
558
- for (const ref of bundledSkillRefs(skills)) {
559
- const skillName = ref.slice(skills.refPrefix.length);
560
- upsertBundledProvisioningAsset({ kind: "skill", name: ref, provider, root: posix.join(skills.dir, skillName), files: readBundledFiles(join(skillsDir, skillName)), title: `${skills.titlePrefix}${skillName}`, description: skills.description });
582
+ if (provisioning.bundledSkills) {
583
+ const skills = provisioning.bundledSkills;
584
+ const skillsDir = resolve(import.meta.dir, "../../", skills.dir);
585
+ for (const ref of bundledSkillRefs(skills)) {
586
+ const skillName = ref.slice(skills.refPrefix.length);
587
+ upsertBundledProvisioningAsset({ kind: "skill", name: ref, provider, root: posix.join(skills.dir, skillName), files: readBundledFiles(join(skillsDir, skillName)), title: `${skills.titlePrefix}${skillName}`, description: skills.description });
588
+ }
589
+ }
590
+ for (const hook of provisioning.bundledHooks ?? []) {
591
+ upsertBundledProvisioningAsset({ kind: "hook", name: hook.ref, provider, root: hook.dir, files: readBundledFiles(resolve(import.meta.dir, "../../", hook.dir), { omitFiles: hook.omitFiles, fileOverrides: hook.fileOverrides }), title: hook.title, description: hook.description });
561
592
  }
562
593
  }
563
594
  }