agent-relay-server 0.10.20 → 0.10.22

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.
package/src/routes.ts CHANGED
@@ -80,10 +80,12 @@ import {
80
80
  getOrchestrator,
81
81
  listOrchestrators,
82
82
  orchestratorHeartbeat,
83
+ setOrchestratorUpgradeState,
83
84
  updateManagedAgents,
84
85
  getWorkspace,
85
86
  listWorkspaces,
86
87
  updateWorkspaceStatus,
88
+ deleteWorkspace,
87
89
  deleteOrchestrator,
88
90
  evaluatePoolBindings,
89
91
  findMessageByTelegramSource,
@@ -133,7 +135,7 @@ import {
133
135
  updateAutomation,
134
136
  type AutomationDispatchResult,
135
137
  } from "./automations";
136
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceStatus } from "./types";
138
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
137
139
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
138
140
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
139
141
  import { listHostDirectories } from "./agent-spawn";
@@ -309,7 +311,7 @@ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as c
309
311
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
310
312
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
311
313
  const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
312
- const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "abandoned", "cleanup_requested", "cleaned"] as const;
314
+ const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
313
315
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
314
316
  const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext"] as const;
315
317
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
@@ -2331,6 +2333,52 @@ const postAgentAction: Handler = async (req, params) => {
2331
2333
  }
2332
2334
  };
2333
2335
 
2336
+ const postAgentPrompt: Handler = async (req, params) => {
2337
+ const parsed = await parseBody<unknown>(req);
2338
+ if (!parsed.ok) return error(parsed.error, parsed.status);
2339
+ try {
2340
+ if (!isRecord(parsed.body)) return error("body required");
2341
+ const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
2342
+ const agent = getAgent(params.id!);
2343
+ if (!agent) return error("agent not found", 404);
2344
+ if (agent.status === "offline") return error("agent is offline", 422);
2345
+ const denied = authorizeRoute(req, {
2346
+ scope: "message:send",
2347
+ resource: { target: agent.id, agentId: "user" },
2348
+ });
2349
+ if (denied) return denied;
2350
+ const message = sendMessage({
2351
+ from: "user",
2352
+ to: agent.id,
2353
+ kind: "session",
2354
+ body,
2355
+ });
2356
+ const command = createCommand({
2357
+ type: "prompt.inject",
2358
+ source: "dashboard",
2359
+ target: agent.id,
2360
+ params: { body, messageId: message.id },
2361
+ });
2362
+ emitCommand(command);
2363
+ emitNewMessage(message);
2364
+ auditEvent({
2365
+ clientId: "server-prompt-inject-" + message.id,
2366
+ kind: "message",
2367
+ title: "Prompt injected",
2368
+ body,
2369
+ meta: `user -> ${agent.id}`,
2370
+ icon: "ti-message-bolt",
2371
+ view: "messages",
2372
+ messageId: message.id,
2373
+ agentId: agent.id,
2374
+ });
2375
+ return json({ ok: true, messageId: message.id, commandId: command.id }, 201);
2376
+ } catch (e) {
2377
+ if (e instanceof ValidationError) return error(e.message, 400);
2378
+ throw e;
2379
+ }
2380
+ };
2381
+
2334
2382
  const postAgentPermissionDecision: Handler = async (req, params) => {
2335
2383
  const parsed = await parseBody<unknown>(req);
2336
2384
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3041,6 +3089,7 @@ const postOrchestrator: Handler = async (req) => {
3041
3089
  providerCatalog: providerCatalog as RegisterOrchestratorInput["providerCatalog"],
3042
3090
  meta,
3043
3091
  });
3092
+ reconcileOrchestratorUpgrade(orch);
3044
3093
  auditEvent({
3045
3094
  clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
3046
3095
  kind: "state",
@@ -3096,6 +3145,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3096
3145
  }
3097
3146
  const orch = orchestratorHeartbeat(params.id!, runtime);
3098
3147
  if (!orch) return error("orchestrator not found", 404);
3148
+ reconcileOrchestratorUpgrade(orch);
3099
3149
  return json({ ok: true });
3100
3150
  } catch (e) {
3101
3151
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -3443,6 +3493,64 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3443
3493
  }
3444
3494
  };
3445
3495
 
