@akira-tl/forgerelay 0.6.1 → 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 (63) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +38 -0
  3. package/capabilities/subagents/GUIDE.md +100 -40
  4. package/dist/activity/lifecycle.js +1 -1
  5. package/dist/activity/mcp-query-tools.js +1 -0
  6. package/dist/activity/query-service.js +1 -0
  7. package/dist/capabilities.js +1 -1
  8. package/dist/capability-registry.js +34 -0
  9. package/dist/cli.js +80 -165
  10. package/dist/composite-activity.js +155 -0
  11. package/dist/composite-workspaces.js +197 -0
  12. package/dist/db/migrations.js +14 -0
  13. package/dist/db/schema.js +6 -0
  14. package/dist/remote-workspace-relay.js +16 -0
  15. package/dist/server.js +698 -172
  16. package/dist/{local-agent-targets.js → subagents/cli-target.js} +6 -6
  17. package/dist/{local-agent-profiles.js → subagents/profiles.js} +13 -5
  18. package/dist/subagents/providers/adapters/acp.js +148 -0
  19. package/dist/subagents/providers/adapters/claude.js +75 -0
  20. package/dist/{local-agent-runtime.js → subagents/providers/adapters/codex.js} +10 -4
  21. package/dist/subagents/providers/adapters/opencode.js +137 -0
  22. package/dist/subagents/providers/adapters/pi.js +232 -0
  23. package/dist/{local-agent-availability.js → subagents/providers/availability.js} +24 -10
  24. package/dist/subagents/providers/continuation.js +11 -0
  25. package/dist/subagents/providers/contract.js +1 -0
  26. package/dist/subagents/providers/registry.js +26 -0
  27. package/dist/subagents/providers/shared.js +40 -0
  28. package/dist/subagents/sessions/capability.js +214 -0
  29. package/dist/subagents/sessions/delivery-mailbox.js +115 -0
  30. package/dist/subagents/sessions/execution.js +171 -0
  31. package/dist/subagents/sessions/manager.js +107 -0
  32. package/dist/subagents/sessions/mcp/audit.js +85 -0
  33. package/dist/subagents/sessions/mcp/runtime.js +19 -0
  34. package/dist/{local-agent-store.js → subagents/sessions/store.js} +75 -39
  35. package/dist/ui/.vite/manifest.json +33 -33
  36. package/dist/ui/activity-panel-app.html +3 -3
  37. package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
  38. package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
  39. package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
  40. package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
  41. package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
  42. package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
  43. package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
  44. package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
  45. package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
  46. package/dist/ui/workspace-app.html +4 -4
  47. package/dist/ui/workspace-lifecycle-app.html +4 -4
  48. package/dist/workspaces.js +3 -3
  49. package/docs/chatgpt-coding-workflow.md +2 -9
  50. package/docs/configuration.md +23 -0
  51. package/docs/debugging.md +7 -0
  52. package/docs/roadmap.md +42 -7
  53. package/package.json +2 -2
  54. package/scripts/debug/runtime.mjs +23 -1
  55. package/scripts/debug/runtime.test.mjs +14 -2
  56. package/scripts/debug/serve.mjs +4 -4
  57. package/scripts/release/release-gate.test.mjs +2 -2
  58. package/dist/local-agent-adapters.js +0 -653
  59. package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
  60. package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
  61. package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
  62. package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
  63. /package/dist/{local-agent-path.js → subagents/providers/path.js} +0 -0
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 });
@@ -0,0 +1,155 @@
1
+ export class CompositeActivityCoordinator {
2
+ composites;
3
+ queries;
4
+ remoteWorkspaces;
5
+ turns = new Map();
6
+ constructor(composites, queries, remoteWorkspaces) {
7
+ this.composites = composites;
8
+ this.queries = queries;
9
+ this.remoteWorkspaces = remoteWorkspaces;
10
+ }
11
+ hasComposite(workspaceId) {
12
+ return this.composites.has(workspaceId);
13
+ }
14
+ beginPanel(workspaceId, conversationScopeId) {
15
+ this.composites.open(workspaceId);
16
+ const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
17
+ this.turns.set(snapshot.turnId, {
18
+ compositeWorkspaceId: workspaceId,
19
+ conversationScopeId,
20
+ remoteMembers: new Map(),
21
+ remoteActivities: new Map(),
22
+ remoteOutputs: new Map(),
23
+ });
24
+ return snapshotResult(snapshot, `Started Composite Workspace Host Turn ${snapshot.turnId}.`);
25
+ }
26
+ currentTurnId(conversationScopeId, compositeWorkspaceId) {
27
+ const turnId = this.queries.currentTurnId(conversationScopeId, compositeWorkspaceId);
28
+ return turnId && this.turns.has(turnId) ? turnId : undefined;
29
+ }
30
+ async prepareMember(compositeWorkspaceId, member, executionWorkspaceId, conversationScopeId) {
31
+ const turnId = this.currentTurnId(conversationScopeId, compositeWorkspaceId);
32
+ if (!turnId)
33
+ return undefined;
34
+ if (!this.remoteWorkspaces.has(executionWorkspaceId))
35
+ return turnId;
36
+ const state = this.turns.get(turnId);
37
+ const existing = state.remoteMembers.get(member);
38
+ if (existing?.workspaceId === executionWorkspaceId)
39
+ return turnId;
40
+ const panel = await this.remoteWorkspaces.activityPanel(executionWorkspaceId, conversationScopeId);
41
+ const remoteTurnId = requiredString(panel.structuredContent, "turnId", "Remote Activity panel");
42
+ state.remoteMembers.set(member, {
43
+ member,
44
+ workspaceId: executionWorkspaceId,
45
+ remoteTurnId,
46
+ });
47
+ return turnId;
48
+ }
49
+ async snapshot(turnId, knownRevision) {
50
+ const state = this.turns.get(turnId);
51
+ if (!state)
52
+ return undefined;
53
+ const local = this.queries.snapshot(turnId);
54
+ const remoteSnapshots = await Promise.all([...state.remoteMembers.values()].map(async (route) => ({
55
+ route,
56
+ snapshot: await this.remoteWorkspaces.activitySnapshot({ turnId: route.remoteTurnId }, state.conversationScopeId),
57
+ })));
58
+ state.remoteActivities.clear();
59
+ state.remoteOutputs.clear();
60
+ const remoteActivities = [];
61
+ let revision = local.revision;
62
+ for (const { route, snapshot } of remoteSnapshots) {
63
+ if (!snapshot?.structuredContent)
64
+ continue;
65
+ const structured = snapshot.structuredContent;
66
+ const remoteRevision = structured.revision;
67
+ if (typeof remoteRevision === "number" && Number.isInteger(remoteRevision) && remoteRevision >= 0) {
68
+ revision += remoteRevision;
69
+ }
70
+ const activities = Array.isArray(structured.activities)
71
+ ? structured.activities
72
+ : [];
73
+ for (const activity of activities) {
74
+ const presented = { ...activity, member: route.member };
75
+ remoteActivities.push(presented);
76
+ state.remoteActivities.set(activity.activityId, route);
77
+ if (activity.outputId)
78
+ state.remoteOutputs.set(activity.outputId, route);
79
+ }
80
+ }
81
+ const activities = [...local.activities, ...remoteActivities]
82
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
83
+ const changed = knownRevision === undefined || knownRevision !== revision;
84
+ const snapshot = {
85
+ turnId,
86
+ revision,
87
+ changed,
88
+ state: aggregateState(activities),
89
+ activities: changed ? activities : [],
90
+ };
91
+ return snapshotResult(snapshot, changed
92
+ ? `Composite Activity snapshot ${turnId} revision ${revision}.`
93
+ : `Composite Activity snapshot ${turnId} unchanged at revision ${revision}.`);
94
+ }
95
+ async detail(turnId, activityId) {
96
+ const state = this.turns.get(turnId);
97
+ if (!state)
98
+ return undefined;
99
+ const route = state.remoteActivities.get(activityId);
100
+ if (!route)
101
+ return undefined;
102
+ const result = await this.remoteWorkspaces.activityDetail(route.remoteTurnId, activityId, state.conversationScopeId);
103
+ if (!result?.structuredContent)
104
+ return result;
105
+ const structured = result.structuredContent;
106
+ const activity = structured.activity;
107
+ return {
108
+ ...result,
109
+ structuredContent: {
110
+ ...structured,
111
+ ...(activity && typeof activity === "object"
112
+ ? { activity: { ...activity, member: route.member } }
113
+ : {}),
114
+ },
115
+ };
116
+ }
117
+ async output(turnId, outputId) {
118
+ const state = this.turns.get(turnId);
119
+ if (!state)
120
+ return undefined;
121
+ const route = state.remoteOutputs.get(outputId);
122
+ if (!route)
123
+ return undefined;
124
+ return this.remoteWorkspaces.activityOutput(route.remoteTurnId, outputId, state.conversationScopeId);
125
+ }
126
+ forgetComposite(workspaceId) {
127
+ for (const [turnId, state] of this.turns) {
128
+ if (state.compositeWorkspaceId === workspaceId)
129
+ this.turns.delete(turnId);
130
+ }
131
+ }
132
+ }
133
+ function snapshotResult(snapshot, text) {
134
+ return {
135
+ content: [{ type: "text", text }],
136
+ structuredContent: snapshot,
137
+ };
138
+ }
139
+ function aggregateState(activities) {
140
+ if (activities.length === 0)
141
+ return "working";
142
+ if (activities.some((activity) => activity.status === "working"))
143
+ return "working";
144
+ if (activities.some((activity) => activity.status === "error"))
145
+ return "error";
146
+ return "done";
147
+ }
148
+ function requiredString(value, field, label) {
149
+ if (!value || typeof value !== "object")
150
+ throw new Error(`${label} did not return structured content.`);
151
+ const fieldValue = value[field];
152
+ if (typeof fieldValue !== "string" || !fieldValue)
153
+ throw new Error(`${label} did not include ${field}.`);
154
+ return fieldValue;
155
+ }
@@ -0,0 +1,197 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export class CompositeWorkspaceRegistry {
5
+ stateDir;
6
+ statePath;
7
+ records = new Map();
8
+ constructor(stateDir) {
9
+ this.stateDir = stateDir;
10
+ this.statePath = join(stateDir, "composite-workspaces.json");
11
+ this.load();
12
+ }
13
+ has(workspaceId) {
14
+ return this.records.has(workspaceId);
15
+ }
16
+ create(name) {
17
+ const normalized = normalizeName(name);
18
+ const existing = [...this.records.values()].find((record) => record.name === normalized);
19
+ if (existing)
20
+ return this.touch(existing.id);
21
+ const now = new Date().toISOString();
22
+ const record = {
23
+ id: `cws_${randomBytes(5).toString("hex")}`,
24
+ kind: "composite",
25
+ name: normalized,
26
+ members: [],
27
+ createdAt: now,
28
+ lastUsedAt: now,
29
+ };
30
+ this.records.set(record.id, record);
31
+ this.persist();
32
+ return cloneRecord(record);
33
+ }
34
+ get(workspaceId) {
35
+ const record = this.records.get(workspaceId);
36
+ if (!record)
37
+ throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
38
+ return cloneRecord(record);
39
+ }
40
+ open(workspaceId) {
41
+ return this.touch(workspaceId);
42
+ }
43
+ list() {
44
+ return [...this.records.values()]
45
+ .map(cloneRecord)
46
+ .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
47
+ }
48
+ addMember(workspaceId, input) {
49
+ const record = this.requireRecord(workspaceId);
50
+ const name = normalizeMemberName(input.name);
51
+ const purpose = input.purpose.trim();
52
+ if (!purpose)
53
+ throw new Error("Composite Workspace member purpose must not be empty.");
54
+ const existing = record.members.find((member) => member.name === name);
55
+ if (existing) {
56
+ if (existing.purpose === purpose && existing.workspaceId === input.workspaceId) {
57
+ return cloneRecord(record);
58
+ }
59
+ throw new Error(`Composite Workspace ${workspaceId} already has member ${name} with a different definition.`);
60
+ }
61
+ record.members.push({ name, purpose, workspaceId: input.workspaceId });
62
+ record.lastUsedAt = new Date().toISOString();
63
+ this.persist();
64
+ return cloneRecord(record);
65
+ }
66
+ updateMember(workspaceId, memberName, input) {
67
+ const record = this.requireRecord(workspaceId);
68
+ const currentName = normalizeMemberName(memberName);
69
+ const index = record.members.findIndex((member) => member.name === currentName);
70
+ if (index < 0)
71
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${currentName}.`);
72
+ const current = record.members[index];
73
+ const nextName = input.name === undefined ? current.name : normalizeMemberName(input.name);
74
+ const nextPurpose = input.purpose === undefined ? current.purpose : input.purpose.trim();
75
+ if (!nextPurpose)
76
+ throw new Error("Composite Workspace member purpose must not be empty.");
77
+ const nextWorkspaceId = input.workspaceId ?? current.workspaceId;
78
+ if (nextName !== current.name &&
79
+ record.members.some((member, memberIndex) => memberIndex !== index && member.name === nextName)) {
80
+ throw new Error(`Composite Workspace ${workspaceId} already has member ${nextName}.`);
81
+ }
82
+ if (nextName === current.name &&
83
+ nextPurpose === current.purpose &&
84
+ nextWorkspaceId === current.workspaceId) {
85
+ return cloneRecord(record);
86
+ }
87
+ record.members[index] = {
88
+ name: nextName,
89
+ purpose: nextPurpose,
90
+ workspaceId: nextWorkspaceId,
91
+ };
92
+ record.lastUsedAt = new Date().toISOString();
93
+ this.persist();
94
+ return cloneRecord(record);
95
+ }
96
+ removeMember(workspaceId, memberName) {
97
+ const record = this.requireRecord(workspaceId);
98
+ const name = normalizeMemberName(memberName);
99
+ const index = record.members.findIndex((member) => member.name === name);
100
+ if (index < 0)
101
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
102
+ record.members.splice(index, 1);
103
+ record.lastUsedAt = new Date().toISOString();
104
+ this.persist();
105
+ return cloneRecord(record);
106
+ }
107
+ member(workspaceId, memberName) {
108
+ const record = this.requireRecord(workspaceId);
109
+ const name = normalizeMemberName(memberName);
110
+ const member = record.members.find((entry) => entry.name === name);
111
+ if (!member)
112
+ throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
113
+ return { ...member };
114
+ }
115
+ dissolve(workspaceId) {
116
+ const record = this.requireRecord(workspaceId);
117
+ this.records.delete(workspaceId);
118
+ this.persist();
119
+ return cloneRecord(record);
120
+ }
121
+ touch(workspaceId) {
122
+ const record = this.requireRecord(workspaceId);
123
+ record.lastUsedAt = new Date().toISOString();
124
+ this.persist();
125
+ return cloneRecord(record);
126
+ }
127
+ requireRecord(workspaceId) {
128
+ const record = this.records.get(workspaceId);
129
+ if (!record)
130
+ throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
131
+ return record;
132
+ }
133
+ load() {
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
137
+ }
138
+ catch (error) {
139
+ if (isMissingFile(error))
140
+ return;
141
+ throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
142
+ }
143
+ if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
144
+ throw new Error("Composite Workspace state has an unsupported format.");
145
+ }
146
+ for (const record of parsed.workspaces) {
147
+ if (!record?.id?.startsWith("cws_") || record.kind !== "composite")
148
+ continue;
149
+ this.records.set(record.id, {
150
+ ...record,
151
+ members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
152
+ });
153
+ }
154
+ }
155
+ persist() {
156
+ mkdirSync(this.stateDir, { recursive: true });
157
+ const state = {
158
+ version: 1,
159
+ workspaces: [...this.records.values()].map(cloneRecord),
160
+ };
161
+ const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
162
+ try {
163
+ writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
164
+ renameSync(tempPath, this.statePath);
165
+ }
166
+ finally {
167
+ rmSync(tempPath, { force: true });
168
+ }
169
+ }
170
+ }
171
+ function normalizeName(name) {
172
+ const value = name.trim();
173
+ if (!value)
174
+ throw new Error("Composite Workspace name must not be empty.");
175
+ if (value.length > 120)
176
+ throw new Error("Composite Workspace name must be at most 120 characters.");
177
+ return value;
178
+ }
179
+ function normalizeMemberName(name) {
180
+ const value = name.trim();
181
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(value)) {
182
+ throw new Error("Composite Workspace member name must match /^[a-z][a-z0-9_-]{0,31}$/.");
183
+ }
184
+ return value;
185
+ }
186
+ function cloneRecord(record) {
187
+ return {
188
+ ...record,
189
+ members: record.members.map((member) => ({ ...member })),
190
+ };
191
+ }
192
+ function isMissingFile(error) {
193
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
194
+ }
195
+ function errorMessage(error) {
196
+ return error instanceof Error ? error.message : String(error);
197
+ }