@akira-tl/forgerelay 0.7.2 → 0.8.0
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 +22 -0
- package/capabilities/subagents/GUIDE.md +2 -0
- package/dist/cli.js +3 -1
- package/dist/db/migrations.js +29 -0
- package/dist/db/schema.js +10 -0
- package/dist/server.js +31 -9
- package/dist/subagents/sessions/capability.js +60 -1
- package/dist/subagents/sessions/manager.js +39 -6
- package/dist/subagents/sessions/mcp/runtime.js +1 -0
- package/dist/subagents/sessions/store.js +20 -3
- package/dist/workspace-store.js +77 -8
- package/dist/workspaces.js +78 -74
- package/docs/chatgpt-coding-workflow.md +37 -34
- package/docs/configuration.md +29 -25
- package/docs/roadmap.md +39 -11
- package/docs/security.md +4 -4
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Workspace identity is now canonical per physical checkout or managed worktree, so different Host conversations reuse one persistent `workspaceId`; legacy duplicate IDs remain compatible aliases, and `newWorkspace` no longer creates same-target duplicates.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Composite member bootstrap now preserves capability guides and subagent provider/profile context exposed by the underlying Workspace.
|
|
16
|
+
- Composite member `context="none"` now truthfully reports suppressed bootstrap instead of claiming it was already delivered.
|
|
17
|
+
|
|
18
|
+
## [0.7.4] - 2026-08-30
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Subagent Run restart reconciliation now preserves live owners and marks stale active Runs `interrupted` without replaying the old prompt.
|
|
23
|
+
- First-class Subagent Sessions now work through Workspace Relay and Composite explicit-member routing while keeping Session state on the Execution ForgeRelay.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Composite Capability result remapping now preserves the declared output schema during relayed Subagent operations.
|
|
28
|
+
|
|
7
29
|
## [0.7.2] - 2026-08-30
|
|
8
30
|
|
|
9
31
|
### Added
|
|
@@ -103,6 +103,8 @@ Run 完成后:
|
|
|
103
103
|
- 成功领取后 mailbox 条目立即删除,同一个 completion 不重复交付;
|
|
104
104
|
- 未领取 completion 可以跨正常 ForgeRelay 进程重启保留。
|
|
105
105
|
|
|
106
|
+
ForgeRelay 只为 active Run 持久化最小 execution-owner 元数据(owner identity / PID),不保存 delegated prompt。后续 Session 操作会先做 restart reconciliation:能够证明 owner 仍存活的 Run 保持 `running`;无法证明仍有执行 owner 的 Run 一次性转为 `interrupted`,Session 回到 `idle`。ForgeRelay 绝不自动 replay 被中断 Run 的旧 prompt;如果 provider continuation 仍有效,由 Host 显式 `resume` 新 prompt。
|
|
107
|
+
|
|
106
108
|
如果需要等待结果,使用 `status` 进行有节制的后续查询;不要高频短轮询。
|
|
107
109
|
|
|
108
110
|
## 数据所有权
|
package/dist/cli.js
CHANGED
|
@@ -580,7 +580,8 @@ function createCliSubagentSessionManager(config = loadConfig()) {
|
|
|
580
580
|
return new SubagentSessionManager(config, {
|
|
581
581
|
launch(request) {
|
|
582
582
|
const promptFile = writeSubagentPromptFile(request.prompt);
|
|
583
|
-
spawnSubagentWorker(request.sessionId, promptFile);
|
|
583
|
+
const pid = spawnSubagentWorker(request.sessionId, promptFile);
|
|
584
|
+
return pid === undefined ? undefined : { id: `subagent-worker-${request.runId}`, pid };
|
|
584
585
|
},
|
|
585
586
|
});
|
|
586
587
|
}
|
|
@@ -599,6 +600,7 @@ function spawnSubagentWorker(sessionId, promptFile) {
|
|
|
599
600
|
env: process.env,
|
|
600
601
|
});
|
|
601
602
|
child.unref();
|
|
603
|
+
return child.pid;
|
|
602
604
|
}
|
|
603
605
|
function writeSubagentPromptFile(prompt) {
|
|
604
606
|
const directory = mkdtempSync(join(tmpdir(), "forgerelay-agent-prompt-"));
|
package/dist/db/migrations.js
CHANGED
|
@@ -69,6 +69,16 @@ const migrations = [
|
|
|
69
69
|
name: "subagent-session-coordination",
|
|
70
70
|
up: migrateSubagentSessionCoordination,
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
version: 15,
|
|
74
|
+
name: "subagent-run-ownership",
|
|
75
|
+
up: migrateSubagentRunOwnership,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
version: 16,
|
|
79
|
+
name: "workspace-session-aliases",
|
|
80
|
+
up: migrateWorkspaceSessionAliases,
|
|
81
|
+
},
|
|
72
82
|
];
|
|
73
83
|
export function migrateDatabase(sqlite) {
|
|
74
84
|
const migrate = sqlite.transaction(() => {
|
|
@@ -358,6 +368,25 @@ function migrateSubagentSessionCoordination(sqlite) {
|
|
|
358
368
|
addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_outcome", "text");
|
|
359
369
|
addColumnIfMissing(sqlite, "local_agent_sessions", "latest_run_finished_at", "text");
|
|
360
370
|
}
|
|
371
|
+
function migrateSubagentRunOwnership(sqlite) {
|
|
372
|
+
migrateSubagentSessionCoordination(sqlite);
|
|
373
|
+
addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_id", "text");
|
|
374
|
+
addColumnIfMissing(sqlite, "local_agent_sessions", "active_owner_pid", "integer");
|
|
375
|
+
}
|
|
376
|
+
function migrateWorkspaceSessionAliases(sqlite) {
|
|
377
|
+
sqlite.exec(`
|
|
378
|
+
create table if not exists workspace_session_aliases (
|
|
379
|
+
alias_id text primary key,
|
|
380
|
+
workspace_session_id text not null,
|
|
381
|
+
foreign key (workspace_session_id)
|
|
382
|
+
references workspace_sessions(id)
|
|
383
|
+
on delete cascade
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
create index if not exists workspace_session_aliases_workspace_idx
|
|
387
|
+
on workspace_session_aliases(workspace_session_id);
|
|
388
|
+
`);
|
|
389
|
+
}
|
|
361
390
|
function migrateActivityHostTurnWorkspace(sqlite) {
|
|
362
391
|
migrateActivityHostTurns(sqlite);
|
|
363
392
|
addColumnIfMissing(sqlite, "activity_host_turns", "workspace_id", "text");
|
package/dist/db/schema.js
CHANGED
|
@@ -16,6 +16,14 @@ export const workspaceSessions = sqliteTable("workspace_sessions", {
|
|
|
16
16
|
index("workspace_sessions_root_idx").on(table.root, table.lastUsedAt),
|
|
17
17
|
index("workspace_sessions_status_idx").on(table.status, table.lastUsedAt),
|
|
18
18
|
]);
|
|
19
|
+
export const workspaceSessionAliases = sqliteTable("workspace_session_aliases", {
|
|
20
|
+
aliasId: text("alias_id").primaryKey(),
|
|
21
|
+
workspaceSessionId: text("workspace_session_id")
|
|
22
|
+
.notNull()
|
|
23
|
+
.references(() => workspaceSessions.id, { onDelete: "cascade" }),
|
|
24
|
+
}, (table) => [
|
|
25
|
+
index("workspace_session_aliases_workspace_idx").on(table.workspaceSessionId),
|
|
26
|
+
]);
|
|
19
27
|
export const loadedAgentFiles = sqliteTable("loaded_agent_files", {
|
|
20
28
|
workspaceSessionId: text("workspace_session_id")
|
|
21
29
|
.notNull()
|
|
@@ -155,6 +163,8 @@ export const localAgentSessions = sqliteTable("local_agent_sessions", {
|
|
|
155
163
|
activeRunId: text("active_run_id"),
|
|
156
164
|
activeActivityId: text("active_activity_id"),
|
|
157
165
|
activeRunStartedAt: text("active_run_started_at"),
|
|
166
|
+
activeOwnerId: text("active_owner_id"),
|
|
167
|
+
activeOwnerPid: integer("active_owner_pid"),
|
|
158
168
|
latestRunId: text("latest_run_id"),
|
|
159
169
|
latestRunOutcome: text("latest_run_outcome"),
|
|
160
170
|
latestRunFinishedAt: text("latest_run_finished_at"),
|
package/dist/server.js
CHANGED
|
@@ -1362,6 +1362,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1362
1362
|
const skills = workspace.skills
|
|
1363
1363
|
.filter((skill) => !skill.disableModelInvocation)
|
|
1364
1364
|
.map((skill) => ({ name: skill.name, description: skill.description }));
|
|
1365
|
+
const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
|
|
1366
|
+
name: guide.name,
|
|
1367
|
+
description: guide.description,
|
|
1368
|
+
whenToRead: guide.whenToRead,
|
|
1369
|
+
path: formatPathForPrompt(guide.filePath),
|
|
1370
|
+
}));
|
|
1371
|
+
const agentProviders = config.subagents ? subagentProviders : [];
|
|
1372
|
+
const agents = workspace.agentProfiles.map((profile) => {
|
|
1373
|
+
const summary = summarizeSubagentProfile(profile);
|
|
1374
|
+
const availability = agentProviders.find((provider) => provider.name === summary.provider);
|
|
1375
|
+
return {
|
|
1376
|
+
...summary,
|
|
1377
|
+
providerAvailable: availability?.available,
|
|
1378
|
+
providerUnavailableReason: availability?.reason,
|
|
1379
|
+
};
|
|
1380
|
+
});
|
|
1365
1381
|
return {
|
|
1366
1382
|
member: memberName,
|
|
1367
1383
|
workspaceId: compositeWorkspaceId,
|
|
@@ -1373,15 +1389,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1373
1389
|
includeBootstrapContext: opened.includeBootstrapContext,
|
|
1374
1390
|
...(opened.includeBootstrapContext
|
|
1375
1391
|
? {
|
|
1392
|
+
capabilityGuides,
|
|
1376
1393
|
agentsFiles,
|
|
1377
1394
|
availableAgentsFiles,
|
|
1378
1395
|
skills,
|
|
1396
|
+
agentProviders,
|
|
1397
|
+
agents,
|
|
1379
1398
|
skillDiagnostics: redactSkillDiagnosticPaths(workspace.skillDiagnostics),
|
|
1380
1399
|
}
|
|
1381
1400
|
: {}),
|
|
1382
1401
|
instruction: opened.includeBootstrapContext
|
|
1383
1402
|
? `Bootstrap context for Composite member ${memberName}. Keep using Composite workspaceId ${compositeWorkspaceId} and pass member=${memberName} for work operations.`
|
|
1384
|
-
:
|
|
1403
|
+
: contextPolicy === "none"
|
|
1404
|
+
? `Bootstrap context for Composite member ${memberName} was intentionally suppressed by context=none. Keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}; request context=auto or context=full when member bootstrap is needed.`
|
|
1405
|
+
: `Composite member ${memberName} context was already delivered for this Host context; keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}.`,
|
|
1385
1406
|
};
|
|
1386
1407
|
};
|
|
1387
1408
|
const coreOperations = createCoreOperationExecutor({
|
|
@@ -1960,7 +1981,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1960
1981
|
action: z
|
|
1961
1982
|
.enum(["open", "list", "member"])
|
|
1962
1983
|
.optional()
|
|
1963
|
-
.describe("Defaults to open. Use list to inspect
|
|
1984
|
+
.describe("Defaults to open. Use list to inspect known Workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
|
|
1964
1985
|
memberAction: z
|
|
1965
1986
|
.enum(["add", "update", "remove"])
|
|
1966
1987
|
.optional()
|
|
@@ -2000,7 +2021,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2000
2021
|
workspaceId: z
|
|
2001
2022
|
.string()
|
|
2002
2023
|
.optional()
|
|
2003
|
-
.describe("For action=open, an existing
|
|
2024
|
+
.describe("For action=open, an existing Workspace ID to resume or reuse. Historical duplicate IDs from earlier ForgeRelay versions may resolve to the canonical Workspace ID. For action=list, filters inventory to one Workspace ID."),
|
|
2004
2025
|
mode: z
|
|
2005
2026
|
.enum(["checkout", "worktree"])
|
|
2006
2027
|
.optional()
|
|
@@ -2016,7 +2037,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2016
2037
|
newWorkspace: z
|
|
2017
2038
|
.boolean()
|
|
2018
2039
|
.optional()
|
|
2019
|
-
.describe("
|
|
2040
|
+
.describe("Deprecated compatibility flag. It no longer creates another Workspace identity for the same physical checkout or managed worktree; ForgeRelay reuses that target's canonical Workspace. Use newWorktree=true when the user explicitly needs separate Git isolation."),
|
|
2020
2041
|
context: z
|
|
2021
2042
|
.enum(["auto", "full", "none"])
|
|
2022
2043
|
.optional()
|
|
@@ -2036,7 +2057,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2036
2057
|
staleOnly: z
|
|
2037
2058
|
.boolean()
|
|
2038
2059
|
.optional()
|
|
2039
|
-
.describe("For action=list, return only active
|
|
2060
|
+
.describe("For action=list, return only active Workspaces idle for more than two days."),
|
|
2040
2061
|
offset: z
|
|
2041
2062
|
.number()
|
|
2042
2063
|
.int()
|
|
@@ -2570,7 +2591,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2570
2591
|
const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
|
|
2571
2592
|
const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
|
|
2572
2593
|
const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
|
|
2573
|
-
const workspaceManagementInstruction = "When you need to continue
|
|
2594
|
+
const workspaceManagementInstruction = "When you need to inspect known Workspaces, continue earlier work, or organize Workspace state, use open_workspace(action=\"list\") to inspect candidates, then resume a selected workspaceId or ask the user before close_workspace cleanup.";
|
|
2574
2595
|
const cardInstruction = config.skillsEnabled
|
|
2575
2596
|
? `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill, load it with read(path=\"skills://<name>\") before proceeding. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`
|
|
2576
2597
|
: `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`;
|
|
@@ -2632,7 +2653,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2632
2653
|
? `Known worktrees: ${knownWorktrees.map((worktree) => `${worktree.path} [${worktree.workspaceId}]${worktree.branch ? ` branch=${worktree.branch}` : ""}${worktree.targetBranch ? ` target=${worktree.targetBranch}` : ""}${worktree.current ? " (current)" : ""}`).join(", ")}`
|
|
2633
2654
|
: undefined,
|
|
2634
2655
|
staleWorkspaces.length > 0
|
|
2635
|
-
? `
|
|
2656
|
+
? `This Workspace has been idle for more than 2 days: ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. It remains available to resume or explicitly close; do not clean it up automatically.`
|
|
2636
2657
|
: undefined,
|
|
2637
2658
|
`ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
|
|
2638
2659
|
instruction,
|
|
@@ -2764,6 +2785,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2764
2785
|
outputSchema: {
|
|
2765
2786
|
name: z.string(),
|
|
2766
2787
|
action: z.enum(["describe", "run"]),
|
|
2788
|
+
member: z.string().optional(),
|
|
2767
2789
|
capability: z.unknown().optional(),
|
|
2768
2790
|
result: z.unknown().optional(),
|
|
2769
2791
|
error: capabilityErrorOutputSchema.optional(),
|
|
@@ -2899,7 +2921,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2899
2921
|
});
|
|
2900
2922
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
2901
2923
|
title: "Close workspace",
|
|
2902
|
-
description: "Close one
|
|
2924
|
+
description: "Close one Workspace after the user chooses cleanup. In v0.8.0, Composite close still dissolves only the Composite identity/member links. Checkout close removes ForgeRelay state but never project files. Managed-worktree-backed Workspaces run the safe finalize lifecycle (hooks, commit, fast-forward integration, cleanup) and require commitMessage. Running processes block ordinary Workspace closure.",
|
|
2903
2925
|
inputSchema: {
|
|
2904
2926
|
workspaceId: z.string().describe("Workspace identifier to close."),
|
|
2905
2927
|
commitMessage: z
|
|
@@ -2991,7 +3013,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2991
3013
|
const busyWorkspaceIds = physicalWorkspaceIds
|
|
2992
3014
|
.filter((id) => processSessions.activeWorkspaceIds().has(id));
|
|
2993
3015
|
if (busyWorkspaceIds.length > 0) {
|
|
2994
|
-
throw new Error(`Cannot close this worktree-backed
|
|
3016
|
+
throw new Error(`Cannot close this worktree-backed Workspace while Workspace processes are still running: ${busyWorkspaceIds.join(", ")}.`);
|
|
2995
3017
|
}
|
|
2996
3018
|
const startedAt = performance.now();
|
|
2997
3019
|
const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
|
|
@@ -9,18 +9,23 @@ export class SubagentSessionCapability {
|
|
|
9
9
|
activityLifecycle;
|
|
10
10
|
mailbox;
|
|
11
11
|
providerRunner;
|
|
12
|
+
ownerAliveOverride;
|
|
12
13
|
activeRuns = new Map();
|
|
13
14
|
constructor(config, activityLifecycle, options = {}) {
|
|
14
15
|
this.config = config;
|
|
15
16
|
this.activityLifecycle = activityLifecycle;
|
|
16
17
|
this.mailbox = new SubagentDeliveryMailbox(config.stateDir);
|
|
17
18
|
this.providerRunner = options.providerRunner ?? defaultProviderRunner;
|
|
19
|
+
this.ownerAliveOverride = options.ownerAlive;
|
|
18
20
|
}
|
|
19
21
|
async run(input, context, options) {
|
|
20
22
|
const manager = new SubagentSessionManager(this.config, {
|
|
21
23
|
launch: (request) => this.launch(request),
|
|
22
24
|
});
|
|
23
25
|
try {
|
|
26
|
+
const reconciled = manager.reconcile({ workspaceId: context.workspaceId }, (run) => this.ownerAlive(run));
|
|
27
|
+
for (const entry of reconciled)
|
|
28
|
+
this.recordInterruption(entry, options.activityId);
|
|
24
29
|
switch (input.operation) {
|
|
25
30
|
case "start": {
|
|
26
31
|
const started = await manager.start({
|
|
@@ -118,17 +123,19 @@ export class SubagentSessionCapability {
|
|
|
118
123
|
};
|
|
119
124
|
}
|
|
120
125
|
launch(request) {
|
|
126
|
+
const ownerId = `subagent-owner-${process.pid}-${request.runId}`;
|
|
121
127
|
const controller = new AbortController();
|
|
122
128
|
const completion = executeSubagentRun(this.config, { ...request, signal: controller.signal }, this.providerRunner).then((result) => {
|
|
123
129
|
this.recordCompletion(result);
|
|
124
130
|
return result;
|
|
125
131
|
});
|
|
126
|
-
this.activeRuns.set(request.runId, { controller, completion });
|
|
132
|
+
this.activeRuns.set(request.runId, { ownerId, controller, completion });
|
|
127
133
|
void completion.finally(() => {
|
|
128
134
|
this.activeRuns.delete(request.runId);
|
|
129
135
|
}).catch(() => {
|
|
130
136
|
// Unexpected orchestration failures surface through later reconciliation.
|
|
131
137
|
});
|
|
138
|
+
return { id: ownerId, pid: process.pid };
|
|
132
139
|
}
|
|
133
140
|
async stop(manager, sessionId, workspaceId) {
|
|
134
141
|
let session = manager.get(sessionId, { workspaceId });
|
|
@@ -158,6 +165,58 @@ export class SubagentSessionCapability {
|
|
|
158
165
|
...(session.latestRun?.id === activeRun.id ? { run: publicRun(session.latestRun) } : {}),
|
|
159
166
|
};
|
|
160
167
|
}
|
|
168
|
+
ownerAlive(run) {
|
|
169
|
+
if (this.ownerAliveOverride)
|
|
170
|
+
return this.ownerAliveOverride(run);
|
|
171
|
+
if (!run.ownerId || run.ownerPid === undefined)
|
|
172
|
+
return false;
|
|
173
|
+
const active = this.activeRuns.get(run.id);
|
|
174
|
+
if (run.ownerPid === process.pid)
|
|
175
|
+
return active?.ownerId === run.ownerId;
|
|
176
|
+
try {
|
|
177
|
+
process.kill(run.ownerPid, 0);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return error.code === "EPERM";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
recordInterruption(entry, fallbackActivityId) {
|
|
185
|
+
const sourceActivityId = entry.run.activityId ?? fallbackActivityId;
|
|
186
|
+
if (!sourceActivityId)
|
|
187
|
+
return;
|
|
188
|
+
const record = () => this.activityLifecycle.recordLinked({
|
|
189
|
+
sourceActivityId,
|
|
190
|
+
tool: "subagent_result",
|
|
191
|
+
request: { sessionId: entry.session.id, runId: entry.run.id },
|
|
192
|
+
result: {
|
|
193
|
+
sessionId: entry.session.id,
|
|
194
|
+
runId: entry.run.id,
|
|
195
|
+
provider: entry.session.provider,
|
|
196
|
+
status: "interrupted",
|
|
197
|
+
},
|
|
198
|
+
outcome: { type: "failed", error: "Subagent Run interrupted." },
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
record();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
if (!fallbackActivityId || fallbackActivityId === sourceActivityId)
|
|
205
|
+
return;
|
|
206
|
+
this.activityLifecycle.recordLinked({
|
|
207
|
+
sourceActivityId: fallbackActivityId,
|
|
208
|
+
tool: "subagent_result",
|
|
209
|
+
request: { sessionId: entry.session.id, runId: entry.run.id },
|
|
210
|
+
result: {
|
|
211
|
+
sessionId: entry.session.id,
|
|
212
|
+
runId: entry.run.id,
|
|
213
|
+
provider: entry.session.provider,
|
|
214
|
+
status: "interrupted",
|
|
215
|
+
},
|
|
216
|
+
outcome: { type: "failed", error: "Subagent Run interrupted." },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
161
220
|
recordCompletion(completion) {
|
|
162
221
|
if (!completion.activityId)
|
|
163
222
|
return;
|
|
@@ -52,13 +52,16 @@ export class SubagentSessionManager {
|
|
|
52
52
|
const run = session.activeRun;
|
|
53
53
|
if (!run)
|
|
54
54
|
throw new Error(`Subagent Session ${session.id} did not create an active Run.`);
|
|
55
|
-
this.launcher.launch({
|
|
55
|
+
const owned = this.assignOwner(session, run, this.launcher.launch({
|
|
56
56
|
sessionId: session.id,
|
|
57
57
|
runId,
|
|
58
58
|
...(input.activityId ? { activityId: input.activityId } : {}),
|
|
59
59
|
prompt: input.prompt,
|
|
60
|
-
});
|
|
61
|
-
return {
|
|
60
|
+
}));
|
|
61
|
+
return {
|
|
62
|
+
session: owned,
|
|
63
|
+
run: owned.activeRun ?? (owned.latestRun?.id === run.id ? owned.latestRun : run),
|
|
64
|
+
};
|
|
62
65
|
}
|
|
63
66
|
resume(input, scope = {}) {
|
|
64
67
|
const existing = this.store.getInScope(input.sessionId, scope);
|
|
@@ -90,13 +93,38 @@ export class SubagentSessionManager {
|
|
|
90
93
|
status: "running",
|
|
91
94
|
activeRun: run,
|
|
92
95
|
});
|
|
93
|
-
this.launcher.launch({
|
|
96
|
+
const owned = this.assignOwner(session, run, this.launcher.launch({
|
|
94
97
|
sessionId: session.id,
|
|
95
98
|
runId,
|
|
96
99
|
...(input.activityId ? { activityId: input.activityId } : {}),
|
|
97
100
|
prompt: input.prompt,
|
|
98
|
-
});
|
|
99
|
-
return {
|
|
101
|
+
}));
|
|
102
|
+
return {
|
|
103
|
+
session: owned,
|
|
104
|
+
run: owned.activeRun ?? (owned.latestRun?.id === run.id ? owned.latestRun : run),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
reconcile(scope, ownerAlive) {
|
|
108
|
+
const reconciled = [];
|
|
109
|
+
for (const session of this.store.list(scope)) {
|
|
110
|
+
const run = session.activeRun;
|
|
111
|
+
if (!run || ownerAlive(run))
|
|
112
|
+
continue;
|
|
113
|
+
const interrupted = {
|
|
114
|
+
id: run.id,
|
|
115
|
+
status: "interrupted",
|
|
116
|
+
...(run.activityId ? { activityId: run.activityId } : {}),
|
|
117
|
+
...(run.startedAt ? { startedAt: run.startedAt } : {}),
|
|
118
|
+
finishedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
const updated = this.store.update(session.id, {
|
|
121
|
+
status: "idle",
|
|
122
|
+
activeRun: undefined,
|
|
123
|
+
latestRun: interrupted,
|
|
124
|
+
});
|
|
125
|
+
reconciled.push({ session: updated, run: interrupted });
|
|
126
|
+
}
|
|
127
|
+
return reconciled;
|
|
100
128
|
}
|
|
101
129
|
delete(sessionId, scope = {}) {
|
|
102
130
|
const session = this.store.getInScope(sessionId, scope);
|
|
@@ -112,6 +140,11 @@ export class SubagentSessionManager {
|
|
|
112
140
|
close() {
|
|
113
141
|
this.store.close();
|
|
114
142
|
}
|
|
143
|
+
assignOwner(session, run, owner) {
|
|
144
|
+
if (!owner)
|
|
145
|
+
return session;
|
|
146
|
+
return this.store.assignActiveRunOwner(session.id, run.id, owner);
|
|
147
|
+
}
|
|
115
148
|
}
|
|
116
149
|
function newRunId() {
|
|
117
150
|
return `run_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
@@ -3,6 +3,7 @@ export function createSubagentMcpRuntime(config, activityLifecycle, options = {}
|
|
|
3
3
|
const capability = config.subagents
|
|
4
4
|
? new SubagentSessionCapability(config, activityLifecycle, {
|
|
5
5
|
providerRunner: options.subagentProviderRunner,
|
|
6
|
+
ownerAlive: options.subagentOwnerAlive,
|
|
6
7
|
})
|
|
7
8
|
: undefined;
|
|
8
9
|
return {
|
|
@@ -47,6 +47,8 @@ export class SubagentSessionStore {
|
|
|
47
47
|
status: "running",
|
|
48
48
|
...(input.activeRun.activityId ? { activityId: input.activeRun.activityId } : {}),
|
|
49
49
|
startedAt: input.activeRun.startedAt,
|
|
50
|
+
...(input.activeRun.ownerId ? { ownerId: input.activeRun.ownerId } : {}),
|
|
51
|
+
...(input.activeRun.ownerPid !== undefined ? { ownerPid: input.activeRun.ownerPid } : {}),
|
|
50
52
|
},
|
|
51
53
|
}
|
|
52
54
|
: {}),
|
|
@@ -67,6 +69,8 @@ export class SubagentSessionStore {
|
|
|
67
69
|
active_run_id,
|
|
68
70
|
active_activity_id,
|
|
69
71
|
active_run_started_at,
|
|
72
|
+
active_owner_id,
|
|
73
|
+
active_owner_pid,
|
|
70
74
|
latest_run_id,
|
|
71
75
|
latest_run_outcome,
|
|
72
76
|
latest_run_finished_at,
|
|
@@ -75,8 +79,8 @@ export class SubagentSessionStore {
|
|
|
75
79
|
hook_reports_json,
|
|
76
80
|
created_at,
|
|
77
81
|
updated_at
|
|
78
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, null, ?, ?)`)
|
|
79
|
-
.run(record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, record.provider, record.model ?? null, record.thinking ?? null, null, record.status, record.activeRun?.id ?? null, record.activeRun?.activityId ?? null, record.activeRun?.startedAt ?? null, null, null, null, record.createdAt, record.updatedAt);
|
|
82
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null, null, ?, ?)`)
|
|
83
|
+
.run(record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, record.provider, record.model ?? null, record.thinking ?? null, null, record.status, record.activeRun?.id ?? null, record.activeRun?.activityId ?? null, record.activeRun?.startedAt ?? null, record.activeRun?.ownerId ?? null, record.activeRun?.ownerPid ?? null, null, null, null, record.createdAt, record.updatedAt);
|
|
80
84
|
return record;
|
|
81
85
|
}
|
|
82
86
|
get(idOrPrefix) {
|
|
@@ -126,6 +130,8 @@ export class SubagentSessionStore {
|
|
|
126
130
|
active_run_id = ?,
|
|
127
131
|
active_activity_id = ?,
|
|
128
132
|
active_run_started_at = ?,
|
|
133
|
+
active_owner_id = ?,
|
|
134
|
+
active_owner_pid = ?,
|
|
129
135
|
latest_run_id = ?,
|
|
130
136
|
latest_run_outcome = ?,
|
|
131
137
|
latest_run_finished_at = ?,
|
|
@@ -134,9 +140,18 @@ export class SubagentSessionStore {
|
|
|
134
140
|
hook_reports_json = null,
|
|
135
141
|
updated_at = ?
|
|
136
142
|
where id = ?`)
|
|
137
|
-
.run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.activeRun?.id ?? null, updated.activeRun?.activityId ?? null, updated.activeRun?.startedAt ?? null, updated.latestRun?.id ?? null, updated.latestRun && updated.latestRun.status !== "running" ? updated.latestRun.status : null, updated.latestRun?.finishedAt ?? null, updated.updatedAt, updated.id);
|
|
143
|
+
.run(updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, updated.provider, updated.model ?? null, updated.thinking ?? null, updated.providerSessionId ?? null, updated.status, updated.activeRun?.id ?? null, updated.activeRun?.activityId ?? null, updated.activeRun?.startedAt ?? null, updated.activeRun?.ownerId ?? null, updated.activeRun?.ownerPid ?? null, updated.latestRun?.id ?? null, updated.latestRun && updated.latestRun.status !== "running" ? updated.latestRun.status : null, updated.latestRun?.finishedAt ?? null, updated.updatedAt, updated.id);
|
|
138
144
|
return updated;
|
|
139
145
|
}
|
|
146
|
+
assignActiveRunOwner(id, runId, owner) {
|
|
147
|
+
this.database.sqlite.prepare(`update local_agent_sessions
|
|
148
|
+
set active_owner_id = ?, active_owner_pid = ?, updated_at = ?
|
|
149
|
+
where id = ? and active_run_id = ?`).run(owner.id, owner.pid ?? null, new Date().toISOString(), id, runId);
|
|
150
|
+
const current = this.getById(id);
|
|
151
|
+
if (!current)
|
|
152
|
+
throw new Error(`Unknown subagent id: ${id}`);
|
|
153
|
+
return current;
|
|
154
|
+
}
|
|
140
155
|
delete(id) {
|
|
141
156
|
this.database.sqlite.prepare("delete from local_agent_sessions where id = ?").run(id);
|
|
142
157
|
}
|
|
@@ -160,6 +175,8 @@ function rowToSubagentSession(row) {
|
|
|
160
175
|
status: "running",
|
|
161
176
|
...(row.active_activity_id ? { activityId: row.active_activity_id } : {}),
|
|
162
177
|
...(row.active_run_started_at ? { startedAt: row.active_run_started_at } : {}),
|
|
178
|
+
...(row.active_owner_id ? { ownerId: row.active_owner_id } : {}),
|
|
179
|
+
...(row.active_owner_pid !== null ? { ownerPid: row.active_owner_pid } : {}),
|
|
163
180
|
}
|
|
164
181
|
: undefined;
|
|
165
182
|
const latestOutcome = readOutcome(row.latest_run_outcome);
|
package/dist/workspace-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { openDatabase } from "./db/client.js";
|
|
3
|
-
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
|
|
3
|
+
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessionAliases, workspaceSessions, } from "./db/schema.js";
|
|
4
4
|
const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
|
|
5
5
|
export class SqliteWorkspaceStore {
|
|
6
6
|
database;
|
|
@@ -61,24 +61,33 @@ export class SqliteWorkspaceStore {
|
|
|
61
61
|
return session;
|
|
62
62
|
}
|
|
63
63
|
getSession(id) {
|
|
64
|
+
const sessionId = this.resolveSessionId(id);
|
|
65
|
+
if (!sessionId)
|
|
66
|
+
return undefined;
|
|
64
67
|
const row = this.database.db
|
|
65
68
|
.select()
|
|
66
69
|
.from(workspaceSessions)
|
|
67
|
-
.where(eq(workspaceSessions.id,
|
|
70
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
68
71
|
.get();
|
|
69
72
|
if (!row)
|
|
70
73
|
return undefined;
|
|
71
|
-
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(
|
|
74
|
+
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(sessionId));
|
|
72
75
|
}
|
|
73
76
|
touchSession(id) {
|
|
74
|
-
this.
|
|
77
|
+
const sessionId = this.resolveSessionId(id);
|
|
78
|
+
if (!sessionId)
|
|
79
|
+
return;
|
|
80
|
+
this.pendingSessionTouches.set(sessionId, this.now().toISOString());
|
|
75
81
|
}
|
|
76
82
|
setSessionStatus(id, status) {
|
|
77
|
-
this.
|
|
83
|
+
const sessionId = this.resolveSessionId(id);
|
|
84
|
+
if (!sessionId)
|
|
85
|
+
return;
|
|
86
|
+
this.pendingSessionTouches.delete(sessionId);
|
|
78
87
|
this.database.db
|
|
79
88
|
.update(workspaceSessions)
|
|
80
89
|
.set({ status, lastUsedAt: this.now().toISOString() })
|
|
81
|
-
.where(eq(workspaceSessions.id,
|
|
90
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
82
91
|
.run();
|
|
83
92
|
}
|
|
84
93
|
listSessions(input = {}) {
|
|
@@ -100,11 +109,56 @@ export class SqliteWorkspaceStore {
|
|
|
100
109
|
.map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
|
|
101
110
|
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
102
111
|
}
|
|
112
|
+
foldSessions(input) {
|
|
113
|
+
const aliases = input.aliasIds.filter((id) => id !== input.canonicalId);
|
|
114
|
+
if (aliases.length === 0)
|
|
115
|
+
return;
|
|
116
|
+
const fold = this.database.sqlite.transaction(() => {
|
|
117
|
+
this.database.sqlite.prepare(`
|
|
118
|
+
update workspace_sessions
|
|
119
|
+
set created_at = ?, last_used_at = ?, status = ?
|
|
120
|
+
where id = ?
|
|
121
|
+
`).run(input.createdAt, input.lastUsedAt, input.status, input.canonicalId);
|
|
122
|
+
const rebindConversation = this.database.sqlite.prepare(`
|
|
123
|
+
update workspace_conversation_bindings
|
|
124
|
+
set workspace_session_id = ?
|
|
125
|
+
where workspace_session_id = ?
|
|
126
|
+
`);
|
|
127
|
+
const rebindSubagents = this.database.sqlite.prepare(`
|
|
128
|
+
update local_agent_sessions
|
|
129
|
+
set workspace_id = ?
|
|
130
|
+
where workspace_id = ?
|
|
131
|
+
`);
|
|
132
|
+
const rebindAliases = this.database.sqlite.prepare(`
|
|
133
|
+
update workspace_session_aliases
|
|
134
|
+
set workspace_session_id = ?
|
|
135
|
+
where workspace_session_id = ?
|
|
136
|
+
`);
|
|
137
|
+
const deleteSession = this.database.sqlite.prepare("delete from workspace_sessions where id = ?");
|
|
138
|
+
const rememberAlias = this.database.sqlite.prepare(`
|
|
139
|
+
insert into workspace_session_aliases (alias_id, workspace_session_id)
|
|
140
|
+
values (?, ?)
|
|
141
|
+
on conflict(alias_id) do update set workspace_session_id = excluded.workspace_session_id
|
|
142
|
+
`);
|
|
143
|
+
for (const aliasId of aliases) {
|
|
144
|
+
rebindConversation.run(input.canonicalId, aliasId);
|
|
145
|
+
rebindSubagents.run(input.canonicalId, aliasId);
|
|
146
|
+
rebindAliases.run(input.canonicalId, aliasId);
|
|
147
|
+
deleteSession.run(aliasId);
|
|
148
|
+
rememberAlias.run(aliasId, input.canonicalId);
|
|
149
|
+
this.pendingSessionTouches.delete(aliasId);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
fold.immediate();
|
|
153
|
+
}
|
|
103
154
|
deleteSession(id) {
|
|
104
|
-
this.
|
|
155
|
+
const sessionId = this.resolveSessionId(id);
|
|
156
|
+
if (!sessionId)
|
|
157
|
+
return;
|
|
158
|
+
this.pendingSessionTouches.delete(sessionId);
|
|
105
159
|
this.database.db
|
|
106
160
|
.delete(workspaceSessions)
|
|
107
|
-
.where(eq(workspaceSessions.id,
|
|
161
|
+
.where(eq(workspaceSessions.id, sessionId))
|
|
108
162
|
.run();
|
|
109
163
|
}
|
|
110
164
|
listConversationBindings() {
|
|
@@ -212,6 +266,21 @@ export class SqliteWorkspaceStore {
|
|
|
212
266
|
.where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
|
|
213
267
|
.run();
|
|
214
268
|
}
|
|
269
|
+
resolveSessionId(id) {
|
|
270
|
+
const direct = this.database.db
|
|
271
|
+
.select({ id: workspaceSessions.id })
|
|
272
|
+
.from(workspaceSessions)
|
|
273
|
+
.where(eq(workspaceSessions.id, id))
|
|
274
|
+
.get();
|
|
275
|
+
if (direct)
|
|
276
|
+
return direct.id;
|
|
277
|
+
return this.database.db
|
|
278
|
+
.select({ workspaceSessionId: workspaceSessionAliases.workspaceSessionId })
|
|
279
|
+
.from(workspaceSessionAliases)
|
|
280
|
+
.where(eq(workspaceSessionAliases.aliasId, id))
|
|
281
|
+
.get()
|
|
282
|
+
?.workspaceSessionId;
|
|
283
|
+
}
|
|
215
284
|
get pendingTouchCount() {
|
|
216
285
|
return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
|
|
217
286
|
}
|
package/dist/workspaces.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -23,6 +24,7 @@ export class WorkspaceRegistry {
|
|
|
23
24
|
this.config = config;
|
|
24
25
|
this.store = store;
|
|
25
26
|
this.hooks = new HookRunner(config.hooks, config.logging);
|
|
27
|
+
this.foldLegacyWorkspaceSessions();
|
|
26
28
|
this.pruneIdleWorkspaceSessions(new Set(), true);
|
|
27
29
|
}
|
|
28
30
|
get cachedWorkspaceCount() {
|
|
@@ -42,7 +44,7 @@ export class WorkspaceRegistry {
|
|
|
42
44
|
if (mode === "worktree") {
|
|
43
45
|
return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
|
|
44
46
|
}
|
|
45
|
-
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId,
|
|
47
|
+
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, bootstrapContext);
|
|
46
48
|
}
|
|
47
49
|
async listWorkspaces(input = {}, openOptions = {}) {
|
|
48
50
|
this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
|
|
@@ -205,22 +207,23 @@ export class WorkspaceRegistry {
|
|
|
205
207
|
}
|
|
206
208
|
closeWorkspace(workspaceId) {
|
|
207
209
|
const workspace = this.getWorkspace(workspaceId);
|
|
210
|
+
const canonicalWorkspaceId = workspace.id;
|
|
208
211
|
if (workspace.mode === "worktree") {
|
|
209
212
|
const aliases = this.activeSessions("worktree")
|
|
210
213
|
.filter((session) => resolve(session.root) === resolve(workspace.root));
|
|
211
214
|
if (aliases.length <= 1) {
|
|
212
|
-
throw new Error(`Workspace ${
|
|
215
|
+
throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle instead of releasing the physical target as a separate handle.`);
|
|
213
216
|
}
|
|
214
217
|
}
|
|
215
218
|
if (this.store) {
|
|
216
219
|
for (const binding of this.store.listConversationBindings()) {
|
|
217
|
-
if (binding.workspaceSessionId ===
|
|
220
|
+
if (binding.workspaceSessionId === canonicalWorkspaceId) {
|
|
218
221
|
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
|
-
this.store.deleteSession(
|
|
224
|
+
this.store.deleteSession(canonicalWorkspaceId);
|
|
222
225
|
}
|
|
223
|
-
this.workspaces.delete(
|
|
226
|
+
this.workspaces.delete(canonicalWorkspaceId);
|
|
224
227
|
}
|
|
225
228
|
workspaceIdsForPhysicalWorkspace(workspace) {
|
|
226
229
|
const root = resolve(workspace.root);
|
|
@@ -325,30 +328,18 @@ export class WorkspaceRegistry {
|
|
|
325
328
|
}
|
|
326
329
|
return { ...result, hookReports };
|
|
327
330
|
}
|
|
328
|
-
async openReusableCheckout(path, conversationScopeId,
|
|
331
|
+
async openReusableCheckout(path, conversationScopeId, bootstrapContext) {
|
|
329
332
|
const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
|
|
330
333
|
const projectKey = await canonicalPath(allowedPath);
|
|
331
334
|
const targetKey = JSON.stringify(["checkout", projectKey, null]);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
if (newWorkspace) {
|
|
338
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
339
|
-
const freshContext = reusableWorkspace
|
|
340
|
-
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
341
|
-
: await this.openCheckoutWorkspace(path);
|
|
342
|
-
return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
|
|
343
|
-
}
|
|
344
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
345
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
335
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
|
|
336
|
+
if (boundContext)
|
|
337
|
+
return boundContext;
|
|
338
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
346
339
|
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
347
340
|
if (!reusableWorkspace)
|
|
348
341
|
return this.openCheckoutWorkspace(path);
|
|
349
|
-
return
|
|
350
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
351
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
342
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
352
343
|
});
|
|
353
344
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
354
345
|
}
|
|
@@ -361,27 +352,15 @@ export class WorkspaceRegistry {
|
|
|
361
352
|
if (managedPath) {
|
|
362
353
|
const worktreeKey = await canonicalPath(managedPath);
|
|
363
354
|
const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
if (input.newWorkspace) {
|
|
370
|
-
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
371
|
-
if (!reusableWorkspace) {
|
|
372
|
-
throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
|
|
373
|
-
}
|
|
374
|
-
return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
|
|
375
|
-
}
|
|
376
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
377
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
355
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
|
|
356
|
+
if (boundContext)
|
|
357
|
+
return boundContext;
|
|
358
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
378
359
|
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
379
360
|
if (!reusableWorkspace) {
|
|
380
361
|
throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
|
|
381
362
|
}
|
|
382
|
-
return
|
|
383
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
384
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
363
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
385
364
|
});
|
|
386
365
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
387
366
|
}
|
|
@@ -396,29 +375,17 @@ export class WorkspaceRegistry {
|
|
|
396
375
|
const context = await this.openWorktreeWorkspace(path, input.baseRef);
|
|
397
376
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
398
377
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
if (input.newWorkspace) {
|
|
408
|
-
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
409
|
-
const freshContext = reusableWorkspace
|
|
410
|
-
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
411
|
-
: await this.openWorktreeWorkspace(path, input.baseRef);
|
|
412
|
-
return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
|
|
413
|
-
}
|
|
414
|
-
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
415
|
-
const context = await this.openOnce(operationKey, async () => {
|
|
378
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
|
|
379
|
+
session.sourceRoot !== undefined &&
|
|
380
|
+
await canonicalPath(session.sourceRoot) === sourceKey &&
|
|
381
|
+
session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
|
|
382
|
+
if (boundContext)
|
|
383
|
+
return boundContext;
|
|
384
|
+
const context = await this.openOnce(targetKey, async () => {
|
|
416
385
|
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
417
386
|
if (!reusableWorkspace)
|
|
418
387
|
return this.openWorktreeWorkspace(path, input.baseRef);
|
|
419
|
-
return
|
|
420
|
-
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
421
|
-
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
388
|
+
return this.reusedWorkspaceContext(reusableWorkspace);
|
|
422
389
|
});
|
|
423
390
|
return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
|
|
424
391
|
}
|
|
@@ -453,11 +420,6 @@ export class WorkspaceRegistry {
|
|
|
453
420
|
}
|
|
454
421
|
return keys;
|
|
455
422
|
}
|
|
456
|
-
conversationOpenKey(targetKey, conversationScopeId) {
|
|
457
|
-
return conversationScopeId && this.store
|
|
458
|
-
? JSON.stringify(["conversation", conversationScopeId, targetKey])
|
|
459
|
-
: targetKey;
|
|
460
|
-
}
|
|
461
423
|
async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
|
|
462
424
|
if (!conversationScopeId || !this.store)
|
|
463
425
|
return undefined;
|
|
@@ -550,10 +512,41 @@ export class WorkspaceRegistry {
|
|
|
550
512
|
worktreeAnchors.get(resolve(session.root))?.id === session.id) {
|
|
551
513
|
continue;
|
|
552
514
|
}
|
|
553
|
-
this.store.deleteSession(session.id);
|
|
554
515
|
this.workspaces.delete(session.id);
|
|
555
516
|
}
|
|
556
517
|
}
|
|
518
|
+
foldLegacyWorkspaceSessions() {
|
|
519
|
+
if (!this.store)
|
|
520
|
+
return;
|
|
521
|
+
const groups = new Map();
|
|
522
|
+
for (const session of this.store.listSessions()) {
|
|
523
|
+
const targetPath = canonicalPersistedWorkspacePath(session.root);
|
|
524
|
+
const key = JSON.stringify([session.mode, targetPath]);
|
|
525
|
+
const group = groups.get(key) ?? [];
|
|
526
|
+
group.push(session);
|
|
527
|
+
groups.set(key, group);
|
|
528
|
+
}
|
|
529
|
+
for (const sessions of groups.values()) {
|
|
530
|
+
if (sessions.length < 2)
|
|
531
|
+
continue;
|
|
532
|
+
const ordered = [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
533
|
+
const canonical = ordered[0];
|
|
534
|
+
if (!canonical)
|
|
535
|
+
continue;
|
|
536
|
+
const createdAt = ordered.reduce((earliest, session) => session.createdAt < earliest ? session.createdAt : earliest, canonical.createdAt);
|
|
537
|
+
const lastUsedAt = ordered.reduce((latest, session) => session.lastUsedAt > latest ? session.lastUsedAt : latest, canonical.lastUsedAt);
|
|
538
|
+
const status = ordered.some((session) => session.status === "active")
|
|
539
|
+
? "active"
|
|
540
|
+
: canonical.status;
|
|
541
|
+
this.store.foldSessions({
|
|
542
|
+
canonicalId: canonical.id,
|
|
543
|
+
aliasIds: ordered.slice(1).map((session) => session.id),
|
|
544
|
+
createdAt,
|
|
545
|
+
lastUsedAt,
|
|
546
|
+
status,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
557
550
|
async findReusableWorkspaceByDirectory(directoryKey, mode) {
|
|
558
551
|
for (const session of this.activeSessions(mode)) {
|
|
559
552
|
const root = await this.validSessionRoot(session);
|
|
@@ -623,14 +616,6 @@ export class WorkspaceRegistry {
|
|
|
623
616
|
throw error;
|
|
624
617
|
}
|
|
625
618
|
}
|
|
626
|
-
async cloneWorkspaceContext(workspace) {
|
|
627
|
-
return this.createWorkspaceContext({
|
|
628
|
-
root: workspace.root,
|
|
629
|
-
mode: workspace.mode,
|
|
630
|
-
sourceRoot: workspace.sourceRoot,
|
|
631
|
-
worktree: workspace.worktree ? { ...workspace.worktree } : undefined,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
619
|
async reusedWorkspaceContext(workspace) {
|
|
635
620
|
Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
|
|
636
621
|
workspace.capabilityGuides = loadCapabilityGuides(this.config);
|
|
@@ -1036,6 +1021,25 @@ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFile
|
|
|
1036
1021
|
};
|
|
1037
1022
|
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
1038
1023
|
}
|
|
1024
|
+
function canonicalPersistedWorkspacePath(path) {
|
|
1025
|
+
const missingSegments = [];
|
|
1026
|
+
let candidate = path;
|
|
1027
|
+
while (true) {
|
|
1028
|
+
try {
|
|
1029
|
+
return resolve(realpathSync(candidate), ...missingSegments.slice().reverse());
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
|
1033
|
+
return resolve(path);
|
|
1034
|
+
}
|
|
1035
|
+
const parent = dirname(candidate);
|
|
1036
|
+
if (parent === candidate)
|
|
1037
|
+
return resolve(path);
|
|
1038
|
+
missingSegments.push(basename(candidate));
|
|
1039
|
+
candidate = parent;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1039
1043
|
async function canonicalPath(path) {
|
|
1040
1044
|
const missingSegments = [];
|
|
1041
1045
|
let candidate = path;
|
|
@@ -9,14 +9,17 @@ checkout.
|
|
|
9
9
|
`open_workspace` returns a `workspaceId`. Continue using that ID for later tools
|
|
10
10
|
in the same directory.
|
|
11
11
|
|
|
12
|
-
`workspaceId`
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
`workspaceId` identifies a persistent ForgeRelay Workspace rather than a
|
|
13
|
+
conversation-scoped handle. A canonical checkout path maps to one checkout
|
|
14
|
+
Workspace, and a managed worktree path maps to one managed-worktree Workspace.
|
|
15
|
+
Different Host conversations may bind to and reuse the same `workspaceId`; pass a
|
|
16
|
+
known ID explicitly when the user wants to resume that Workspace. Historical
|
|
17
|
+
duplicate IDs created by older ForgeRelay versions are accepted during migration
|
|
18
|
+
and resolve to the canonical Workspace identity.
|
|
19
|
+
|
|
20
|
+
A Git worktree directory is a separate physical Workspace target from its source
|
|
21
|
+
checkout. Separate parallel identities require separate managed worktrees rather
|
|
22
|
+
than multiple logical handles pointing at one physical target.
|
|
20
23
|
|
|
21
24
|
### Bootstrap context and workspace inventory
|
|
22
25
|
|
|
@@ -28,12 +31,11 @@ open_workspace(path="~/project")
|
|
|
28
31
|
|
|
29
32
|
The default `context="auto"` keeps the first useful bootstrap while avoiding
|
|
30
33
|
replay. ForgeRelay tracks delivered context by conversation plus canonical
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
automatic open returns the refreshed bootstrap.
|
|
34
|
+
Workspace target and a content fingerprint, independently from persistent
|
|
35
|
+
Workspace identity. Different conversations may reuse the same `workspaceId` while
|
|
36
|
+
each receives the current bootstrap once. If loaded instruction contents or
|
|
37
|
+
relevant Skill, Capability guide, profile, diagnostic, or nested-instruction
|
|
38
|
+
metadata changes, the next automatic open returns the refreshed bootstrap.
|
|
37
39
|
|
|
38
40
|
Two explicit controls are available for exceptional cases:
|
|
39
41
|
|
|
@@ -42,15 +44,14 @@ open_workspace(workspaceId="ws_...", context="full")
|
|
|
42
44
|
open_workspace(workspaceId="ws_...", context="none")
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
`full` forces a bootstrap refresh. `none` opens/resumes the
|
|
47
|
+
`full` forces a bootstrap refresh. `none` opens/resumes the Workspace without
|
|
46
48
|
returning the full project context and does not record the current fingerprint as
|
|
47
|
-
already delivered. Context-delivery state
|
|
48
|
-
|
|
49
|
-
forget unchanged project context it already received.
|
|
49
|
+
already delivered. Context-delivery state remains conversation-scoped and does not
|
|
50
|
+
change the persistent Workspace identity.
|
|
50
51
|
|
|
51
|
-
Do not enumerate
|
|
52
|
-
continue
|
|
53
|
-
|
|
52
|
+
Do not enumerate Workspace state on every normal open. When the user wants to
|
|
53
|
+
continue earlier work, inspect known Workspaces, or clean up accumulated state, use
|
|
54
|
+
the same Core tool in inventory mode:
|
|
54
55
|
|
|
55
56
|
```text
|
|
56
57
|
open_workspace(action="list")
|
|
@@ -62,7 +63,7 @@ Inventory is paginated, defaults to 50 entries, and caps each page at 100. It ca
|
|
|
62
63
|
filter by `workspaceId`, persisted `status`, derived `state`, `mode`, canonical
|
|
63
64
|
root/source root, or stale-only state. Entries include a compact label such as
|
|
64
65
|
`project/ws_...`, checkout/worktree backing metadata, timestamps, idle duration,
|
|
65
|
-
root validity, and whether that
|
|
66
|
+
root validity, and whether that Workspace is currently selected by this
|
|
66
67
|
conversation. Listing is observational and does not refresh `lastUsedAt`.
|
|
67
68
|
|
|
68
69
|
Treat persisted status and derived state separately. `status="active"` means the
|
|
@@ -278,18 +279,20 @@ surface, including direct `rename`/`delete` path mutations alongside `apply_patc
|
|
|
278
279
|
unified move/rename primitive for both files and directories; ForgeRelay does not
|
|
279
280
|
expose a separate `move` tool.
|
|
280
281
|
|
|
281
|
-
Workspace IDs are
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
may still include `staleWorkspaces`
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
282
|
+
Workspace IDs are persistent ForgeRelay identities. The same canonical checkout
|
|
283
|
+
or managed worktree is reused across conversations; older duplicate IDs remain
|
|
284
|
+
compatibility aliases that resolve to the canonical Workspace. `newWorkspace` is
|
|
285
|
+
deprecated and no longer allocates a second identity for the same physical target;
|
|
286
|
+
use `newWorktree=true` when genuinely separate Git isolation is required. The
|
|
287
|
+
normal open path may still include `staleWorkspaces` for an idle persistent
|
|
288
|
+
Workspace; use `open_workspace(action="list")` for complete, filtered inventory
|
|
289
|
+
when continuation or cleanup actually requires it.
|
|
290
|
+
|
|
291
|
+
In v0.8.0, `close_workspace` is still the existing public close operation:
|
|
292
|
+
checkout-backed Workspaces remove their ForgeRelay record without deleting project
|
|
293
|
+
files, while managed-worktree-backed Workspaces require `commitMessage` and run the
|
|
294
|
+
safe commit / fast-forward-only integration / cleanup lifecycle. Persistent
|
|
295
|
+
closed/reopen semantics land in the following lifecycle stage.
|
|
293
296
|
|
|
294
297
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
295
298
|
natural part of the user's requested development task; ForgeRelay does not apply
|
package/docs/configuration.md
CHANGED
|
@@ -286,24 +286,27 @@ is no separate `move` MCP tool.
|
|
|
286
286
|
Codex-mode commands run without a PTY by default. `tty: true` enables interactive
|
|
287
287
|
programs when the optional `node-pty` dependency is available.
|
|
288
288
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
Workspace IDs identify persistent ForgeRelay Workspaces rather than individual
|
|
290
|
+
conversation handles. A canonical checkout path maps to one checkout Workspace, and
|
|
291
|
+
a managed worktree path maps to one managed-worktree Workspace; different Host
|
|
292
|
+
conversations can bind to and reuse the same `workspaceId`. Pass `workspaceId` to
|
|
293
|
+
`open_workspace` to resume that known Workspace explicitly. Historical duplicate
|
|
294
|
+
IDs created by older ForgeRelay versions remain accepted during migration and
|
|
295
|
+
resolve to the canonical Workspace. `newWorkspace: true` is retained only as a
|
|
296
|
+
deprecated compatibility input and no longer allocates another identity for the
|
|
297
|
+
same physical target; use `newWorktree: true` for genuinely separate Git isolation.
|
|
298
|
+
|
|
299
|
+
Bootstrap delivery is tracked separately from Workspace identity.
|
|
298
300
|
`open_workspace` defaults to `context="auto"`: ForgeRelay fingerprints the current
|
|
299
301
|
project context and returns the full AGENTS/Skills/Capability-guide/profile bootstrap
|
|
300
302
|
only when that conversation has not already received the current fingerprint for
|
|
301
303
|
the canonical workspace target. `context="full"` forces a refresh;
|
|
302
|
-
`context="none"` opens or resumes the
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
304
|
+
`context="none"` opens or resumes the Workspace without returning the full bootstrap
|
|
305
|
+
and does not mark the current fingerprint as delivered. Conversation-scoped
|
|
306
|
+
bootstrap delivery therefore remains independent from the persistent Workspace
|
|
307
|
+
identity: another conversation may reuse the same Workspace while independently
|
|
308
|
+
receiving the current bootstrap once, and changed context produces a new fingerprint
|
|
309
|
+
for the next `auto` open.
|
|
307
310
|
|
|
308
311
|
Composite Workspaces use the same `open_workspace` entry point with
|
|
309
312
|
`kind="composite"` and a human-readable `name`. They have no filesystem root of
|
|
@@ -324,20 +327,21 @@ Hooks, Skills, language services, and Activity facts remain owned by the underly
|
|
|
324
327
|
Workspace. The Composite Activity Panel only aggregates their presentation into one
|
|
325
328
|
Host Turn.
|
|
326
329
|
|
|
327
|
-
Use `open_workspace(action="list")` only when the Agent needs to
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
Use `open_workspace(action="list")` only when the Agent needs to inspect known
|
|
331
|
+
Workspaces, continue earlier work, or organize Workspace state. The inventory is
|
|
332
|
+
paginated (50 records by default, at most 100) and can filter by Workspace ID,
|
|
333
|
+
persisted status, derived state, mode, canonical root/source root, or stale-only
|
|
334
|
+
state. Reading inventory does not refresh `lastUsedAt`. Persisted
|
|
332
335
|
`status="active"` means the record has not been explicitly closed; the derived
|
|
333
336
|
`state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
|
|
334
337
|
or externally removed managed-worktree root can therefore remain diagnostically
|
|
335
|
-
`status="active"` while appearing as `state="invalid"`.
|
|
336
|
-
|
|
337
|
-
`action="list"`
|
|
338
|
+
`status="active"` while appearing as `state="invalid"`. Canonical identity means
|
|
339
|
+
ordinary same-target opens no longer accumulate duplicate inventory rows;
|
|
340
|
+
`action="list"` remains the formal on-demand inventory path.
|
|
338
341
|
|
|
339
|
-
`close_workspace` removes a checkout-backed
|
|
340
|
-
files
|
|
342
|
+
In v0.8.0, `close_workspace` still removes a checkout-backed Workspace record without
|
|
343
|
+
deleting checkout files; the persistent closed/reopen lifecycle is introduced by
|
|
344
|
+
the next lifecycle stage. For a managed-worktree-backed Workspace, `close_workspace` requires
|
|
341
345
|
`commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
|
|
342
346
|
commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
|
|
343
347
|
For a Composite Workspace, the same tool means dissolve: it removes only the
|
|
@@ -366,7 +370,7 @@ the initial run request terminates a not-yet-handed-off process so ForgeRelay do
|
|
|
366
370
|
not leave an orphan process whose `processId` the Agent never received.
|
|
367
371
|
|
|
368
372
|
Completed background processes are delivered once with a later tool result for the
|
|
369
|
-
same
|
|
373
|
+
same Workspace ID. Full buffered completion output is retained for five
|
|
370
374
|
minutes; after that ForgeRelay compacts the completion to a bounded head/tail record
|
|
371
375
|
and keeps it deliverable for up to 24 hours, still subject to the global completed
|
|
372
376
|
process count bound. Completed processes no longer prevent `close_workspace`; the
|
package/docs/roadmap.md
CHANGED
|
@@ -284,6 +284,34 @@ Git state, processes, Hooks, Skills, Language services, Activity, and Audit fact
|
|
|
284
284
|
Workspace Relay is not file synchronization or failover, and Composite Workspace
|
|
285
285
|
never silently chooses a member.
|
|
286
286
|
|
|
287
|
+
## 0.8 — Persistent Workspace lifecycle and Task Lists
|
|
288
|
+
|
|
289
|
+
0.8 turns Workspace identity from a disposable logical handle into a persistent
|
|
290
|
+
ForgeRelay work boundary, then layers lightweight Workspace-owned Task Lists and
|
|
291
|
+
safe cross-Workspace inspection on top. Development is release-gated: each stage
|
|
292
|
+
must be published successfully before work begins on the next stage.
|
|
293
|
+
|
|
294
|
+
- **0.8.0** — fix Composite bootstrap correctness and establish one canonical,
|
|
295
|
+
persistent Workspace identity per physical checkout/worktree target, including
|
|
296
|
+
migration compatibility for legacy aliases;
|
|
297
|
+
- **0.8.1** — make checkout close non-destructive and reversible, add explicit
|
|
298
|
+
Workspace deletion, and prevent GC from deleting persistent Workspace identity;
|
|
299
|
+
- **0.8.2** — carry the persistent lifecycle through managed worktrees and Composite
|
|
300
|
+
Workspaces, preserving state across close/reopen while keeping safe worktree
|
|
301
|
+
finalize semantics;
|
|
302
|
+
- **0.8.3** — add versioned file-backed Task Lists in ForgeRelay-owned Workspace
|
|
303
|
+
state, with mutations scoped to the current Workspace;
|
|
304
|
+
- **0.8.4** — add progressive Task disclosure, forgotten-update reminders, and
|
|
305
|
+
allowlist-based read-only inspection of other Workspaces and their safe Task
|
|
306
|
+
projections;
|
|
307
|
+
- **0.8.5** — verify the complete contract across Workspace Relay and publish the
|
|
308
|
+
accepted 0.8 lifecycle/Task model.
|
|
309
|
+
|
|
310
|
+
The release boundary is part of the dependency graph, not just a documentation
|
|
311
|
+
milestone: the next stage remains blocked until the previous version's tag-triggered
|
|
312
|
+
release workflow has completed successfully. Runtime acceptance uses only the
|
|
313
|
+
reserved 7677/7678 debug instances and never touches the normal 7676 installation.
|
|
314
|
+
|
|
287
315
|
## Later: first-class subagent MCP
|
|
288
316
|
|
|
289
317
|
ForgeRelay already owns provider adapters and resumable local agent sessions. A
|
|
@@ -311,20 +339,20 @@ are ready:
|
|
|
311
339
|
Checkpoint restore must protect concurrent/external user edits rather than
|
|
312
340
|
blindly overwriting a working tree.
|
|
313
341
|
|
|
314
|
-
##
|
|
342
|
+
## Workspace Task Lists
|
|
315
343
|
|
|
316
|
-
|
|
317
|
-
|
|
344
|
+
ForgeRelay may provide persistent, lightweight Task Lists owned by a Workspace for
|
|
345
|
+
work that should survive Host Turns, conversation changes, and Workspace close.
|
|
346
|
+
Task is a checklist/continuation primitive rather than an execution scheduler:
|
|
318
347
|
|
|
319
|
-
-
|
|
320
|
-
-
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
-
|
|
348
|
+
- one Workspace may own multiple named Task Lists;
|
|
349
|
+
- a Task records a work requirement, lightweight progress state, and Agent-managed continuation text;
|
|
350
|
+
- Task does not imply a Queue, Goal object, dependency graph, Subagent assignment, Run binding, or autonomous execution policy;
|
|
351
|
+
- current-Workspace Task state may be mutated through a Capability, while information about another Workspace, including Task projections, is exposed only through the read-only Workspace inspection path;
|
|
352
|
+
- Task state is durable Workspace-owned local state and is not deleted by ordinary Workspace close or GC.
|
|
324
353
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
insufficient.
|
|
354
|
+
Subagent coordination can reference Task content when useful, but ForgeRelay does
|
|
355
|
+
not bind Tasks to Subagent Sessions or infer Task completion from execution facts.
|
|
328
356
|
|
|
329
357
|
## Compatibility policy
|
|
330
358
|
|
package/docs/security.md
CHANGED
|
@@ -144,10 +144,10 @@ process lifetime. When `bash` is still running after that window, ForgeRelay
|
|
|
144
144
|
returns a canonical `processId` and leaves the process alive. Regular tool modes
|
|
145
145
|
reuse `bash(action="process")` to poll, wait, write input, resize a PTY, or
|
|
146
146
|
explicitly interrupt that process. An asynchronously completed process
|
|
147
|
-
is reported on a later tool result for the same
|
|
148
|
-
error-result paths, and is never broadcast to another
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
is reported on a later tool result for the same persistent Workspace ID, including
|
|
148
|
+
error-result paths, and is never broadcast to another Workspace ID. Reusing that
|
|
149
|
+
Workspace from another conversation intentionally shares the same completion scope
|
|
150
|
+
as well. Hook handlers keep their separate bounded timeout
|
|
151
151
|
policy because they are lifecycle gates rather than user-command execution.
|
|
152
152
|
|
|
153
153
|
## Lifecycle hooks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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/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",
|
|
50
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
51
51
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
52
|
"release:check": "node scripts/release-version.mjs check",
|
|
53
53
|
"release:tag-check": "node scripts/release-version.mjs tag",
|