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
@@ -10,7 +10,8 @@
10
10
  // content — a policy switch here, zero schema/composer change. `project_entries.source`
11
11
  // is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
12
12
  // policy includes `manual`+`promoted` while excluding `ingested`.
13
- import { resolveProjectEntries } from "./db";
13
+ import { DEFAULT_USER_ID } from "agent-relay-sdk";
14
+ import { resolveProjectEntries, resolveProjectForCwd, resolveScopedAnchor } from "./db";
14
15
  import { renderTemplate } from "./prompt-resolver";
15
16
  import { PROJECT_ENTRY_SOURCES, type AgentProfile, type Project, type ProjectEntryKind, type ProjectEntrySource, type RelayInjectionCategory, type ResolvedProjectEntry } from "./types";
16
17
 
@@ -57,7 +58,7 @@ interface SpawnInstructionInjectionEvent {
57
58
  detail: Record<string, unknown>;
58
59
  }
59
60
 
60
- interface ComposedSpawnInstructions {
61
+ export interface ComposedSpawnInstructions {
61
62
  systemPromptAppend?: string;
62
63
  relayInjectionEvents: SpawnInstructionInjectionEvent[];
63
64
  }
@@ -295,6 +296,39 @@ export function composeSpawnInstructions(input: Parameters<typeof composeSpawnIn
295
296
  return composeSpawnInstructionsWithEvents(input).systemPromptAppend;
296
297
  }
297
298
 
299
+ /**
300
+ * #1052 — the SINGLE home that resolves a profile's spawn-time knowledge world (project
301
+ * preamble for the cwd + scoped {profile|user} memory anchors) and runs
302
+ * `composeSpawnInstructionsWithEvents`. Every path that (re)builds a profile-bearing spawn —
303
+ * fresh managed spawn, managed-policy restart, AND the dashboard "restart Runner/Agent" relaunch
304
+ * for a NON-policy agent — must call this so a restarted agent is onboarded byte-identically to
305
+ * its first launch (role charter + project knowledge + scoped memory). Before #1052-v2 the
306
+ * dashboard-restart path for a dashboard-spawned (non-policy) coordinator called
307
+ * `buildSpawnCommand` WITHOUT this projection, so the relaunched process came back with EMPTY
308
+ * injection logs and silently reverted to provider-default IC behavior.
309
+ */
310
+ export function composeProfileSpawnInstructions(input: {
311
+ cwd?: string;
312
+ profileName?: string;
313
+ profile?: Pick<AgentProfile, "instructions" | "maxSpawnedAgents" | "projectInstructions">;
314
+ callerSystemPromptAppend?: string;
315
+ /** Operator/user anchor key; defaults to the single-operator default store. */
316
+ operatorId?: string;
317
+ }): ComposedSpawnInstructions {
318
+ const callerProject = input.cwd ? resolveProjectForCwd(input.cwd) ?? undefined : undefined;
319
+ const projectInstructions = input.profile?.projectInstructions;
320
+ const profileAnchor = input.profileName ? resolveScopedAnchor("profile", input.profileName) ?? undefined : undefined;
321
+ const userAnchor = resolveScopedAnchor("user", input.operatorId ?? DEFAULT_USER_ID) ?? undefined;
322
+ return composeSpawnInstructionsWithEvents({
323
+ ...(callerProject ? { callerProject } : {}),
324
+ ...(projectInstructions ? { projectInstructions } : {}),
325
+ ...(profileAnchor ? { profileAnchor } : {}),
326
+ ...(userAnchor ? { userAnchor } : {}),
327
+ ...(input.profile ? { profile: input.profile } : {}),
328
+ ...(input.callerSystemPromptAppend ? { callerSystemPromptAppend: input.callerSystemPromptAppend } : {}),
329
+ });
330
+ }
331
+
298
332
  /**
299
333
  * Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
300
334
  * (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
@@ -5,7 +5,7 @@ import { emitRelayEvent } from "./events";
5
5
  import { routeNotification } from "./notification-router";
6
6
  import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
7
7
  import { parseJson } from "./utils";
8
- import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
8
+ import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput, type ProviderQuotaAdvisoryResult } from "./db/provider-quotas.ts";
9
9
  import { processBandTransitions } from "./quota-band-transition";
10
10
  import type { QuotaState } from "./types";
11
11
 
@@ -40,7 +40,7 @@ export function installQuotaAdvisoryHandler(): void {
40
40
  setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
41
41
  }
42
42
 
43
- function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string {
43
+ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): ProviderQuotaAdvisoryResult {
44
44
  const config = effectiveQuotaNotificationConfig().quotaThreshold;
45
45
  const state = parseJson<QuotaAdvisoryState>(input.previousAdvisoryState ?? "{}", {});
46
46
  const nextState: QuotaAdvisoryState = { ...state };
@@ -101,8 +101,10 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
101
101
  };
102
102
  }
103
103
 
104
- processBandTransitions(input);
105
- return JSON.stringify(nextState);
104
+ // #1074 — thread the persisted band-hysteresis state through the transition pipeline and return
105
+ // the new state so it is written back to quota_band_state, surviving relay restarts.
106
+ const bandState = processBandTransitions(input, input.previousBandState);
107
+ return { advisoryState: JSON.stringify(nextState), bandState };
106
108
  }
107
109
 
108
110
  function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): boolean {
@@ -38,6 +38,7 @@ import { getSpawnCapableAgentIds } from "./routing-policy-push";
38
38
  import { getProviderBandInfo, quotaBandFromSignal, windowRecommendedAction } from "./spawn-targets";
39
39
  import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
40
40
  import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
41
+ import { parseJson } from "./utils";
41
42
 
42
43
  const DEBOUNCE_SAMPLES = 3;
43
44
  // #798 — EMA time constant for the low-pass. "A few minutes": longer than the ~5-min poll-cadence
@@ -68,6 +69,13 @@ interface TransitionState {
68
69
  emaUpdatedAt: number;
69
70
  }
70
71
 
72
+ // #1074 — the per-`provider:role` hysteresis state (EMA low-pass, lastNotifiedBand, debounce
73
+ // counters, lastTransitionAt). Serialized to the provider_quotas.quota_band_state DB column so it
74
+ // survives a relay restart: without it every restart re-seeds lastNotifiedBand="normal" and re-fires
75
+ // an already-sent degradation to the whole fleet. Mirrors the previousAdvisoryState round-trip in
76
+ // quota-advisory.ts — state is passed IN and returned OUT, this module holds no cross-call memory.
77
+ type BandTransitionState = Record<string, TransitionState>;
78
+
71
79
  // #798 — dt-based EMA blend factor: 1 − e^(−dt/τ). dt = 0 (or first sample) → seed verbatim.
72
80
  function emaAlpha(dtMs: number): number {
73
81
  if (!(dtMs > 0)) return 1;
@@ -79,12 +87,6 @@ function emaStep(prev: number | null, value: number, dtMs: number): number {
79
87
  return prev + emaAlpha(dtMs) * (value - prev);
80
88
  }
81
89
 
82
- const bandState = new Map<string, TransitionState>();
83
-
84
- export function _resetBandTransitionStateForTesting(): void {
85
- bandState.clear();
86
- }
87
-
88
90
  // Test hook: supply a controlled band-info function to exercise hysteresis logic
89
91
  // without a full DB setup. Set to null to restore the real implementation.
90
92
  let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
@@ -95,15 +97,24 @@ export function _clearBandInfoSupplierForTesting(): void {
95
97
  _bandInfoSupplier = null;
96
98
  }
97
99
 
98
- export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
100
+ // #1074 pure state-threading transition step. Takes the previously persisted band state
101
+ // (serialized `quota_band_state`, may be null/empty on a fresh provider), applies the band-transition
102
+ // hysteresis pipeline for this sample, and returns the new serialized state to persist. Holds no
103
+ // module-level memory, so a relay restart that re-hydrates from the DB continues hysteresis seamlessly
104
+ // instead of re-seeding to "normal" and re-firing an already-sent degradation.
105
+ export function processBandTransitions(
106
+ input: ProviderQuotaAdvisoryInput,
107
+ previousBandState?: string | null,
108
+ ): string {
109
+ const bandState = parseJson<BandTransitionState>(previousBandState ?? "{}", {});
99
110
  const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
100
- if (!info) return;
111
+ if (!info) return JSON.stringify(bandState);
101
112
 
102
113
  for (const window of info.windows) {
103
114
  if (window.confidence === "low" || window.burnRatio === null) continue;
104
115
 
105
116
  const key = `${input.provider}:${window.role}`;
106
- const state: TransitionState = bandState.get(key) ?? {
117
+ const state: TransitionState = bandState[key] ?? {
107
118
  lastNotifiedBand: "normal",
108
119
  pendingBand: null,
109
120
  pendingCount: 0,
@@ -124,7 +135,7 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
124
135
  state.emaUpdatedAt = input.now;
125
136
  // Persist immediately so the low-pass state survives every early-continue below (the state is
126
137
  // then mutated in place by reference — the EMA must carry across ticks to smooth excursions).
127
- bandState.set(key, state);
138
+ bandState[key] = state;
128
139
  const smoothedBand = quotaBandFromSignal(emaPenalty, emaSlack, info.quotaBands, info.preferSlackAt);
129
140
 
130
141
  // #756 (layer 2): asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the
@@ -149,7 +160,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
149
160
  // #756: rate-limit recovery — it is not an actionable signal for coordinators.
150
161
  const isRecovery = bandRank(effectiveBand) < bandRank(state.lastNotifiedBand);
151
162
  if (isRecovery && input.now - state.lastTransitionAt < RECOVERY_MIN_INTERVAL_MS) {
152
- bandState.set(key, state);
153
163
  continue;
154
164
  }
155
165
 
@@ -158,7 +168,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
158
168
  state.pendingBand = null;
159
169
  state.pendingCount = 0;
160
170
  state.lastTransitionAt = input.now;
161
- bandState.set(key, state);
162
171
  emitBandTransition({
163
172
  provider: input.provider,
164
173
  window,
@@ -169,10 +178,10 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
169
178
  recommendedAction: windowRecommendedAction(effectiveBand, window.slack),
170
179
  now: input.now,
171
180
  });
172
- } else {
173
- bandState.set(key, state);
174
181
  }
175
182
  }
183
+
184
+ return JSON.stringify(bandState);
176
185
  }
177
186
 
178
187
  // Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
@@ -2,7 +2,7 @@
2
2
  import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
3
3
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
5
- import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
5
+ import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, readSpawnCallerSystemPromptAppend, resolveAgentOwnership, sendMessage, withResolvedProvisioning } from "../db";
6
6
  import { announcePresence, viewersForAgent } from "../dashboard-presence";
7
7
  import { buildManagedSpawnParams } from "../managed-policy";
8
8
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -19,6 +19,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
19
19
  import { type AgentCard, type SpawnApprovalMode } from "../types";
20
20
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
21
21
  import { getManifest } from "agent-relay-providers";
22
+ import { composeProfileSpawnInstructions } from "../project-instructions";
22
23
  import { relayToken } from "../config";
23
24
 
24
25
  // 409 when a second human tries to drive an agent another user is already driving (#394). The
@@ -95,7 +96,7 @@ function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<ty
95
96
  return candidates.find((orch) => orch.status === "online") ?? null;
96
97
  }
97
98
 
98
- function restartSpawnParamsForAgent(
99
+ export function restartSpawnParamsForAgent(
99
100
  agent: AgentCard,
100
101
  orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
101
102
  policyName?: string,
@@ -103,7 +104,15 @@ function restartSpawnParamsForAgent(
103
104
  opts: { resumeId?: string } = {},
104
105
  ): Record<string, unknown> | undefined {
105
106
  if (!orchestrator) return undefined;
106
- const requestId = spawnRequestId ?? spawnRequestIdForRestart();
107
+ // #1065 mint a FRESH spawnRequestId for the successor, NEVER reuse the predecessor's. Reusing
108
+ // it voided the #1059 instance guard (exitCertificateMatchesInstance keys death-certificate
109
+ // identity on spawnRequestId): a late/duplicate exit report for the dead predecessor carried the
110
+ // same id as the live successor, so the guard treated the stale certificate as a match and
111
+ // false-reaped the running session (#1054/#1059 reopener). Every other respawn path mints fresh
112
+ // (lifecycle-manager, spawn-policy) and the runner documents the invariant (control.ts:284). The
113
+ // predecessor id (`spawnRequestId`) is still used below to recover the #1062 parent brief from the
114
+ // ORIGINAL agent.spawn command — read before the mint so the brief follows the agent, not the id.
115
+ const requestId = generateSpawnRequestId();
107
116
  const policy = policyName ? getSpawnPolicy(policyName) : null;
108
117
  const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
109
118
  if (policy) {
@@ -124,6 +133,27 @@ function restartSpawnParamsForAgent(
124
133
  const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
125
134
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
126
135
  const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
136
+ // #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
137
+ // `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
138
+ // charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
139
+ // relaunch is onboarded byte-identically instead of reverting to provider-default IC behavior.
140
+ // #1062 — two remaining fresh-spawn divergences closed here: (1) anchor the user/operator scoped
141
+ // memory to the agent's OWNER via resolveAgentOwnership (a fresh spawn does the same), not the
142
+ // default operator; and (2) re-thread the parent's ORIGINAL caller brief, recovered from the
143
+ // persisted agent.spawn command, so a restarted spawned child keeps its task brief.
144
+ // #1065 — key this on the PREDECESSOR id (`spawnRequestId`), NOT the freshly-minted `requestId`:
145
+ // the caller brief was persisted on the original agent.spawn command, which the successor's fresh
146
+ // id has no row for. Recovering it here re-injects the brief onto the new instance.
147
+ // #1067 — recovery must survive N restarts, not just one. Because #1065 rotates the id every
148
+ // restart and never writes an agent.spawn row for the successor, a naive single-hop lookup found
149
+ // the brief on restart #1 (predecessor = original id) but lost it on restart #2 (predecessor =
150
+ // the fresh id from restart #1, which has no spawn row). Fix: re-persist the RAW brief under the
151
+ // fresh id below (passed through to `callerSystemPromptAppend` on this restart's `restartSpawn`
152
+ // params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
153
+ // recovers it from either the original agent.spawn row OR a later agent.restart row, so each
154
+ // generation has its own recoverable copy and the brief follows the agent across every rotation.
155
+ const callerAppend = readSpawnCallerSystemPromptAppend(spawnRequestId);
156
+ const composed = composeProfileSpawnInstructions({ cwd, operatorId: resolveAgentOwnership(agent.id).ownerUserId, ...(profileName ? { profileName } : {}), ...(agentProfile ? { profile: agentProfile } : {}), ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}) });
127
157
  const params = buildSpawnCommand({
128
158
  provider,
129
159
  modelParams: resolvedModel,
@@ -138,6 +168,12 @@ function restartSpawnParamsForAgent(
138
168
  approvalMode: approvalMode ?? "guarded",
139
169
  permissionMode: approvalMode ?? "guarded",
140
170
  providerArgs: providerArgs.length ? providerArgs : undefined,
171
+ ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
172
+ // #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
173
+ // the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
174
+ // survives only one restart (the original agent.spawn row is keyed on the very first id).
175
+ ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
176
+ ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
141
177
  policyName: policyName || undefined,
142
178
  headless: true,
143
179
  spawnRequestId: requestId,
@@ -157,10 +193,6 @@ function restartSpawnParamsForAgent(
157
193
  return opts.resumeId ? withResumeParams(params, opts.resumeId, agent.id) : params;
158
194
  }
159
195
 
160
- function spawnRequestIdForRestart(): string {
161
- return generateSpawnRequestId();
162
- }
163
-
164
196
  function withResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
165
197
  return {
166
198
  ...params,
@@ -2,6 +2,7 @@
2
2
  import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
3
3
  import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
+ import { attachCommandActivities, attachCommandActivity } from "../agent-command-activity";
5
6
  import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
6
7
  import { cleanStringArray } from "../validation";
7
8
  import { clearActiveMemories, memoryBroker } from "../memory-service";
@@ -53,7 +54,7 @@ export const getAgents: Handler = (req) => {
53
54
  // (visibleAgentIdsForRequest returns every id), so today's list is byte-for-byte unchanged.
54
55
  const agents = listAgents({ tag, machine, status });
55
56
  const visible = visibleAgentIdsForRequest(req, agents.map((a) => a.id));
56
- return json(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id)))));
57
+ return json(attachCommandActivities(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id))))));
57
58
  };
58
59
 
59
60
  export const findAgents: Handler = (req) => {
@@ -69,7 +70,7 @@ export const getAgentById: Handler = (req, params) => {
69
70
  if (!agent) return error("agent not found", 404);
70
71
  // #392: a non-visible agent must look indistinguishable from a missing one (no existence leak).
71
72
  if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
72
- return json(attachIssueRepo(attachBranchState(agent)));
73
+ return json(attachCommandActivity(attachIssueRepo(attachBranchState(agent))));
73
74
  };
74
75
 
75
76
  // #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
@@ -3,9 +3,9 @@ import { MAX_BODY_BYTES } from "../config";
3
3
  import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
4
4
  import { getComponentAuth, isFullReachRequest } from "../security";
5
5
  import { authContextFromRequest } from "../services/auth-context";
6
- import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
6
+ import { agentIdFromComponent, hasTaskReadAdminScope, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
7
7
  import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
8
- import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
8
+ import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus, type TaskStatusActor } from "../db";
9
9
  import { readTaskMemoryEnvelope } from "../db/task-memory";
10
10
  import {
11
11
  addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
@@ -531,9 +531,21 @@ export const patchTaskStatus: Handler = async (req, params) => {
531
531
  if (id === null) return error("invalid task id");
532
532
  const parsed = await parseBody<unknown>(req);
533
533
  if (!parsed.ok) return error(parsed.error, parsed.status);
534
+ // Ownership fence (#1063): bind the write to the AUTHENTICATED caller, not the request body.
535
+ // Full-reach/system callers and task-admin scopes bypass (same convention as isTaskDispatchAuthorized
536
+ // / #1043's ensureCanMintThread); an ordinary task:write holder is pinned to its own agent id and
537
+ // can only advance a task it actually owns. An unidentified non-privileged caller is refused rather
538
+ // than silently falling back to the owner id.
539
+ const ctx = authContextFromRequest(req);
540
+ const privileged = ctx.actor.kind === "system" || hasTaskReadAdminScope(ctx.scopes);
541
+ if (!privileged && !ctx.callerAgentId) return error("task status requires an identified caller", 403);
542
+ const actor: TaskStatusActor = privileged ? { privileged: true } : { agentId: ctx.callerAgentId };
534
543
  try {
535
- const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
536
- if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
544
+ const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body), actor);
545
+ if (!result.ok) {
546
+ const status = result.error === "task not found" ? 404 : result.conflict ? 409 : agentSessionStatus(result.error);
547
+ return error(result.error!, status);
548
+ }
537
549
  emitTaskChanged(result.task!, "task.status");
538
550
  const capturedMemory = result.task!.status === "done"
539
551
  ? await captureTaskResultMemory(result.task!, result.task!.result)
@@ -433,6 +433,12 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
433
433
  const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
434
434
  const dryRun = body.dryRun !== false; // safe by default
435
435
  const landedOnly = true;
436
+ // #965 — an explicit operator force-discard for a genuinely-abandoned branch: clear even when
437
+ // git state is a conflict / unlanded / unprobeable. The land-safety gate is correct for LIVE
438
+ // work but wrong for a confirmed-abandoned branch, which otherwise left a raw DELETE as the only
439
+ // path. Gated hard on the owner NOT being online (force can never override liveness) and, like
440
+ // every mutation here, on `--execute` (dryRun:false) — the two together are the confirmation.
441
+ const force = body.force === true;
436
442
 
437
443
  const candidates = listWorkspaces().filter((ws) =>
438
444
  ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
@@ -455,22 +461,41 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
455
461
  const landed = gitState?.landed === true;
456
462
  // Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
457
463
  const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
464
+ // #965 — a force-discard clears an unsafe (conflict/unlanded/unprobeable) row too, but only
465
+ // for an offline owner. #1066: the `ownerOnline` snapshot above predates the
466
+ // `fetchWorkspaceGitState` await, so it is stale by dispatch time; liveness is re-verified
467
+ // immediately before dispatch below. Force overrides the land-safety gate, NEVER liveness.
468
+ const forced = force && !safe;
469
+ const discardable = safe || force;
458
470
  const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
459
- const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
460
- if (safe && !dryRun) {
461
- const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
462
- if (built.ok) {
463
- updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
464
- emitCommand(built.command);
465
- row.commandId = built.command.id;
466
- cleaned.push(ws.id);
471
+ const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, discardable, ...(forced ? { forced: true } : {}), proof };
472
+ if (discardable && !dryRun) {
473
+ // #1066 re-snapshot liveness AFTER the git-state probe, immediately before dispatch. An
474
+ // owner offline at the initial snapshot can reconnect during the probe window (relay lag —
475
+ // #1054), and `force` bypasses buildWorkspaceCleanupCommand's dispatch-time live-owner guard,
476
+ // so this route is the only place that can refuse a just-reconnected owner under force.
477
+ // Force may clear an unsafe/conflict/unlanded row, but it must never nuke a live owner's tree.
478
+ if (isOwnerAlive(ws.ownerAgentId)) {
479
+ row.discardable = false;
480
+ row.cleanupError = `workspace ${ws.id} owner reconnected during git-state probe; force cannot override liveness`;
481
+ row.proof = { ...proof, ownerOnline: true };
467
482
  } else {
468
- row.cleanupError = built.error;
483
+ // Owner re-confirmed offline just above; force past buildWorkspaceCleanupCommand's
484
+ // live-owner guard and carry a reason so the host records an intentional force-discard.
485
+ const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale", forced ? { force: true, reason: "operator force-discard: abandoned branch, owner offline" } : {});
486
+ if (built.ok) {
487
+ updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: { ...proof, forced } });
488
+ emitCommand(built.command);
489
+ row.commandId = built.command.id;
490
+ cleaned.push(ws.id);
491
+ } else {
492
+ row.cleanupError = built.error;
493
+ }
469
494
  }
470
495
  }
471
496
  rows.push(row);
472
497
  }
473
- return json({ dryRun, landedOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
498
+ return json({ dryRun, landedOnly, force, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.discardable).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
474
499
  };
475
500
 
476
501
  export const postWorkspaceAction: Handler = async (req, params) => {
@@ -1,4 +1,4 @@
1
- import { isRecord } from "agent-relay-sdk";
1
+ import { hasScope, isRecord } from "agent-relay-sdk";
2
2
  import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, sendMessage, setPlanArchived, ValidationError } from "../db";
3
3
  import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
4
4
  import { resolveIssueRefFromString } from "../db/issue-refs";
@@ -9,6 +9,7 @@ import { issueRepoForAgent } from "../agent-issue-repo";
9
9
  import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
10
10
  import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
11
11
  import type { AuthContext } from "./auth-context";
12
+ import { ServiceAuthError } from "./errors";
12
13
  import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
13
14
 
14
15
  export const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
@@ -37,7 +38,7 @@ interface PlanNodePatch {
37
38
  }
38
39
 
39
40
  interface PlanCreateInput {
40
- threadId: unknown;
41
+ threadId?: unknown;
41
42
  teamId?: unknown;
42
43
  channel?: unknown;
43
44
  title?: unknown;
@@ -269,7 +270,9 @@ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan:
269
270
  // thread server-side by self-threading a lightweight system message (the same sendMessage path the
270
271
  // caller would have used). Sent AS the actor when that agent is registered, else the reserved
271
272
  // `system` sink, so the mint always passes the sender-registration gate.
272
- function mintPlanThread(actor: string, channel: string | undefined): number {
273
+ function mintPlanThread(ctx: AuthContext, channel: string | undefined): number {
274
+ ensureCanMintThread(ctx);
275
+ const actor = actorId(ctx);
273
276
  const sender = getAgent(actor) ? actor : "system";
274
277
  const root = sendMessage({
275
278
  from: sender,
@@ -280,24 +283,39 @@ function mintPlanThread(actor: string, channel: string | undefined): number {
280
283
  return root.threadId ?? root.id;
281
284
  }
282
285
 
286
+ // #1043: the mint inserts a real message row through the db `sendMessage` path, which normally
287
+ // lives behind the `message:send` scope. `plans:write` alone must not be a side-door around it, so
288
+ // gate the mint on `message:send`. Internal `system` principals (empty-scope trusted callers) and
289
+ // full-reach `*` tokens pass through untouched — `hasScope` short-circuits `*`.
290
+ function ensureCanMintThread(ctx: AuthContext): void {
291
+ if (ctx.actor.kind === "system") return;
292
+ if (!hasScope(ctx.scopes, "message:send")) {
293
+ throw new ServiceAuthError("minting a plan thread requires the message:send scope");
294
+ }
295
+ }
296
+
283
297
  export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
284
298
  const channel = cleanString(input.channel, "channel", { max: 120 });
285
299
  const actor = actorId(ctx);
286
- const threadId = input.threadId === undefined || input.threadId === null
287
- ? mintPlanThread(actor, channel)
288
- : cleanThreadId(input.threadId);
289
- if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
290
300
  const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
291
301
  const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
292
302
  const nodes = cleanNodes(input.nodes, actor, teamId);
293
303
  const edges = cleanEdges(input.edges);
294
304
  validateEdges(nodes, edges);
305
+ const title = cleanString(input.title, "title", { max: 240 });
306
+ const objective = cleanString(input.objective, "objective", { max: 4000 });
307
+ // Resolve the thread only AFTER the payload validates (#1043): auto-minting first left an orphan
308
+ // 'Plan thread root' row behind on every rejected create.
309
+ const threadId = input.threadId === undefined || input.threadId === null
310
+ ? mintPlanThread(ctx, channel)
311
+ : cleanThreadId(input.threadId);
312
+ if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
295
313
  const plan = createPlanRow({
296
314
  threadId,
297
315
  teamId,
298
316
  channel,
299
- title: cleanString(input.title, "title", { max: 240 }),
300
- objective: cleanString(input.objective, "objective", { max: 4000 }),
317
+ title,
318
+ objective,
301
319
  nodes,
302
320
  edges,
303
321
  createdBy: actor,
@@ -10,7 +10,7 @@
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
12
  import { errMessage, isReservedAgentId } from "agent-relay-sdk";
13
- import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, revokeSupersededRuntimeTokens, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
13
+ import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
14
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
16
  import { agentProviderTimelineEvent } from "../provider-timeline-event";
@@ -91,18 +91,18 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
91
91
  // Registration labels are spawn defaults. Runtime /label changes remain authoritative.
92
92
  const registrationInput = existing ? { ...input } : input;
93
93
  if (existing) delete registrationInput.label;
94
- const agent = upsertAgent(registrationInput);
95
-
96
94
  // #1051/#484 — instance takeover is where the restart-strand invariant fires now that a
97
- // heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers
98
- // under this agent id (new instanceId → epoch bump), revoke the PRIOR instance's runtime
99
- // tokens "stale": they can no longer authenticate a request directly, yet stay re-mintable
100
- // via the orchestrator (#484). A same-instance reconnect keeps its instanceId, so the epoch
101
- // is unchanged and the live token is left valid — the whole point of #1051 (in-band recovery
102
- // after lag/relay-restart without a re-mint).
103
- if (existing?.instanceId && agent.instanceId && existing.instanceId !== agent.instanceId) {
104
- revokeSupersededRuntimeTokens(existing, agent);
105
- }
95
+ // heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers under
96
+ // this agent id, the epoch bump inside upsertAgent atomically revokes the PRIOR instance's
97
+ // runtime tokens "stale": refused for auth yet re-mintable via the orchestrator (#484). A
98
+ // same-instance reconnect keeps its instanceId, so the epoch is unchanged and the live token
99
+ // is left valid — the whole point of #1051 (in-band recovery after lag without a re-mint).
100
+ // #1057(3) — the ONLY jti spared from that revoke is server-verified: the token that
101
+ // authenticated THIS registration, and only when it resolves to this very agent (a
102
+ // same-credential instance rotation, e.g. a respawn reusing its unexpired env token).
103
+ // Client-sent meta.auth can no longer nominate jtis to keep.
104
+ const verifiedKeepJti = ctx.callerAgentId === input.id ? ctx.component?.jti : undefined;
105
+ const agent = upsertAgent(registrationInput, verifiedKeepJti ? { verifiedKeepJtis: [verifiedKeepJti] } : undefined);
106
106
 
107
107
  // Managed came-up-running reconcile — no-op for non-managed agents (the guard lives
108
108
  // in markManagedAgentRunning). Clears backoff, flushes the policy queue, transitions
@@ -21,12 +21,13 @@
21
21
  //
22
22
  // OUT OF SCOPE (transport-edge, excluded from the parity facet): automatic memory injection
23
23
  // (needs request-derived context) and per-transport audit (HTTP domain row vs MCP tool-call row).
24
- import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
24
+ import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
27
  flushDeferredLineageReport,
28
28
  getAgent,
29
29
  getMessage,
30
+ latestInboundMessage,
30
31
  listAgents,
31
32
  listRecentFinalResponseCaptures,
32
33
  promoteLineageCapturedResponse,
@@ -287,6 +288,14 @@ function emitSendResults(results: DbSendMessageResult[]): void {
287
288
  emitNewMessages(liveMessages);
288
289
  }
289
290
 
291
+ // #976 — a spawned child (results-only profile) whose turn-final response Relay already promotes to
292
+ // its spawn-parent does not also need to fire a bare ack/report via relay_send_message. Codex workers
293
+ // do this routinely ("Noted.", "Cleared.", "Going forward I'll…", or a duplicate of the final report),
294
+ // and each redundant message wakes the coordinator and burns the context coordinators exist to
295
+ // protect. This ONE home suppresses such acks on BOTH transports (HTTP + MCP) and BOTH providers
296
+ // (codex + claude): a reply-path ack collapses onto its parent; a standalone self→parent ack (no
297
+ // replyTo — the codex relay_send_message leak) collapses onto the latest inbound it answers. A
298
+ // genuine result (final capture, a session-final step, or non-ack body) always passes through.
290
299
  function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
291
300
  if (input.kind && input.kind !== "chat" && input.kind !== "session") return null;
292
301
  const sender = getAgent(input.from);
@@ -296,21 +305,40 @@ function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
296
305
  if (!isPassiveAck(input.body)) return null;
297
306
  if (isRecord(input.payload?.responseCapture)) return null;
298
307
  const session = isRecord(input.payload?.session) ? input.payload.session : undefined;
299
- if (session?.final === true) return null;
300
- return input.replyTo ? getMessage(input.replyTo) : null;
308
+ if (isTurnFinalSession(session)) return null;
309
+ // Reply-path: collapse onto the parent message being acked.
310
+ if (input.replyTo) return getMessage(input.replyTo);
311
+ // Standalone self→parent path (#976): only a spawned child acking its OWN spawn-parent. Anchor to
312
+ // the latest inbound from that parent so the drop mirrors the reply-path collapse; if there is no
313
+ // prior inbound to anchor to, deliver (never drop a message we can't attribute to a conversation).
314
+ if (sender?.spawnedBy && sender.spawnedBy === input.to) {
315
+ return latestInboundMessage(input.from, sender.spawnedBy);
316
+ }
317
+ return null;
301
318
  }
302
319
 
320
+ // Exact-match ceremonial acks (after normalization). Kept an explicit set — high precision, so a
321
+ // real short status ("Cleared the queue", "PASS") never matches, only the bare acknowledgement.
322
+ const PASSIVE_ACK_PHRASES = new Set([
323
+ "noted", "noted standing by",
324
+ "acknowledged", "acknowledged standing by",
325
+ "standing by", "ok standing by", "okay standing by",
326
+ "understood", "understood standing by",
327
+ "cleared", "will do", "will do standing by", "will comply",
328
+ "on it", "got it", "roger", "roger that", "copy", "copy that",
329
+ "sounds good", "ok", "okay", "okay will do", "thanks", "thank you",
330
+ "no problem", "no worries",
331
+ ]);
332
+
333
+ // A short SELF-REFERENTIAL acknowledgement of an instruction about the agent's OWN reporting
334
+ // behavior ("Going forward, I'll only use Relay mid-task…"). Bounded length + it must name the
335
+ // messaging/reporting channel, so a genuine forward-looking REPORT that happens to open with
336
+ // "going forward" ("going forward the build fails unless we bump node") still passes through.
337
+ const REPORTING_META_ACK = /^(going forward|from now on)\b.{0,160}\b(relay|messages?|acks?|reports?|reporting|pings?|notif\w*)\b/;
338
+
303
339
  function isPassiveAck(body: string): boolean {
304
- const text = body.toLowerCase().replace(/[.!]+/g, "").replace(/\s+/g, " ").trim();
305
- return [
306
- "noted",
307
- "noted standing by",
308
- "acknowledged",
309
- "acknowledged standing by",
310
- "standing by",
311
- "ok standing by",
312
- "okay standing by",
313
- "understood",
314
- "understood standing by",
315
- ].includes(text);
340
+ const text = body.toLowerCase().replace(/[.!,…]+/g, " ").replace(/\s+/g, " ").trim();
341
+ if (!text) return false;
342
+ if (PASSIVE_ACK_PHRASES.has(text)) return true;
343
+ return text.length <= 160 && REPORTING_META_ACK.test(text);
316
344
  }
@@ -647,6 +647,9 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
647
647
  providerArgs: input.providerArgs ?? [],
648
648
  prompt: resolvedPrompt,
649
649
  systemPromptAppend,
650
+ // #1062 — stash the RAW caller brief (pre-composition) so a dashboard (non-policy) restart can
651
+ // re-thread it as callerSystemPromptAppend and stay fresh-spawn-identical instead of dropping it.
652
+ ...(resolvedSystemPromptAppend ? { callerSystemPromptAppend: resolvedSystemPromptAppend } : {}),
650
653
  relayInjectionEvents: composedInstructions.relayInjectionEvents,
651
654
  landStrategy: input.landStrategy,
652
655
  autoMerge: input.autoMerge,