agent-relay-server 0.119.11 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-U5sX3SNZ.js → MessageBubble-BJXjk3GB.js} +2 -2
  4. package/public/assets/{MessageBubble-U5sX3SNZ.js.map → MessageBubble-BJXjk3GB.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-Bi2gvXeE.js → PlanGraphPanel-DI5JOnm1.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Bi2gvXeE.js.map → PlanGraphPanel-DI5JOnm1.js.map} +1 -1
  7. package/public/assets/{activity-Cwkz51pG.js → activity-BUt4FiFQ.js} +2 -2
  8. package/public/assets/{activity-Cwkz51pG.js.map → activity-BUt4FiFQ.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BgddVqRp.js → agent-profiles-C1dvEaGS.js} +2 -2
  10. package/public/assets/{agent-profiles-BgddVqRp.js.map → agent-profiles-C1dvEaGS.js.map} +1 -1
  11. package/public/assets/{agents-CiGYBw0h.js → agents-DvJZ0Tcz.js} +2 -2
  12. package/public/assets/{agents-CiGYBw0h.js.map → agents-DvJZ0Tcz.js.map} +1 -1
  13. package/public/assets/{analytics-CmSHiDAI.js → analytics-DMLoMwta.js} +2 -2
  14. package/public/assets/{analytics-CmSHiDAI.js.map → analytics-DMLoMwta.js.map} +1 -1
  15. package/public/assets/{automation-BBJB4RaE.js → automation-DICpRUGK.js} +2 -2
  16. package/public/assets/{automation-BBJB4RaE.js.map → automation-DICpRUGK.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-Db6uU7yT.js → branch-state-badge-BYE2U_y4.js} +2 -2
  18. package/public/assets/{branch-state-badge-Db6uU7yT.js.map → branch-state-badge-BYE2U_y4.js.map} +1 -1
  19. package/public/assets/{channels-trEI29j0.js → channels-3SlDQL3j.js} +2 -2
  20. package/public/assets/{channels-trEI29j0.js.map → channels-3SlDQL3j.js.map} +1 -1
  21. package/public/assets/{chat-DS578gZy.js → chat-_I9nvxj2.js} +2 -2
  22. package/public/assets/{chat-DS578gZy.js.map → chat-_I9nvxj2.js.map} +1 -1
  23. package/public/assets/{connectors-DNV138gK.js → connectors-CrEW591Q.js} +2 -2
  24. package/public/assets/{connectors-DNV138gK.js.map → connectors-CrEW591Q.js.map} +1 -1
  25. package/public/assets/{formatted-body-DYUVqWym.js → formatted-body-ChERiS0y.js} +3 -3
  26. package/public/assets/{formatted-body-DYUVqWym.js.map → formatted-body-ChERiS0y.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-DdcyxVO0.js → formatted-body-impl-CiaUIrP3.js} +2 -2
  28. package/public/assets/{formatted-body-impl-DdcyxVO0.js.map → formatted-body-impl-CiaUIrP3.js.map} +1 -1
  29. package/public/assets/{index-CqcAqQKW.js → index-CtBqO8vm.js} +7 -7
  30. package/public/assets/index-CtBqO8vm.js.map +1 -0
  31. package/public/assets/{insights-BunHbsTA.js → insights-DBjaf3N5.js} +2 -2
  32. package/public/assets/{insights-BunHbsTA.js.map → insights-DBjaf3N5.js.map} +1 -1
  33. package/public/assets/{integrations-COnkRfWW.js → integrations-B-bdliwU.js} +2 -2
  34. package/public/assets/{integrations-COnkRfWW.js.map → integrations-B-bdliwU.js.map} +1 -1
  35. package/public/assets/{maintenance-cICBVcpJ.js → maintenance-EWlKbynM.js} +2 -2
  36. package/public/assets/{maintenance-cICBVcpJ.js.map → maintenance-EWlKbynM.js.map} +1 -1
  37. package/public/assets/{managed-agents-BK9_Gi-M.js → managed-agents-DzjKwBh4.js} +2 -2
  38. package/public/assets/{managed-agents-BK9_Gi-M.js.map → managed-agents-DzjKwBh4.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-EZfcTHG9.js → markdown-preview-impl-bwLB6Gc-.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-EZfcTHG9.js.map → markdown-preview-impl-bwLB6Gc-.js.map} +1 -1
  41. package/public/assets/{memory-Vi-8HvK3.js → memory-DEwJsFNK.js} +2 -2
  42. package/public/assets/{memory-Vi-8HvK3.js.map → memory-DEwJsFNK.js.map} +1 -1
  43. package/public/assets/{messages-DScIiBMI.js → messages-BSMkT9X2.js} +2 -2
  44. package/public/assets/{messages-DScIiBMI.js.map → messages-BSMkT9X2.js.map} +1 -1
  45. package/public/assets/{orchestrators-DLogldXx.js → orchestrators-Bl8k3KxQ.js} +2 -2
  46. package/public/assets/{orchestrators-DLogldXx.js.map → orchestrators-Bl8k3KxQ.js.map} +1 -1
  47. package/public/assets/{overview-B_RQAQR8.js → overview-CdFe7Dmc.js} +2 -2
  48. package/public/assets/{overview-B_RQAQR8.js.map → overview-CdFe7Dmc.js.map} +1 -1
  49. package/public/assets/{pairs-CSiPQ7E2.js → pairs-0ewxtvFZ.js} +2 -2
  50. package/public/assets/{pairs-CSiPQ7E2.js.map → pairs-0ewxtvFZ.js.map} +1 -1
  51. package/public/assets/{projects-Dd8gYzcn.js → projects-DEn85LQb.js} +2 -2
  52. package/public/assets/{projects-Dd8gYzcn.js.map → projects-DEn85LQb.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DsDi_Xpd.js → prompt-settings-CMQ2tHe0.js} +2 -2
  54. package/public/assets/{prompt-settings-DsDi_Xpd.js.map → prompt-settings-CMQ2tHe0.js.map} +1 -1
  55. package/public/assets/{registry-DHFUWIdb.js → registry-DntwLc8a.js} +2 -2
  56. package/public/assets/{registry-DHFUWIdb.js.map → registry-DntwLc8a.js.map} +1 -1
  57. package/public/assets/{security-D2nHkJH6.js → security-CYwC-26O.js} +2 -2
  58. package/public/assets/{security-D2nHkJH6.js.map → security-CYwC-26O.js.map} +1 -1
  59. package/public/assets/{select-BjNcommA.js → select-B19D5ToH.js} +2 -2
  60. package/public/assets/{select-BjNcommA.js.map → select-B19D5ToH.js.map} +1 -1
  61. package/public/assets/{settings-DiUEq54n.js → settings-BzYvlKIL.js} +2 -2
  62. package/public/assets/{settings-DiUEq54n.js.map → settings-BzYvlKIL.js.map} +1 -1
  63. package/public/assets/store-BuuJUn70.js +9 -0
  64. package/public/assets/store-BuuJUn70.js.map +1 -0
  65. package/public/assets/{tasks-Dx8GVQiR.js → tasks-vZ5VRoO8.js} +2 -2
  66. package/public/assets/{tasks-Dx8GVQiR.js.map → tasks-vZ5VRoO8.js.map} +1 -1
  67. package/public/assets/{teams-DQtf7bha.js → teams-E3rI5gkA.js} +2 -2
  68. package/public/assets/{teams-DQtf7bha.js.map → teams-E3rI5gkA.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-D5rhXV4u.js → terminal-viewer-impl-24nB6STe.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-D5rhXV4u.js.map → terminal-viewer-impl-24nB6STe.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-BfWNDp7U.js → work-queue-Bql5E-C3.js} +2 -2
  73. package/public/assets/{work-queue-BfWNDp7U.js.map → work-queue-Bql5E-C3.js.map} +1 -1
  74. package/public/assets/{workspaces-M3xEMNXu.js → workspaces-DjQ6EdrZ.js} +2 -2
  75. package/public/assets/{workspaces-M3xEMNXu.js.map → workspaces-DjQ6EdrZ.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/config-store.ts +1 -1
  79. package/src/db/migrations.ts +5 -1
  80. package/src/db/scheduled-timers.ts +48 -4
  81. package/src/db/schema.ts +2 -1
  82. package/src/mcp/index.ts +2 -1
  83. package/src/mcp/tools-scheduler.ts +22 -3
  84. package/src/prompt-resolver.ts +6 -4
  85. package/src/routes/agent-profiles.ts +9 -0
  86. package/src/routes/commands.ts +8 -1
  87. package/src/routes/index.ts +2 -1
  88. package/src/services/dispatch-command.ts +136 -5
  89. package/src/services/scheduler-command.ts +360 -0
  90. package/src/services/scheduler.ts +25 -5
  91. package/src/sse.ts +58 -10
  92. package/public/assets/index-CqcAqQKW.js.map +0 -1
  93. package/public/assets/store-OLe8gFh0.js +0 -9
  94. package/public/assets/store-OLe8gFh0.js.map +0 -1
@@ -41,6 +41,23 @@ export interface ScheduledTimer {
41
41
  lastMessageId?: number;
42
42
  claimedBy?: string;
43
43
  claimExpiresAt?: number;
44
+ command?: ScheduledTimerCommand;
45
+ }
46
+
47
+ export interface ScheduledTimerCommand {
48
+ commandId?: string;
49
+ orchestratorId: string;
50
+ cwd: string;
51
+ command: string;
52
+ env?: Record<string, string>;
53
+ status: "pending" | "running" | "succeeded" | "failed" | "timed_out" | "canceled";
54
+ startedAt?: number;
55
+ finishedAt?: number;
56
+ exitCode?: number | null;
57
+ durationMs?: number;
58
+ stdoutTail?: string;
59
+ stderrTail?: string;
60
+ logArtifactId?: string;
44
61
  }
45
62
 
46
63
  interface ScheduledTimerRow {
@@ -74,6 +91,7 @@ interface ScheduledTimerRow {
74
91
  last_message_id: number | null;
75
92
  claimed_by: string | null;
76
93
  claim_expires_at: number | null;
94
+ command: string | null;
77
95
  }
78
96
 
79
97
  export interface CreateScheduledTimerRowInput {
@@ -96,6 +114,7 @@ export interface CreateScheduledTimerRowInput {
96
114
  maxRuns?: number;
97
115
  expiresAt?: number;
98
116
  idempotencyKey?: string;
117
+ command?: ScheduledTimerCommand;
99
118
  }
100
119
 
101
120
  export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now = Date.now()): ScheduledTimer {
@@ -105,9 +124,9 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
105
124
  INSERT INTO scheduled_timers (
106
125
  id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
107
126
  created_at, next_run_at, schedule_kind, schedule_def, message_kind, channel, subject, body, claimable, payload, meta, enabled,
108
- run_count, max_runs, expires_at, idempotency_key, correlation_id
127
+ run_count, max_runs, expires_at, idempotency_key, correlation_id, command
109
128
  )
110
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?, ?)
111
130
  `).run(
112
131
  id,
113
132
  input.target,
@@ -131,6 +150,7 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
131
150
  input.expiresAt ?? null,
132
151
  input.idempotencyKey ?? null,
133
152
  correlationId,
153
+ input.command ? JSON.stringify(input.command) : null,
134
154
  );
135
155
  return getScheduledTimer(id)!;
136
156
  }
@@ -215,12 +235,35 @@ export function completeScheduledTimerRun(input: {
215
235
  claimed_by = NULL,
216
236
  claim_expires_at = NULL
217
237
  WHERE id = ?
218
- AND claimed_by = ?
238
+ AND (claimed_by = ? OR (claimed_by IS NULL AND ? = ''))
219
239
  AND enabled = 1
220
- `).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
240
+ `).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "", input.timer.claimedBy ?? "");
221
241
  return getScheduledTimer(input.timer.id)!;
222
242
  }
223
243
 
244
+ export function setScheduledTimerCommandId(timerId: string, commandId: string): ScheduledTimer | null {
245
+ const timer = getScheduledTimer(timerId);
246
+ if (!timer?.command) return timer;
247
+ getDb().query("UPDATE scheduled_timers SET command = ? WHERE id = ?")
248
+ .run(JSON.stringify({ ...timer.command, commandId }), timerId);
249
+ return getScheduledTimer(timerId);
250
+ }
251
+
252
+ export function findScheduledTimerByCommandId(commandId: string): ScheduledTimer | null {
253
+ const row = getDb()
254
+ .query("SELECT * FROM scheduled_timers WHERE json_extract(command, '$.commandId') = ? ORDER BY created_at DESC LIMIT 1")
255
+ .get(commandId) as ScheduledTimerRow | undefined;
256
+ return row ? rowToTimer(row) : null;
257
+ }
258
+
259
+ export function updateScheduledTimerCommand(timerId: string, patch: Partial<ScheduledTimerCommand>): ScheduledTimer | null {
260
+ const timer = getScheduledTimer(timerId);
261
+ if (!timer?.command) return timer;
262
+ getDb().query("UPDATE scheduled_timers SET command = ? WHERE id = ?")
263
+ .run(JSON.stringify({ ...timer.command, ...patch }), timerId);
264
+ return getScheduledTimer(timerId);
265
+ }
266
+
224
267
  export function recordScheduledTimerFailure(input: {
225
268
  timer: ScheduledTimer;
226
269
  error: string;
@@ -295,6 +338,7 @@ function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
295
338
  lastMessageId: row.last_message_id ?? undefined,
296
339
  claimedBy: row.claimed_by ?? undefined,
297
340
  claimExpiresAt: row.claim_expires_at ?? undefined,
341
+ command: parseRecord(row.command) as ScheduledTimerCommand | undefined,
298
342
  };
299
343
  }
300
344
 
package/src/db/schema.ts CHANGED
@@ -197,7 +197,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
197
197
  correlation_id TEXT NOT NULL,
198
198
  last_message_id INTEGER REFERENCES messages(id),
199
199
  claimed_by TEXT,
200
- claim_expires_at INTEGER
200
+ claim_expires_at INTEGER,
201
+ command TEXT
201
202
  );
202
203
  CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at);
203
204
  CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at);
package/src/mcp/index.ts CHANGED
@@ -53,7 +53,8 @@ export const RELAY_TOOL_GROUPS: Record<string, readonly string[]> = {
53
53
  memory: ["relay_recall", "relay_compact_and_resume", "relay_memory_write", "relay_memory_promote", "relay_memory_retract", "relay_memory_read", "relay_memory_search"],
54
54
  artifacts: ["relay_upload_artifact", "relay_attach_artifact"],
55
55
  workspace: ["relay_workspace_status", "relay_workspace_ready", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_workspace_land", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
56
- "spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report", "relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
56
+ "spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report"],
57
+ schedule: ["relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
57
58
  plans: ["relay_plan_create", "relay_plan_add_node", "relay_plan_update_node", "relay_plan_add_edge", "relay_plan_remove_node", "relay_plan_remove_edge", "relay_plan_archive", "relay_plan_delete", "relay_plan_get"],
58
59
  tasks: ["relay_task_create", "relay_task_get", "relay_task_list", "relay_task_update", "relay_task_set_status", "relay_task_set_stage", "relay_task_record_gate", "relay_task_add_dependency", "relay_task_remove_dependency", "relay_task_add_ref", "relay_task_remove_ref", "relay_task_assign_agent", "relay_task_history", "relay_task_memory_read", "relay_task_memory_replace_working_state", "relay_task_memory_append_ruled_out", "relay_task_memory_append_decision", "relay_task_dispatch", "relay_task_comment", "relay_task_close_bridge", "relay_release_readiness"],
59
60
  teams: ["relay_team_get", "relay_team_set_brief", "relay_team_claim", "relay_team_dissolve"],
@@ -9,7 +9,7 @@ import { optionalBoolean, optionalPositiveInt, optionalRecord, optionalString, s
9
9
  export const SCHEDULER_TOOLS: ToolDefinition[] = [
10
10
  {
11
11
  name: "relay_schedule_timer",
12
- description: "Create a durable Relay timer that wakes a target by enqueueing a normal Relay message when due. Supports one-shot afterMs/at and fixed recurring everyMs. RRULE is intentionally deferred.",
12
+ description: "Create a durable Relay timer that wakes a target by enqueueing a normal Relay message when due. With command, runs a background orchestrator command and wakes on exit or afterMs timeout.",
13
13
  requiredScopes: ["schedule:write"],
14
14
  inputSchema: {
15
15
  type: "object",
@@ -21,11 +21,13 @@ export const SCHEDULER_TOOLS: ToolDefinition[] = [
21
21
  subject: { type: "string" },
22
22
  body: { type: "string" },
23
23
  payload: { type: "object" },
24
+ command: { type: "string", description: "Optional background shell command. Requires command:write. When set, afterMs is the max timeout and everyMs/at are rejected." },
25
+ cwd: { type: "string", description: "Command working directory. Must resolve inside an online orchestrator baseDir. Defaults to the caller agent cwd when available." },
26
+ env: { type: "object", additionalProperties: { type: "string" }, description: "Optional string environment overrides for the command." },
24
27
  maxRuns: { type: "integer", minimum: 1 },
25
28
  expiresAt: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
26
29
  idempotencyKey: { type: "string" },
27
30
  },
28
- required: ["body"],
29
31
  additionalProperties: false,
30
32
  },
31
33
  },
@@ -94,14 +96,19 @@ function cleanScheduleInput(args: Record<string, unknown>): ScheduleTimerInput {
94
96
  at: optionalString(args.at, "at", 80),
95
97
  everyMs: optionalPositiveInt(args.everyMs, "everyMs"),
96
98
  subject: optionalString(args.subject, "subject", 200),
97
- body: stringField(args.body, "body", { required: true }),
99
+ body: optionalString(args.body, "body", MAX_SAFE_BODY_CHARS),
98
100
  payload: optionalRecord(args.payload, "payload"),
101
+ command: optionalString(args.command, "command", 8_000),
102
+ cwd: optionalString(args.cwd, "cwd", 500),
103
+ env: optionalStringRecord(args.env, "env"),
99
104
  maxRuns: optionalPositiveInt(args.maxRuns, "maxRuns"),
100
105
  expiresAt: typeof args.expiresAt === "number" ? args.expiresAt : optionalString(args.expiresAt, "expiresAt", 80),
101
106
  idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
102
107
  };
103
108
  }
104
109
 
110
+ const MAX_SAFE_BODY_CHARS = 64_000;
111
+
105
112
  function authContextPayload(auth: McpAuthContext): Parameters<typeof authContextFromMcp>[0] {
106
113
  return { kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component };
107
114
  }
@@ -128,9 +135,21 @@ function serializeTimer(timer: ScheduledTimer): Record<string, unknown> {
128
135
  idempotencyKey: timer.idempotencyKey,
129
136
  correlationId: timer.correlationId,
130
137
  lastMessageId: timer.lastMessageId,
138
+ command: timer.command,
131
139
  };
132
140
  }
133
141
 
142
+ function optionalStringRecord(value: unknown, field: string): Record<string, string> | undefined {
143
+ const record = optionalRecord(value, field);
144
+ if (!record) return undefined;
145
+ const out: Record<string, string> = {};
146
+ for (const [key, item] of Object.entries(record)) {
147
+ if (typeof item !== "string") throw new ValidationError(`${field}.${key} must be a string`);
148
+ out[key] = item;
149
+ }
150
+ return out;
151
+ }
152
+
134
153
  function withAuthErrors<T>(fn: () => T): T {
135
154
  try {
136
155
  return fn();
@@ -30,15 +30,17 @@ const DEFAULT_PROMPT_TEMPLATES = [
30
30
  description: "Built-in ops-assistant append instruction.",
31
31
  template: lines(
32
32
  "Ops-assistant standing brief: execute mechanical operations on your coordinator's behalf: test suites, release commands such as `bun run release`, smoke and verification probes, gates, and host/version/status checks.",
33
- "When running a self-verifying long script, especially `bun run release` (gates -> CI-watch -> deploy -> host-verify, then one final result), redirect its output to a file rather than letting it stream into your context: `bun run release > /tmp/ar-release.log 2>&1`. Wait for it to exit, then read only the final result with `tail /tmp/ar-release.log`. Never run it with inline streaming output. Do not poll, monitor, tail logs, run parallel status/version checks, or otherwise babysit it while it runs. Trust the script; act only on its final result. This generalizes to any long-running command whose intermediate output you do not need.",
33
+ "For any long command, start it detached in the background with stdout/stderr redirected to a log so it returns immediately and survives your turn; then call `relay_schedule_timer` with `target: \"me\"`, `afterMs` set near the expected duration, and a body telling your future self to check the log and report the exit code plus tail. End the turn after scheduling. Do not poll, tail, `ps`, or babysit the command in a loop.",
34
+ "On the timer wake, check the log and process result. If the command is done, report the outcome; if it is still running, reschedule once, or investigate if it is well past the expected duration.",
34
35
  "Hold and apply session-local ops conventions the coordinator gives you. Report crisp outcomes only: command/gate, pass/fail, commit/version/host evidence, and the next blocker when one exists. Do not dump logs unless asked.",
35
- "Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent. Report operational results to your spawn-parent by default and keep their context clean.",
36
+ "Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.",
36
37
  ),