3496
+ const ORCH_UPGRADE_DEADLINE_MS = 5 * 60_000;
3497
+ const ORCH_UPGRADE_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
3498
+ const VALID_ORCH_UPGRADE_PROVIDERS = ["auto", "all", "codex", "claude", "orchestrator"] as const;
3499
+
3500
+ function semverCore(v: string): [number, number, number] | null {
3501
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
3502
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
3503
+ }
3504
+
3505
+ function compareSemverCore(a: string, b: string): number {
3506
+ const ca = semverCore(a), cb = semverCore(b);
3507
+ if (!ca || !cb) return 0;
3508
+ for (let i = 0; i < 3; i++) if (ca[i] !== cb[i]) return ca[i]! - cb[i]!;
3509
+ return 0;
3510
+ }
3511
+
3512
+ /**
3513
+ * Settle an in-flight orchestrator upgrade by comparing the version the
3514
+ * orchestrator now reports against the desired version (decision 2a). Called on
3515
+ * every register/heartbeat — the only signal that survives the self-restart.
3516
+ */
3517
+ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
3518
+ if (!orch) return;
3519
+ const up = orch.upgrade;
3520
+ if (!up || up.status !== "pending") return;
3521
+ const reported = orch.version;
3522
+ if (reported && reported === up.desiredVersion) {
3523
+ if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
3524
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "succeeded", settledAt: Date.now() });
3525
+ auditEvent({
3526
+ clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
3527
+ kind: "state",
3528
+ title: "Orchestrator upgraded",
3529
+ body: `${up.fromVersion ?? "?"} → ${reported}`,
3530
+ meta: orch.id,
3531
+ icon: "ti-arrow-up-circle",
3532
+ view: "orchestrators",
3533
+ metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
3534
+ });
3535
+ emitOrchestratorStatus(orch.id);
3536
+ } else if (Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
3537
+ const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
3538
+ if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
3539
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
3540
+ auditEvent({
3541
+ clientId: `server-orchestrator-upgrade-failed-${orch.id}-${Date.now()}`,
3542
+ kind: "state",
3543
+ title: "Orchestrator upgrade failed",
3544
+ body: errMsg,
3545
+ meta: orch.id,
3546
+ icon: "ti-alert-triangle",
3547
+ view: "orchestrators",
3548
+ metadata: { orchestratorId: orch.id, commandId: up.commandId },
3549
+ });
3550
+ emitOrchestratorStatus(orch.id);
3551
+ }
3552
+ }
3553
+
3446
3554
  const postOrchestratorAction: Handler = async (req, params) => {
3447
3555
  const parsed = await parseBody<unknown>(req);
3448
3556
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3451,8 +3559,52 @@ const postOrchestratorAction: Handler = async (req, params) => {
3451
3559
  if (!orch) return error("orchestrator not found", 404);
3452
3560
 
3453
3561
  if (!isRecord(parsed.body)) return error("body required");
3454
- const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
3562
+ const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3455
3563
  if (!action) return error("action required");
3564
+
3565
+ if (action === "upgrade") {
3566
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
3567
+ if (denied) return denied;
3568
+ const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
3569
+ if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
3570
+ const force = parsed.body.force === true;
3571
+ const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
3572
+ .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
3573
+ if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
3574
+ if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
3575
+ return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
3576
+ }
3577
+ const requestedAt = Date.now();
3578
+ const command = createCommand({
3579
+ type: "orchestrator.upgrade",
3580
+ source: "system",
3581
+ target: orch.agentId,
3582
+ ttlMs: ORCH_UPGRADE_DEADLINE_MS + 5 * 60_000,
3583
+ params: { targetVersion, providers, force, orchestratorId: orch.id, requestedBy: "dashboard", requestedAt },
3584
+ });
3585
+ setOrchestratorUpgradeState(orch.id, {
3586
+ desiredVersion: targetVersion,
3587
+ status: "pending",
3588
+ commandId: command.id,
3589
+ providers,
3590
+ ...(orch.version ? { fromVersion: orch.version } : {}),
3591
+ requestedBy: "dashboard",
3592
+ requestedAt,
3593
+ });
3594
+ emitCommand(command);
3595
+ emitOrchestratorStatus(orch.id);
3596
+ auditEvent({
3597
+ clientId: `server-orchestrator-upgrade-${orch.id}-${command.id}`,
3598
+ kind: "state",
3599
+ title: "Orchestrator upgrade requested",
3600
+ body: `${orch.version ?? "?"} → ${targetVersion}`,
3601
+ meta: orch.id,
3602
+ icon: "ti-arrow-up-circle",
3603
+ view: "orchestrators",
3604
+ metadata: { orchestratorId: orch.id, targetVersion, providers, commandId: command.id, ...authAuditMetadata(req) },
3605
+ });
3606
+ return json({ ok: true, action, command, targetVersion }, 202);
3607
+ }
3456
3608
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
3457
3609
  const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
3458
3610
  const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
@@ -3558,6 +3710,135 @@ const getWorkspaceById: Handler = (_req, params) => {
3558
3710
  return json(workspace);
3559
3711
  };
3560
3712
 
3713
+ // Proxy a read-only workspace interrogation to the owning orchestrator's host
3714
+ // API. Degrades to { available: false } rather than erroring so the dashboard
3715
+ // can render a placeholder when the host is offline or there's no worktree.
3716
+ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extraQuery?: Record<string, string>): Promise<Response> {
3717
+ const workspace = getWorkspace(workspaceId);
3718
+ if (!workspace) return error("workspace not found", 404);
3719
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
3720
+ return json({ available: false, reason: "no isolated worktree" });
3721
+ }
3722
+ const orch = listOrchestrators().find(
3723
+ (candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
3724
+ );
3725
+ if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
3726
+ const query = new URLSearchParams({ path: workspace.worktreePath });
3727
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
3728
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
3729
+ for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
3730
+ const headers: Record<string, string> = {};
3731
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
3732
+ if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3733
+ try {
3734
+ const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
3735
+ headers,
3736
+ signal: AbortSignal.timeout(10_000),
3737
+ });
3738
+ const body = await res.text();
3739
+ return new Response(body, { status: res.status, headers: { "Content-Type": res.headers.get("content-type") || "application/json" } });
3740
+ } catch (e) {
3741
+ return json({ available: false, reason: `orchestrator unreachable: ${(e as Error).message}` });
3742
+ }
3743
+ }
3744
+
3745
+ // Live git state of a workspace's worktree (ahead/behind/dirty/last commit).
3746
+ const getWorkspaceGitState: Handler = (_req, params) => proxyWorkspaceHostGet(params.id!, "/api/workspace/state");
3747
+
3748
+ // Pre-flight check for merging a workspace: clean-ff vs would-conflict, plus
3749
+ // the strategy `auto` would choose. Lets the dashboard warn before merging.
3750
+ const getWorkspaceMergePreview: Handler = (req, params) => {
3751
+ const strategy = new URL(req.url).searchParams.get("strategy");
3752
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/merge-preview", strategy ? { strategy } : undefined);
3753
+ };
3754
+
3755
+ // Diff of a workspace's committed work against base — per-file line counts and
3756
+ // a capped unified patch, so work can be eyeballed without an SSH session.
3757
+ const getWorkspaceDiff: Handler = (req, params) => {
3758
+ const patch = new URL(req.url).searchParams.get("patch");
3759
+ return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
3760
+ };
3761
+
3762
+ const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
3763
+
3764
+ // Worktrees found on disk (agent/* branches) with no live workspace row — left
3765
+ // behind by crashes or failed cleanups. Probes each known repo's owning host
3766
+ // and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
3767
+ const getWorkspaceOrphans: Handler = async () => {
3768
+ const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
3769
+ if (!orchestrators.length) return json({ orphans: [], reason: "no online orchestrators" });
3770
+ const all = listWorkspaces();
3771
+ const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
3772
+ const headers: Record<string, string> = {};
3773
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
3774
+ if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3775
+ const orphans: WorkspaceOrphan[] = [];
3776
+
3777
+ for (const repoRoot of repoRoots) {
3778
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
3779
+ if (!orch?.apiUrl) continue;
3780
+ let probe: WorkspaceProbe | undefined;
3781
+ try {
3782
+ const res = await fetch(`${orch.apiUrl}/api/workspace/probe?path=${encodeURIComponent(repoRoot)}`, { headers, signal: AbortSignal.timeout(10_000) });
3783
+ if (!res.ok) continue;
3784
+ probe = await res.json() as WorkspaceProbe;
3785
+ } catch {
3786
+ continue;
3787
+ }
3788
+ const rowsByPath = new Map(all.filter((ws) => ws.repoRoot === repoRoot && ws.worktreePath).map((ws) => [resolve(ws.worktreePath), ws]));
3789
+ for (const worktree of probe?.worktrees ?? []) {
3790
+ if (!worktree.path || resolve(worktree.path) === resolve(repoRoot)) continue;
3791
+ // Only agent-relay-created worktrees (agent/* branches) are reclaimable —
3792
+ // never touch a user's own linked worktrees.
3793
+ if (!worktree.branch?.startsWith("agent/")) continue;
3794
+ const row = rowsByPath.get(resolve(worktree.path));
3795
+ if (row && !TERMINAL_WORKSPACE_STATUSES.has(row.status)) continue; // tracked & live
3796
+ orphans.push({ worktreePath: worktree.path, repoRoot, branch: worktree.branch, headSha: worktree.headSha, hadTerminalRow: Boolean(row) });
3797
+ }
3798
+ }
3799
+ return json({ orphans });
3800
+ };
3801
+
3802
+ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3803
+ const denied = authorizeRoute(req, { scope: "command:write" });
3804
+ if (denied) return denied;
3805
+ const parsed = await parseBody<unknown>(req);
3806
+ if (!parsed.ok) return error(parsed.error, parsed.status);
3807
+ try {
3808
+ if (!isRecord(parsed.body)) return error("body required");
3809
+ const worktreePath = cleanString(parsed.body.worktreePath, "worktreePath", { max: 1000 });
3810
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
3811
+ const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
3812
+ if (!worktreePath || !repoRoot) return error("worktreePath and repoRoot required", 400);
3813
+ // Refuse to reclaim a path that still backs a live workspace row.
3814
+ const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
3815
+ if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
3816
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
3817
+ if (!orch) return error("no online orchestrator owns this path", 409);
3818
+ const command = createCommand({
3819
+ type: "workspace.cleanup",
3820
+ source: "system",
3821
+ target: orch.agentId,
3822
+ params: { action: "cleanup", worktreePath, repoRoot, branch, deleteBranch: true, reclaim: true, requestedBy: "dashboard", requestedAt: Date.now() },
3823
+ });
3824
+ emitCommand(command);
3825
+ auditEvent({
3826
+ clientId: `workspace-orphan-reclaim-${Date.now()}`,
3827
+ kind: "state",
3828
+ title: "Workspace orphan reclaim",
3829
+ body: worktreePath,
3830
+ meta: branch ?? repoRoot,
3831
+ icon: "ti-trash",
3832
+ view: "orchestrators",
3833
+ metadata: { worktreePath, repoRoot, branch, commandId: command.id, ...authAuditMetadata(req) },
3834
+ });
3835
+ return json({ command }, 202);
3836
+ } catch (e) {
3837
+ if (e instanceof ValidationError) return error(e.message, 400);
3838
+ throw e;
3839
+ }
3840
+ };
3841
+
3561
3842
  const postWorkspaceAction: Handler = async (req, params) => {
3562
3843
  const parsed = await parseBody<unknown>(req);
3563
3844
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3565,12 +3846,18 @@ const postWorkspaceAction: Handler = async (req, params) => {
3565
3846
  if (!isRecord(parsed.body)) return error("body required");
3566
3847
  const workspace = getWorkspace(params.id!);
3567
3848
  if (!workspace) return error("workspace not found", 404);
3568
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "abandon", "cleanup"] as const);
3849
+ const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
3569
3850
  if (!action) return error("action required", 400);
