@akira-tl/forgerelay 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/capabilities/subagents/GUIDE.md +100 -40
  3. package/dist/activity/lifecycle.js +1 -1
  4. package/dist/capabilities.js +1 -1
  5. package/dist/capability-registry.js +34 -0
  6. package/dist/cli.js +80 -165
  7. package/dist/db/migrations.js +14 -0
  8. package/dist/db/schema.js +6 -0
  9. package/dist/server.js +29 -32
  10. package/dist/{local-agent-targets.js → subagents/cli-target.js} +6 -6
  11. package/dist/{local-agent-profiles.js → subagents/profiles.js} +13 -5
  12. package/dist/subagents/providers/adapters/acp.js +148 -0
  13. package/dist/subagents/providers/adapters/claude.js +75 -0
  14. package/dist/{local-agent-runtime.js → subagents/providers/adapters/codex.js} +10 -4
  15. package/dist/subagents/providers/adapters/opencode.js +137 -0
  16. package/dist/subagents/providers/adapters/pi.js +232 -0
  17. package/dist/{local-agent-availability.js → subagents/providers/availability.js} +24 -10
  18. package/dist/subagents/providers/continuation.js +11 -0
  19. package/dist/subagents/providers/contract.js +1 -0
  20. package/dist/subagents/providers/registry.js +26 -0
  21. package/dist/subagents/providers/shared.js +40 -0
  22. package/dist/subagents/sessions/capability.js +214 -0
  23. package/dist/subagents/sessions/delivery-mailbox.js +115 -0
  24. package/dist/subagents/sessions/execution.js +171 -0
  25. package/dist/subagents/sessions/manager.js +107 -0
  26. package/dist/subagents/sessions/mcp/audit.js +85 -0
  27. package/dist/subagents/sessions/mcp/runtime.js +19 -0
  28. package/dist/{local-agent-store.js → subagents/sessions/store.js} +75 -39
  29. package/dist/workspaces.js +3 -3
  30. package/docs/chatgpt-coding-workflow.md +2 -9
  31. package/docs/roadmap.md +42 -7
  32. package/package.json +2 -2
  33. package/scripts/release/release-gate.test.mjs +2 -2
  34. package/dist/local-agent-adapters.js +0 -653
  35. /package/dist/{local-agent-path.js → subagents/providers/path.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.1] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - `subagent.session resume` 现在基于 provider-native continuation 继续已有 Session;Codex、Claude、OpenCode、Pi 支持真实 continuation,Cursor/Copilot 明确保持 start-only。
12
+
13
+ ### Changed
14
+
15
+ - Session 固定创建时的 provider/profile/model/thinking,并严格限制在所属 Execution Workspace;busy resume、跨 Workspace 访问和不受支持的 continuation 都返回明确错误。
16
+
17
+ ### Fixed
18
+
19
+ - Windows restart mailbox 测试在清理临时 SQLite state 前显式关闭 restored server,避免 `EBUSY`。
20
+
21
+ ## [0.7.0] - 2026-08-30
22
+
23
+ ### Added
24
+
25
+ - 新增 first-class `subagent.session` Capability tracer:Host 可通过现有 Capability Gateway 发现并启动 provider-backed Subagent Session、查询 `status`、列出当前 Execution Workspace 拥有的 Session,并通过有界单次 delivery mailbox 接收后台 Run 的最终结果;canonical Core MCP tool 仍保持九个。
26
+ - 为 ForgeRelay Subagent 引入稳定的 Session / Run identity 和 workspace-scoped coordination,使每次 delegated execution 都有明确的 Run 生命周期、Activity 关联和 provider continuation 映射,而无需经由 `bash -> forgerelay agents ...` CLI 间接执行。
27
+
28
+ ### Changed
29
+
30
+ - 将 Claude Code、Codex、OpenCode、Pi 等 provider 的原生 session store 作为 conversation history 真源;ForgeRelay SQLite 停止写入 delegated prompt、profile body、final response、Hook report history 和 provider event/items,仅保留轻量 ownership、provider/profile/model/thinking 与 active/latest Run 协调元数据。
31
+ - Provider adapter contract 收敛为 continuation identity、final response 与必要执行元数据,provider streaming/messages/events 只瞬时消费;profile body 仅在 Session 首次启动时交给 provider,ForgeRelay 不保存正文副本。
32
+
7
33
  ## [0.6.2] - 2026-08-29
