@akira-tl/forgerelay 0.7.1 → 0.7.2

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,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.2] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - `subagent.session stop/delete` 提供真实 Run 取消和显式 ForgeRelay Session 清理;取消后可继续 resume,active Session 不能直接 delete。
12
+
13
+ ### Changed
14
+
15
+ - `SubagentStart` / `SubagentStop` 按 Run 触发,只携带有界身份与状态元数据;不传 prompt、response 或 provider session ID。
16
+ - `delete` 只清理 ForgeRelay coordination/mailbox,provider-native conversation history 保持不变。
17
+
7
18
  ## [0.7.1] - 2026-08-30
8
19
 
9
20
  ### 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
 
@@ -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", [
@@ -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,6 +9,7 @@ export class SubagentSessionCapability {
9
9
  activityLifecycle;
10
10
  mailbox;
11
11
  providerRunner;
12
+ activeRuns = new Map();
12
13
  constructor(config, activityLifecycle, options = {}) {
13
14
  this.config = config;
14
15
  this.activityLifecycle = activityLifecycle;
@@ -67,6 +68,18 @@ export class SubagentSessionCapability {
67
68
  },
68
69
  };
69
70
  }
71
+ case "stop":
72
+ return { value: await this.stop(manager, input.sessionId, context.workspaceId) };
73
+ case "delete": {
74
+ const deleted = manager.delete(input.sessionId, { workspaceId: context.workspaceId });
75
+ this.mailbox.discardSession(deleted.id);
76
+ return {
77
+ value: {
78
+ operation: "delete",
79
+ deletedSessionId: deleted.id,
80
+ },
81
+ };
82
+ }
70
83
  case "list":
71
84
  return {
72
85
  value: {
@@ -105,13 +118,46 @@ export class SubagentSessionCapability {
105
118
  };
106
119
  }
107
120
  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.
121
+ const controller = new AbortController();
122
+ const completion = executeSubagentRun(this.config, { ...request, signal: controller.signal }, this.providerRunner).then((result) => {
123
+ this.recordCompletion(result);
124
+ return result;
125
+ });
126
+ this.activeRuns.set(request.runId, { controller, completion });
127
+ void completion.finally(() => {
128
+ this.activeRuns.delete(request.runId);
129
+ }).catch(() => {
130
+ // Unexpected orchestration failures surface through later reconciliation.
113
131
  });
114
132
  }
133
+ async stop(manager, sessionId, workspaceId) {
134
+ let session = manager.get(sessionId, { workspaceId });
135
+ if (!session) {
136
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
137
+ }
138
+ const activeRun = session.activeRun;
139
+ if (!activeRun) {
140
+ return { operation: "stop", session: publicSession(session) };
141
+ }
142
+ const handle = this.activeRuns.get(activeRun.id);
143
+ if (!handle) {
144
+ session = manager.get(session.id, { workspaceId }) ?? session;
145
+ if (!session.activeRun)
146
+ return { operation: "stop", session: publicSession(session) };
147
+ throw new SubagentSessionError("subagent.cancel_unavailable", `Subagent Run ${activeRun.id} has no live cancellation owner.`);
148
+ }
149
+ handle.controller.abort(new Error(`Subagent Run ${activeRun.id} cancelled by stop.`));
150
+ await handle.completion;
151
+ session = manager.get(session.id, { workspaceId });
152
+ if (!session) {
153
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
154
+ }
155
+ return {
156
+ operation: "stop",
157
+ session: publicSession(session),
158
+ ...(session.latestRun?.id === activeRun.id ? { run: publicRun(session.latestRun) } : {}),
159
+ };
160
+ }
115
161
  recordCompletion(completion) {
116
162
  if (!completion.activityId)
117
163
  return;
@@ -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,
@@ -98,6 +98,17 @@ export class SubagentSessionManager {
98
98
  });
99
99
  return { session, run };
100
100
  }
101
+ delete(sessionId, scope = {}) {
102
+ const session = this.store.getInScope(sessionId, scope);
103
+ if (!session) {
104
+ throw new SubagentSessionError("subagent.session_not_found", `Unknown Subagent Session in this Workspace: ${sessionId}`);
105
+ }
106
+ if (session.activeRun) {
107
+ throw new SubagentSessionError("subagent.busy", `Subagent Session ${session.id} already has active Run ${session.activeRun.id}.`);
108
+ }
109
+ this.store.delete(session.id);
110
+ return session;
111
+ }
101
112
  close() {
102
113
  this.store.close();
103
114
  }
@@ -137,6 +137,9 @@ export class SubagentSessionStore {
137
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);
138
138
  return updated;
139
139
  }
140
+ delete(id) {
141
+ this.database.sqlite.prepare("delete from local_agent_sessions where id = ?").run(id);
142
+ }
140
143
  close() {
141
144
  this.database.close();
142
145
  }
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.2",
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/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",