3570
3851
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
3571
3852
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
3572
3853
  const metadata = cleanMeta(parsed.body.metadata) ?? {};
3573
- const denied = authorizeRoute(req, { scope: action === "cleanup" ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3854
+ const requiresCommand = action === "cleanup" || action === "merge";
3855
+ // Shared-mode rows are occupancy markers with no worktree — there is nothing
3856
+ // on disk to merge or clean. Reject host commands against them up front.
3857
+ if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
3858
+ return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
3859
+ }
3860
+ const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3574
3861
  if (denied) return denied;
3575
3862
  if (action === "status") return json(workspace);
3576
3863
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
@@ -3579,6 +3866,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
3579
3866
  "conflict-found": "conflict",
3580
3867
  "request-review": "review_requested",
3581
3868
  "merge-plan": "merge_planned",
3869
+ merge: "merge_planned",
3582
3870
  abandon: "abandoned",
3583
3871
  cleanup: "cleanup_requested",
3584
3872
  };
@@ -3591,23 +3879,52 @@ const postWorkspaceAction: Handler = async (req, params) => {
3591
3879
  });
3592
3880
  if (!updated) return error("workspace not found", 404);
3593
3881
  let command: Command | undefined;
3594
- if (action === "cleanup") {
3595
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3596
- if (!orch) return error("no online orchestrator available for workspace cleanup", 409);
3597
- command = createCommand({
3598
- type: "workspace.cleanup",
3599
- source: "system",
3600
- target: orch.agentId,
3601
- correlationId: workspace.id,
3602
- params: {
3603
- action: "cleanup",
3604
- workspaceId: workspace.id,
3605
- repoRoot: workspace.repoRoot,
3606
- worktreePath: workspace.worktreePath,
3607
- requestedBy: agentId ?? "dashboard",
3608
- requestedAt: Date.now(),
3609
- },
3610
- });
3882
+ if (requiresCommand) {
3883
+ // All orchestrators whose baseDir contains the workspace; prefer an online one.
3884
+ const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3885
+ const onlineOwner = owners.find((candidate) => candidate.status === "online");
3886
+ const baseParams = {
3887
+ workspaceId: workspace.id,
3888
+ repoRoot: workspace.repoRoot,
3889
+ worktreePath: workspace.worktreePath,
3890
+ branch: workspace.branch,
3891
+ requestedBy: agentId ?? "dashboard",
3892
+ requestedAt: Date.now(),
3893
+ };
3894
+ if (action === "merge") {
3895
+ // Merge needs a live host: rebasing against a stale base later is unsafe.
3896
+ if (!onlineOwner) return error("no online orchestrator available for workspace merge", 409);
3897
+ const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto");
3898
+ command = createCommand({
3899
+ type: "workspace.merge",
3900
+ source: "system",
3901
+ target: onlineOwner.agentId,
3902
+ correlationId: workspace.id,
3903
+ params: {
3904
+ action: "merge",
3905
+ ...baseParams,
3906
+ baseRef: workspace.baseRef,
3907
+ baseSha: workspace.baseSha,
3908
+ strategy,
3909
+ deleteBranch: parsed.body.deleteBranch !== false,
3910
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
3911
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
3912
+ },
3913
+ });
3914
+ } else {
3915
+ // Cleanup may queue: if the owning orchestrator is offline, the command
3916
+ // (no TTL) waits and reconciles when it reconnects. Only hard-fail when
3917
+ // no orchestrator owns the path at all — then DELETE is the escape.
3918
+ const owner = onlineOwner ?? owners[0];
3919
+ if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
3920
+ command = createCommand({
3921
+ type: "workspace.cleanup",
3922
+ source: "system",
3923
+ target: owner.agentId,
3924
+ correlationId: workspace.id,
3925
+ params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
3926
+ });
3927
+ }
3611
3928
  emitCommand(command);
3612
3929
  }
3613
3930
  auditEvent({
@@ -3616,18 +3933,40 @@ const postWorkspaceAction: Handler = async (req, params) => {
3616
3933
  title: `Workspace ${action}`,
3617
3934
  body: detail ?? workspace.worktreePath,
3618
3935
  meta: workspace.branch ?? workspace.id,
3619
- icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
3936
+ icon: action === "cleanup" ? "ti-trash" : action === "merge" ? "ti-git-merge" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
3620
3937
  view: "orchestrators",
3621
3938
  agentId,
3622
3939
  metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
3623
3940
  });
3624
- return json({ workspace: updated, command }, action === "cleanup" ? 202 : 200);
3941
+ return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
3625
3942
  } catch (e) {
3626
3943
  if (e instanceof ValidationError) return error(e.message, 400);
3627
3944
  throw e;
3628
3945
  }
3629
3946
  };
