@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 +11 -0
- package/capabilities/subagents/GUIDE.md +11 -3
- package/dist/capability-registry.js +8 -0
- package/dist/subagents/providers/adapters/acp.js +3 -1
- package/dist/subagents/providers/adapters/claude.js +38 -31
- package/dist/subagents/providers/adapters/codex.js +1 -1
- package/dist/subagents/providers/adapters/opencode.js +41 -14
- package/dist/subagents/providers/adapters/pi.js +22 -8
- package/dist/subagents/providers/shared.js +26 -0
- package/dist/subagents/sessions/capability.js +51 -5
- package/dist/subagents/sessions/delivery-mailbox.js +3 -0
- package/dist/subagents/sessions/execution.js +56 -59
- package/dist/subagents/sessions/manager.js +11 -0
- package/dist/subagents/sessions/store.js +3 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
30
|
+
let outcome = "succeeded";
|
|
31
|
+
let result;
|
|
32
|
+
let errorMessage;
|
|
31
33
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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:
|
|
54
|
+
status: outcome,
|
|
72
55
|
},
|
|
73
56
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
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",
|