@evandu/super-pi 0.1.1 → 0.1.3

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 (38) hide show
  1. package/agents/super-pi-task/core.ts +748 -485
  2. package/agents/super-pi-task/index.ts +198 -141
  3. package/dist/client/assets/{index-CsbZzuMy.css → index-BhFeQ1E-.css} +1 -1
  4. package/dist/client/assets/index-D_VwFuME.js +43 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/agents.d.ts +20 -1
  7. package/dist/server/agents.js +92 -18
  8. package/dist/server/application/catalog-service.d.ts +22 -2
  9. package/dist/server/application/catalog-service.js +61 -9
  10. package/dist/server/application/conversation-service.d.ts +3 -2
  11. package/dist/server/application/conversation-service.js +5 -1
  12. package/dist/server/application/run-service.d.ts +49 -1
  13. package/dist/server/application/run-service.js +81 -2
  14. package/dist/server/application/workspace-service.d.ts +3 -2
  15. package/dist/server/application/workspace-service.js +5 -1
  16. package/dist/server/capability.d.ts +89 -0
  17. package/dist/server/capability.js +136 -0
  18. package/dist/server/command-handler.js +12 -0
  19. package/dist/server/http-server.js +20 -0
  20. package/dist/server/index.js +47 -9
  21. package/dist/server/runtime.d.ts +6 -2
  22. package/dist/server/runtime.js +19 -4
  23. package/dist/server/server-context.d.ts +1 -0
  24. package/dist/server/store.d.ts +48 -0
  25. package/dist/server/store.js +160 -1
  26. package/dist/server/subagent-runs.d.ts +2 -0
  27. package/dist/server/subagent-runs.js +4 -0
  28. package/dist/server/subagents.d.ts +3 -2
  29. package/dist/server/subagents.js +5 -1
  30. package/dist/server/task-tool.d.ts +21 -0
  31. package/dist/server/task-tool.js +21 -0
  32. package/dist/server/types.d.ts +45 -0
  33. package/dist/server/workflow-engine.d.ts +129 -0
  34. package/dist/server/workflow-engine.js +253 -0
  35. package/dist/server/workflow.d.ts +287 -0
  36. package/dist/server/workflow.js +387 -0
  37. package/package.json +8 -2
  38. package/dist/client/assets/index-BMQsEdio.js +0 -24
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Proving that a dispatch request came from THIS server's primary agent, rather than from anything else on
3
+ * the machine that can open a socket to localhost.
4
+ *
5
+ * WHY THIS EXISTS, since the dispatch endpoint already looks like it authenticates. It does not. It
6
+ * validates ANCESTRY - that a claimed `parentRunId` names a real run belonging to the right conversation -
7
+ * and `docs/AGENT_LOOP_DESIGN.md` states the limit in as many words: "The current HTTP boundary validates
8
+ * supplied ancestry, not caller identity. A local process that omits both parentRunId and depth is
9
+ * indistinguishable from a primary caller." That is true, and it was tolerable while the only reachable
10
+ * primitive was a single dispatch. It stops being tolerable the moment an endpoint can trigger a SEQUENCE
11
+ * of dispatches: one unauthenticated POST becomes an unattended pipeline of sub-agents writing to the
12
+ * user's workspace, which is a far larger lever for the same missing check.
13
+ *
14
+ * WHY A CAPABILITY RATHER THAN A SHARED SECRET. `SUPER_PI_ACCESS_TOKEN` already exists and every endpoint
15
+ * already checks it - but it is SERVER-WIDE and optional: unset is the default, and when unset the whole
16
+ * server runs open on loopback, which is a reasonable choice for an app meant to be run by the person
17
+ * sitting at the machine. A dispatch capability is narrower and non-optional in the way that matters: it
18
+ * exists only for the duration of one turn, for one conversation, and only the primary agent's environment
19
+ * ever holds it. `SubagentSupervisor` passes no `env` to a child at all (`subagents.ts:371-380`), so a
20
+ * sub-agent - whose whole threat is that a model controls its inputs and therefore its shell commands -
21
+ * structurally cannot read it back out of its own environment and post it.
22
+ *
23
+ * WHAT THIS DOES NOT CLAIM. It is not defence against a compromised primary agent, which can read its own
24
+ * environment and would simply forward the capability. It is defence against the two neighbours of the
25
+ * primary that are NOT it: an unrelated local process, and a sub-agent that escaped its read-only
26
+ * allowlist. Neither is the primary, and neither is handed the token.
27
+ */
28
+ /**
29
+ * The environment variable the capability travels in.
30
+ *
31
+ * Named on both sides of the wire rather than imported by the extension, for the reason every one of
32
+ * these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by tsc, so a
33
+ * relative import across the two would resolve differently under each. `scripts/task-tool.test.mjs`
34
+ * asserts the two copies agree, because a rename on one side would otherwise fail at dispatch time with a
35
+ * message nobody reads.
36
+ */
37
+ export declare const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
38
+ /**
39
+ * Mints the capability for one turn of one conversation, and returns it for the extension's environment.
40
+ *
41
+ * CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
42
+ * A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
43
+ *
44
+ * ROTATED EVERY TURN, and that is the property the duration would not give on its own. A token valid for
45
+ * the whole server lifetime would still be a token an unrelated process could not guess - but a token
46
+ * valid only while its holder is alive is a token that stops being a credential the moment the turn ends,
47
+ * so there is no window in which a leaked value is still live.
48
+ */
49
+ export declare function mintCapability(conversationId: string): string;
50
+ /**
51
+ * Forgets a conversation's capability, at the end of its turn.
52
+ *
53
+ * CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
54
+ * and a turn whose child was killed. A capability that outlived its turn would keep working for a process
55
+ * that no longer exists, which is precisely the shape of a credential with no owner.
56
+ */
57
+ export declare function releaseCapability(conversationId: string): void;
58
+ /** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
59
+ export declare function releaseAllCapabilities(): void;
60
+ /**
61
+ * The capability currently valid for a conversation, or `undefined` when no primary turn is running.
62
+ *
63
+ * Exported for the endpoint's own diagnostics and for a test that needs to assert the lifecycle without
64
+ * reading a primary turn's environment. Not exported as a way to READ a conversation's capability and use
65
+ * it - a caller in this process is already inside the trust boundary it protects.
66
+ */
67
+ export declare function capabilityFor(conversationId: string): string | undefined;
68
+ /** The outcome of a check, with a message written for whoever the refusal will be shown to. */
69
+ export type CapabilityVerdict = {
70
+ ok: true;
71
+ } | {
72
+ ok: false;
73
+ message: string;
74
+ };
75
+ /**
76
+ * Checks a presented capability against the one this conversation's turn was given.
77
+ *
78
+ * `timingSafeEqual` rather than `===`, because `===` on a token short-circuits at the first differing byte
79
+ * and turns the comparison into a timing oracle for the value itself. The length check runs first because
80
+ * `timingSafeEqual` throws on mismatched lengths rather than returning false.
81
+ *
82
+ * THE MESSAGE NAMES BOTH HALVES OF THE PROBLEM, because the reader is a model about to retry and there
83
+ * are two genuinely different failures here. A conversation with no live capability is being dispatched
84
+ * from OUTSIDE a turn - the request never came from a primary agent. A conversation with one, and the
85
+ * presented value does not match it, is a turn whose value was not carried through. An extension that
86
+ * silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
87
+ * retrying forever.
88
+ */
89
+ export declare function verifyCapability(conversationId: string, presented: unknown): CapabilityVerdict;
@@ -0,0 +1,136 @@
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Proving that a dispatch request came from THIS server's primary agent, rather than from anything else on
4
+ * the machine that can open a socket to localhost.
5
+ *
6
+ * WHY THIS EXISTS, since the dispatch endpoint already looks like it authenticates. It does not. It
7
+ * validates ANCESTRY - that a claimed `parentRunId` names a real run belonging to the right conversation -
8
+ * and `docs/AGENT_LOOP_DESIGN.md` states the limit in as many words: "The current HTTP boundary validates
9
+ * supplied ancestry, not caller identity. A local process that omits both parentRunId and depth is
10
+ * indistinguishable from a primary caller." That is true, and it was tolerable while the only reachable
11
+ * primitive was a single dispatch. It stops being tolerable the moment an endpoint can trigger a SEQUENCE
12
+ * of dispatches: one unauthenticated POST becomes an unattended pipeline of sub-agents writing to the
13
+ * user's workspace, which is a far larger lever for the same missing check.
14
+ *
15
+ * WHY A CAPABILITY RATHER THAN A SHARED SECRET. `SUPER_PI_ACCESS_TOKEN` already exists and every endpoint
16
+ * already checks it - but it is SERVER-WIDE and optional: unset is the default, and when unset the whole
17
+ * server runs open on loopback, which is a reasonable choice for an app meant to be run by the person
18
+ * sitting at the machine. A dispatch capability is narrower and non-optional in the way that matters: it
19
+ * exists only for the duration of one turn, for one conversation, and only the primary agent's environment
20
+ * ever holds it. `SubagentSupervisor` passes no `env` to a child at all (`subagents.ts:371-380`), so a
21
+ * sub-agent - whose whole threat is that a model controls its inputs and therefore its shell commands -
22
+ * structurally cannot read it back out of its own environment and post it.
23
+ *
24
+ * WHAT THIS DOES NOT CLAIM. It is not defence against a compromised primary agent, which can read its own
25
+ * environment and would simply forward the capability. It is defence against the two neighbours of the
26
+ * primary that are NOT it: an unrelated local process, and a sub-agent that escaped its read-only
27
+ * allowlist. Neither is the primary, and neither is handed the token.
28
+ */
29
+ /**
30
+ * The environment variable the capability travels in.
31
+ *
32
+ * Named on both sides of the wire rather than imported by the extension, for the reason every one of
33
+ * these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by tsc, so a
34
+ * relative import across the two would resolve differently under each. `scripts/task-tool.test.mjs`
35
+ * asserts the two copies agree, because a rename on one side would otherwise fail at dispatch time with a
36
+ * message nobody reads.
37
+ */
38
+ export const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
39
+ /**
40
+ * The live capability per conversation, while that conversation's primary turn is running.
41
+ *
42
+ * Keyed by CONVERSATION rather than being one global token, so two conversations being driven at once
43
+ * cannot dispatch as each other and so releasing one turn's capability cannot revoke another's. The value
44
+ * is replaced rather than accumulated: a conversation has one primary turn in flight, and a second spawn
45
+ * for the same conversation supersedes the first.
46
+ */
47
+ const live = new Map();
48
+ /** A fixed capability, for tests. See `seededCapability` for why this does not weaken anything. */
49
+ const SEED = process.env.SUPER_PI_TASK_CAPABILITY_SEED;
50
+ /** Announced once, because a capability that silently stopped rotating is a security change nobody agreed to. */
51
+ let warnedSeed = false;
52
+ /**
53
+ * Mints the capability for one turn of one conversation, and returns it for the extension's environment.
54
+ *
55
+ * CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
56
+ * A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
57
+ *
58
+ * ROTATED EVERY TURN, and that is the property the duration would not give on its own. A token valid for
59
+ * the whole server lifetime would still be a token an unrelated process could not guess - but a token
60
+ * valid only while its holder is alive is a token that stops being a credential the moment the turn ends,
61
+ * so there is no window in which a leaked value is still live.
62
+ */
63
+ export function mintCapability(conversationId) {
64
+ if (SEED) {
65
+ if (!warnedSeed) {
66
+ warnedSeed = true;
67
+ console.warn(`Super Pi: SUPER_PI_TASK_CAPABILITY_SEED is set, so every dispatch capability is the same fixed value instead of being minted per turn. This is for tests - set it only on a server nothing else can reach.`);
68
+ }
69
+ live.set(conversationId, SEED);
70
+ return SEED;
71
+ }
72
+ const token = randomUUID();
73
+ live.set(conversationId, token);
74
+ return token;
75
+ }
76
+ /**
77
+ * Forgets a conversation's capability, at the end of its turn.
78
+ *
79
+ * CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
80
+ * and a turn whose child was killed. A capability that outlived its turn would keep working for a process
81
+ * that no longer exists, which is precisely the shape of a credential with no owner.
82
+ */
83
+ export function releaseCapability(conversationId) {
84
+ live.delete(conversationId);
85
+ }
86
+ /** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
87
+ export function releaseAllCapabilities() {
88
+ live.clear();
89
+ }
90
+ /**
91
+ * The capability currently valid for a conversation, or `undefined` when no primary turn is running.
92
+ *
93
+ * Exported for the endpoint's own diagnostics and for a test that needs to assert the lifecycle without
94
+ * reading a primary turn's environment. Not exported as a way to READ a conversation's capability and use
95
+ * it - a caller in this process is already inside the trust boundary it protects.
96
+ */
97
+ export function capabilityFor(conversationId) {
98
+ return live.get(conversationId);
99
+ }
100
+ /**
101
+ * Checks a presented capability against the one this conversation's turn was given.
102
+ *
103
+ * `timingSafeEqual` rather than `===`, because `===` on a token short-circuits at the first differing byte
104
+ * and turns the comparison into a timing oracle for the value itself. The length check runs first because
105
+ * `timingSafeEqual` throws on mismatched lengths rather than returning false.
106
+ *
107
+ * THE MESSAGE NAMES BOTH HALVES OF THE PROBLEM, because the reader is a model about to retry and there
108
+ * are two genuinely different failures here. A conversation with no live capability is being dispatched
109
+ * from OUTSIDE a turn - the request never came from a primary agent. A conversation with one, and the
110
+ * presented value does not match it, is a turn whose value was not carried through. An extension that
111
+ * silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
112
+ * retrying forever.
113
+ */
114
+ export function verifyCapability(conversationId, presented) {
115
+ // A seeded server accepts its fixed value for EVERY conversation, including one with no turn running.
116
+ // That relaxation is exactly the test affordance and nothing else: `SEED` is documented as test-only,
117
+ // and a process that can set it could set `SUPER_PI_ACCESS_TOKEN` instead and already have full control.
118
+ // The comparison below is still real - a wrong value is still refused - so a seeded server still catches
119
+ // a request that simply forgot the capability, which is the mistake a real extension is most likely to make.
120
+ const expected = live.get(conversationId) ?? SEED;
121
+ if (!expected) {
122
+ return {
123
+ ok: false,
124
+ message: `No primary turn is running for conversation ${conversationId}, so there is nothing that could have dispatched this. Dispatch happens from inside a turn: send the tool call during a turn rather than posting to the server directly.`,
125
+ };
126
+ }
127
+ if (typeof presented !== "string" || !presented) {
128
+ return { ok: false, message: `This dispatch carries no ${TASK_CAPABILITY_ENV}. The capability is passed from the primary agent's own environment; a request without it did not come from one. Do not retry the same call - it is not a transient failure.` };
129
+ }
130
+ const left = Buffer.from(expected);
131
+ const right = Buffer.from(presented);
132
+ if (left.length !== right.length || !timingSafeEqual(left, right)) {
133
+ return { ok: false, message: `The ${TASK_CAPABILITY_ENV} presented does not match the one this turn was given. It is minted per turn, so a value from an earlier turn - or from another conversation - is not accepted. Do not retry the same call.` };
134
+ }
135
+ return { ok: true };
136
+ }
@@ -113,6 +113,10 @@ export class CommandHandler {
113
113
  this.runService.stopSubagent(command.runId);
114
114
  return;
115
115
  }