3630
3947
 
3948
+ // Last-resort purge of a workspace DB row. Does NOT touch disk — use the
3949
+ // cleanup action for that. For stuck rows whose orchestrator is gone for good.
3950
+ const deleteWorkspaceById: Handler = (req, params) => {
3951
+ const denied = authorizeRoute(req, { scope: "command:write" });
3952
+ if (denied) return denied;
3953
+ const workspace = getWorkspace(params.id!);
3954
+ if (!workspace) return error("workspace not found", 404);
3955
+ const removed = deleteWorkspace(workspace.id);
3956
+ if (!removed) return error("workspace not found", 404);
3957
+ auditEvent({
3958
+ clientId: `workspace-delete-${workspace.id}-${Date.now()}`,
3959
+ kind: "state",
3960
+ title: "Workspace record purged",
3961
+ body: workspace.worktreePath,
3962
+ meta: workspace.branch ?? workspace.id,
3963
+ icon: "ti-trash",
3964
+ view: "orchestrators",
3965
+ metadata: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: workspace.status, diskUntouched: true, ...authAuditMetadata(req) },
3966
+ });
3967
+ return json({ deleted: true, workspace });
3968
+ };
3969
+
3631
3970
  async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
3632
3971
  const orch = getOrchestrator(orchestratorId);
