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,508 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
4
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
5
+ import {
6
+ profileAllowsRelayFeature,
7
+ profileIsVanillaBase,
8
+ type ProviderConfig,
9
+ type RunnerSpawnConfig,
10
+ } from "./adapter";
11
+ import {
12
+ projectConfigProjectionOn,
13
+ hostUserScopeMcpServers,
14
+ profileAllowsHostAssets,
15
+ profileUsesProviderHostGlobals,
16
+ providerHomePathFor,
17
+ providerProvisioningHomePathFor,
18
+ } from "./profile-home";
19
+ import { getManifest } from "agent-relay-providers";
20
+ import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
21
+ import {
22
+ materializeResolvedAssets,
23
+ resolveClaudeProjectSkills,
24
+ resolveClaudeHostSkills,
25
+ resolveProjectClaudeSettings,
26
+ resolveClaudeProvisionedPlugins,
27
+ resolveClaudeProvisionedSkills,
28
+ resolveCodexProvisionedSkills,
29
+ resolveProjectMcpServers,
30
+ profileProvisioning,
31
+ } from "./provisioning";
32
+ import {
33
+ claudeMcpLaunchArgs,
34
+ codexProvisionedMcpArgs,
35
+ relayMcpCodexConfigArgs,
36
+ resolveSharedMcpUrl,
37
+ sharedMcpBridgeServer,
38
+ tomlString,
39
+ RELAY_MCP_SERVER_NAME,
40
+ SHARED_MCP_SERVER_NAME,
41
+ } from "./relay-mcp";
42
+
43
+ // #557 — THE single launch-assembly contract. Given a resolved AgentProfile (+ its
44
+ // spawn-time ResolvedProvisioning), `assembleLaunch` produces ONE provider-neutral
45
+ // description of the launch: the relay-provisioned skills/plugins/MCP, the bundled relay
46
+ // surface, the instruction-source disposition, and `base:"vanilla"` as the zero-host
47
+ // floor. Both adapters' buildSpawnArgs splice `assembled.args`/`assembled.env`, and
48
+ // profile-projection describes `assembled` directly — so projection and reality come
49
+ // from one source and cannot drift.
50
+ //
51
+ // `assembleLaunch` is PURE (it reads the filesystem to decide which declared dirs
52
+ // resolve, but writes nothing). `materializeLaunchAssembly` performs the disk writes the
53
+ // description implies, driven by the SAME resolvers — so what's written equals what's
54
+ // reported equals what's launched.
55
+
56
+ export interface AssembledAsset {
57
+ name: string;
58
+ /** Materialized/in-place dir feeding the launch (e.g. a --plugin-dir or skills.config path). */
59
+ dir: string;
60
+ }
61
+
62
+ export type AssembledInstructionDisposition = "allow" | "suppressed" | "isolated" | "unsupported";
63
+
64
+ export interface AssembledInstructions {
65
+ /** Repo CLAUDE.md/AGENTS.md cascade: "allow" left available, "suppressed" by the
66
+ * vanilla launch mechanism, "unsupported" requested-but-not-enforced (non-vanilla isolated). */
67
+ repo: AssembledInstructionDisposition;
68
+ /** Host/global provider instructions: "allow", "isolated" (dropped by the isolated home), or "unsupported". */
69
+ global: AssembledInstructionDisposition;
70
+ /** A composed systemPromptAppend block (project preamble + profile system/append +
71
+ * caller context, composed relay-side by composeSpawnInstructions) flows into this launch. */
72
+ systemPromptAppend: boolean;
73
+ // ── #559 SEAM ──────────────────────────────────────────────────────────────────
74
+ // Scoped {profile|user} memory does not exist yet (#559 generalizes Projects' anchor
75
+ // to project|profile|user). When it lands, the assembled instruction world becomes
76
+ // "instructions + provisioned assets + scoped knowledge": the relay-side composer
77
+ // (composeSpawnInstructions) gains a profile/user-scoped preamble beside the existing
78
+ // project preamble, and THIS field reports which scopes contributed. Until then it is
79
+ // always "none" — a thin add for #559, not a refactor.
80
+ scopedKnowledge: "none";
81
+ }
82
+
83
+ export interface AssembledMcp {
84
+ /** The identity-bearing relay HTTP MCP endpoint is injected. */
85
+ relayEndpoint: boolean;
86
+ /** Only the assembled servers may load (Claude --strict-mcp-config). #662: true for ALL
87
+ * managed Claude launches so the project `.mcp.json` enable-modal can't wedge a headless
88
+ * agent; relay's endpoint + provisioned servers (via --mcp-config) are preserved. Always
89
+ * false for Codex (no equivalent discovery modal). */
90
+ strict: boolean;
91
+ /** Relay-provisioned server names (excludes the relay endpoint itself). */
92
+ servers: string[];
93
+ /** #667 — project `.mcp.json` server names projected from the spawn cwd's committed
94
+ * `.mcp.json` (via `--mcp-config`, so they survive strict). Sourced from the project,
95
+ * not ambient host MCP. Empty unless the profile/spawn opted into `projectMcp`, and a
96
+ * name already declared by relay-provisioned `servers` wins (not double-listed here). */
97
+ projectServers: string[];
98
+ /** #662 — host USER-scope MCP server names (`~/.claude.json` `mcpServers`) re-injected
99
+ * into `--mcp-config` for an `mcp.mode:"host"` profile, so `--strict-mcp-config` keeps
100
+ * the ambient host MCP (callmux/qmd/gh) instead of dropping it. Empty for non-host mcp
101
+ * modes, isolated bases (own config home, never had host MCP), and Codex. */
102
+ hostServers: string[];
103
+ /** #672 — when the profile/spawn opted into `sharedMcp`, the agent's NON-IDENTITY MCP
104
+ * (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via
105
+ * a per-agent `callmux bridge --cwd "$WORKTREE"` (session-cwd isolation), injected through
106
+ * the same `--mcp-config` / `-c` channel so it survives strict. `url` is the listener the
107
+ * bridge connects to. Undefined when projection is OFF (the default). The bridge is opaque
108
+ * at assembly time — its server SET is owned by the listener config, not enumerated here.
109
+ * The identity-bearing relay endpoint is never on the shared listener (#215). */
110
+ sharedListener?: { url: string };
111
+ }
112
+
113
+ export interface AssembledLaunch {
114
+ provider: SpawnProvider;
115
+ /** The resolved profile this launch was assembled from; undefined for a profile-less
116
+ * (legacy default) spawn, which behaves as host base with relay features default-on. */
117
+ profile?: AgentProfile;
118
+ base: AgentProfileBase;
119
+ vanilla: boolean;
120
+ /** Isolated provider config home (CLAUDE_CONFIG_DIR / CODEX_HOME), or undefined for host base. */
121
+ configHome?: string;
122
+ relayContext: boolean;
123
+ /** Bundled relay skills are available (Codex skills.config / Claude via the relay plugin). */
124
+ relaySkills: boolean;
125
+ /** Bundled relay plugin dir is passed (Claude only). */
126
+ relayPlugin: boolean;
127
+ /** Relay status-line context probe is injected (Claude only). */
128
+ statusLine: boolean;
129
+ /** Relay-provisioned skills materialized for this launch. */
130
+ skills: AssembledAsset[];
131
+ /** Project `.claude/skills` materialized for this Claude launch (#668). */
132
+ projectSkills: AssembledAsset[];
133
+ /** Host/global Claude skills explicitly inherited without enabling host MCP. */
134
+ hostSkills: AssembledAsset[];
135
+ /** Project `.claude/settings.json` sanitized and injected for this Claude launch (#669). */
136
+ projectSettings: { applied: boolean; keys: string[]; ignoredKeys: string[]; file?: string };
137
+ /** Relay-provisioned plugins (Claude --plugin-dir). Empty for Codex (no plugin surface). */
138
+ plugins: AssembledAsset[];
139
+ /** Host/global provider plugin dirs explicitly inherited without enabling host MCP. */
140
+ hostPluginDirs: string[];
141
+ mcp: AssembledMcp;
142
+ instructions: AssembledInstructions;
143
+ /** Provider provisioning/vanilla/instruction launch-arg block, in launch order. */
144
+ args: string[];
145
+ /** Provisioning-derived env additions (the isolated config-home var). */
146
+ env: Record<string, string>;
147
+ }
148
+
149
+ // ── Bundled relay assets (relocated from the adapters so the assembler — not each
150
+ // adapter — owns launch-arg construction; #557). Adapters re-export for test parity. ──
151
+
152
+ const CLAUDE_RELAY_PLUGIN_DIR = resolve(import.meta.dir, "../plugins/claude");
153
+ const CODEX_BUNDLED_SKILLS_DIR = resolve(import.meta.dir, "../plugins/codex/skills");
154
+
155
+ export function bundledCodexSkillDirs(baseDir = CODEX_BUNDLED_SKILLS_DIR): string[] {
156
+ if (!existsSync(baseDir)) return [];
157
+ return readdirSync(baseDir, { withFileTypes: true })
158
+ .filter((entry) => entry.isDirectory() && existsSync(join(baseDir, entry.name, "SKILL.md")))
159
+ .map((entry) => join(baseDir, entry.name))
160
+ .sort();
161
+ }
162
+
163
+ export function bundledSkillConfigArgs(skillDirs = bundledCodexSkillDirs()): string[] {
164
+ if (skillDirs.length === 0) return [];
165
+ const skillsConfig = skillDirs.map((dir) => `{path=${tomlString(dir)},enabled=true}`).join(",");
166
+ return ["-c", `skills.config=[${skillsConfig}]`];
167
+ }
168
+
169
+ // #552 vanilla base (Codex): suppress the AGENTS.md project-doc cascade. project_doc_max_bytes
170
+ // (default 32768) caps bytes Codex reads from AGENTS.md walking root→cwd; 0 stops before adding
171
+ // any, so no repo (or fresh-CODEX_HOME global) project doc loads. The isolated CODEX_HOME already
172
+ // drops host config.toml MCP/hooks; this closes the cwd-resident repo instruction channel.
173
+ export function codexProjectDocSuppressionArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
174
+ return profileIsVanillaBase(config) ? ["-c", "project_doc_max_bytes=0"] : [];
175
+ }
176
+
177
+ function hasSettingsArg(args: string[]): boolean {
178
+ return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
179
+ }
180
+
181
+ // Claude relay status-line + readable-thinking settings, injected only when no caller
182
+ // --settings is already present (caller wins). Relocated from claude.ts (#557).
183
+ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
184
+ if (argLists.some(hasSettingsArg)) return [];
185
+ return ["--settings", JSON.stringify(relayStatusLineSettings())];
186
+ }
187
+
188
+ function relayStatusLineSettings(): Record<string, unknown> {
189
+ return {
190
+ statusLine: { type: "command", command: `node ${shellQuote(claudeStatusLineProbePath())}`, refreshInterval: 30 },
191
+ showThinkingSummaries: true,
192
+ };
193
+ }
194
+
195
+ function claudeStatusLineProbePath(): string {
196
+ return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
197
+ }
198
+
199
+ function claudeSettingsArgs(
200
+ projectSettings: Record<string, unknown>,
201
+ includeStatusLine: boolean,
202
+ ...argLists: string[][]
203
+ ): string[] {
204
+ if (argLists.some(hasSettingsArg)) return [];
205
+ const settings = {
206
+ ...projectSettings,
207
+ ...(includeStatusLine ? relayStatusLineSettings() : {}),
208
+ };
209
+ return Object.keys(settings).length ? ["--settings", JSON.stringify(settings)] : [];
210
+ }
211
+
212
+ // ── Instruction disposition (the vanilla floor's effect on the host instruction cascade) ──
213
+
214
+ function repoInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
215
+ if (profile.instructions.repoInstructions === "allow") return "allow";
216
+ if (profile.base === "vanilla") return "suppressed";
217
+ // Non-vanilla isolated bases can request ignore but have no launch-time enforcement yet.
218
+ return "unsupported";
219
+ }
220
+
221
+ function globalInstructionDisposition(profile: AgentProfile): AssembledInstructionDisposition {
222
+ if (profile.instructions.globalInstructions === "allow") return "allow";
223
+ if (profile.base !== "host") return "isolated";
224
+ return "unsupported";
225
+ }
226
+
227
+ function assembleInstructions(config: RunnerSpawnConfig): AssembledInstructions {
228
+ const profile = config.agentProfile;
229
+ return {
230
+ // A profile-less spawn is host-base: the full host instruction cascade is left available.
231
+ repo: profile ? repoInstructionDisposition(profile) : "allow",
232
+ global: profile ? globalInstructionDisposition(profile) : "allow",
233
+ systemPromptAppend: Boolean(config.systemPromptAppend?.trim()),
234
+ scopedKnowledge: "none",
235
+ };
236
+ }
237
+
238
+ // ── Per-provider arg assembly ──────────────────────────────────────────────────────
239
+
240
+ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]): AssembledLaunch {
241
+ const configHome = providerHomePathFor("claude", config);
242
+ const assetHome = providerProvisioningHomePathFor("claude", config);
243
+ const vanilla = profileIsVanillaBase(config);
244
+ const projectConfigProjection = projectConfigProjectionOn(config);
245
+ const relayPlugin = profileAllowsRelayFeature(config, "plugins");
246
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
247
+ const statusLine = profileAllowsRelayFeature(config, "statusLine");
248
+
249
+ const skills = configHome ? resolveClaudeProvisionedSkills(configHome, config) : [];
250
+ const hostSkills = configHome
251
+ ? resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)))
252
+ : [];
253
+ const projectSkills = configHome
254
+ ? resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name)))
255
+ : [];
256
+ const projectSettings = resolveProjectClaudeSettings(config);
257
+ const provisionedPlugins = assetHome ? resolveClaudeProvisionedPlugins(assetHome, config) : [];
258
+
259
+ // Plugin dirs (--plugin-dir loads explicitly, independent of --setting-sources):
260
+ // relay-bundled plugin → only when relay.plugins (carries the relay monitor +
261
+ // turn-lifecycle hooks #556, not reproducible via settings.json).
262
+ // relay-provisioned plugins (#556) → materialized into the isolated home.
263
+ // host plugin dirs → ONLY host-base profiles; isolated/vanilla must not pull them back in.
264
+ const relayPluginDirs = !config.agentProfile && relayPlugin ? [CLAUDE_RELAY_PLUGIN_DIR] : [];
265
+ const provisionedPluginDirs = provisionedPlugins.map((p) => p.dir);
266
+ const hostPluginDirs = profileAllowsHostAssets(config, "plugins") ? providerConfig.pluginDirs : [];
267
+ const pluginDirs = [...new Set([...relayPluginDirs, ...provisionedPluginDirs, ...hostPluginDirs])];
268
+
269
+ const provisionedMcp = profileProvisioning(config).mcpServers;
270
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
271
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
272
+
273
+ // #667 — project `.mcp.json` projection (opt-in). The spawn cwd's committed servers are
274
+ // injected through the SAME `--mcp-config` channel as relay-provisioned servers, so they
275
+ // survive `--strict-mcp-config` without the ambient project-discovery modal. A relay-
276
+ // provisioned server of the same name wins (the profile is the explicit, relay-owned
277
+ // source); the relay endpoint name is reserved. `declaredMcp` is the union relay injects.
278
+ const projectMcpRaw = resolveProjectMcpServers(config);
279
+ const projectMcp = Object.fromEntries(
280
+ Object.entries(projectMcpRaw).filter(
281
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
282
+ ),
283
+ );
284
+ const projectServers = Object.keys(projectMcp);
285
+
286
+ // #672 — shared host-listener MCP (opt-in, default OFF). When on, route the agent's
287
+ // NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned shared `callmux`
288
+ // listener via a per-agent `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry:
289
+ // the MCP session survives a listener/downstream restart, and --cwd gives each isolated
290
+ // worktree session-cwd isolation. It's a plain stdio server descriptor, so it rides the same
291
+ // controlled --mcp-config channel as provisioned servers (survives --strict-mcp-config) — no
292
+ // ambient discovery, no modal. Relay-owned infra → authoritative on its name (spread last);
293
+ // the relay endpoint stays per-agent (#215), NEVER on the shared listener.
294
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
295
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
296
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
297
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
298
+ : {};
299
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
300
+
301
+ // #662/#663 — strict controlled MCP is a Claude prompt-gate prevention entry, not a
302
+ // launch-assembly special case. It prevents the blocking "N new MCP servers found in
303
+ // this project" modal while preserving every relay-declared server in --mcp-config.
304
+ const strictMcp = claudeLaunchPromptGateSettings(config).strictMcpConfig;
305
+
306
+ // #662 follow-up — strict drops ALL on-disk config, including the host user-scope
307
+ // `~/.claude.json` MCP that a host-base agent loaded ambiently before strict. That
308
+ // over-reached: it stopped honoring `mcp.mode:"host"`, so default-relay workers/reviewers
309
+ // lost callmux/qmd/gh. Re-inject the host USER-scope servers (NOT the project `.mcp.json`,
310
+ // still excluded) when the launch both uses the host config home AND its mcp mode is host.
311
+ // An isolated/minimal base has its own config home and never had the real host's user MCP,
312
+ // so it stays relay+provisioned only even if a profile sets mode:"host".
313
+ const usesHostMcp = profileUsesProviderHostGlobals(config)
314
+ && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
315
+ const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
316
+ const hostMcpServers = Object.keys(hostMcp).filter(
317
+ (name) => name !== RELAY_MCP_SERVER_NAME && !(name in declaredMcp),
318
+ );
319
+
320
+ const instructions = assembleInstructions(config);
321
+
322
+ // Launch order matches the historical claude buildSpawnArgs block exactly so existing
323
+ // arg assertions hold: vanilla scope → plugin dirs → MCP → status-line → append.
324
+ const args = [
325
+ ...(vanilla || projectConfigProjection ? ["--setting-sources", "user"] : []),
326
+ ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
327
+ ...claudeMcpLaunchArgs({
328
+ relayUrl: config.relayUrl,
329
+ ...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
330
+ includeRelay: relayEndpoint,
331
+ servers: declaredMcp,
332
+ hostServers: hostMcp,
333
+ strict: strictMcp,
334
+ }),
335
+ ...claudeSettingsArgs(projectSettings.settings, statusLine, hostDefaultArgs, config.providerArgs),
336
+ ...(config.systemPromptAppend ? ["--append-system-prompt", config.systemPromptAppend] : []),
337
+ ];
338
+
339
+ return {
340
+ provider: "claude",
341
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
342
+ base: config.agentProfile?.base ?? "host",
343
+ vanilla,
344
+ ...(configHome ? { configHome } : {}),
345
+ relayContext: profileAllowsRelayFeature(config, "context"),
346
+ relaySkills,
347
+ relayPlugin,
348
+ statusLine,
349
+ skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
350
+ hostSkills: hostSkills.map((s) => ({ name: s.name, dir: s.dir })),
351
+ projectSkills: projectSkills.map((s) => ({ name: s.name, dir: s.dir })),
352
+ projectSettings: {
353
+ applied: projectSettings.applied,
354
+ keys: projectSettings.keys,
355
+ ignoredKeys: projectSettings.ignoredKeys,
356
+ ...(projectSettings.file ? { file: projectSettings.file } : {}),
357
+ },
358
+ plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
359
+ hostPluginDirs,
360
+ mcp: {
361
+ relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
362
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
363
+ },
364
+ instructions,
365
+ args,
366
+ env: configHomeEnv("claude", configHome),
367
+ };
368
+ }
369
+
370
+ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfig): AssembledLaunch {
371
+ const configHome = providerHomePathFor("codex", config);
372
+ const assetHome = providerProvisioningHomePathFor("codex", config);
373
+ const vanilla = profileIsVanillaBase(config);
374
+ const relaySkills = profileAllowsRelayFeature(config, "skills");
375
+ const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
376
+
377
+ const provisionedSkills = assetHome ? resolveCodexProvisionedSkills(assetHome, config) : [];
378
+ const bundledSkillDirs = !config.agentProfile && relaySkills ? bundledCodexSkillDirs() : [];
379
+ const skillDirs = [...bundledSkillDirs, ...provisionedSkills.map((s) => s.dir)];
380
+
381
+ const provisionedMcp = profileProvisioning(config).mcpServers;
382
+ const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
383
+
384
+ // #667 — project `.mcp.json` projection (opt-in), same union rule as Claude: a relay-
385
+ // provisioned server of the same name wins; the relay endpoint name is reserved. Codex has
386
+ // no `.mcp.json` discovery of its own, so this is purely additive (no strict/modal concern).
387
+ const projectMcpRaw = resolveProjectMcpServers(config);
388
+ const projectMcp = Object.fromEntries(
389
+ Object.entries(projectMcpRaw).filter(
390
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
391
+ ),
392
+ );
393
+ const projectServers = Object.keys(projectMcp);
394
+
395
+ // #672 — shared host-listener MCP (opt-in, default OFF), same as Claude: a per-agent
396
+ // `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry routes the agent's
397
+ // NON-IDENTITY MCP (tokenlean, github) through the shared listener (session survives a
398
+ // restart; --cwd gives session-cwd isolation). Emitted via the same `-c mcp_servers.*`
399
+ // overrides as provisioned servers. The relay endpoint stays per-agent (#215).
400
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
401
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
402
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
403
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
404
+ : {};
405
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
406
+
407
+ const instructions = assembleInstructions(config);
408
+
409
+ // The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
410
+ // in the adapter. -c overrides are order-independent, so the assembled block is contiguous.
411
+ const args = [
412
+ ...bundledSkillConfigArgs(skillDirs),
413
+ ...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
414
+ ...codexProvisionedMcpArgs(declaredMcp),
415
+ ...codexProjectDocSuppressionArgs(config),
416
+ ];
417
+
418
+ return {
419
+ provider: "codex",
420
+ ...(config.agentProfile ? { profile: config.agentProfile } : {}),
421
+ base: config.agentProfile?.base ?? "host",
422
+ vanilla,
423
+ ...(configHome ? { configHome } : {}),
424
+ relayContext: profileAllowsRelayFeature(config, "context"),
425
+ relaySkills,
426
+ relayPlugin: false,
427
+ statusLine: false,
428
+ skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
429
+ hostSkills: [],
430
+ projectSkills: [],
431
+ projectSettings: { applied: false, keys: [], ignoredKeys: [] },
432
+ plugins: [],
433
+ hostPluginDirs: [],
434
+ mcp: {
435
+ relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
436
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
437
+ },
438
+ instructions,
439
+ args,
440
+ env: configHomeEnv("codex", configHome),
441
+ };
442
+ }
443
+
444
+ // ── Manifest-driven env + dispatch ────────────────────────────────────────────
445
+
446
+ function configHomeEnv(provider: string, configHome: string | undefined): Record<string, string> {
447
+ if (!configHome) return {};
448
+ const manifest = getManifest(provider);
449
+ const envVar = manifest?.home?.configDirEnvVar;
450
+ return envVar ? { [envVar]: configHome } : {};
451
+ }
452
+
453
+ type Assembler = (config: RunnerSpawnConfig, providerConfig: ProviderConfig, hostDefaultArgs: string[]) => AssembledLaunch;
454
+ type Materializer = (config: RunnerSpawnConfig) => void;
455
+
456
+ const ASSEMBLERS: Record<string, Assembler> = {
457
+ claude: assembleClaude,
458
+ codex: (config, providerConfig) => assembleCodex(config, providerConfig),
459
+ };
460
+
461
+ const MATERIALIZERS: Record<string, Materializer> = {
462
+ claude: materializeClaudeAssets,
463
+ codex: materializeCodexAssets,
464
+ };
465
+
466
+ function materializeClaudeAssets(config: RunnerSpawnConfig): void {
467
+ const configHome = providerHomePathFor("claude", config);
468
+ const assetHome = providerProvisioningHomePathFor("claude", config);
469
+ if (configHome) {
470
+ const skills = resolveClaudeProvisionedSkills(configHome, config);
471
+ materializeResolvedAssets(skills);
472
+ const hostSkills = resolveClaudeHostSkills(configHome, config, new Set(skills.map((s) => s.name)));
473
+ materializeResolvedAssets(hostSkills);
474
+ materializeResolvedAssets(resolveClaudeProjectSkills(configHome, config, new Set([...skills, ...hostSkills].map((s) => s.name))));
475
+ }
476
+ if (assetHome) materializeResolvedAssets(resolveClaudeProvisionedPlugins(assetHome, config));
477
+ }
478
+
479
+ function materializeCodexAssets(config: RunnerSpawnConfig): void {
480
+ const assetHome = providerProvisioningHomePathFor("codex", config);
481
+ if (assetHome) materializeResolvedAssets(resolveCodexProvisionedSkills(assetHome, config));
482
+ }
483
+
484
+ /**
485
+ * Assemble the launch for `provider` from the resolved profile + provisioning. PURE:
486
+ * resolves which declared assets exist (filesystem reads) but writes nothing. A missing
487
+ * `agentProfile` assembles the legacy default (host base, relay features on, no provisioning).
488
+ */
489
+ export function assembleLaunch(provider: SpawnProvider, config: RunnerSpawnConfig, providerConfig: ProviderConfig): AssembledLaunch {
490
+ const assembler = ASSEMBLERS[provider];
491
+ if (!assembler) throw new Error(`no launch assembler registered for provider: ${provider}`);
492
+ const hostDefaultArgs = profileUsesProviderHostGlobals(config) ? providerConfig.defaultArgs : [];
493
+ return assembler(config, providerConfig, hostDefaultArgs);
494
+ }
495
+
496
+ /**
497
+ * Write the provisioned assets the assembly describes (Claude skills + plugins, Codex
498
+ * skills). Driven by the SAME resolvers `assembleLaunch` uses, so disk == launch ==
499
+ * report. Idempotent; the isolated home must already exist (prepare*ProfileHome). No-op
500
+ * for a host-base profile.
501
+ */
502
+ export function materializeLaunchAssembly(provider: SpawnProvider, config: RunnerSpawnConfig): void {
503
+ const configHome = providerHomePathFor(provider, config);
504
+ const assetHome = providerProvisioningHomePathFor(provider, config);
505
+ if (!configHome && !assetHome) return;
506
+ const materializer = MATERIALIZERS[provider];
507
+ if (materializer) materializer(config);
508
+ }
@@ -0,0 +1,50 @@
1
+ import type { LivenessSignal } from "agent-relay-sdk";
2
+
3
+ // The raw runner-tracked signals an adapter folds into a normalized LivenessSignal.
4
+ // This is the INPUT to the contract computation (#746): the runner owns this state
5
+ // (transport frames, the last provider event, the pending timeline transition, the
6
+ // active-work set), passes it to the adapter's `liveness()` verdict, and ships the
7
+ // resulting LivenessSignal on the agent card. Provider-raw interpretation stops here —
8
+ // nothing above the runner ever reads these fields again.
9
+ export interface LivenessInputs {
10
+ /** Runner→server transport frame/ack timestamps (sdk BusClient.transportState). */
11
+ transport?: { lastServerFrameAt?: number; lastHeartbeatAckAt?: number };
12
+ /** Epoch-ms of the most recent provider status/token event the runner observed. */
13
+ lastProviderEventAt?: number;
14
+ /** The most recent lifecycle/timeline transition the runner published (status + ts). */
15
+ timelineEvent?: { status?: string; timestamp?: number };
16
+ /** Active work records. The adapter classifies its own kinds; `provider-turn` is the
17
+ * bare turn, anything else (subagent, background-script, tool, subprocess) is live work. */
18
+ activeWork: Array<{ kind?: string; startedAt?: number }>;
19
+ /** Fallback progress floor when no other progress evidence exists yet (e.g. work start). */
20
+ fallbackProgressAt?: number;
21
+ }
22
+
23
+ function num(value: unknown): number {
24
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
25
+ }
26
+
27
+ /**
28
+ * The shared liveness fold (#725 / #746). Both reference adapters (Claude #713, Codex #724)
29
+ * route through this so the verdict is computed in ONE place; each adapter's `liveness()`
30
+ * decides WHICH raw signals it feeds in (Claude surfaces a `compacting` timeline transition
31
+ * as progress; Codex surfaces live subprocess work-kinds). The fold itself is provider-
32
+ * agnostic, which is the point of the contract: a new provider only has to feed its signals.
33
+ */
34
+ export function computeLivenessSignal(inputs: LivenessInputs): LivenessSignal {
35
+ const transportLiveAt = Math.max(
36
+ num(inputs.transport?.lastServerFrameAt),
37
+ num(inputs.transport?.lastHeartbeatAckAt),
38
+ );
39
+ // Progress = provider tokens OR a lifecycle/timeline transition (e.g. native compaction,
40
+ // #713) — whichever is latest, with the caller-supplied floor as the baseline.
41
+ const lastProgressAt = Math.max(
42
+ num(inputs.lastProviderEventAt),
43
+ num(inputs.timelineEvent?.timestamp),
44
+ num(inputs.fallbackProgressAt),
45
+ );
46
+ // Any work-kind that is NOT the bare provider turn is live tool/subprocess work (#724):
47
+ // a wedged provider turn with a live `bun test` child must read as progressing.
48
+ const hasLiveWork = inputs.activeWork.some((work) => Boolean(work.kind && work.kind !== "provider-turn"));
49
+ return { transportLiveAt, lastProgressAt, hasLiveWork };
50
+ }
@@ -0,0 +1,99 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
+ import { logLevelFromEnv } from "./config";
5
+
6
+ // Phase 1 observability (#198): one leveled, runtime-togglable logger for the
7
+ // Runner and the provider adapters below it. Replaces the ad-hoc scatter of
8
+ // `console.error`, `logRunnerDiagnostic` (-> runner-<agent>.log) and
9
+ // `sessionLog`/`sessionDebug` (-> session-mirror-<agent>.log) with a single
10
+ // switch and a single greppable, ANSI-free sink.
11
+ //
12
+ // Sink: the per-agent `session-mirror-<agent>.log` — the file the orchestrator
13
+ // already surfaces to the dashboard log-viewer (captureSessionMirror). One place
14
+ // to look when anything in the Runner misbehaves.
15
+ //
16
+ // Level is read once from AGENT_RELAY_LOG_LEVEL (default "info") and can be
17
+ // flipped at runtime via the control port (no restart) — so a phase refactor can
18
+ // be watched at debug without bouncing the agent.
19
+
20
+ type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
21
+
22
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40, fatal: 50 };
23
+ export const LOG_LEVELS = Object.keys(ORDER) as LogLevel[];
24
+
25
+ export function parseLogLevel(value: string | undefined | null): LogLevel | undefined {
26
+ if (!value) return undefined;
27
+ const v = value.trim().toLowerCase();
28
+ return (LOG_LEVELS as string[]).includes(v) ? (v as LogLevel) : undefined;
29
+ }
30
+
31
+ // Shared with the orchestrator (session-mirror reader) + runner outbox via the
32
+ // SDK helper, so all sites resolve the identical filename for a given agent id.
33
+ function safeLogName(value: string): string {
34
+ return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
35
+ }
36
+
37
+ interface LoggerConfig {
38
+ agentId?: string;
39
+ level?: LogLevel;
40
+ headless?: boolean;
41
+ logDir?: string;
42
+ }
43
+
44
+ export class Logger {
45
+ private level: LogLevel;
46
+ private agentId: string;
47
+ private headless: boolean;
48
+ private logDir: string;
49
+
50
+ constructor(config: LoggerConfig = {}) {
51
+ this.level = config.level ?? parseLogLevel(logLevelFromEnv()) ?? "info";
52
+ this.agentId = config.agentId ?? "runner";
53
+ this.headless = config.headless ?? false;
54
+ this.logDir = config.logDir ?? join(process.env.HOME || ".", ".agent-relay", "logs");
55
+ }
56
+
57
+ // Bind the logger to a concrete agent once the runner knows its id. Preserves a
58
+ // level already set via env/runtime unless an explicit level is passed.
59
+ configure(config: LoggerConfig): void {
60
+ if (config.agentId !== undefined) this.agentId = config.agentId;
61
+ if (config.headless !== undefined) this.headless = config.headless;
62
+ if (config.logDir !== undefined) this.logDir = config.logDir;
63
+ if (config.level !== undefined) this.level = config.level;
64
+ }
65
+
66
+ setLevel(level: LogLevel): void { this.level = level; }
67
+ getLevel(): LogLevel { return this.level; }
68
+ isEnabled(level: LogLevel): boolean { return ORDER[level] >= ORDER[this.level]; }
69
+
70
+ debug(component: string, message: string): void { this.log("debug", component, message); }
71
+ info(component: string, message: string): void { this.log("info", component, message); }
72
+ warn(component: string, message: string): void { this.log("warn", component, message); }
73
+ error(component: string, message: string): void { this.log("error", component, message); }
74
+ fatal(component: string, message: string): void { this.log("fatal", component, message); }
75
+
76
+ log(level: LogLevel, component: string, message: string): void {
77
+ if (!this.isEnabled(level)) return;
78
+ const line = `[${new Date().toISOString()}] ${level.toUpperCase().padEnd(5)} [${component}] ${oneLine(message)}\n`;
79
+ try {
80
+ mkdirSync(this.logDir, { recursive: true });
81
+ appendFileSync(join(this.logDir, `session-mirror-${safeLogName(this.agentId)}.log`), line);
82
+ } catch {
83
+ // Best-effort. If the per-agent file can't be written, surface error/fatal to
84
+ // stderr so it is not lost entirely (headless: lands in the orchestrator log).
85
+ if (ORDER[level] >= ORDER.error) { try { console.error(line.trimEnd()); } catch { /* give up */ } }
86
+ }
87
+ }
88
+ }
89
+
90
+ // Newlines would split one record across several log lines and break greppability;
91
+ // collapse them so a multi-line message stays one line.
92
+ function oneLine(message: string): string {
93
+ return message.replace(/\r?\n/g, " ⏎ ");
94
+ }
95
+
96
+ // Process-global logger. A runner process serves exactly one agent, so a singleton
97
+ // is the right scope; the runner calls configure() once it knows its id, and
98
+ // adapters import this instance directly (no constructor threading).
99
+ export const logger = new Logger();