@akira-tl/forgerelay 0.7.1 → 0.7.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.4] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - Subagent Run restart reconciliation now preserves live owners and marks stale active Runs `interrupted` without replaying the old prompt.
12
+ - First-class Subagent Sessions now work through Workspace Relay and Composite explicit-member routing while keeping Session state on the Execution ForgeRelay.
13
+
14
+ ### Fixed
15
+
16
+ - Composite Capability result remapping now preserves the declared output schema during relayed Subagent operations.
17
+
18
+ ## [0.7.2] - 2026-08-30
19
+
20
+ ### Added
21
+
22
+ - `subagent.session stop/delete` 提供真实 Run 取消和显式 ForgeRelay Session 清理;取消后可继续 resume,active Session 不能直接 delete。
23
+
24
+ ### Changed
25
+
26
+ - `SubagentStart` / `SubagentStop` 按 Run 触发,只携带有界身份与状态元数据;不传 prompt、response 或 provider session ID。
27
+ - `delete` 只清理 ForgeRelay coordination/mailbox,provider-native conversation history 保持不变。
28
+
7
29
  ## [0.7.1] - 2026-08-30
8
30
 
9
31
  ### Added
@@ -13,12 +13,14 @@ name = subagent.session
13
13
  action = run
14
14
  ```
15
15
 
16
- 当前支持四个 operation:
16
+ 当前支持六个 operation:
17
17
 
18
18
  - `start`:创建 Subagent Session,并立即启动第一个 Subagent Run;
19
19
  - `resume`:在支持真实 continuation 的 provider 上继续现有 Session;
20
20
  - `status`:读取当前 Workspace 中一个 Session 的协调状态;
21
- - `list`:列出当前 Workspace 拥有的 Session 摘要。
21
+ - `list`:列出当前 Workspace 拥有的 Session 摘要;
22
+ - `stop`:取消当前 active Run,但保留 Session;
23
+ - `delete`:显式删除 idle Session 的 ForgeRelay coordination state。
22
24
 
23
25
  `subagent.session` 不支持 `batch.execute`。
24
26
 
@@ -82,7 +84,13 @@ action = run
82
84
 
83
85
  Session 受实际 Execution Workspace 所有权约束;Session ID 不是跨 Workspace 的访问凭证。`status` / `resume` 都只能访问当前实际 Execution Workspace 拥有的 Session,`list` 也只返回当前 Workspace 的紧凑摘要。
84
86
 
85
- Session 返回中会给出 `continuationSupported` 与 `resumable`;`open_workspace` 的 provider metadata 也会明确给出 `continuationSupported`。当前尚未通过 first-class Capability 开放 `stop` 或 `delete`,不要伪造这些 operation,也不要用新的 Core MCP tool 绕过 Capability Gateway。
87
+ Session 返回中会给出 `continuationSupported` 与 `resumable`;`open_workspace` 的 provider metadata 也会明确给出 `continuationSupported`。不要用新的 Core MCP tool 绕过 Capability Gateway。
88
+
89
+ ## stop / delete
90
+
91
+ `stop` 只取消当前 active Run,并等待 provider execution 真正观察取消后才返回。取消完成后 Run 状态为 `cancelled`,Session 回到 `idle`;如果已有 provider continuation identity,之后仍可 `resume`。对 idle Session 调用 `stop` 是幂等的。
92
+
93
+ `delete` 只允许 idle Session。它只删除 ForgeRelay 的 Session mapping 和尚未领取的 delivery mailbox,不删除或修改 Claude Code、Codex、OpenCode、Pi、Cursor/Copilot 等 provider-native conversation/session history。ForgeRelay 不自动清理 Subagent Session;只有显式 `delete` 才删除协调状态。
86
94
 
87
95
  ## 后台完成与结果交付
88
96
 
@@ -95,6 +103,8 @@ Run 完成后:
95
103
  - 成功领取后 mailbox 条目立即删除,同一个 completion 不重复交付;
96
104
  - 未领取 completion 可以跨正常 ForgeRelay 进程重启保留。
97
105
 
106
+ ForgeRelay 只为 active Run 持久化最小 execution-owner 元数据(owner identity / PID),不保存 delegated prompt。后续 Session 操作会先做 restart reconciliation:能够证明 owner 仍存活的 Run 保持 `running`;无法证明仍有执行 owner 的 Run 一次性转为 `interrupted`,Session 回到 `idle`。ForgeRelay 绝不自动 replay 被中断 Run 的旧 prompt;如果 provider continuation 仍有效,由 Host 显式 `resume` 新 prompt。
107
+
98
108
  如果需要等待结果,使用 `status` 进行有节制的后续查询;不要高频短轮询。
99
109
 
100
110
  ## 数据所有权
@@ -147,6 +147,14 @@ export function createCapabilityRegistry(dependencies) {
147
147
  operation: z.literal("status"),
148
148
  sessionId: z.string().min(1),
149
149
  }).strict(),
150
+ z.object({
151
+ operation: z.literal("stop"),
152
+ sessionId: z.string().min(1),
153
+ }).strict(),
154
+ z.object({
155
+ operation: z.literal("delete"),
156
+ sessionId: z.string().min(1),
157
+ }).strict(),
150
158
  z.object({ operation: z.literal("list") }).strict(),
151
159
  ]);
152
160
  const codeIntelligenceInput = z.discriminatedUnion("operation", [
package/dist/cli.js CHANGED
@@ -580,7 +580,8 @@ function createCliSubagentSessionManager(config = loadConfig()) {
580
580
  return new SubagentSessionManager(config, {
581
581
  launch(request) {
582
582
  const promptFile = writeSubagentPromptFile(request.prompt);
583
- spawnSubagentWorker(request.sessionId, promptFile);
583
+ const pid = spawnSubagentWorker(request.sessionId, promptFile);
584
+ return pid === undefined ? undefined : { id: `subagent-worker-${request.runId}`, pid };
584
585
  },
585
586
  });
586
587
  }
@@ -599,6 +600,7 @@ function spawnSubagentWorker(sessionId, promptFile) {
599
600
  env: process.env,
600
601
  });
601
602
  child.unref();
603
+ return child.pid;
602
604
  }
603
605
  function writeSubagentPromptFile(prompt) {
604
606
  const directory = mkdtempSync(join(tmpdir(), "forgerelay-agent-prompt-"));
@@ -69,6 +69,11 @@ const migrations = [
69
69
  name: "subagent-session-coordination",
70
70
  up: migrateSubagentSessionCoordination,
71
71
  },
72
+ {
73
+ version: 15,
74
+ name: "subagent-run-ownership",
75
+ up: migrateSubagentRunOwnership,
76
+ },
72
77
  ];
73
78
  export function migrateDatabase(sqlite) {
74
79
  const migrate = sqlite.transaction(() => {
@@ -358,6 +363,11 @@ function migrateSubagentSessionCoordination(sqlite) {
358
363
  addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_outcome", "text");
359
364
  addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_finished_at", "text");
360
365
  }
366
+ function migrateSubagentRunOwnership(sqlite) {
367
+ migrateSubagentSessionCoordination(sqlite);
368
+ addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_id", "text");
369
+ addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_pid", "integer");
370
+ }
361
371
  function migrateActivityHostTurnWorkspace(sqlite) {
362
372
  migrateActivityHostTurns(sqlite);
363
373
  addColumnIfMissing(sqlite, "activity_host_turns", "workspace_id", "text");
package/dist/db/schema.js CHANGED
@@ -155,6 +155,8 @@ export const localAgentSessions = sqliteTable("local_agent_sessions", {
155
155
  activeRunId: text("active_run_id"),
156
156
  activeActivityId: text("active_activity_id"),
157
157
  activeRunStartedAt: text("active_run_started_at"),
158
+ activeOwnerId: text("active_owner_id"),
159
+ activeOwnerPid: integer("active_owner_pid"),
158
160
  latestRunId: text("latest_run_id"),
159
161
  latestRunOutcome: text("latest_run_outcome"),
160
162
  latestRunFinishedAt: text("latest_run_finished_at"),
package/dist/server.js CHANGED
@@ -2764,6 +2764,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2764
2764
  outputSchema: {
2765
2765
  name: z.string(),
2766
2766
  action: z.enum(["describe", "run"]),
2767
+ member: z.string().optional(),
2767
2768
  capability: z.unknown().optional(),
2768
2769
  result: z.unknown().optional(),
2769
2770
  error: capabilityErrorOutputSchema.optional(),
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { Readable, Writable } from "node:stream";
3
- import { asRecord, assertPipedChild, directString, errorMessage, readArray, } from "../shared.js";
3
+ import { asRecord, assertPipedChild, directString, errorMessage, readArray, terminateChildOnAbort, } from "../shared.js";
4
4
  const ACP_COMMANDS = {
5
5
  cursor: ["cursor-agent", "acp"],
6
6
  copilot: ["copilot", "--acp"],
@@ -24,6 +24,7 @@ export class AcpSubagentAdapter {
24
24
  windowsHide: true,
25
25
  });
26
26
  assertPipedChild(child);
27
+ const detachAbort = terminateChildOnAbort(child, input.signal);
27
28
  let stderr = "";
28
29
  child.stderr.on("data", (chunk) => {
29
30
  stderr += chunk.toString("utf8");
@@ -80,6 +81,7 @@ export class AcpSubagentAdapter {
80
81
  throw new Error(`${this.provider} ACP run failed: ${errorMessage(error)}${stderr ? `\n${stderr.trim()}` : ""}`);
81
82
  }
82
83
  finally {
84
+ detachAbort();
83
85
  child.kill();
84
86
  }
85
87
  }
@@ -1,42 +1,49 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { directString, requireFinalResponse } from "../shared.js";
2
+ import { directString, linkedAbortController, requireFinalResponse } from "../shared.js";
3
3
  export class ClaudeSubagentAdapter {
4
4
  provider = "claude";
5
5
  async run(input) {
6
6
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
7
7
  const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude");
8
- const messages = query({
9
- prompt: input.prompt,
10
- options: {
11
- cwd: input.workspace,
12
- model: input.model,
13
- ...(input.thinking ? { thinking: { type: "adaptive" }, effort: input.thinking } : {}),
14
- resume: input.providerSessionId,
15
- permissionMode: "bypassPermissions",
16
- allowDangerouslySkipPermissions: true,
17
- env: claudeCommandEnvironment(process.env),
18
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
19
- },
20
- });
21
- let providerSessionId = input.providerSessionId ?? null;
22
- let finalResponse = "";
23
- for await (const message of messages) {
24
- const record = message;
25
- if (typeof record.session_id === "string")
26
- providerSessionId = record.session_id;
27
- if (record.type === "result" && typeof record.result === "string") {
28
- const resultError = claudeResultError(record);
29
- if (resultError)
30
- throw new Error(resultError);
31
- finalResponse = record.result;
8
+ const linkedAbort = linkedAbortController(input.signal);
9
+ try {
10
+ const messages = query({
11
+ prompt: input.prompt,
12
+ options: {
13
+ cwd: input.workspace,
14
+ model: input.model,
15
+ ...(input.thinking ? { thinking: { type: "adaptive" }, effort: input.thinking } : {}),
16
+ resume: input.providerSessionId,
17
+ permissionMode: "bypassPermissions",
18
+ allowDangerouslySkipPermissions: true,
19
+ env: claudeCommandEnvironment(process.env),
20
+ ...(linkedAbort.controller ? { abortController: linkedAbort.controller } : {}),
21
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
22
+ },
23
+ });
24
+ let providerSessionId = input.providerSessionId ?? null;
25
+ let finalResponse = "";
26
+ for await (const message of messages) {
27
+ const record = message;
28
+ if (typeof record.session_id === "string")
29
+ providerSessionId = record.session_id;
30
+ if (record.type === "result" && typeof record.result === "string") {
31
+ const resultError = claudeResultError(record);
32
+ if (resultError)
33
+ throw new Error(resultError);
34
+ finalResponse = record.result;
35
+ }
32
36
  }
37
+ finalResponse = requireFinalResponse("Claude", finalResponse);
38
+ return {
39
+ provider: this.provider,
40
+ providerSessionId,
41
+ finalResponse,
42
+ };
43
+ }
44
+ finally {
45
+ linkedAbort.dispose();
33
46
  }
34
- finalResponse = requireFinalResponse("Claude", finalResponse);
35
- return {
36
- provider: this.provider,
37
- providerSessionId,
38
- finalResponse,
39
- };
40
47
  }
41
48
  }
42
49
  function claudeResultError(record) {
@@ -29,7 +29,7 @@ export class CodexSdkSubagentRuntime {
29
29
  const thread = input.providerSessionId
30
30
  ? this.codex.resumeThread(input.providerSessionId, options)
31
31
  : this.codex.startThread(options);
32
- const turn = await thread.run(input.prompt);
32
+ const turn = await thread.run(input.prompt, { signal: input.signal });
33
33
  return {
34
34
  provider: this.provider,
35
35
  providerSessionId: thread.id,
@@ -6,15 +6,31 @@ export class OpencodeSubagentAdapter {
6
6
  const { client, server } = await createOpencode();
7
7
  try {
8
8
  const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input);
9
- const promptResult = await promptOpencodeSession(client, sessionId, input);
10
- await waitForOpencodeSession(client, sessionId);
11
- const messages = await readOpencodeMessages(client, sessionId);
12
- const finalResponse = requireFinalResponse("OpenCode", extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult));
13
- return {
14
- provider: this.provider,
15
- providerSessionId: sessionId,
16
- finalResponse,
9
+ let abortPromise;
10
+ const abort = () => {
11
+ abortPromise ??= abortOpencodeSession(client, sessionId, input.workspace);
17
12
  };
13
+ if (input.signal?.aborted)
14
+ abort();
15
+ else
16
+ input.signal?.addEventListener("abort", abort, { once: true });
17
+ try {
18
+ input.signal?.throwIfAborted();
19
+ const promptResult = await promptOpencodeSession(client, sessionId, input);
20
+ await waitForOpencodeSession(client, sessionId, input.signal);
21
+ const messages = await readOpencodeMessages(client, sessionId, input.signal);
22
+ const finalResponse = requireFinalResponse("OpenCode", extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult));
23
+ return {
24
+ provider: this.provider,
25
+ providerSessionId: sessionId,
26
+ finalResponse,
27
+ };
28
+ }
29
+ finally {
30
+ input.signal?.removeEventListener("abort", abort);
31
+ if (abortPromise)
32
+ await abortPromise;
33
+ }
18
34
  }
19
35
  finally {
20
36
  server.close();
@@ -27,7 +43,7 @@ async function createOpencodeSession(client, input) {
27
43
  directory: input.workspace,
28
44
  location: { directory: input.workspace },
29
45
  ...(input.model ? { model: parseOpencodeModel(input.model) } : {}),
30
- }, { throwOnError: true });
46
+ }, { throwOnError: true, signal: input.signal });
31
47
  const id = readNestedString(result, ["id"]) ??
32
48
  readNestedString(result, ["data", "id"]) ??
33
49
  readNestedString(result, ["session", "id"]) ??
@@ -47,19 +63,30 @@ async function promptOpencodeSession(client, sessionId, input) {
47
63
  ...(input.model ? { model: parseOpencodeModel(input.model) } : {}),
48
64
  ...(input.thinking ? { variant: input.thinking } : {}),
49
65
  };
50
- return session.prompt(promptInput, { throwOnError: true });
66
+ return session.prompt(promptInput, { throwOnError: true, signal: input.signal });
51
67
  }
52
- async function waitForOpencodeSession(client, sessionId) {
68
+ async function waitForOpencodeSession(client, sessionId, signal) {
53
69
  const session = client.session;
54
70
  if (!session?.wait)
55
71
  return;
56
- await session.wait({ sessionID: sessionId }, { throwOnError: true });
72
+ await session.wait({ sessionID: sessionId }, { throwOnError: true, signal });
57
73
  }
58
- async function readOpencodeMessages(client, sessionId) {
74
+ async function readOpencodeMessages(client, sessionId, signal) {
59
75
  const session = client.session;
60
76
  if (!session?.messages)
61
77
  return undefined;
62
- return session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true });
78
+ return session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true, signal });
79
+ }
80
+ async function abortOpencodeSession(client, sessionId, workspace) {
81
+ const session = client.session;
82
+ if (!session?.abort)
83
+ return;
84
+ try {
85
+ await session.abort({ sessionID: sessionId, directory: workspace }, { throwOnError: true });
86
+ }
87
+ catch {
88
+ // The prompt request may already have observed the AbortSignal and closed the local server.
89
+ }
63
90
  }
64
91
  function parseOpencodeModel(model) {
65
92
  const separator = model.indexOf("/");
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { removeDevspaceNodeModulesBinFromPath } from "../path.js";
3
- import { asRecord, assertPipedChild, errorMessage, readArray, readNestedString, requireFinalResponse, unwrapProviderPayload, } from "../shared.js";
3
+ import { asRecord, assertPipedChild, errorMessage, readArray, readNestedString, requireFinalResponse, terminateChildOnAbort, unwrapProviderPayload, } from "../shared.js";
4
4
  const PI_AGENT_TIMEOUT_MS = 120_000;
5
5
  export class PiRpcSubagentAdapter {
6
6
  provider = "pi";
@@ -19,6 +19,7 @@ export class PiRpcSubagentAdapter {
19
19
  windowsHide: true,
20
20
  });
21
21
  assertPipedChild(child);
22
+ const detachAbort = terminateChildOnAbort(child, input.signal);
22
23
  const rpc = new JsonLineRpc(child);
23
24
  let streamingText = "";
24
25
  let streamingProviderError = "";
@@ -33,7 +34,7 @@ export class PiRpcSubagentAdapter {
33
34
  try {
34
35
  const state = await rpc.request({ type: "get_state" });
35
36
  const providerSessionId = readNestedString(state, ["sessionId"]) ?? input.providerSessionId ?? null;
36
- const done = rpc.waitForEvent((event) => asRecord(event)?.type === "agent_end", PI_AGENT_TIMEOUT_MS);
37
+ const done = rpc.waitForEvent((event) => asRecord(event)?.type === "agent_end", PI_AGENT_TIMEOUT_MS, input.signal);
37
38
  await rpc.request({ type: "prompt", message: input.prompt });
38
39
  const agentEnd = await done;
39
40
  const sessionMessages = await rpc.request({ type: "get_messages" });
@@ -55,6 +56,7 @@ export class PiRpcSubagentAdapter {
55
56
  };
56
57
  }
57
58
  finally {
59
+ detachAbort();
58
60
  child.kill();
59
61
  }
60
62
  }
@@ -103,19 +105,31 @@ class JsonLineRpc {
103
105
  this.eventSubscribers.add(callback);
104
106
  return () => this.eventSubscribers.delete(callback);
105
107
  }
106
- waitForEvent(predicate, timeoutMs) {
108
+ waitForEvent(predicate, timeoutMs, signal) {
107
109
  return new Promise((resolve, reject) => {
108
- const timer = setTimeout(() => {
110
+ const finish = (callback) => {
111
+ clearTimeout(timer);
109
112
  unsubscribe();
113
+ signal?.removeEventListener("abort", abort);
114
+ callback();
115
+ };
116
+ const abort = () => finish(() => {
117
+ const error = new Error("Pi RPC cancelled.");
118
+ error.name = "AbortError";
119
+ reject(error);
120
+ });
121
+ const timer = setTimeout(() => finish(() => {
110
122
  reject(new Error(`Pi RPC timed out waiting for agent completion\n${this.stderr}`.trim()));
111
- }, timeoutMs);
123
+ }), timeoutMs);
112
124
  const unsubscribe = this.onEvent((event) => {
113
125
  if (!predicate(event))
114
126
  return;
115
- clearTimeout(timer);
116
- unsubscribe();
117
- resolve(event);
127
+ finish(() => resolve(event));
118
128
  });
129
+ if (signal?.aborted)
130
+ abort();
131
+ else
132
+ signal?.addEventListener("abort", abort, { once: true });
119
133
  });
120
134
  }
121
135
  handleStdout(chunk) {
@@ -38,3 +38,29 @@ export function requireFinalResponse(provider, response) {
38
38
  }
39
39
  return trimmed;
40
40
  }
41
+ export function linkedAbortController(signal) {
42
+ if (!signal)
43
+ return { dispose() { } };
44
+ const controller = new AbortController();
45
+ const abort = () => controller.abort(signal.reason);
46
+ if (signal.aborted)
47
+ abort();
48
+ else
49
+ signal.addEventListener("abort", abort, { once: true });
50
+ return {
51
+ controller,
52
+ dispose() {
53
+ signal.removeEventListener("abort", abort);
54
+ },
55
+ };
56
+ }
57
+ export function terminateChildOnAbort(child, signal) {
58
+ if (!signal)
59
+ return () => { };
60
+ const abort = () => child.kill();
61
+ if (signal.aborted)
62
+ abort();
63
+ else
64
+ signal.addEventListener("abort", abort, { once: true });
65
+ return () => signal.removeEventListener("abort", abort);
66
+ }
@@ -9,17 +9,23 @@ export class SubagentSessionCapability {
9
9
  activityLifecycle;
10
10
  mailbox;
11
11
  providerRunner;
12
+ ownerAliveOverride;
13
+ activeRuns = new Map();
12
14
  constructor(config, activityLifecycle, options = {}) {
13
15
  this.config = config;
14
16
  this.activityLifecycle = activityLifecycle;
15
17
  this.mailbox = new SubagentDeliveryMailbox(config.stateDir);
16
18
  this.providerRunner = options.providerRunner ?? defaultProviderRunner;
19
+ this.ownerAliveOverride = options.ownerAlive;
17
20
  }
18
21
  async run(input, context, options) {
19
22
  const manager = new SubagentSessionManager(this.config, {
20
23
  launch: (request) => this.launch(request),
21
24
  });
22
25
  try {
26
+ const reconciled = manager.reconcile({ workspaceId: context.workspaceId }, (run) => this.ownerAlive(run));
27
+ for (const entry of reconciled)
28
+ this.recordInterruption(entry, options.activityId);
23
29
  switch (input.operation) {
24
30
  case "start": {
25
31
  const started = await manager.start({
@@ -67,6 +73,18 @@ export class SubagentSessionCapability {
67
73
  },
68
74
  };
69
75
  }
76
+ case "stop":
77
+ return { value: await this.stop(manager, input.sessionId, context.workspaceId) };
78
+ case "delete": {
79
+ const deleted = manager.delete(input.sessionId, { workspaceId: context.workspaceId });
80
+ this.mailbox.discardSession(deleted.id);
81
+ return {
82
+ value: {
83
+ operation: "delete",
84
+ deletedSessionId: deleted.id,
85
+ },
86
+ };
87
+ }
70
88
  case "list":
71
89
  return {
72
90
  value: {
@@ -105,12 +123,99 @@ export class SubagentSessionCapability {
105
123
  };
106
124
  }
107
125
  launch(request) {
108
- void executeSubagentRun(this.config, request, this.providerRunner)
109
- .then((completion) => this.recordCompletion(completion))
110
- .catch(() => {
111
- // The worker writes durable Session/mailbox state for provider failures.
112
- // An unexpected orchestration failure must not become an unhandled rejection.
126
+ const ownerId = `subagent-owner-${process.pid}-${request.runId}`;
127
+ const controller = new AbortController();
128
+ const completion = executeSubagentRun(this.config, { ...request, signal: controller.signal }, this.providerRunner).then((result) => {
129
+ this.recordCompletion(result);
130
+ return result;
131
+ });
132
+ this.activeRuns.set(request.runId, { ownerId, controller, completion });
133
+ void completion.finally(() => {
134
+ this.activeRuns.delete(request.runId);
135
+ }).catch(() => {
136
+ // Unexpected orchestration failures surface through later reconciliation.
137
+ });
138
+ return { id: ownerId, pid: process.pid };
139
+ }
140
+ async stop(manager, sessionId, workspaceId) {
141
+ let session = manager.get(sessionId, { workspaceId });
142
+ if (!session) {
143
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
144
+ }
145
+ const activeRun = session.activeRun;
146
+ if (!activeRun) {
147
+ return { operation: "stop", session: publicSession(session) };
148
+ }
149
+ const handle = this.activeRuns.get(activeRun.id);
150
+ if (!handle) {
151
+ session = manager.get(session.id, { workspaceId }) ?? session;
152
+ if (!session.activeRun)
153
+ return { operation: "stop", session: publicSession(session) };
154
+ throw new SubagentSessionError("subagent.cancel_unavailable", `Subagent Run ${activeRun.id} has no live cancellation owner.`);
155
+ }
156
+ handle.controller.abort(new Error(`Subagent Run ${activeRun.id} cancelled by stop.`));
157
+ await handle.completion;
158
+ session = manager.get(session.id, { workspaceId });
159
+ if (!session) {
160
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
161
+ }
162
+ return {
163
+ operation: "stop",
164
+ session: publicSession(session),
165
+ ...(session.latestRun?.id === activeRun.id ? { run: publicRun(session.latestRun) } : {}),
166
+ };
167
+ }
168
+ ownerAlive(run) {
169
+ if (this.ownerAliveOverride)
170
+ return this.ownerAliveOverride(run);
171
+ if (!run.ownerId || run.ownerPid === undefined)
172
+ return false;
173
+ const active = this.activeRuns.get(run.id);
174
+ if (run.ownerPid === process.pid)
175
+ return active?.ownerId === run.ownerId;
176
+ try {
177
+ process.kill(run.ownerPid, 0);
178
+ return true;
179
+ }
180
+ catch (error) {
181
+ return error.code === "EPERM";
182
+ }
183
+ }
184
+ recordInterruption(entry, fallbackActivityId) {
185
+ const sourceActivityId = entry.run.activityId ?? fallbackActivityId;
186
+ if (!sourceActivityId)
187
+ return;
188
+ const record = () => this.activityLifecycle.recordLinked({
189
+ sourceActivityId,
190
+ tool: "subagent_result",
191
+ request: { sessionId: entry.session.id, runId: entry.run.id },
192
+ result: {
193
+ sessionId: entry.session.id,
194
+ runId: entry.run.id,
195
+ provider: entry.session.provider,
196
+ status: "interrupted",
197
+ },
198
+ outcome: { type: "failed", error: "Subagent Run interrupted." },
113
199
  });
200
+ try {
201
+ record();
202
+ }
203
+ catch {
204
+ if (!fallbackActivityId || fallbackActivityId === sourceActivityId)
205
+ return;
206
+ this.activityLifecycle.recordLinked({
207
+ sourceActivityId: fallbackActivityId,
208
+ tool: "subagent_result",
209
+ request: { sessionId: entry.session.id, runId: entry.run.id },
210
+ result: {
211
+ sessionId: entry.session.id,
212
+ runId: entry.run.id,
213
+ provider: entry.session.provider,
214
+ status: "interrupted",
215
+ },
216
+ outcome: { type: "failed", error: "Subagent Run interrupted." },
217
+ });
218
+ }
114
219
  }
115
220
  recordCompletion(completion) {
116
221
  if (!completion.activityId)
@@ -39,6 +39,9 @@ export class SubagentDeliveryMailbox {
39
39
  hasSession(sessionId) {
40
40
  return this.files().includes(`${sessionId}.json`);
41
41
  }
42
+ discardSession(sessionId) {
43
+ rmSync(this.pathFor(sessionId), { force: true });
44
+ }
42
45
  claim(predicate) {
43
46
  const deliveries = [];
44
47
  for (const file of this.files()) {
@@ -27,73 +27,64 @@ export async function executeSubagentRun(config, input, providerRunner = runSuba
27
27
  thinking: record.thinking,
28
28
  },
29
29
  };
30
- await hooks.run("SubagentStart", hookInvocation);
30
+ let outcome = "succeeded";
31
+ let result;
32
+ let errorMessage;
31
33
  try {
32
- const result = await runSessionProvider(config, record, input.prompt, providerRunner);
33
- await hooks.run("SubagentStop", {
34
- ...hookInvocation,
35
- payload: {
36
- ...hookInvocation.payload,
37
- status: "succeeded",
38
- providerSessionId: result.providerSessionId,
39
- },
40
- });
41
- const finishedAt = new Date().toISOString();
42
- store.update(record.id, {
43
- providerSessionId: result.providerSessionId ?? undefined,
44
- status: "idle",
45
- activeRun: undefined,
46
- latestRun: {
47
- id: input.runId,
48
- status: "succeeded",
49
- finishedAt,
50
- },
51
- });
52
- if (record.workspaceId) {
53
- mailbox.write({
54
- sessionId: record.id,
55
- runId: input.runId,
56
- workspaceId: record.workspaceId,
57
- ...(input.activityId ? { activityId: input.activityId } : {}),
58
- provider: record.provider,
59
- outcome: "succeeded",
60
- finalResponse: result.finalResponse,
61
- });
62
- }
63
- return completion(record, input, "succeeded");
34
+ await hooks.run("SubagentStart", hookInvocation);
35
+ input.signal?.throwIfAborted();
36
+ result = await runSessionProvider(config, record, input.prompt, providerRunner, input.signal);
37
+ input.signal?.throwIfAborted();
64
38
  }
65
39
  catch (error) {
66
- const message = error instanceof Error ? error.message : String(error);
40
+ if (isCancelled(error, input.signal)) {
41
+ outcome = "cancelled";
42
+ errorMessage = "Subagent Run cancelled.";
43
+ }
44
+ else {
45
+ outcome = "failed";
46
+ errorMessage = error instanceof Error ? error.message : String(error);
47
+ }
48
+ }
49
+ try {
67
50
  await hooks.run("SubagentStop", {
68
51
  ...hookInvocation,
69
52
  payload: {
70
53
  ...hookInvocation.payload,
71
- status: "failed",
54
+ status: outcome,
72
55
  },
73
56
  });
74
- const finishedAt = new Date().toISOString();
75
- store.update(record.id, {
76
- status: "idle",
77
- activeRun: undefined,
78
- latestRun: {
79
- id: input.runId,
80
- status: "failed",
81
- finishedAt,
82
- },
83
- });
84
- if (record.workspaceId) {
85
- mailbox.write({
86
- sessionId: record.id,
87
- runId: input.runId,
88
- workspaceId: record.workspaceId,
89
- ...(input.activityId ? { activityId: input.activityId } : {}),
90
- provider: record.provider,
91
- outcome: "failed",
92
- error: message,
93
- });
57
+ }
58
+ catch (error) {
59
+ if (outcome === "succeeded") {
60
+ outcome = "failed";
61
+ errorMessage = error instanceof Error ? error.message : String(error);
94
62
  }
95
- return completion(record, input, "failed", message);
96
63
  }
64
+ const finishedAt = new Date().toISOString();
65
+ store.update(record.id, {
66
+ ...(result?.providerSessionId ? { providerSessionId: result.providerSessionId } : {}),
67
+ status: "idle",
68
+ activeRun: undefined,
69
+ latestRun: {
70
+ id: input.runId,
71
+ status: outcome,
72
+ finishedAt,
73
+ },
74
+ });
75
+ if (record.workspaceId) {
76
+ mailbox.write({
77
+ sessionId: record.id,
78
+ runId: input.runId,
79
+ workspaceId: record.workspaceId,
80
+ ...(input.activityId ? { activityId: input.activityId } : {}),
81
+ provider: record.provider,
82
+ outcome,
83
+ ...(outcome === "succeeded" && result ? { finalResponse: result.finalResponse } : {}),
84
+ ...(outcome !== "succeeded" && errorMessage ? { error: errorMessage } : {}),
85
+ });
86
+ }
87
+ return completion(record, input, outcome, errorMessage);
97
88
  }
98
89
  finally {
99
90
  store.close();
@@ -118,7 +109,7 @@ export async function executeSubagentSession(config, sessionId, prompt) {
118
109
  store.close();
119
110
  }
120
111
  }
121
- async function runSessionProvider(config, session, prompt, providerRunner) {
112
+ async function runSessionProvider(config, session, prompt, providerRunner, signal) {
122
113
  if (!isSubagentProvider(session.provider)) {
123
114
  throw new Error(`Unknown subagent provider for Session ${session.id}: ${session.provider}`);
124
115
  }
@@ -130,6 +121,7 @@ async function runSessionProvider(config, session, prompt, providerRunner) {
130
121
  writeMode: "allowed",
131
122
  model: session.model,
132
123
  thinking: session.thinking,
124
+ signal,
133
125
  });
134
126
  }
135
127
  if (session.profileName === session.provider) {
@@ -139,15 +131,16 @@ async function runSessionProvider(config, session, prompt, providerRunner) {
139
131
  writeMode: "allowed",
140
132
  model: session.model,
141
133
  thinking: session.thinking,
134
+ signal,
142
135
  });
143
136
  }
144
137
  const profiles = await loadSubagentProfiles(config, session.workspaceRoot);
145
138
  const profile = profiles.find((candidate) => candidate.name === session.profileName);
146
139
  if (!profile)
147
140
  throw new Error(`Subagent profile not found: ${session.profileName}`);
148
- return runSubagentProfile(profile, session, prompt, providerRunner);
141
+ return runSubagentProfile(profile, session, prompt, providerRunner, signal);
149
142
  }
150
- async function runSubagentProfile(profile, session, prompt, providerRunner) {
143
+ async function runSubagentProfile(profile, session, prompt, providerRunner, signal) {
151
144
  const body = profile.body.trim();
152
145
  const firstPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt;
153
146
  return providerRunner(session.provider, {
@@ -156,8 +149,12 @@ async function runSubagentProfile(profile, session, prompt, providerRunner) {
156
149
  writeMode: "allowed",
157
150
  model: session.model,
158
151
  thinking: session.thinking,
152
+ signal,
159
153
  });
160
154
  }
155
+ function isCancelled(error, signal) {
156
+ return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
157
+ }
161
158
  function completion(session, input, outcome, error) {
162
159
  return {
163
160
  sessionId: session.id,
@@ -52,13 +52,16 @@ export class SubagentSessionManager {
52
52
  const run = session.activeRun;
53
53
  if (!run)
54
54
  throw new Error(`Subagent Session ${session.id} did not create an active Run.`);
55
- this.launcher.launch({
55
+ const owned = this.assignOwner(session, run, this.launcher.launch({
56
56
  sessionId: session.id,
57
57
  runId,
58
58
  ...(input.activityId ? { activityId: input.activityId } : {}),
59
59
  prompt: input.prompt,
60
- });
61
- return { session, run };
60
+ }));
61
+ return {
62
+ session: owned,
63
+ run: owned.activeRun ?? (owned.latestRun?.id === run.id ? owned.latestRun : run),
64
+ };
62
65
  }
63
66
  resume(input, scope = {}) {
64
67
  const existing = this.store.getInScope(input.sessionId, scope);
@@ -90,17 +93,58 @@ export class SubagentSessionManager {
90
93
  status: "running",
91
94
  activeRun: run,
92
95
  });
93
- this.launcher.launch({
96
+ const owned = this.assignOwner(session, run, this.launcher.launch({
94
97
  sessionId: session.id,
95
98
  runId,
96
99
  ...(input.activityId ? { activityId: input.activityId } : {}),
97
100
  prompt: input.prompt,
98
- });
99
- return { session, run };
101
+ }));
102
+ return {
103
+ session: owned,
104
+ run: owned.activeRun ?? (owned.latestRun?.id === run.id ? owned.latestRun : run),
105
+ };
106
+ }
107
+ reconcile(scope, ownerAlive) {
108
+ const reconciled = [];
109
+ for (const session of this.store.list(scope)) {
110
+ const run = session.activeRun;
111
+ if (!run || ownerAlive(run))
112
+ continue;
113
+ const interrupted = {
114
+ id: run.id,
115
+ status: "interrupted",
116
+ ...(run.activityId ? { activityId: run.activityId } : {}),
117
+ ...(run.startedAt ? { startedAt: run.startedAt } : {}),
118
+ finishedAt: new Date().toISOString(),
119
+ };
120
+ const updated = this.store.update(session.id, {
121
+ status: "idle",
122
+ activeRun: undefined,
123
+ latestRun: interrupted,
124
+ });
125
+ reconciled.push({ session: updated, run: interrupted });
126
+ }
127
+ return reconciled;
128
+ }
129
+ delete(sessionId, scope = {}) {
130
+ const session = this.store.getInScope(sessionId, scope);
131
+ if (!session) {
132
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
133
+ }
134
+ if (session.activeRun) {
135
+ throw new SubagentSessionError("subagent.busy", `Subagent Session ${session.id} already has active Run ${session.activeRun.id}.`);
136
+ }
137
+ this.store.delete(session.id);
138
+ return session;
100
139
  }
101
140
  close() {
102
141
  this.store.close();
103
142
  }
143
+ assignOwner(session, run, owner) {
144
+ if (!owner)
145
+ return session;
146
+ return this.store.assignActiveRunOwner(session.id, run.id, owner);
147
+ }
104
148
  }
105
149
  function newRunId() {
106
150
  return `run_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