3633
3972
  if (!orch) return error("orchestrator not found", 404);
@@ -3907,6 +4246,44 @@ const patchCommand: Handler = async (req, params) => {
3907
4246
  });
3908
4247
  }
3909
4248
  }
4249
+ if (command.type === "workspace.merge") {
4250
+ if (command.status === "succeeded" && isRecord(command.result)) {
4251
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4252
+ const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4253
+ if (workspaceId && resultStatus) {
4254
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4255
+ mergeResult: command.result,
4256
+ mergeCommandId: command.id,
4257
+ mergedAt: Date.now(),
4258
+ });
4259
+ }
4260
+ } else if (command.status === "failed" && command.correlationId) {
4261
+ // Merge couldn't complete — don't leave it stuck in merge_planned.
4262
+ const current = getWorkspace(command.correlationId);
4263
+ if (current && current.status === "merge_planned") {
4264
+ updateWorkspaceStatus(command.correlationId, "review_requested", {
4265
+ mergeCommandId: command.id,
4266
+ mergeError: command.error ?? "merge failed",
4267
+ });
4268
+ }
4269
+ }
4270
+ }
4271
+ if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4272
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4273
+ const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4274
+ if (workspaceId && resultStatus) {
4275
+ // Only act on workspaces the agent left in a live state; never overwrite
4276
+ // a status a human/agent has since moved on (merge_planned, abandoned, …).
4277
+ const current = getWorkspace(workspaceId);
4278
+ if (current && (current.status === "active" || current.status === "ready")) {
4279
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4280
+ reconcileResult: command.result,
4281
+ reconcileCommandId: command.id,
4282
+ reconciledAt: Date.now(),
4283
+ });
4284
+ }
4285
+ }
4286
+ }
3910
4287
  emitCommand(command);