37
38
  defaultTemplate: lines(
38
39
  "Ops-assistant standing brief: execute mechanical operations on your coordinator's behalf: test suites, release commands such as `bun run release`, smoke and verification probes, gates, and host/version/status checks.",
39
- "When running a self-verifying long script, especially `bun run release` (gates -> CI-watch -> deploy -> host-verify, then one final result), redirect its output to a file rather than letting it stream into your context: `bun run release > /tmp/ar-release.log 2>&1`. Wait for it to exit, then read only the final result with `tail /tmp/ar-release.log`. Never run it with inline streaming output. Do not poll, monitor, tail logs, run parallel status/version checks, or otherwise babysit it while it runs. Trust the script; act only on its final result. This generalizes to any long-running command whose intermediate output you do not need.",
40
+ "For any long command, start it detached in the background with stdout/stderr redirected to a log so it returns immediately and survives your turn; then call `relay_schedule_timer` with `target: \"me\"`, `afterMs` set near the expected duration, and a body telling your future self to check the log and report the exit code plus tail. End the turn after scheduling. Do not poll, tail, `ps`, or babysit the command in a loop.",
41
+ "On the timer wake, check the log and process result. If the command is done, report the outcome; if it is still running, reschedule once, or investigate if it is well past the expected duration.",
40
42
  "Hold and apply session-local ops conventions the coordinator gives you. Report crisp outcomes only: command/gate, pass/fail, commit/version/host evidence, and the next blocker when one exists. Do not dump logs unless asked.",
41
- "Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent. Report operational results to your spawn-parent by default and keep their context clean.",
43
+ "Do not make strategy, design, scope, or release-timing calls. Escalate those decisions to your spawn-parent.",
42
44
  ),