@@ -3,6 +3,7 @@ export function createSubagentMcpRuntime(config, activityLifecycle, options = {}
3
3
  const capability = config.subagents
4
4
  ? new SubagentSessionCapability(config, activityLifecycle, {
5
5
  providerRunner: options.subagentProviderRunner,
6
+ ownerAlive: options.subagentOwnerAlive,
6
7
  })
7
8
  : undefined;
8
9
  return {
@@ -47,6 +47,8 @@ export class SubagentSessionStore {
47
47
  status: "running",
48
48
  ...(input.activeRun.activityId ? { activityId: input.activeRun.activityId } : {}),
49
49
  startedAt: input.activeRun.startedAt,
50
+ ...(input.activeRun.ownerId ? { ownerId: input.activeRun.ownerId } : {}),
51
+ ...(input.activeRun.ownerPid !== undefined ? { ownerPid: input.activeRun.ownerPid } : {}),
50
52
  },
51
53
  }
52
54
  : {}),
@@ -67,6 +69,8 @@ export class SubagentSessionStore {
67
69
  active_run_id,
68
70
  active_activity_id,
69
71
  active_run_started_at,
72
+ active_owner_id,
73
+ active_owner_pid,
70
74
  latest_run_id,
71
75
  latest_run_outcome,
72
76
  latest_run_finished_at,
@@ -75,8 +79,8 @@ export class SubagentSessionStore {
75
79
  hook_reports_json,
76
80
  created_at,
77
81
  updated_at
78
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, null, ?, ?)`)
79
- .run(record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, record.provider, record.model ?? null, record.thinking ?? null, null, record.status, record.activeRun?.id ?? null, record.activeRun?.activityId ?? null, record.activeRun?.startedAt ?? null, null, null, null, record.createdAt, record.updatedAt);
82
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, null, ?, ?)`)
83
+ .run(record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, record.provider, record.model ?? null, record.thinking ?? null, null, record.status, record.activeRun?.id ?? null, record.activeRun?.activityId ?? null, record.activeRun?.startedAt ?? null, record.activeRun?.ownerId ?? null, record.activeRun?.ownerPid ?? null, null, null, null, record.createdAt, record.updatedAt);
80
84
  return record;