3911
4288
  auditCommandOutcome(command);
3912
4289
  return json(command);
@@ -4385,7 +4762,7 @@ const deleteCommandById: Handler = (_req, params) => {
4385
4762
 
4386
4763
  // --- Message routes ---
4387
4764
 
4388
- const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
4765
+ const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
4389
4766
 
4390
4767
  const getQueuedMessagesRoute: Handler = (req) => {
4391
4768
  const url = new URL(req.url);
@@ -4473,7 +4850,10 @@ const postMessage: Handler = async (req) => {
4473
4850
  resource: { target: input.to, channel: input.channel, agentId: input.from },
4474
4851
  });
4475
4852
  if (denied) return denied;
4476
- const bypassKinds = ["system", "control"];
4853
+ // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
4854
+ // from the provider transcript and stored for the dashboard chat; it must persist
4855
+ // regardless of target liveness and never be re-delivered into a session.
4856
+ const bypassKinds = ["system", "control", "session"];
4477
4857
  if (isDirectTarget(input.to) && !bypassKinds.includes(input.kind ?? "")) {
4478
4858
  const target = getAgent(input.to);
4479
4859
  if (target && target.status === "offline") {
@@ -4519,7 +4899,7 @@ const postMessage: Handler = async (req) => {
4519
4899
  };
4520
4900
 
4521
4901
  function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
4522
- if (message.kind === "system" || message.kind === "control") return null;
4902
+ if (message.kind === "system" || message.kind === "control" || message.kind === "session") return null;
4523
4903
  const target = message.resolvedToAgent ?? message.to;
4524
4904
  if (!isDirectTarget(target)) return null;
4525
4905
  const agent = getAgent(target);
@@ -5805,6 +6185,7 @@ const routes: Route[] = [
5805
6185
  route("PATCH", "/api/agents/:id/tags", patchAgentTags),
5806
6186
  route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
5807
6187
  route("POST", "/api/agents/:id/actions", postAgentAction),
6188
+ route("POST", "/api/agents/:id/prompt", postAgentPrompt),
5808
6189
  route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
5809
6190
  route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
5810
6191
  route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
@@ -5835,8 +6216,15 @@ const routes: Route[] = [
5835
6216
  route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
5836
6217
 
5837
6218
  route("GET", "/api/workspaces", getWorkspaces),
6219
+ // Static segments before :id so "/workspaces/orphans" isn't captured as an id.
6220
+ route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
6221
+ route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
5838
6222
  route("GET", "/api/workspaces/:id", getWorkspaceById),
6223
+ route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
6224
+ route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
6225
+ route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
5839
6226
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
6227
+ route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
5840
6228
 
5841
6229
  route("POST", "/api/system/broadcast", postSystemBroadcast),
5842
6230
  route("GET", "/api/recipes", getRecipes),