43
45
  variables: [],
44
46
  },
@@ -2,6 +2,7 @@
2
2
  import { ValidationError, withResolvedProvisioning } from "../db";
3
3
  import { cleanString, optionalEnum } from "../validation";
4
4
  import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile } from "../config-store";
5
+ import { RELAY_TOOL_GROUPS } from "../mcp";
5
6
  import { emitConfigChanged } from "../sse";
6
7
  import { error, isRootCredentialRequest, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
7
8
  import { getComponentAuth, hasComponentScope } from "../security";
@@ -10,6 +11,14 @@ import { SPAWN_PROVIDERS, type AgentProfile, type SpawnProvider } from "../types
10
11
 
11
12
  export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
12
13
 
14
+ /**
15
+ * The canonical tool-group catalog (#970): name + member tool names, read straight off
16
+ * `RELAY_TOOL_GROUPS` so the dashboard multiselect can never drift from the code registry
17
+ * that `visibleTools` (src/mcp/index.ts) actually enforces at tools/list time.
18
+ */
19
+ export const getAgentProfileToolGroupsRoute: Handler = () =>
20
+ json(Object.entries(RELAY_TOOL_GROUPS).map(([name, tools]) => ({ name, tools: [...tools] })));
21
+
13
22
  export const getAgentProfileRoute: Handler = (_req, params) => {
14
23
  const name = normalizeConfigPathParam(params.name, "name");
15
24
  const profile = getAgentProfile(name);
@@ -12,6 +12,7 @@ import { isRecord, sanitizeFsName, type LandGateRunResult } from "agent-relay-sd
12
12
  import { storeTextArtifact } from "../artifact-text";
13
13
  import { authContextFromRequest } from "../services/auth-context";
14
14
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
15
+ import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "../services/scheduler-command";
15
16
  import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
16
17
  import { ServiceAuthError } from "../services/errors";
17
18
  import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
@@ -204,7 +205,10 @@ export const patchCommand: Handler = async (req, params) => {
204
205
  const denied = authorizeRoute(req, { scope: "command:write", resource: dispatchCommandAuthorizationResource(current) });
205
206
  if (denied) return denied;
206
207
  const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
207
- const result = cleanParams(parsed.body.result, "result");
208
+ let result = cleanParams(parsed.body.result, "result");
209
+ if (result && current.type === "scheduler.command") {
210
+ result = await prepareScheduledCommandResultForStorage(current, result);
211
+ }
208
212
  const err = cleanString(parsed.body.error, "error", { max: 4000 });
209
213
  const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
210
214
  if (!command) return error("command not found", 404);
@@ -225,6 +229,9 @@ export const patchCommand: Handler = async (req, params) => {
225
229
  getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
226
230
  }
227
231
  enqueueContinuationInjectionAfterSelfResume(command);
232
+ if (command.type === "scheduler.command") {
233
+ await settleScheduledCommandTimer(command);
234
+ }
228
235
  if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
229
236
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
230
237
  if (workspaceId) {
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
2
2
  import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
3
- import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, getResolvedAgentProfileRoute, putAgentProfileRoute } from "./agent-profiles";
3
+ import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, getAgentProfileToolGroupsRoute, getResolvedAgentProfileRoute, putAgentProfileRoute } from "./agent-profiles";
4
4
  import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
5
  import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
6
6
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
@@ -235,6 +235,7 @@ const routes: Route[] = [
235
235
  route("PATCH", "/api/commands/:id", patchCommand),
236
236
  route("DELETE", "/api/commands/:id", deleteCommandById),
237
237
  route("GET", "/api/agent-profiles", getAgentProfilesRoute),
238
+ route("GET", "/api/agent-profiles/tool-groups", getAgentProfileToolGroupsRoute),
238
239
  route("GET", "/api/agent-profiles/:name/resolved", getResolvedAgentProfileRoute),
239
240
  route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
240
241
  route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
@@ -14,9 +14,10 @@ import { isRecord, stringValue } from "agent-relay-sdk";
14
14
  import { createCommand } from "../commands-db";
15
15
  import { emitCommandEvent } from "../command-events";
16
16
  import { isComponentAuthorizedFor } from "../security";
17
+ import { listOrchestrators } from "../db";
17
18
  import { ServiceAuthError } from "./errors";
18
19
  import type { AuthContext } from "./auth-context";
19
- import type { Command, CreateCommandInput } from "../types";
20
+ import type { Command, ComponentToken, CreateCommandInput, TokenConstraints } from "../types";
20
21
 
21
22
  /** THE single home for the command authorization resource. Was duplicated in
22
23
  * routes/commands.ts and bus.ts (diverging on `cleanString` vs `stringValue`) plus their
@@ -34,6 +35,130 @@ export function commandAuthorizationResource(input: Pick<CreateCommandInput, "ta
34
35
  };
35
36
  }
36
37
 
38
+ /** Does the token pin the cwd(s) a command may run in? An absent path resource field must NOT
39
+ * silently bypass such a constraint (the orchestrator would default it to baseDir — a full
40
+ * cwd-constraint escape, #968 hole 1), so we force-resolve the effective paths before the check. */
41
+ function hasCwdConstraint(constraints: TokenConstraints | undefined): boolean {
42
+ return Boolean(constraints?.cwd || constraints?.cwdPrefixes?.length);
43
+ }
44
+
45
+ /** THE universe of param fields that can carry a host filesystem path a command executes at.
46
+ * A constrained token's `cwd`/`cwdPrefixes` must cover EVERY such field a command runs at — the
47
+ * round-1/2 fixes only ever looked at `cwd`, so commands that execute at `worktreePath`/`repoRoot`
48
+ * (workspace.* → execProcess/git in orchestrator/src/workspace-probe/*) escaped the constraint
49
+ * entirely (#968 round-3). Adding a NEW path-bearing param field means adding it here — otherwise
50
+ * the fail-closed check below cannot see it. */
51
+ const PATH_AXIS_FIELDS = ["cwd", "worktreePath", "repoRoot"] as const;
52
+ type PathAxisField = (typeof PATH_AXIS_FIELDS)[number];
53
+
54
+ /** Declarative map: command type → the path-bearing param field(s) it executes host work at.
55
+ * The orchestrator runs a shell/git at these paths (scheduler.command → control.ts; workspace.* →
56
+ * control.ts → workspace-probe/*). A constrained token must have EACH resolved axis inside its
57
+ * cwdPrefixes/cwd.
58
+ *
59
+ * A command type absent from BOTH this map and NON_PATH_COMMAND_TYPES is UNKNOWN: under a
60
+ * constrained token it FAILS CLOSED if it carries any PATH_AXIS_FIELD (a new host-executing type
61
+ * that forgot to declare its axes cannot silently bypass the constraint). This is deliberately
62
+ * NOT an allowlist of "types that run at cwd" — that's exactly what was too narrow twice. */
63
+ const COMMAND_PATH_AXES: Record<string, readonly PathAxisField[]> = {
64
+ "scheduler.command": ["cwd"],
65
+ "workspace.merge": ["worktreePath", "repoRoot"],
66
+ "workspace.cleanup": ["worktreePath", "repoRoot"],
67
+ "workspace.reconcile": ["worktreePath", "repoRoot"],
68
+ "workspace.pr-arm-auto-merge": ["worktreePath", "repoRoot"],
69
+ "workspace.pr-merge": ["worktreePath", "repoRoot"],
70
+ "workspace.pr-refresh": ["worktreePath", "repoRoot"],
71
+ "workspace.deps-refresh": ["worktreePath", "repoRoot"],
72
+ "workspace.idle-refresh": ["worktreePath", "repoRoot"],
73
+ "workspace.recovery-branch-discard": ["repoRoot"],
74
+ "workspace.prune": ["repoRoot"],
75
+ };
76
+
77
+ /** Command types that do NOT execute host filesystem work at a param-supplied path — a cwd
78
+ * constraint must not gate them, or we regress unrelated dispatch. agent.* operate on tmux
79
+ * sessions / relay state; orchestrator.migrate-provider-config only reads local config. Any OTHER
80
+ * unmapped type is trusted only if it carries no path field at all (see assertPathAxesWithinConstraint). */
81
+ const NON_PATH_COMMAND_TYPES = new Set<string>([
82
+ "agent.restart",
83
+ "agent.shutdown",
84
+ "orchestrator.migrate-provider-config",
85
+ ]);
86
+
87
+ /** The cwd a dispatched command will ACTUALLY run in — the same default the orchestrator applies.
88
+ * If `params.cwd` is set, that's it. Otherwise the orchestrator (`orchestrator-<id>` target, or
89
+ * `params.orchestratorId`) defaults a missing cwd to its own `baseDir` (control.ts). We resolve
90
+ * that baseDir here so the auth gate evaluates the REAL cwd, not an absent (constraint-skipping)
91
+ * one. Returns undefined only when the effective cwd genuinely can't be determined. */
92
+ function resolveEffectiveCommandCwd(input: Pick<CreateCommandInput, "target" | "params">): string | undefined {
93
+ const params = isRecord(input.params) ? input.params : {};
94
+ const explicit = stringValue(params.cwd);
95
+ if (explicit) return explicit;
96
+ const orchestratorId = stringValue(params.orchestratorId)
97
+ ?? (typeof input.target === "string" && input.target.startsWith("orchestrator-")
98
+ ? input.target.slice("orchestrator-".length)
99
+ : undefined);
100
+ if (!orchestratorId) return undefined;
101
+ return listOrchestrators().find((item) => item.id === orchestratorId)?.baseDir;
102
+ }
103
+
104
+ /** Effective value of one path axis, applying the SAME defaulting the orchestrator would.
105
+ * Only `cwd` has a baseDir default (scheduler.command); `worktreePath`/`repoRoot` have none —
106
+ * an absent value stays absent and drives the fail-closed "no axis resolved" deny below. */
107
+ function resolveAxisValue(field: PathAxisField, input: CreateCommandInput, params: Record<string, unknown>): string | undefined {
108
+ const explicit = stringValue(params[field]);
109
+ if (explicit) return explicit;
110
+ if (field === "cwd") return resolveEffectiveCommandCwd(input);
111
+ return undefined;
112
+ }
113
+
114
+ /** Is a single resolved path inside the token's cwd/cwdPrefixes? Reuses the exact production
115
+ * matcher (`constraintsAllow` → `cwdAllows`) via a cwd-only authorization resource. */
116
+ function pathAxisAllowed(component: ComponentToken, value: string): boolean {
117
+ return isComponentAuthorizedFor(component, { scope: "command:write", resource: { cwd: value } });
118
+ }
119
+
120
+ /** #968 round-3 — the COMPREHENSIVE cwd-constraint gate. For a cwd-constrained component token,
121
+ * every host path the command executes at must be inside the constraint. Single choke point for
122
+ * BOTH transports (HTTP `POST /api/commands`, WS bus command frame). Throws to DENY. */
123
+ function assertPathAxesWithinConstraint(input: CreateCommandInput, component: ComponentToken): void {
124
+ const params = isRecord(input.params) ? input.params : {};
125
+ const declared = COMMAND_PATH_AXES[input.type];
126
+
127
+ if (!declared) {
128
+ // Unmapped type. If it carries ANY known path field, it's a host-executing command that failed
129
+ // to declare its axes → FAIL CLOSED. Otherwise it's a non-path command (agent.* etc.) and a cwd
130
+ // constraint must not gate it — but only trust that for types explicitly known non-path, or that
131
+ // simply carry no path field at all (so a stray unmapped type can't smuggle a path through).
132
+ const strayPath = PATH_AXIS_FIELDS.some((field) => stringValue(params[field]));
133
+ if (strayPath && !NON_PATH_COMMAND_TYPES.has(input.type)) {
134
+ throw new ServiceAuthError("component token lacks command scope");
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Mapped host-executing type: resolve + constrain-check each declared axis (and, defensively, any
140
+ // other present path field), DENY if any is out of bounds. If NONE of the axes resolve to a value,
141
+ // the command's run location can't be proven inside the constraint — the orchestrator would default
142
+ // it out of bounds — so fail closed.
143
+ let anyResolved = false;
144
+ const checked = new Set<PathAxisField>();
145
+ for (const field of declared) {
146
+ const value = resolveAxisValue(field, input, params);
147
+ if (!value) continue;
148
+ anyResolved = true;
149
+ checked.add(field);
150
+ if (!pathAxisAllowed(component, value)) throw new ServiceAuthError("component token lacks command scope");
151
+ }
152
+ for (const field of PATH_AXIS_FIELDS) {
153
+ if (checked.has(field)) continue;
154
+ const value = stringValue(params[field]);
155
+ if (!value) continue;
156
+ anyResolved = true;
157
+ if (!pathAxisAllowed(component, value)) throw new ServiceAuthError("component token lacks command scope");
158
+ }
159
+ if (!anyResolved) throw new ServiceAuthError("component token lacks command scope");
160
+ }
161
+
37
162
  /** Authorize a command dispatch from the unified auth context. Mirrors BOTH old paths
38
163
  * exactly: only a COMPONENT token carries per-resource constraints, so the gate is a no-op
39
164
  * for admin/system/integration principals (which already cleared the coarse `command:write`
@@ -42,10 +167,16 @@ export function commandAuthorizationResource(input: Pick<CreateCommandInput, "ta
42
167
  * "no component token ⇒ allow; else `isComponentAuthorizedFor(command:write, resource)`". */
43
168
  function assertCommandAuthorized(ctx: AuthContext, input: CreateCommandInput): void {
44
169
  if (!ctx.component) return;
45
- const ok = isComponentAuthorizedFor(ctx.component, {
46
- scope: "command:write",
47
- resource: commandAuthorizationResource(input),
48
- });
170
+ const resource = commandAuthorizationResource(input);
171
+ // #968 round-3: a cwd-constrained token must have EVERY host path the command executes at
172
+ // (not just `params.cwd` — also `worktreePath`/`repoRoot`, and any future path axis) inside its
173
+ // constraint. Force-resolve each declared axis (applying the orchestrator's baseDir defaulting)
174
+ // and constrain-check it; an unmapped-but-path-bearing command fails closed. Covers both the
175
+ // round-1 (absent cwd) and round-2 (scheduler-only) narrowness in one declarative choke point.
176
+ if (hasCwdConstraint(ctx.component.constraints)) {
177
+ assertPathAxesWithinConstraint(input, ctx.component);
178
+ }
179
+ const ok = isComponentAuthorizedFor(ctx.component, { scope: "command:write", resource });
49
180
  if (!ok) throw new ServiceAuthError("component token lacks command scope");
50
181
  }
51
182