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,189 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import type { Artifact, Message } from "agent-relay-sdk";
5
+ import { errMessage, isRecord } from "agent-relay-sdk";
6
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
+ import { attachmentCacheDirFromEnv } from "./config";
8
+
9
+ const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
+
11
+ interface AttachmentCacheClient {
12
+ downloadArtifact(id: string): Promise<{ stream: ReadableStream<Uint8Array>; meta: Artifact }>;
13
+ }
14
+
15
+ interface AttachmentCacheOptions {
16
+ agentId: string;
17
+ rootDir?: string;
18
+ maxAgeMs?: number;
19
+ now?: number;
20
+ onError?: (message: string) => void;
21
+ }
22
+
23
+ interface CachedAttachment {
24
+ path: string;
25
+ filename: string;
26
+ mediaType: string;
27
+ size: number;
28
+ digest: string;
29
+ }
30
+
31
+ function attachmentRefs(message: Message): Record<string, unknown>[] {
32
+ const payloadRefs = message.payload?.attachments;
33
+ const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
34
+ const refs = Array.isArray(payloadRefs) ? payloadRefs : Array.isArray(topLevelRefs) ? topLevelRefs : [];
35
+ return refs.filter(isRecord);
36
+ }
37
+
38
+ function attachmentCacheRoot(agentId: string, rootDir = attachmentCacheDirFromEnv()): string {
39
+ return join(attachmentCacheBase(rootDir), safePathPart(agentId));
40
+ }
41
+
42
+ function attachmentCacheBase(rootDir = attachmentCacheDirFromEnv()): string {
43
+ return rootDir || join(homedir(), ".agent-relay", "attachments");
44
+ }
45
+
46
+ export async function messagesWithCachedAttachments(
47
+ messages: Message[],
48
+ client: AttachmentCacheClient,
49
+ options: AttachmentCacheOptions,
50
+ ): Promise<Message[]> {
51
+ pruneAttachmentCache(options.agentId, options.rootDir, options.maxAgeMs ?? DEFAULT_CACHE_MAX_AGE_MS, options.now ?? Date.now());
52
+ const enriched: Message[] = [];
53
+ for (const message of messages) enriched.push(await messageWithCachedAttachments(message, client, options));
54
+ return enriched;
55
+ }
56
+
57
+ async function messageWithCachedAttachments(
58
+ message: Message,
59
+ client: AttachmentCacheClient,
60
+ options: AttachmentCacheOptions,
61
+ ): Promise<Message> {
62
+ const refs = attachmentRefs(message);
63
+ if (!refs.length) return message;
64
+ const nextRefs = [];
65
+ let changed = false;
66
+ for (const ref of refs) {
67
+ const artifactId = typeof ref.artifactId === "string" ? ref.artifactId.trim() : "";
68
+ if (!artifactId) {
69
+ nextRefs.push(ref);
70
+ continue;
71
+ }
72
+ try {
73
+ const cached = await cacheArtifact(client, artifactId, options);
74
+ nextRefs.push(withCacheMetadata(ref, cached));
75
+ changed = true;
76
+ } catch (error) {
77
+ options.onError?.(`attachment ${artifactId} cache failed: ${errMessage(error)}`);
78
+ nextRefs.push(withCacheError(ref, error));
79
+ changed = true;
80
+ }
81
+ }
82
+ if (!changed) return message;
83
+ return {
84
+ ...message,
85
+ payload: {
86
+ ...message.payload,
87
+ attachments: nextRefs,
88
+ },
89
+ };
90
+ }
91
+
92
+ async function cacheArtifact(client: AttachmentCacheClient, artifactId: string, options: AttachmentCacheOptions): Promise<CachedAttachment> {
93
+ const { stream, meta } = await client.downloadArtifact(artifactId);
94
+ const root = attachmentCacheRoot(options.agentId, options.rootDir);
95
+ mkdirSync(root, { recursive: true, mode: 0o700 });
96
+ const filename = safeFilename(meta.filename || artifactId);
97
+ const digestPart = safePathPart((meta.digest || meta.blobDigest || artifactId).replace(/^sha256:/, "")).slice(0, 24) || safePathPart(artifactId);
98
+ const path = join(root, `${digestPart}-${filename}`);
99
+ if (existsSync(path)) {
100
+ const stat = statSync(path);
101
+ if (stat.isFile() && stat.size === meta.size) {
102
+ return { path, filename, mediaType: meta.mediaType, size: meta.size, digest: meta.digest };
103
+ }
104
+ }
105
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
106
+ if (bytes.byteLength !== meta.size) throw new Error(`artifact size mismatch: expected ${meta.size}, got ${bytes.byteLength}`);
107
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
108
+ writeFileSync(temp, bytes, { mode: 0o600 });
109
+ rmSync(path, { force: true });
110
+ renameSync(temp, path);
111
+ return { path, filename, mediaType: meta.mediaType, size: meta.size, digest: meta.digest };
112
+ }
113
+
114
+ function withCacheMetadata(ref: Record<string, unknown>, cached: CachedAttachment): Record<string, unknown> {
115
+ const metadata = isRecord(ref.metadata) ? ref.metadata : {};
116
+ const agentRelay = isRecord(metadata.agentRelay) ? metadata.agentRelay : {};
117
+ return {
118
+ ...ref,
119
+ metadata: {
120
+ ...metadata,
121
+ agentRelay: {
122
+ ...agentRelay,
123
+ localPath: cached.path,
124
+ filename: cached.filename,
125
+ mediaType: cached.mediaType,
126
+ size: cached.size,
127
+ digest: cached.digest,
128
+ cachedAt: new Date().toISOString(),
129
+ },
130
+ },
131
+ };
132
+ }
133
+
134
+ function withCacheError(ref: Record<string, unknown>, error: unknown): Record<string, unknown> {
135
+ const metadata = isRecord(ref.metadata) ? ref.metadata : {};
136
+ const agentRelay = isRecord(metadata.agentRelay) ? metadata.agentRelay : {};
137
+ return {
138
+ ...ref,
139
+ metadata: {
140
+ ...metadata,
141
+ agentRelay: {
142
+ ...agentRelay,
143
+ cacheError: errMessage(error),
144
+ },
145
+ },
146
+ };
147
+ }
148
+
149
+ export function pruneAttachmentCache(
150
+ agentId: string,
151
+ rootDir = attachmentCacheDirFromEnv(),
152
+ maxAgeMs = DEFAULT_CACHE_MAX_AGE_MS,
153
+ now = Date.now(),
154
+ ): void {
155
+ const base = attachmentCacheBase(rootDir);
156
+ if (!existsSync(base)) return;
157
+ const roots = [attachmentCacheRoot(agentId, rootDir)];
158
+ for (const entry of readdirSync(base)) roots.push(join(base, entry));
159
+ for (const root of [...new Set(roots)]) pruneAttachmentCacheDir(root, maxAgeMs, now);
160
+ }
161
+
162
+ function pruneAttachmentCacheDir(root: string, maxAgeMs: number, now: number): void {
163
+ if (!existsSync(root)) return;
164
+ const entries = readdirSync(root);
165
+ for (const entry of entries) {
166
+ const path = join(root, entry);
167
+ try {
168
+ const stat = statSync(path);
169
+ if (!stat.isFile()) continue;
170
+ if (now - stat.mtimeMs > maxAgeMs) rmSync(path, { force: true });
171
+ } catch {
172
+ // Best-effort cache cleanup only.
173
+ }
174
+ }
175
+ try {
176
+ if (readdirSync(root).length === 0) rmSync(root, { recursive: true, force: true });
177
+ } catch {
178
+ // Best-effort cache cleanup only.
179
+ }
180
+ }
181
+
182
+ function safeFilename(value: string): string {
183
+ const name = safePathPart(basename(value));
184
+ return name || "artifact";
185
+ }
186
+
187
+ function safePathPart(value: string): string {
188
+ return sanitizeFsName(value, { replacement: "_", collapseReplacement: true, maxLen: 180 });
189
+ }
@@ -0,0 +1,102 @@
1
+ type ProviderActivity = "busy" | "idle" | "unknown";
2
+
3
+ const BUSY_RECONCILE_POLL_MS = 4_000;
4
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
5
+ const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
6
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
7
+
8
+ interface BusyReconcilerDeps {
9
+ isStopped(): boolean;
10
+ hasProcess(): boolean;
11
+ currentStatus(): "idle" | "busy" | "offline" | "error";
12
+ isProviderBlocked(): boolean;
13
+ hasProviderTurn(): boolean;
14
+ canProbeActivity(): boolean;
15
+ probeActivity(): Promise<ProviderActivity> | undefined;
16
+ // #769 P1-A: optional independent pane-busy read consulted ONLY at the force-clear
17
+ // moment. When it resolves true, a probe-`idle` must not clear the live turn.
18
+ probePaneBusy?(): Promise<boolean> | undefined;
19
+ clearProviderTurn(reason: string): void;
20
+ sessionDebug(message: string): void;
21
+ }
22
+
23
+ export class BusyReconciler {
24
+ private idleStreak = 0;
25
+ private timer?: ReturnType<typeof setInterval>;
26
+ private sawBusy = false;
27
+
28
+ constructor(private readonly deps: BusyReconcilerDeps) {}
29
+
30
+ arm(): void {
31
+ if (this.timer || !this.deps.canProbeActivity()) return;
32
+ this.idleStreak = 0;
33
+ this.sawBusy = false;
34
+ this.timer = setInterval(() => { void this.run(); }, BUSY_RECONCILE_POLL_MS);
35
+ }
36
+
37
+ disarm(): void {
38
+ if (this.timer) clearInterval(this.timer);
39
+ this.timer = undefined;
40
+ this.idleStreak = 0;
41
+ this.sawBusy = false;
42
+ }
43
+
44
+ scheduleInterruptReconcile(): void {
45
+ setTimeout(() => {
46
+ if (this.deps.isStopped() || !this.deps.hasProcess()) return;
47
+ void (async () => {
48
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
49
+ const probe = this.deps.probeActivity();
50
+ let activity: ProviderActivity = "unknown";
51
+ try { if (probe) activity = await probe; } catch { return; }
52
+ this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
53
+ if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
54
+ })();
55
+ }, INTERRUPT_RECONCILE_DELAY_MS);
56
+ }
57
+
58
+ private async run(): Promise<void> {
59
+ const probe = this.deps.probeActivity();
60
+ if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
61
+ this.disarm();
62
+ return;
63
+ }
64
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) {
65
+ this.idleStreak = 0;
66
+ return;
67
+ }
68
+ if (!this.deps.hasProviderTurn()) {
69
+ this.disarm();
70
+ return;
71
+ }
72
+ let activity: ProviderActivity;
73
+ try { activity = await probe; } catch { return; }
74
+ if (activity === "busy") this.sawBusy = true;
75
+ if (activity !== "idle") {
76
+ this.idleStreak = 0;
77
+ this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
78
+ return;
79
+ }
80
+ this.idleStreak += 1;
81
+ const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
82
+ this.deps.sessionDebug(`reconcile probe=idle sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
83
+ if (this.idleStreak < confirm) return;
84
+ // #769 P1-A: before force-clearing, give an independent pane-busy read a veto.
85
+ // If the pane spinner still says busy, the probe-`idle` is untrustworthy (e.g. a
86
+ // connection retry wrote status:idle mid-turn) — hold the turn instead of clearing.
87
+ // Symmetric defense-in-depth: only clear when BOTH sources agree the turn is done.
88
+ const paneBusy = this.deps.probePaneBusy?.();
89
+ if (paneBusy) {
90
+ let stillBusy = false;
91
+ try { stillBusy = (await paneBusy) === true; } catch { stillBusy = false; }
92
+ if (stillBusy) {
93
+ this.idleStreak = 0;
94
+ this.sawBusy = true;
95
+ this.deps.sessionDebug("reconcile clear vetoed: pane still reads busy");
96
+ return;
97
+ }
98
+ }
99
+ this.disarm();
100
+ this.deps.clearProviderTurn(this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
101
+ }
102
+ }
@@ -0,0 +1,140 @@
1
+ type ClaimKind = "message" | "task" | "command";
2
+ type RunnerWorkKind = "provider-turn" | "subagent" | "background-script";
3
+ type RunnerBusyReason = RunnerWorkKind | ClaimKind;
4
+ type RunnerSemanticStatus = "idle" | "busy" | "offline" | "error";
5
+
6
+ interface ClaimRecord {
7
+ id: string;
8
+ kind: ClaimKind;
9
+ startedAt: number;
10
+ expiresAt?: number;
11
+ }
12
+
13
+ interface WorkRecord {
14
+ id: string;
15
+ kind: RunnerWorkKind;
16
+ label?: string;
17
+ role?: string;
18
+ parentId?: string;
19
+ metadata?: Record<string, unknown>;
20
+ // OS pids of the underlying process(es) this work tracks (#650). When present,
21
+ // a periodic liveness reconcile clears the record once every pid is dead — so a
22
+ // tracked process killed out-of-band (no exit event) can't strand the agent busy.
23
+ pids?: number[];
24
+ startedAt: number;
25
+ }
26
+
27
+ export class ClaimTracker {
28
+ private status: RunnerSemanticStatus = "idle";
29
+ private readonly claims = new Map<string, ClaimRecord>();
30
+ private readonly work = new Map<string, WorkRecord>();
31
+
32
+ setProviderBusy(busy: boolean): boolean {
33
+ return busy ? this.startWork("provider-turn", "provider-turn") : this.finishWork("provider-turn", "provider-turn");
34
+ }
35
+
36
+ startWork(kind: RunnerWorkKind, id: string = kind, details: Omit<Partial<WorkRecord>, "id" | "kind" | "startedAt"> = {}): boolean {
37
+ const before = this.currentStatus();
38
+ const key = `${kind}:${id}`;
39
+ const existing = this.work.get(key);
40
+ this.work.set(key, {
41
+ id,
42
+ kind,
43
+ label: details.label ?? existing?.label,
44
+ role: details.role ?? existing?.role,
45
+ parentId: details.parentId ?? existing?.parentId,
46
+ metadata: details.metadata ?? existing?.metadata,
47
+ pids: details.pids ?? existing?.pids,
48
+ startedAt: existing?.startedAt ?? Date.now(),
49
+ });
50
+ return before !== this.currentStatus();
51
+ }
52
+
53
+ finishWork(kind: RunnerWorkKind, id: string = kind): boolean {
54
+ const before = this.currentStatus();
55
+ this.work.delete(`${kind}:${id}`);
56
+ return before !== this.currentStatus();
57
+ }
58
+
59
+ clearWorkKind(kind: RunnerWorkKind): boolean {
60
+ const before = this.currentStatus();
61
+ for (const key of [...this.work.keys()]) {
62
+ if (key.startsWith(`${kind}:`)) this.work.delete(key);
63
+ }
64
+ return before !== this.currentStatus();
65
+ }
66
+
67
+ setTerminalStatus(status: "offline" | "error"): boolean {
68
+ const before = this.status;
69
+ this.status = status;
70
+ return before !== status;
71
+ }
72
+
73
+ clearTerminalStatus(): boolean {
74
+ const before = this.status;
75
+ this.status = "idle";
76
+ return before !== this.status;
77
+ }
78
+
79
+ startClaim(kind: ClaimKind, id: string, expiresAt?: number): boolean {
80
+ const before = this.currentStatus();
81
+ this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
82
+ return before !== this.currentStatus();
83
+ }
84
+
85
+ finishClaim(kind: ClaimKind, id: string): boolean {
86
+ const before = this.currentStatus();
87
+ this.claims.delete(`${kind}:${id}`);
88
+ return before !== this.currentStatus();
89
+ }
90
+
91
+ expire(now = Date.now()): boolean {
92
+ const before = this.currentStatus();
93
+ for (const [key, claim] of this.claims) {
94
+ if (claim.expiresAt !== undefined && claim.expiresAt <= now) this.claims.delete(key);
95
+ }
96
+ return before !== this.currentStatus();
97
+ }
98
+
99
+ currentStatus(): RunnerSemanticStatus {
100
+ if (this.status === "offline" || this.status === "error") return this.status;
101
+ return this.work.size > 0 || this.claims.size > 0 ? "busy" : "idle";
102
+ }
103
+
104
+ reasons(): RunnerBusyReason[] {
105
+ const reasons = new Set<RunnerBusyReason>();
106
+ for (const item of this.work.values()) reasons.add(item.kind);
107
+ for (const claim of this.claims.values()) reasons.add(claim.kind);
108
+ return [...reasons];
109
+ }
110
+
111
+ activeWork(): WorkRecord[] {
112
+ return [...this.work.values()];
113
+ }
114
+
115
+ /** True iff any active work carries pids the liveness reconcile can check (#650). */
116
+ hasLivenessTrackedWork(): boolean {
117
+ for (const item of this.work.values()) {
118
+ if (item.pids && item.pids.length > 0) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Reconcile pid-bearing work against actual process liveness (#650): drop any
125
+ * record whose tracked pids are ALL dead — its underlying process died out-of-band
126
+ * (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
127
+ * with no pids are never touched (no liveness signal to act on). Returns the removed
128
+ * records so the caller can emit the matching completion/abort + republish status.
129
+ */
130
+ reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
131
+ const removed: WorkRecord[] = [];
132
+ for (const [key, item] of this.work) {
133
+ if (!item.pids || item.pids.length === 0) continue;
134
+ if (item.pids.some(isAlive)) continue;
135
+ this.work.delete(key);
136
+ removed.push(item);
137
+ }
138
+ return removed;
139
+ }
140
+ }
@@ -0,0 +1,268 @@
1
+ import { resolve } from "node:path";
2
+ import { isRecord, type ProviderState } from "agent-relay-sdk";
3
+ import type { ProviderStatusUpdate, RunnerSpawnConfig } from "./adapter";
4
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "./rate-limit";
5
+
6
+ export interface ClaudeConfigPromptGatePrevention {
7
+ id: string;
8
+ description: string;
9
+ apply(seed: Record<string, unknown>, context: { host?: Record<string, unknown>; config: RunnerSpawnConfig }): void;
10
+ }
11
+
12
+ export interface ClaudeLaunchPromptGatePrevention {
13
+ id: string;
14
+ description: string;
15
+ apply(config: RunnerSpawnConfig): { strictMcpConfig?: boolean };
16
+ }
17
+
18
+ type PaneGateMatch = { message?: string; resetAt?: number; kind?: "usage_limit" | "transient_overload"; errorType?: string };
19
+
20
+ interface ClaudePanePromptGate {
21
+ id: string;
22
+ label: string;
23
+ policy: string;
24
+ keystroke: string;
25
+ minDetections: number;
26
+ match(text: string, nowMs?: number): PaneGateMatch | null;
27
+ providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): ProviderState;
28
+ }
29
+
30
+ export interface ClaudePromptGatePaneState {
31
+ currentGateId?: string;
32
+ consecutiveDetections: number;
33
+ handledGateId?: string;
34
+ }
35
+
36
+ export interface ClaudePromptGatePaneContext {
37
+ sessionName?: string;
38
+ busy?: boolean;
39
+ nowMs?: number;
40
+ }
41
+
42
+ export interface ClaudePromptGateAction {
43
+ gateId: string;
44
+ label: string;
45
+ policy: string;
46
+ keystroke: string;
47
+ source: "claude-pane";
48
+ }
49
+
50
+ export interface ClaudePromptGatePaneResult {
51
+ action?: ClaudePromptGateAction;
52
+ status?: ProviderStatusUpdate;
53
+ state: ClaudePromptGatePaneState;
54
+ }
55
+
56
+ export const claudeConfigPromptGatePreventions: ClaudeConfigPromptGatePrevention[] = [
57
+ {
58
+ id: "first-run-onboarding",
59
+ description: "Pre-accept Claude Code's first-run onboarding gate.",
60
+ apply(seed, { host }) {
61
+ seed.hasCompletedOnboarding = true;
62
+ if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
63
+ },
64
+ },
65
+ {
66
+ id: "theme-selection",
67
+ description: "Pre-seed a theme so the first-run theme picker does not render.",
68
+ apply(seed, { host }) {
69
+ seed.theme = typeof host?.theme === "string" ? host.theme : "dark";
70
+ },
71
+ },
72
+ {
73
+ id: "bypass-permissions-mode",
74
+ description: "Pre-accept Claude's open-approval bypass-permissions modal.",
75
+ apply(seed, { config }) {
76
+ if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
77
+ },
78
+ },
79
+ {
80
+ id: "workspace-trust",
81
+ description: "Trust the managed spawn cwd and clear project onboarding.",
82
+ apply(seed, { config }) {
83
+ if (!config.cwd) return;
84
+ seed.projects = {
85
+ ...(isRecord(seed.projects) ? seed.projects : {}),
86
+ [resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
87
+ };
88
+ },
89
+ },
90
+ ];
91
+
92
+ export const claudeLaunchPromptGatePreventions: ClaudeLaunchPromptGatePrevention[] = [
93
+ {
94
+ id: "project-mcp-discovery",
95
+ description: "Use strict controlled MCP config so Claude never prompts for ambient project .mcp.json servers.",
96
+ apply() {
97
+ return { strictMcpConfig: true };
98
+ },
99
+ },
100
+ ];
101
+
102
+ const panePromptGates: ClaudePanePromptGate[] = [
103
+ {
104
+ id: "usage-limit",
105
+ label: "Usage limit",
106
+ policy: "wait-for-reset",
107
+ keystroke: "Escape",
108
+ minDetections: 2,
109
+ match: (text, nowMs) => parseClaudeRateLimitPane(text, nowMs),
110
+ providerState(match, context) {
111
+ return buildRateLimitProviderState({
112
+ errorType: match.errorType ?? "session_limit",
113
+ kind: match.kind ?? "usage_limit",
114
+ ...(match.resetAt ? { resetAt: match.resetAt } : {}),
115
+ message: match.message,
116
+ source: context.sessionName ? `claude-pane:${context.sessionName}` : "claude-pane",
117
+ });
118
+ },
119
+ },
120
+ {
121
+ id: "workspace-trust",
122
+ label: "Workspace trust",
123
+ policy: "accept-trusted-workspace",
124
+ keystroke: "Enter",
125
+ minDetections: 2,
126
+ match: (text) => /do you trust (?:the files in )?(?:this folder|this project|this directory)/i.test(text)
127
+ && /\b(?:yes|proceed|trust|continue)\b/i.test(text)
128
+ ? { message: lineMatching(text, /do you trust/i) }
129
+ : null,
130
+ },
131
+ {
132
+ id: "bypass-permissions-mode",
133
+ label: "Bypass permissions mode",
134
+ policy: "accept-configured-open-approval",
135
+ keystroke: "Enter",
136
+ minDetections: 2,
137
+ match: (text) => /bypass permissions mode/i.test(text) && /\b(?:accept|proceed|continue|yes)\b/i.test(text)
138
+ ? { message: lineMatching(text, /bypass permissions mode/i) }
139
+ : null,
140
+ },
141
+ {
142
+ id: "project-mcp-discovery",
143
+ label: "Project MCP discovery",
144
+ policy: "reject-ambient-project-mcp",
145
+ keystroke: "Escape",
146
+ minDetections: 2,
147
+ match: (text) => /\bnew MCP servers? found\b/i.test(text) && /\b(?:select|enable|servers?)\b/i.test(text)
148
+ ? { message: lineMatching(text, /new MCP servers? found/i) }
149
+ : null,
150
+ },
151
+ {
152
+ id: "theme-selection",
153
+ label: "Theme selection",
154
+ policy: "accept-default-theme",
155
+ keystroke: "Enter",
156
+ minDetections: 2,
157
+ match: (text) => /choose (?:the )?(?:text style|theme).*Claude Code/i.test(text) && /\b(?:dark|light) mode\b/i.test(text)
158
+ ? { message: lineMatching(text, /choose/i) }
159
+ : null,
160
+ },
161
+ {
162
+ id: "first-run-onboarding",
163
+ label: "First-run onboarding",
164
+ policy: "accept-onboarding",
165
+ keystroke: "Enter",
166
+ minDetections: 2,
167
+ match: (text) => /welcome to Claude Code/i.test(text) && /\b(?:let'?s get started|continue|get started)\b/i.test(text)
168
+ ? { message: lineMatching(text, /welcome to Claude Code/i) }
169
+ : null,
170
+ },
171
+ ];
172
+
173
+ export function applyClaudeConfigPromptGatePreventions(
174
+ seed: Record<string, unknown>,
175
+ host: Record<string, unknown> | undefined,
176
+ config: RunnerSpawnConfig,
177
+ ): Record<string, unknown> {
178
+ const next = { ...seed };
179
+ for (const prevention of claudeConfigPromptGatePreventions) prevention.apply(next, { host, config });
180
+ return next;
181
+ }
182
+
183
+ export function claudeLaunchPromptGateSettings(config: RunnerSpawnConfig): { strictMcpConfig: boolean } {
184
+ return {
185
+ strictMcpConfig: claudeLaunchPromptGatePreventions.some((prevention) => prevention.apply(config).strictMcpConfig === true),
186
+ };
187
+ }
188
+
189
+ export function initialClaudePromptGatePaneState(): ClaudePromptGatePaneState {
190
+ return { consecutiveDetections: 0 };
191
+ }
192
+
193
+ export function evaluateClaudePromptGatePane(
194
+ text: string,
195
+ state: ClaudePromptGatePaneState,
196
+ context: ClaudePromptGatePaneContext = {},
197
+ ): ClaudePromptGatePaneResult {
198
+ const detected = firstDetectedPaneGate(text, context.nowMs);
199
+ if (!detected) return { state: initialClaudePromptGatePaneState() };
200
+ if (context.busy) {
201
+ return { state: { ...state, currentGateId: undefined, consecutiveDetections: 0 } };
202
+ }
203
+ if (state.handledGateId === detected.entry.id) {
204
+ return { state };
205
+ }
206
+
207
+ const consecutiveDetections = state.currentGateId === detected.entry.id ? state.consecutiveDetections + 1 : 1;
208
+ const nextState: ClaudePromptGatePaneState = {
209
+ currentGateId: detected.entry.id,
210
+ consecutiveDetections,
211
+ ...(state.handledGateId ? { handledGateId: state.handledGateId } : {}),
212
+ };
213
+ if (consecutiveDetections < detected.entry.minDetections) return { state: nextState };
214
+
215
+ const action: ClaudePromptGateAction = {
216
+ gateId: detected.entry.id,
217
+ label: detected.entry.label,
218
+ policy: detected.entry.policy,
219
+ keystroke: detected.entry.keystroke,
220
+ source: "claude-pane",
221
+ };
222
+ return {
223
+ action,
224
+ status: promptGateStatus(detected.entry, detected.match, action, context),
225
+ state: { ...nextState, handledGateId: detected.entry.id },
226
+ };
227
+ }
228
+
229
+ function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
230
+ for (const entry of panePromptGates) {
231
+ const match = entry.match(text, nowMs);
232
+ if (match) return { entry, match };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function promptGateStatus(
238
+ entry: ClaudePanePromptGate,
239
+ match: PaneGateMatch,
240
+ action: ClaudePromptGateAction,
241
+ context: ClaudePromptGatePaneContext,
242
+ ): ProviderStatusUpdate {
243
+ const metadata = {
244
+ eventType: "claude.prompt_gate.auto_handled",
245
+ gateId: action.gateId,
246
+ policy: action.policy,
247
+ keystroke: action.keystroke,
248
+ source: action.source,
249
+ ...(context.sessionName ? { sessionName: context.sessionName } : {}),
250
+ };
251
+ return {
252
+ status: "idle",
253
+ clear: ["subagent"],
254
+ ...(entry.providerState ? { providerState: entry.providerState(match, context) } : {}),
255
+ timeline: {
256
+ status: "claude.prompt_gate.auto_handled",
257
+ id: `claude-prompt-gate-${entry.id}-${Date.now()}`,
258
+ timestamp: Date.now(),
259
+ title: "Claude prompt gate auto-handled",
260
+ body: match.message ?? `${entry.label} dismissed with ${entry.keystroke}`,
261
+ metadata,
262
+ },
263
+ };
264
+ }
265
+
266
+ function lineMatching(text: string, pattern: RegExp): string | undefined {
267
+ return text.split(/\r?\n/).map((line) => line.trim()).find((line) => pattern.test(line));
268
+ }