116
+ if (command.type === "workflow.stop") {
117
+ this.runService.stopWorkflow(command.workflowId);
118
+ return;
119
+ }
116
120
  if (command.type === "extension.ui.answer") {
117
121
  const answer = command.cancelled
118
122
  ? { cancelled: true }
@@ -132,6 +136,14 @@ export class CommandHandler {
132
136
  await this.catalogService.broadcastSubagents();
133
137
  return;
134
138
  }
139
+ if (command.type === "agents.setCustomDirs") {
140
+ await this.catalogService.setCustomDirs(command.dirs);
141
+ return;
142
+ }
143
+ if (command.type === "agents.rescan") {
144
+ await this.catalogService.rescan();
145
+ return;
146
+ }
135
147
  throw new Error(`未知的命令类型:${JSON.stringify(command.type ?? null)}`);
136
148
  }
137
149
  }
@@ -76,6 +76,26 @@ export function createHttpServer(options) {
76
76
  }
77
77
  return;
78
78
  }
79
+ if (url.pathname === "/api/workflow/run") {
80
+ if (request.method !== "POST" || !isLoopbackAddress(remote)) {
81
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback POST");
82
+ return;
83
+ }
84
+ let raw = "";
85
+ for await (const chunk of request)
86
+ raw += chunk;
87
+ const json = (status, body) => {
88
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
89
+ };
90
+ try {
91
+ const result = await runService.runWorkflow(JSON.parse(raw));
92
+ json(result.status, result.body);
93
+ }
94
+ catch (error) {
95
+ json(400, { error: error instanceof Error ? error.message : "workflow dispatch failed" });
96
+ }
97
+ return;
98
+ }
79
99
  if (url.pathname === "/api/todos") {
80
100
  if (request.method !== "POST" || !isLoopbackAddress(remote)) {
81
101
  response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback POST");
@@ -4,8 +4,11 @@ import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { defaultStorePath, Store } from "./store.js";
6
6
  import { PiRpcRuntime, WelcomeRuntime } from "./runtime.js";
7
+ import { resolveAgentDirectories } from "./agents.js";
8
+ import { releaseAllCapabilities } from "./capability.js";
7
9
  import { SubagentSupervisor } from "./subagents.js";
8
10
  import { SubagentRunRecorder } from "./subagent-runs.js";
11
+ import { WorkflowEngine } from "./workflow-engine.js";
9
12
  import { ExtensionUiService } from "./application/extension-ui-service.js";
10
13
  import { CatalogService } from "./application/catalog-service.js";
11
14
  import { RunService } from "./application/run-service.js";
@@ -22,7 +25,6 @@ const defaultDataDir = !rootDir.includes("node_modules") && existsSync(join(root
22
25
  ? join(rootDir, ".super-pi")
23
26
  : join(homedir(), ".super-pi");
24
27
  const dataDir = resolve(process.env.SUPER_PI_DATA_DIR ?? defaultDataDir);
25
- const agentsDir = resolve(process.env.SUPER_PI_AGENTS_DIR ?? join(rootDir, "agents"));
26
28
  const agentPromptDir = join(dataDir, "agent-prompts");
27
29
  const clientDir = join(rootDir, "dist", "client");
28
30
  const accessToken = process.env.SUPER_PI_ACCESS_TOKEN;
@@ -30,23 +32,30 @@ const uploadsDir = join(dataDir, "uploads");
30
32
  await mkdir(uploadsDir, { recursive: true }).catch(() => { });
31
33
  const taskEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/subagent/dispatch`;
32
34
  const todoEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/todos`;
35
+ const workflowEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/workflow/run`;
36
+ // ------------------------------------------------------------- Core singletons
37
+ const store = await Store.open(defaultStorePath(dataDir));
38
+ const initialAgentDirs = resolveAgentDirectories({
39
+ customDirs: store.snapshot().customAgentDirs,
40
+ rootDir,
41
+ });
42
+ const agentsDir = initialAgentDirs[0] ?? resolve(process.env.SUPER_PI_AGENTS_DIR ?? join(rootDir, "agents"));
33
43
  const paths = {
34
44
  dataDir,
35
45
  rootDir,
36
46
  agentsDir,
47
+ agentDirs: initialAgentDirs,
37
48
  agentPromptDir,
38
49
  uploadsDir,
39
50
  clientDir,
40
51
  };
41
- // ------------------------------------------------------------- Core singletons
42
- const store = await Store.open(defaultStorePath(dataDir));
43
52
  const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
44
53
  ? new WelcomeRuntime()
45
- : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: agentsDir, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, uploadsDir });
54
+ : new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, uploadsDir });
46
55
  const broadcaster = new WsBroadcaster(() => store.snapshot());
47
56
  const extensionUiService = new ExtensionUiService(broadcaster);
48
57
  const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
49
- dir: agentsDir,
58
+ dir: initialAgentDirs,
50
59
  promptDir: agentPromptDir,
51
60
  });
52
61
  const subagents = new SubagentRunRecorder(supervisor, {
@@ -58,11 +67,38 @@ const subagents = new SubagentRunRecorder(supervisor, {
58
67
  },
59
68
  host: (conversationId, runId) => extensionUiService.createHost(conversationId, runId),
60
69
  });
70
+ /**
71
+ * The orchestration engine: walks a whole plan the primary agent declared, dispatching each step through
72
+ * the recorder above and handing each step's output to the next.
73
+ *
74
+ * Broadcasts `workflow.event` AND the full state, exactly as the recorder does for `subagent.event`. The
75
+ * extra state frame is what puts a plan into `AppState.workflows`, so a browser that reloads mid-plan
76
+ * rebuilds the timeline from the file rather than from the events it happened to receive - the same reason
77
+ * a run's card survives a refresh. It costs nothing in practice because the engine publishes twice per
78
+ * step rather than per delta.
79
+ */
80
+ const workflows = new WorkflowEngine(subagents, {
81
+ store,
82
+ broadcast: (event) => {
83
+ broadcaster.broadcast(event);
84
+ if (event.type === "workflow.event")
85
+ broadcaster.broadcastState();
86
+ },
87
+ });
61
88
  // ------------------------------------------------------------- Application services
62
- const catalogService = new CatalogService(runtime, broadcaster, agentsDir);
63
- const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService);
64
- const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, agentsDir);
65
- const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, agentsDir);
89
+ const onDirsChanged = (dirs) => {
90
+ paths.agentDirs = dirs;
91
+ paths.agentsDir = dirs[0] ?? "";
92
+ runtime.setAgentsDir?.(dirs);
93
+ subagents.setAgentsDir(dirs);
94
+ runService.setAgentsDir(dirs);
95
+ conversationService.setAgentsDir(dirs);
96
+ workspaceService.setAgentsDir(dirs);
97
+ };
98
+ const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs, store, rootDir, onDirsChanged);
99
+ const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
100
+ const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
101
+ const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
66
102
  const serverContext = {
67
103
  paths,
68
104
  store,
@@ -85,6 +121,8 @@ function shutdown(reason) {
85
121
  shuttingDown = true;
86
122
  console.log(`Super Pi is shutting down (${reason}).`);
87
123
  runService.abortAll();
124
+ workflows.abortAll();
125
+ releaseAllCapabilities();
88
126
  for (const c of store.snapshot().conversations) {
89
127
  extensionUiService.cancelPendingUi(c.id, "aborted");
90
128
  }
@@ -199,6 +199,8 @@ export interface AgentRuntime {
199
199
  * refusal, exactly as `revert`'s absence is, rather than passed over.
200
200
  */
201
201
  fork?(request: ForkRequest): Promise<ForkOutcome>;
202
+ /** Updates the active agent directory or directories to search. */
203
+ setAgentsDir?(dir: string | string[]): void;
202
204
  }
203
205
  /** Deterministic offline runtime used by tests and local UI development. */
204
206
  export declare class WelcomeRuntime implements AgentRuntime {
@@ -527,7 +529,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
527
529
  * call site keeps its meaning - moving the agent directory into that slot would have silently fed a
528
530
  * launcher's file path in as a directory of agent definitions.
529
531
  */
530
- private readonly agents;
532
+ private agents;
531
533
  /**
532
534
  * How the `task` extension reaches this server. A FOURTH positional parameter for the same reason as
533
535
  * the third: an options bag in one of the earlier slots would silently change what an existing
@@ -587,7 +589,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
587
589
  * launcher's file path in as a directory of agent definitions.
588
590
  */
589
591
  agents?: {
590
- dir: string;
592
+ dir: string | string[];
591
593
  promptDir: string;
592
594
  },
593
595
  /**
@@ -612,6 +614,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
612
614
  options?: {
613
615
  endpoint?: string;
614
616
  todoEndpoint?: string;
617
+ workflowEndpoint?: string;
615
618
  uploadsDir?: string;
616
619
  });
617
620
  /**
@@ -623,6 +626,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
623
626
  * as the conversation's own role.
624
627
  */
625
628
  agentFor(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
629
+ setAgentsDir(dir: string | string[]): void;
626
630
  /**
627
631
  * The `--extension` argv for this turn, or `[]` when the task tool is not available.
628
632
  *
@@ -3,8 +3,9 @@ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve, join } from "node:path";
5
5
  import { agentSpawnArgs, readAgents } from "./agents.js";
6
+ import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
6
7
  import { resolvePiCommand, shellQuote } from "./pi-command.js";
7
- import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, rosterPayload, } from "./task-tool.js";
8
+ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, rosterPayload, } from "./task-tool.js";
8
9
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
9
10
  import { loadConversationImages } from "./upload.js";
10
11
  import { describeToolCall, describeToolCallArgs } from "./tool-calls.js";
@@ -630,9 +631,13 @@ export class PiRpcRuntime {
630
631
  const agent = agents.find((item) => item.id === conversation.agentId);
631
632
  if (agent)
632
633
  return agent;
633
- console.warn(`PiRpcRuntime: conversation ${conversation.id} names agent ${JSON.stringify(conversation.agentId)}, which is not a primary agent in ${this.agents.dir}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""} - running as Pi itself.`);
634
+ const dirLabel = Array.isArray(this.agents.dir) ? this.agents.dir.join(", ") : this.agents.dir;
635
+ console.warn(`PiRpcRuntime: conversation ${conversation.id} names agent ${JSON.stringify(conversation.agentId)}, which is not a primary agent in ${dirLabel}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""} - running as Pi itself.`);
634
636
  return undefined;
635
637
  }
638
+ setAgentsDir(dir) {
639
+ this.agents.dir = dir;
640
+ }
636
641
  /**
637
642
  * The `--extension` argv for this turn, or `[]` when the task tool is not available.
638
643
  *
@@ -676,13 +681,19 @@ export class PiRpcRuntime {
676
681
  async taskExtensionEnv(conversationId) {
677
682
  const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
678
683
  for (const problem of problems)
679
- console.warn(`PiRpcRuntime: ${this.agents.dir}/${problem.file} is not a usable sub-agent - ${problem.message}`);
684
+ console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
680
685
  return {
681
686
  [TASK_ENDPOINT_ENV]: this.options.endpoint ?? "",
682
687
  [TASK_ROSTER_ENV]: rosterPayload(agents),
683
688
  [TASK_CONVERSATION_ENV]: conversationId,
684
689
  [TASK_DEPTH_ENV]: "0",
685
690
  [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
691
+ [WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
692
+ // MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
693
+ // decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
694
+ // function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
695
+ // unable to dispatch, however its allowlist is broken.
696
+ [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
686
697
  };
687
698
  }
688
699
  async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }) {
@@ -718,7 +729,7 @@ export class PiRpcRuntime {
718
729
  // other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
719
730
  // `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
720
731
  // the other is not.
721
- const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME] : [] });
732
+ const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : [] });
722
733
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
723
734
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
724
735
  if (!this.pi.found)