8
34
 
9
35
  ### Added
@@ -1,69 +1,129 @@
1
- # ForgeRelay Subagents
1
+ # ForgeRelay Subagent
2
2
 
3
- 当任务涉及委派给另一个本地 coding agent、获取第二意见、并行调查,或用户明确点名 subagent 时读取本指南。
3
+ 当任务需要委派给另一个本地 coding agent、获取第二意见、并行调查,或用户明确点名 Subagent/provider 时读取本指南。
4
4
 
5
- ## 当前接口边界
5
+ ## 当前接口
6
6
 
7
- 0.3 仍然使用 ForgeRelay CLI 协调 provider-backed local subagent;当前没有 first-class MCP subagent tool。不要把 Host 自己的 subagent 功能与 ForgeRelay subagent 混为一谈。
7
+ 启用 Subagent 后,`open_workspace` 会在 `capabilityCatalog` 中公开 `subagent.session`,同时返回紧凑的 provider/profile 元数据。不要为 Subagent 寻找或假设额外的 Core MCP tool;长期可见 Core tool surface 保持不变。
8
8
 
9
- 启用方式:
9
+ 通过统一 `capability` gateway 调用:
10
10
 
11
- ```bash
12
- FORGERELAY_SUBAGENTS=1 forgerelay serve
11
+ ```text
12
+ name = subagent.session
13
+ action = run
13
14
  ```
14
15
 
15
- 启用后,`open_workspace` 会返回紧凑的 `agentProviders` 和 `agents` 元数据。配置 profile 通常来自全局 ForgeRelay 配置目录以及项目内:
16
+ 当前支持四个 operation:
16
17
 
17
- ```text
18
- .forgerelay/agents/*.md
19
- .devspace/agents/*.md
18
+ - `start`:创建 Subagent Session,并立即启动第一个 Subagent Run;
19
+ - `resume`:在支持真实 continuation 的 provider 上继续现有 Session;
20
+ - `status`:读取当前 Workspace 中一个 Session 的协调状态;
21
+ - `list`:列出当前 Workspace 拥有的 Session 摘要。
22
+
23
+ `subagent.session` 不支持 `batch.execute`。
24
+
25
+ ## start
26
+
27
+ 使用 profile:
28
+
29
+ ```json
30
+ {
31
+ "operation": "start",
32
+ "target": "reviewer",
33
+ "prompt": "检查当前改动的并发与错误处理。"
34
+ }
20
35
  ```
21
36
 
22
- `.devspace` 路径仅用于迁移兼容。
37
+ 也可以把 ForgeRelay 支持的 provider 名直接作为 `target`。创建新 Session 时可以显式给出 `model` / `thinking`;profile 已有默认值时通常无需重复覆盖。
23
38
 
24
- ## 何时使用
39
+ 返回值包含:
25
40
 
26
- 不要因为存在 profile 就自动委派普通开发工作。只有在用户要求委派、第二意见、并行工作或指定 subagent/provider 时才使用,并明确告诉用户正在使用另一个 subagent。
41
+ - `session.id`:ForgeRelay Subagent Session identity;
42
+ - `session.status`:`running` 或 `idle`;
43
+ - `run.id`:本次 delegated execution 的 Subagent Run identity;
44
+ - provider/profile/model 等紧凑协调元数据。
27
45
 
28
- 选择 profile 时以 `open_workspace` 返回的 compact profile catalog 为准。`forgerelay agents ls` 显示的是当前 workspace 的已有 subagent sessions,不是 profile 定义列表。
46
+ 首次 `start` 使用当时有效的 profile body 作为 provider-native conversation 的初始 instructions。ForgeRelay 不把 profile body 复制进自己的 Session SQLite。
29
47
 
30
- ## 常用命令
48
+ ## resume
31
49
 
32
- 正常委派只需要:
50
+ 已有 Session 完成一次 Run 后,可发送新的 delegated prompt:
33
51
 
34
- ```bash
35
- forgerelay agents ls
36
- forgerelay agents run <profile-or-provider-or-id> "<prompt>"
37
- forgerelay agents show <id>
52
+ ```json
53
+ {
54
+ "operation": "resume",
55
+ "sessionId": "agt_...",
56
+ "prompt": "继续检查测试覆盖。"
57
+ }
38
58
  ```
39
59
 
40
- 语义:
60
+ `resume` 只接受 `sessionId + prompt`,不能改变 model/thinking。Session 创建时确定的 provider/profile/model/thinking 在整个 Session 生命周期中保持固定;resume 不重新读取或注入当前 profile body,而是使用 provider-native continuation history。
41
61
 
42
- - `run <profile>`:启动配置好的 profile;
43
- - `run <provider>`:没有合适 profile 时直接启动 ForgeRelay 内建 provider;
44
- - `run <id>`:向现有 agent session 发送 follow-up;
45
- - `show <id>`:查看状态和最新响应;仍在运行时可稍后再次调用;
46
- - shell workspace 环境会把 CLI 操作自动限定到当前 ForgeRelay workspace。
62
+ 同一个 Session 最多只有一个 active Run;Session 正在执行时再次 resume 会返回 `subagent.busy`,不会排队或并发执行。Codex、Claude、OpenCode、Pi 支持真实 continuation;Cursor/Copilot 当前明确标记为 start-only,resume 返回 `subagent.continuation_unsupported`,不会伪装成新 provider session。
47
63
 
48
- 如确实需要覆盖 profile/provider 默认值,可使用:
64
+ ## status / list
49
65
 
50
- ```bash
51
- forgerelay agents run <profile-or-provider> --model <model> "<prompt>"
52
- forgerelay agents run <profile-or-provider> --thinking <level> "<prompt>"
66
+ 查询一个 Session:
67
+
68
+ ```json
69
+ {
70
+ "operation": "status",
71
+ "sessionId": "agt_..."
72
+ }
53
73
  ```
54
74
 
55
- `thinking` provider-specific passthrough;ForgeRelay 不在 provider 之间翻译 reasoning level。
75
+ 列出当前 Workspace Session:
76
+
77
+ ```json
78
+ {
79
+ "operation": "list"
80
+ }
81
+ ```
82
+
83
+ Session 受实际 Execution Workspace 所有权约束;Session ID 不是跨 Workspace 的访问凭证。`status` / `resume` 都只能访问当前实际 Execution Workspace 拥有的 Session,`list` 也只返回当前 Workspace 的紧凑摘要。
84
+
85
+ Session 返回中会给出 `continuationSupported` 与 `resumable`;`open_workspace` 的 provider metadata 也会明确给出 `continuationSupported`。当前尚未通过 first-class Capability 开放 `stop` 或 `delete`,不要伪造这些 operation,也不要用新的 Core MCP tool 绕过 Capability Gateway。
86
+
87
+ ## 后台完成与结果交付
88
+
89
+ `start` 接受执行后会尽快把 Session/Run identity 返回给 Host,Subagent Run 在后台继续执行。
90
+
91
+ Run 完成后:
92
+
93
+ - ForgeRelay 创建一个 linked `subagent_result` Activity,只记录 Session/Run/provider/status 等有界元数据;
94
+ - final response 放入有界 delivery mailbox,等待同一 Workspace 的后续 ForgeRelay 调用领取;
95
+ - 成功领取后 mailbox 条目立即删除,同一个 completion 不重复交付;
96
+ - 未领取 completion 可以跨正常 ForgeRelay 进程重启保留。
97
+
98
+ 如果需要等待结果,使用 `status` 进行有节制的后续查询;不要高频短轮询。
99
+
100
+ ## 数据所有权
101
+
102
+ Claude Code、Codex、OpenCode、Pi 等 provider 自己的 session store 是 conversation history 的真源。ForgeRelay 只保存恢复、Workspace ownership 和执行协调所需的小型元数据。
103
+
104
+ ForgeRelay 不把以下内容保存进 Subagent Session SQLite 或 Activity Audit:
105
+
106
+ - delegated prompt;
107
+ - profile body;
108
+ - provider transcript/messages/events/items;
109
+ - final response 正文;
110
+ - 完整 Hook report 历史;
111
+ - 完整 Subagent Run 历史。
112
+
113
+ Activity 只保留操作摘要;delivery mailbox 只用于尚未交付的 bounded completion,不是 conversation/history store。
114
+
115
+ ## Hooks
56
116
 
57
- 除非正在明确调试 ForgeRelay provider integration,不要绕过 ForgeRelay 直接运行 `codex`、`claude`、`opencode`、`pi`、`cursor-agent`、`copilot` provider CLI
117
+ `SubagentStart` / `SubagentStop` 在每一次 Subagent Run 生命周期中触发。Hook payload 使用 Session/Run/profile/provider/model/thinking/status 等协调元数据,不包含 delegated prompt 或 final response
58
118
 
59
- ## Prompt 与验证
119
+ ## CLI 兼容
60
120
 
61
- Subagent 只收到你发送的 prompt 和它自己的 profile instructions,因此 prompt 必须自包含:目标、相关模块/文件、约束、验收标准,以及它能否修改文件。
121
+ `forgerelay agents` 仍可用于本地诊断和兼容性操作,但 Host 正常委派优先使用 `subagent.session` Capability,不要通过 `bash -> forgerelay agents ...` 实现 first-class Subagent。
62
122
 
63
- Subagent 的结果不是自动验证过的最终答案。收到响应后:
123
+ ## 验证责任
64
124
 
65
- - 写入型任务:检查实际改动并运行相关测试;
66
- - 只读调查:核对关键结论是否有仓库证据;
67
- - 向用户说明使用了哪个 profile/provider、它给出的结论、你做了哪些验证,以及剩余风险。
125
+ Subagent 返回不是自动验证过的最终结论。收到结果后仍应按照任务类型验证:
68
126
 
69
- `SubagentStart` / `SubagentStop` lifecycle Hooks 会在 worker 生命周期中触发;异步报告会随 ForgeRelay session 持久化并可由 `forgerelay agents show` 查看。
127
+ - 写入型任务检查真实 diff,并运行相关测试;
128
+ - 只读调查核对关键结论对应的仓库事实;
129
+ - 向用户说明使用的 profile/provider、核心结论、已做验证与剩余风险。
@@ -32,7 +32,7 @@ export class ActivityLifecycle {
32
32
  const activityId = executionContext.activityId;
33
33
  try {
34
34
  const result = await options.operation(executionContext);
35
- this.finish(activityId, result, options.outcome?.(result) ?? { type: "succeeded" });
35
+ this.finish(activityId, options.auditResult?.(result) ?? result, options.outcome?.(result) ?? { type: "succeeded" });
36
36
  return result;
37
37
  }
38
38
  catch (error) {
@@ -92,7 +92,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
92
92
  capabilities.push("batch.execute");
93
93
  }
94
94
  if (config.subagents) {
95
- capabilities.push("subagent.profiles");
95
+ capabilities.push("subagent.session");
96
96
  }
97
97
  if (config.artifactsEnabled && context.artifactDownloadSupported) {
98
98
  capabilities.push("artifact.native-download");
@@ -130,6 +130,25 @@ export function createCapabilityRegistry(dependencies) {
130
130
  line: z.number().int(),
131
131
  column: z.number().int(),
132
132
  };
133
+ const subagentSessionInput = z.discriminatedUnion("operation", [
134
+ z.object({
135
+ operation: z.literal("start"),
136
+ target: z.string().min(1),
137
+ prompt: z.string().min(1),
138
+ model: z.string().min(1).optional(),
139
+ thinking: z.string().min(1).optional(),
140
+ }).strict(),
141
+ z.object({
142
+ operation: z.literal("resume"),
143
+ sessionId: z.string().min(1),
144
+ prompt: z.string().min(1),
145
+ }).strict(),
146
+ z.object({
147
+ operation: z.literal("status"),
148
+ sessionId: z.string().min(1),
149
+ }).strict(),
150
+ z.object({ operation: z.literal("list") }).strict(),
151
+ ]);
133
152
  const codeIntelligenceInput = z.discriminatedUnion("operation", [
134
153
  z.object({ operation: z.literal("definition"), ...positionInput }).strict(),
135
154
  z.object({ operation: z.literal("hover"), ...positionInput }).strict(),
@@ -201,6 +220,21 @@ export function createCapabilityRegistry(dependencies) {
201
220
  run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
202
221
  }]
203
222
  : []),
223
+ ...(dependencies.subagentSession
224
+ ? [{
225
+ name: "subagent.session",
226
+ description: "Coordinate provider-backed Subagent Sessions in the current Execution Workspace.",
227
+ guideName: "subagents",
228
+ readGuideBeforeFirstUse: true,
229
+ batchPolicy: "unsupported",
230
+ inputSchema: subagentSessionInput,
231
+ availability: () => ({
232
+ available: dependencies.subagentSession?.available ?? false,
233
+ reason: dependencies.subagentSession?.unavailableReason,
234
+ }),
235
+ run: async (input, context, options) => dependencies.subagentSession.run(input, context, options),
236
+ }]
237
+ : []),
204
238
  ...(dependencies.batchExecute
205
239
  ? [{
206
240
  name: "batch.execute",
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { spawn } from "node:child_process";
5
5
  import { mkdtempSync, writeFileSync } from "node:fs";
6
- import { readFile } from "node:fs/promises";
6
+ import { readFile, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -11,13 +11,12 @@ import * as prompts from "@clack/prompts";
11
11
  import { getShellConfig } from "@earendil-works/pi-coding-agent";
12
12
  import { satisfies } from "semver";
13
13
  import { loadConfig } from "./config.js";
14
- import { formatVisibleHookReports, HookRunner } from "./hooks.js";
15
14
  import { runHooksCommand } from "./hook-cli.js";
16
- import { runLocalAgentProvider } from "./local-agent-adapters.js";
17
- import { isLocalAgentProvider, loadLocalAgentProfiles, } from "./local-agent-profiles.js";
18
- import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js";
19
- import { formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, resolveLocalAgentTarget, } from "./local-agent-targets.js";
20
- import { createLocalAgentStore } from "./local-agent-store.js";
15
+ import { executeSubagentSession } from "./subagents/sessions/execution.js";
16
+ import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
17
+ import { SubagentSessionManager } from "./subagents/sessions/manager.js";
18
+ import { formatSubagentProviderAvailabilitySummary } from "./subagents/providers/availability.js";
19
+ import { parseSubagentRunArgs } from "./subagents/cli-target.js";
21
20
  import { ensureForgeRelayInstanceId, generateInstanceId, generateOwnerToken, loadDevspaceFiles, removeForgeRelayRemote, renameForgeRelayRemote, resolveSubagentsFlag, writeDevspaceAuth, writeDevspaceConfig, writeForgeRelayRemote, } from "./user-config.js";
22
21
  import { expandHomePath } from "./roots.js";
23
22
  import { shutdownHttpServer } from "./server-shutdown.js";
@@ -193,7 +192,7 @@ async function serve() {
193
192
  }
194
193
  const { createServer } = await import("./server.js");
195
194
  const config = loadConfig();
196
- const { app, close, localAgentProviders } = createServer(config);
195
+ const { app, close, subagentProviders } = createServer(config);
197
196
  const httpServer = app.listen(config.port, config.host, () => {
198
197
  console.log(`forgerelay listening on http://${config.host}:${config.port}/mcp`);
199
198
  console.log(`public base url: ${config.publicBaseUrl}`);
@@ -205,7 +204,7 @@ async function serve() {
205
204
  console.log("auth: Owner password approval required");
206
205
  console.log(`logging: ${config.logging.level} ${config.logging.format}`);
207
206
  if (config.subagents) {
208
- console.log(`subagent providers: ${formatLocalAgentProviderAvailabilitySummary(localAgentProviders)}`);
207
+ console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
209
208
  }
210
209
  });
211
210
  let shuttingDown = false;
@@ -492,92 +491,79 @@ async function runAgentsCommand(args) {
492
491
  }
493
492
  }
494
493
  async function runAgentsList() {
495
- const config = loadConfig();
496
- const store = createLocalAgentStore(config);
497
- const agents = store.list(resolveCurrentWorkspaceScope());
498
- if (agents.length === 0) {
499
- console.log("No subagent sessions found for this workspace.");
500
- return;
494
+ const manager = createCliSubagentSessionManager();
495
+ try {
496
+ const agents = manager.list(resolveCurrentWorkspaceScope());
497
+ if (agents.length === 0) {
498
+ console.log("No subagent sessions found for this workspace.");
499
+ return;
500
+ }
501
+ for (const agent of agents) {
502
+ console.log(formatAgentLine(agent));
503
+ }
501
504
  }
502
- for (const agent of agents) {
503
- console.log(formatAgentLine(agent));
505
+ finally {
506
+ manager.close();
504
507
  }
505
508
  }
506
509
  async function runAgentsRun(args) {
507
- const parsed = parseLocalAgentRunArgs(args);
508
- const config = loadConfig();
510
+ const parsed = parseSubagentRunArgs(args);
509
511
  const workspaceRoot = resolveCurrentWorkspaceRoot();
510
- const store = createLocalAgentStore(config);
511
- const existing = store.get(parsed.target);
512
- if (existing) {
513
- if (!isLocalAgentProvider(existing.provider)) {
514
- throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`);
515
- }
516
- assertLocalAgentProviderAvailable(existing.provider);
517
- const promptFile = writeAgentPromptFile(parsed.prompt);
518
- store.update(existing.id, {
519
- status: "starting",
520
- model: parsed.model ?? existing.model,
521
- thinking: parsed.thinking ?? existing.thinking,
522
- latestResponse: undefined,
523
- error: undefined,
524
- hookReports: undefined,
525
- });
526
- spawnAgentWorker(existing.id, promptFile);
527
- console.log(formatAgentLine({
528
- ...existing,
529
- status: "running",
530
- model: parsed.model ?? existing.model,
531
- thinking: parsed.thinking ?? existing.thinking,
532
- }));
533
- return;
534
- }
535
- const profiles = await loadLocalAgentProfiles(config, workspaceRoot);
536
- const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking);
537
- if (!target) {
538
- throw new Error(`Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`);
512
+ const manager = createCliSubagentSessionManager();
513
+ try {
514
+ const existing = manager.get(parsed.target);
515
+ if (existing && (parsed.model || parsed.thinking))
516
+ throw new Error("Existing Subagent Sessions cannot override model or thinking.");
517
+ const started = existing
518
+ ? manager.resume({ sessionId: existing.id, prompt: parsed.prompt })
519
+ : await manager.start({
520
+ workspaceId: process.env.FORGERELAY_WORKSPACE_ID ?? process.env.DEVSPACE_WORKSPACE_ID,
521
+ workspaceRoot,
522
+ target: parsed.target,
523
+ prompt: parsed.prompt,
524
+ model: parsed.model,
525
+ thinking: parsed.thinking,
526
+ });
527
+ console.log(formatAgentLine(started.session));
528
+ }
529
+ finally {
530
+ manager.close();
539
531
  }
540
- assertLocalAgentProviderAvailable(target.provider);
541
- const promptFile = writeAgentPromptFile(parsed.prompt);
542
- const record = store.create({
543
- workspaceId: process.env.FORGERELAY_WORKSPACE_ID ?? process.env.DEVSPACE_WORKSPACE_ID,
544
- workspaceRoot,
545
- profileName: target.name,
546
- provider: target.provider,
547
- model: target.model,
548
- thinking: target.thinking,
549
- });
550
- spawnAgentWorker(record.id, promptFile);
551
- console.log(formatAgentLine({ ...record, status: "running" }));
552
532
  }
553
533
  async function runAgentsShow(args) {
554
534
  const [id] = args;
555
535
  if (!id)
556
536
  throw new Error("Usage: forgerelay agents show <id>");
557
537
  const config = loadConfig();
558
- const store = createLocalAgentStore(config);
559
- let record = store.get(id);
560
- if (!record)
561
- throw new Error(`Unknown subagent id: ${id}`);
562
- const deadline = Date.now() + 15_000;
563
- while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) {
564
- await sleep(500);
565
- record = store.get(id) ?? record;
566
- }
567
- console.log(formatAgentLine(record));
568
- const hookSummary = formatVisibleHookReports(record.hookReports ?? []);
569
- if (hookSummary)
570
- console.log(hookSummary);
571
- if (record.latestResponse) {
572
- console.log(record.latestResponse);
573
- return;
574
- }
575
- if (record.error) {
576
- console.log(record.error);
577
- return;
538
+ const manager = createCliSubagentSessionManager(config);
539
+ try {
540
+ let record = manager.get(id);
541
+ if (!record)
542
+ throw new Error(`Unknown subagent id: ${id}`);
543
+ const deadline = Date.now() + 15_000;
544
+ while (record.status === "running" && Date.now() < deadline) {
545
+ await sleep(500);
546
+ record = manager.get(id) ?? record;
547
+ }
548
+ console.log(formatAgentLine(record));
549
+ if (record.workspaceId) {
550
+ const deliveries = new SubagentDeliveryMailbox(config.stateDir).claimSession(record.workspaceId, record.id);
551
+ for (const delivery of deliveries) {
552
+ const text = delivery.outcome === "succeeded" ? delivery.finalResponse : delivery.error;
553
+ if (text)
554
+ console.log(text);
555
+ }
556
+ if (deliveries.length > 0)
557
+ return;
558
+ }
559
+ if (record.latestRun)
560
+ console.log(`Latest run ${record.latestRun.id}: ${record.latestRun.status}`);
561
+ if (record.status === "running") {
562
+ console.log(`No final response yet. Call \`forgerelay agents show ${record.id}\` again later.`);
563
+ }
578
564
  }
579
- if (record.status === "starting" || record.status === "running") {
580
- console.log(`No final response yet. Call \`forgerelay agents show ${record.id}\` again later.`);
565
+ finally {
566
+ manager.close();
581
567
  }
582
568
  }
583
569
  async function runAgentsWorker(args) {
@@ -586,96 +572,25 @@ async function runAgentsWorker(args) {
586
572
  throw new Error("Usage: forgerelay agents __worker <id> --prompt-file <path>");
587
573
  }
588
574
  const config = loadConfig();
589
- const store = createLocalAgentStore(config);
590
- const record = store.get(id);
591
- if (!record)
592
- throw new Error(`Unknown subagent id: ${id}`);
593
- const hooks = new HookRunner(config.hooks, config.logging);
594
- const hookInvocation = {
595
- workspaceId: record.workspaceId,
596
- workspaceRoot: record.workspaceRoot,
597
- payload: {
598
- agentId: record.id,
599
- profile: record.profileName,
600
- provider: record.provider,
601
- model: record.model,
602
- thinking: record.thinking,
575
+ const prompt = await readFile(promptFile, "utf8");
576
+ await rm(promptFile, { force: true });
577
+ await executeSubagentSession(config, id, prompt);
578
+ }
579
+ function createCliSubagentSessionManager(config = loadConfig()) {
580
+ return new SubagentSessionManager(config, {
581
+ launch(request) {
582
+ const promptFile = writeSubagentPromptFile(request.prompt);
583
+ spawnSubagentWorker(request.sessionId, promptFile);
603
584
  },
604
- };
605
- store.update(record.id, { status: "running", error: undefined, hookReports: undefined });
606
- const hookReports = await hooks.run("SubagentStart", hookInvocation);
607
- store.update(record.id, { hookReports });
608
- try {
609
- const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
610
- const profile = profiles.find((candidate) => candidate.name === record.profileName);
611
- const prompt = await readFile(promptFile, "utf8");
612
- const result = profile
613
- ? await runLocalAgentProfile(profile, record, prompt)
614
- : await runRawLocalAgentProvider(record, prompt);
615
- hookReports.push(...await hooks.run("SubagentStop", {
616
- ...hookInvocation,
617
- payload: {
618
- ...hookInvocation.payload,
619
- status: "idle",
620
- providerSessionId: result.providerSessionId,
621
- },
622
- }));
623
- store.update(record.id, {
624
- providerSessionId: result.providerSessionId ?? undefined,
625
- status: "idle",
626
- latestResponse: result.finalResponse,
627
- error: undefined,
628
- hookReports,
629
- });
630
- }
631
- catch (error) {
632
- const message = error instanceof Error ? error.message : String(error);
633
- hookReports.push(...await hooks.run("SubagentStop", {
634
- ...hookInvocation,
635
- payload: {
636
- ...hookInvocation.payload,
637
- status: "error",
638
- },
639
- }));
640
- store.update(record.id, {
641
- status: "error",
642
- error: message,
643
- hookReports,
644
- });
645
- }
646
- }
647
- async function runLocalAgentProfile(profile, record, prompt) {
648
- const body = profile.body.trim();
649
- const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt;
650
- return runLocalAgentProvider(profile.provider, {
651
- prompt: fullPrompt,
652
- workspace: record.workspaceRoot,
653
- providerSessionId: record.providerSessionId,
654
- writeMode: "allowed",
655
- model: record.model ?? profile.model,
656
- thinking: record.thinking ?? profile.thinking,
657
- });
658
- }
659
- async function runRawLocalAgentProvider(record, prompt) {
660
- if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) {
661
- throw new Error(`Subagent profile not found: ${record.profileName}`);
662
- }
663
- return runLocalAgentProvider(record.provider, {
664
- prompt,
665
- workspace: record.workspaceRoot,
666
- providerSessionId: record.providerSessionId,
667
- writeMode: "allowed",
668
- model: record.model,
669
- thinking: record.thinking,
670
585
  });
671
586
  }
672
- function spawnAgentWorker(agentId, promptFile) {
587
+ function spawnSubagentWorker(sessionId, promptFile) {
673
588
  const child = spawn(process.execPath, [
674
589
  ...process.execArgv,
675
590
  fileURLToPath(import.meta.url),
676
591
  "agents",
677
592
  "__worker",
678
- agentId,
593
+ sessionId,
679
594
  "--prompt-file",
680
595
  promptFile,
681
596
  ], {
@@ -685,7 +600,7 @@ function spawnAgentWorker(agentId, promptFile) {
685
600
  });
686
601
  child.unref();
687
602
  }
688
- function writeAgentPromptFile(prompt) {
603
+ function writeSubagentPromptFile(prompt) {
689
604
  const directory = mkdtempSync(join(tmpdir(), "forgerelay-agent-prompt-"));
690
605
  const filePath = join(directory, "prompt.txt");
691
606
  writeFileSync(filePath, prompt, { mode: 0o600 });
@@ -64,6 +64,11 @@ const migrations = [
64
64
  name: "activity-host-turn-workspace",
65
65
  up: migrateActivityHostTurnWorkspace,
66
66
  },
67
+ {
68
+ version: 14,
69
+ name: "subagent-session-coordination",
70
+ up: migrateSubagentSessionCoordination,
71
+ },
67
72
  ];
68
73
  export function migrateDatabase(sqlite) {
69
74
  const migrate = sqlite.transaction(() => {
@@ -344,6 +349,15 @@ function migrateBashOutputAuditColumns(sqlite) {
344
349
  addColumnIfMissing(sqlite, "bash_output_streams", "returned", "integer not null default 0");
345
350
  addColumnIfMissing(sqlite, "bash_output_streams", "completion_claimed_at", "text");
346
351
  }
352
+ function migrateSubagentSessionCoordination(sqlite) {
353
+ migrateLocalAgentSessions(sqlite);
354
+ addColumnIfMissing(sqlite, "local_agent_sessions", "active_run_id", "text");
355
+ addColumnIfMissing(sqlite, "local_agent_sessions", "active_activity_id", "text");
356
+ addColumnIfMissing(sqlite, "local_agent_sessions", "active_run_started_at", "text");
357
+ addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_id", "text");
358
+ addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_outcome", "text");
359
+ addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_finished_at", "text");
360
+ }
347
361
  function migrateActivityHostTurnWorkspace(sqlite) {
348
362
  migrateActivityHostTurns(sqlite);
349
363
  addColumnIfMissing(sqlite, "activity_host_turns", "workspace_id", "text");
package/dist/db/schema.js CHANGED
@@ -152,6 +152,12 @@ export const localAgentSessions = sqliteTable("local_agent_sessions", {
152
152
  thinking: text("thinking"),
153
153
  providerSessionId: text("provider_session_id"),
154
154
  status: text("status").notNull(),
155
+ activeRunId: text("active_run_id"),
156
+ activeActivityId: text("active_activity_id"),
157
+ activeRunStartedAt: text("active_run_started_at"),
158
+ latestRunId: text("latest_run_id"),
159
+ latestRunOutcome: text("latest_run_outcome"),
160
+ latestRunFinishedAt: text("latest_run_finished_at"),
155
161
  latestResponse: text("latest_response"),
156
162
  error: text("error"),
157
163
  hookReportsJson: text("hook_reports_json"),