81
85
  }
82
86
  get(idOrPrefix) {
@@ -126,6 +130,8 @@ export class SubagentSessionStore {
126
130
  active_run_id = ?,
127
131
  active_activity_id = ?,
128
132
  active_run_started_at = ?,
133
+ active_owner_id = ?,
134
+ active_owner_pid = ?,
129
135
  latest_run_id = ?,
130
136
  latest_run_outcome = ?,
131
137
  latest_run_finished_at = ?,
@@ -134,9 +140,21 @@ export class SubagentSessionStore {
134
140
  hook_reports_json = null,
135
141
  updated_at = ?
136
142
  where id = ?`)
137
- .run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.activeRun?.id ?? null, updated.activeRun?.activityId ?? null, updated.activeRun?.startedAt ?? null, updated.latestRun?.id ?? null, updated.latestRun && updated.latestRun.status !== "running" ? updated.latestRun.status : null, updated.latestRun?.finishedAt ?? null, updated.updatedAt, updated.id);
143
+ .run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.activeRun?.id ?? null, updated.activeRun?.activityId ?? null, updated.activeRun?.startedAt ?? null, updated.activeRun?.ownerId ?? null, updated.activeRun?.ownerPid ?? null, updated.latestRun?.id ?? null, updated.latestRun && updated.latestRun.status !== "running" ? updated.latestRun.status : null, updated.latestRun?.finishedAt ?? null, updated.updatedAt, updated.id);
138
144
  return updated;
139
145
  }
146
+ assignActiveRunOwner(id, runId, owner) {
147
+ this.database.sqlite.prepare(`update local_agent_sessions
148
+ set active_owner_id = ?, active_owner_pid = ?, updated_at = ?
149
+ where id = ? and active_run_id = ?`).run(owner.id, owner.pid ?? null, new Date().toISOString(), id, runId);
150
+ const current = this.getById(id);
151
+ if (!current)
152
+ throw new Error(`Unknown subagent id: ${id}`);
153
+ return current;
154
+ }
155
+ delete(id) {
156
+ this.database.sqlite.prepare("delete from local_agent_sessions where id = ?").run(id);
157
+ }
140
158
  close() {
141
159
  this.database.close();
142
160
  }
@@ -157,6 +175,8 @@ function rowToSubagentSession(row) {
157
175
  status: "running",
158
176
  ...(row.active_activity_id ? { activityId: row.active_activity_id } : {}),
159
177
  ...(row.active_run_started_at ? { startedAt: row.active_run_started_at } : {}),
178
+ ...(row.active_owner_id ? { ownerId: row.active_owner_id } : {}),
179
+ ...(row.active_owner_pid !== null ? { ownerPid: row.active_owner_pid } : {}),
160
180
  }
161
181
  : undefined;
162
182
  const latestOutcome = readOutcome(row.latest_run_outcome);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -47,7 +47,7 @@
47
47
  "release:publish": "node scripts/release/publish.mjs",
48
48
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
49
49
  "start": "node dist/cli.js serve",
50
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
50
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
51
51
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
52
  "release:check": "node scripts/release-version.mjs check",
53
53
  "release:tag-check": "node scripts/release-version.mjs tag",