@@ -930,6 +941,10 @@ export class PiRpcRuntime {
930
941
  finally {
931
942
  signal.removeEventListener("abort", abort);
932
943
  this.runs.delete(conversation.id);
944
+ // The capability dies with the turn that was given it. In this `finally`, so it happens on every
945
+ // exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
946
+ // credential whose turn has ended is a credential with no owner.
947
+ releaseCapability(conversation.id);
933
948
  // A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
934
949
  // is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
935
950
  // promise: the caller would wait for a timeout to learn something this process already knows. The
@@ -14,6 +14,7 @@ export interface ServerPaths {
14
14
  dataDir: string;
15
15
  rootDir: string;
16
16
  agentsDir: string;
17
+ agentDirs?: string[];
17
18
  agentPromptDir?: string;
18
19
  uploadsDir: string;
19
20
  clientDir: string;
@@ -1,3 +1,4 @@
1
+ import type { WorkflowRun } from "./workflow.js";
1
2
  import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
2
3
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
3
4
  *
@@ -31,6 +32,17 @@ export declare function newConversationId(): string;
31
32
  * re-deriving it.
32
33
  */
33
34
  export declare const SUBAGENT_RUN_RETENTION = 100;
35
+ /**
36
+ * How many orchestrated plans are kept.
37
+ *
38
+ * SMALLER THAN THE RUN RETENTION, and deliberately so. A plan does not duplicate the runs it executed -
39
+ * it holds one row per STEP - so fifty plans point at runs that are themselves evicted at 100. Keeping
40
+ * more plans than runs would leave timelines whose cards have all been trimmed away, which is a worse
41
+ * outcome than forgetting the plan: an empty timeline reads as "nothing ran" rather than as "the details
42
+ * aged out". Fifty is also well past what one working day of orchestration produces, since a plan is a
43
+ * request that needed several specialists rather than a unit of work.
44
+ */
45
+ export declare const WORKFLOW_RETENTION = 50;
34
46
  /**
35
47
  * Everything a revert decided, before anything is changed.
36
48
  *
@@ -365,6 +377,36 @@ export declare class Store {
365
377
  * back on disk, and one that trimmed on read would show a browser a roster the file still holds.
366
378
  */
367
379
  beginSubagentRun(run: SubagentRun): Promise<SubagentRun>;
380
+ /**
381
+ * Writes a whole plan BEFORE step one runs, and persists it.
382
+ *
383
+ * THE ORDER THIS EXISTS TO ENFORCE. A plan recorded only as it executes leaves a reload showing
384
+ * nothing recoverable - which steps were intended, in what order, under which failure policy - because
385
+ * a transcript of the model asking for a plan is not the plan. Writing it whole first makes the record
386
+ * a CLAIM the engine then makes good, exactly the relationship a run has to its child process, and it
387
+ * means an engine that dies mid-plan leaves a document a person can read instead of a gap.
388
+ *
389
+ * Steps arrive already in their pending form with empty `attempts`, rather than being built as the
390
+ * engine reaches them, so what lands on disk before anything runs is the whole intention.
391
+ *
392
+ * `structuredClone` on the way out, like `beginSubagentRun`: the engine mutates its own copy on every
393
+ * transition, and a caller holding a live reference would watch the record change under itself.
394
+ */
395
+ beginWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
396
+ /**
397
+ * Replaces a plan wholesale, and persists it.
398
+ *
399
+ * THE WHOLE PLAN RATHER THAN A FIELD PATCH, and the reason is that a plan's live state is three moving
400
+ * parts at once - `currentIndex`, the status of one step, and an appended attempt - so a patch type
401
+ * narrow enough to be safe for all three is a shape nothing could be typechecked against. Writing the
402
+ * plan also cannot forget a field, which is the failure a hand-maintained patch list invites as the
403
+ * plan grows new parts.
404
+ *
405
+ * The whole array is cloned before the write, for the same reason the recorder keeps its own copy of a
406
+ * run (`subagent-runs.ts:170`): the caller mutates the object it is about to hand back here, and a store
407
+ * that persisted by reference would write out those edits before they were ever announced.
408
+ */
409
+ setWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
368
410
  setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
369
411
  removeBackgroundTask(id: string): Promise<void>;
370
412
  /**
@@ -406,6 +448,12 @@ export declare class Store {
406
448
  todos: TodoItem[];
407
449
  dropped: number;
408
450
  }>;
451
+ /**
452
+ * Updates user-configured custom agent discovery directories.
453
+ * Empty/whitespace paths are ignored, and if no valid paths remain, the key is deleted
454
+ * to keep state.json clean.
455
+ */
456
+ setCustomAgentDirs(dirs: string[]): Promise<string[]>;
409
457
  /**
410
458
  * Writes the whole state atomically: a temp file, then a rename over the real one.
411
459
  *