@pi-unipi/subagents 2.6.1 → 2.9.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.
Files changed (107) hide show
  1. package/README.md +89 -78
  2. package/agents/delegate.md +14 -0
  3. package/agents/oracle.md +78 -0
  4. package/agents/researcher.md +52 -0
  5. package/agents/reviewer.md +79 -0
  6. package/agents/scout.md +50 -0
  7. package/agents/worker.md +59 -0
  8. package/package.json +22 -20
  9. package/prompts/council.md +48 -0
  10. package/prompts/gather-context-and-clarify.md +13 -0
  11. package/prompts/parallel-cleanup.md +59 -0
  12. package/prompts/parallel-research.md +50 -0
  13. package/prompts/parallel-review.md +54 -0
  14. package/prompts/review-loop.md +43 -0
  15. package/skills/council-mode/SKILL.md +230 -0
  16. package/skills/subagents/SKILL.md +49 -0
  17. package/skills/subagents/references/constraints-and-recipes.md +259 -0
  18. package/skills/subagents/references/execution-controls.md +454 -0
  19. package/skills/subagents/references/management-authoring-rpc.md +161 -0
  20. package/skills/subagents/references/multi-lane-orchestration.md +39 -0
  21. package/skills/subagents/references/prompting-and-roles.md +267 -0
  22. package/src/acceptance.ts +331 -0
  23. package/src/agent-manager.ts +405 -0
  24. package/src/agent-memory.ts +300 -0
  25. package/src/agent-overrides.ts +183 -0
  26. package/src/agent-runner.ts +271 -0
  27. package/src/async-runner.ts +417 -0
  28. package/src/authority-policy.ts +47 -0
  29. package/src/budgets.ts +285 -0
  30. package/src/child-safety.ts +140 -0
  31. package/src/config.ts +226 -0
  32. package/src/conversation-viewer.ts +281 -0
  33. package/src/core-compat.ts +94 -0
  34. package/src/custom-agents.ts +323 -0
  35. package/src/file-system-retry.ts +50 -0
  36. package/src/fleet-data.ts +61 -0
  37. package/src/fleet-view.ts +316 -0
  38. package/src/foreground-detach.ts +59 -0
  39. package/src/fork-context.ts +285 -0
  40. package/src/global.d.ts +7 -0
  41. package/src/guide.ts +129 -0
  42. package/src/index.ts +1149 -0
  43. package/src/mission-state.ts +133 -0
  44. package/src/mission-store.ts +434 -0
  45. package/src/model-resolver.ts +79 -0
  46. package/src/output-limits.ts +142 -0
  47. package/src/parity-types.ts +315 -0
  48. package/src/pi-args.ts +161 -0
  49. package/src/pi-spawn.ts +147 -0
  50. package/src/result-files.ts +232 -0
  51. package/src/result-watcher.ts +221 -0
  52. package/src/retained-children.ts +176 -0
  53. package/src/run-fanout-budget.ts +356 -0
  54. package/src/scheduled-runs.ts +351 -0
  55. package/src/schemas.ts +263 -0
  56. package/src/slash-commands.ts +77 -0
  57. package/src/supervisor-channel.ts +249 -0
  58. package/src/tool-handler.ts +1144 -0
  59. package/src/types.ts +238 -0
  60. package/src/widget.ts +447 -0
  61. package/src/workflow-script.ts +737 -0
  62. package/src/workflow-worker.ts +384 -0
  63. package/src/worktree.ts +614 -0
  64. package/dist/agent-manager.d.ts +0 -81
  65. package/dist/agent-manager.d.ts.map +0 -1
  66. package/dist/agent-manager.js +0 -295
  67. package/dist/agent-manager.js.map +0 -1
  68. package/dist/agent-runner.d.ts +0 -51
  69. package/dist/agent-runner.d.ts.map +0 -1
  70. package/dist/agent-runner.js +0 -262
  71. package/dist/agent-runner.js.map +0 -1
  72. package/dist/config.d.ts +0 -24
  73. package/dist/config.d.ts.map +0 -1
  74. package/dist/config.js +0 -132
  75. package/dist/config.js.map +0 -1
  76. package/dist/conversation-viewer.d.ts +0 -40
  77. package/dist/conversation-viewer.d.ts.map +0 -1
  78. package/dist/conversation-viewer.js +0 -276
  79. package/dist/conversation-viewer.js.map +0 -1
  80. package/dist/core-compat.d.ts +0 -21
  81. package/dist/core-compat.d.ts.map +0 -1
  82. package/dist/core-compat.js +0 -86
  83. package/dist/core-compat.js.map +0 -1
  84. package/dist/custom-agents.d.ts +0 -14
  85. package/dist/custom-agents.d.ts.map +0 -1
  86. package/dist/custom-agents.js +0 -113
  87. package/dist/custom-agents.js.map +0 -1
  88. package/dist/file-lock.d.ts +0 -42
  89. package/dist/file-lock.d.ts.map +0 -1
  90. package/dist/file-lock.js +0 -91
  91. package/dist/file-lock.js.map +0 -1
  92. package/dist/index.d.ts +0 -10
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -758
  95. package/dist/index.js.map +0 -1
  96. package/dist/model-resolver.d.ts +0 -19
  97. package/dist/model-resolver.d.ts.map +0 -1
  98. package/dist/model-resolver.js +0 -61
  99. package/dist/model-resolver.js.map +0 -1
  100. package/dist/types.d.ts +0 -98
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -47
  103. package/dist/types.js.map +0 -1
  104. package/dist/widget.d.ts +0 -56
  105. package/dist/widget.d.ts.map +0 -1
  106. package/dist/widget.js +0 -396
  107. package/dist/widget.js.map +0 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @pi-unipi/subagents — Mission workflow state (state.get/set)
3
+ *
4
+ * Ported from pi-subagents src/missions/workflow-state.ts. Durable JSON
5
+ * key/value state shared across workflows attached to the same mission:
6
+ * each set takes a lock file, merges the key, enforces a strict 256 KiB
7
+ * total-size limit.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "./file-system-retry.js";
14
+
15
+ export const MISSION_STATE_MAX_BYTES = 256 * 1024;
16
+
17
+ interface StateFile {
18
+ version: 1;
19
+ values: Record<string, unknown>;
20
+ }
21
+
22
+ function statePath(missionDir: string, missionId: string): string {
23
+ return path.join(missionDir, `${missionId}.state.json`);
24
+ }
25
+
26
+ function readState(file: string): StateFile {
27
+ try {
28
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as StateFile;
29
+ if (parsed.version === 1 && parsed.values && typeof parsed.values === "object" && !Array.isArray(parsed.values)) {
30
+ return parsed;
31
+ }
32
+ } catch {
33
+ // Missing/corrupt → fresh state.
34
+ }
35
+ return { version: 1, values: {} };
36
+ }
37
+
38
+ function validateKey(key: string): string {
39
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(key)) {
40
+ throw new Error("state key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
41
+ }
42
+ return key;
43
+ }
44
+
45
+ function withStateLock<T>(lockPath: string, operation: () => T): T {
46
+ const owner = { pid: process.pid, token: randomUUID() };
47
+ for (let attempt = 0; ; attempt++) {
48
+ try {
49
+ fs.mkdirSync(lockPath, { mode: 0o700 });
50
+ try {
51
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { mode: 0o600 });
52
+ } catch (error) {
53
+ fs.rmSync(lockPath, { recursive: true, force: true });
54
+ throw error;
55
+ }
56
+ break;
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
59
+ // Stale-lock reclaim via pid probe (reference behavior).
60
+ try {
61
+ const ownerRaw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
62
+ const held = JSON.parse(ownerRaw) as { pid?: number };
63
+ if (held.pid && held.pid > 0) {
64
+ try {
65
+ process.kill(held.pid, 0);
66
+ } catch (killError) {
67
+ if ((killError as NodeJS.ErrnoException).code !== "EPERM") {
68
+ fs.rmSync(lockPath, { recursive: true, force: true });
69
+ continue;
70
+ }
71
+ }
72
+ }
73
+ } catch {
74
+ // Unreadable owner → age-based fallback below.
75
+ }
76
+ const delay = DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt];
77
+ if (delay === undefined) throw new Error("Timed out acquiring mission state lock.");
78
+ waitForFileSystemRetry(delay);
79
+ }
80
+ }
81
+ try {
82
+ return operation();
83
+ } finally {
84
+ try {
85
+ const ownerRaw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
86
+ if (JSON.parse(ownerRaw).token === owner.token) fs.rmSync(lockPath, { recursive: true, force: true });
87
+ } catch {
88
+ // best effort
89
+ }
90
+ }
91
+ }
92
+
93
+ export interface MissionWorkflowState {
94
+ get(key: string): unknown;
95
+ set(key: string, value: unknown): void;
96
+ }
97
+
98
+ /** Create the state adapter for one mission. */
99
+ export function createMissionWorkflowState(missionDir: string, missionId: string): MissionWorkflowState {
100
+ const file = statePath(missionDir, missionId);
101
+ const lock = `${file}.lock`;
102
+
103
+ return {
104
+ get(key: string): unknown {
105
+ validateKey(key);
106
+ return withStateLock(lock, () => {
107
+ const state = readState(file);
108
+ return state.values[key];
109
+ });
110
+ },
111
+ set(key: string, value: unknown): void {
112
+ validateKey(key);
113
+ withStateLock(lock, () => {
114
+ const state = readState(file);
115
+ state.values[key] = value;
116
+ const serialized = JSON.stringify(state);
117
+ if (Buffer.byteLength(serialized, "utf8") > MISSION_STATE_MAX_BYTES) {
118
+ throw new Error(
119
+ `Mission state exceeds the strict ${MISSION_STATE_MAX_BYTES} byte limit; remove keys or store large data in files.`,
120
+ );
121
+ }
122
+ writePrivateAtomic(file, serialized);
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ function writePrivateAtomic(file: string, content: string): void {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
130
+ const tmp = `${file}.tmp-${process.pid}`;
131
+ fs.writeFileSync(tmp, content, { mode: 0o600 });
132
+ fs.renameSync(tmp, file);
133
+ }
@@ -0,0 +1,434 @@
1
+ /**
2
+ * @pi-unipi/subagents — Mission store
3
+ *
4
+ * Ported from pi-subagents src/missions/store.ts (core). Durable mission
5
+ * records under OUR layout: ~/.unipi/missions/<project-hash>/<missionId>.json
6
+ * (project-keyed by sha256 of the resolved cwd), with an optional global
7
+ * pointer index (~/.unipi/missions/index/) and retainTerminal pruning that
8
+ * removes only the oldest terminal records.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { homedir } from "node:os";
15
+
16
+ export const MISSION_STATUSES = [
17
+ "planned",
18
+ "active",
19
+ "waiting",
20
+ "needs_decision",
21
+ "completed",
22
+ "failed",
23
+ "cancelled",
24
+ ] as const;
25
+
26
+ export type MissionStatus = (typeof MISSION_STATUSES)[number];
27
+
28
+ const TERMINAL_MISSION_STATUSES = new Set<string>(["completed", "failed", "cancelled"]);
29
+ const DEFAULT_TERMINAL_MISSION_RETENTION = 200;
30
+
31
+ export interface MissionGoal {
32
+ status: "active" | "paused" | "budget-exhausted";
33
+ }
34
+
35
+ export interface MissionTokenBudget {
36
+ tokens: number;
37
+ }
38
+
39
+ export interface MissionRunLink {
40
+ runId: string;
41
+ agent?: string;
42
+ mode?: string;
43
+ status?: string;
44
+ startedAt?: string;
45
+ completedAt?: string;
46
+ }
47
+
48
+ export interface MissionDecision {
49
+ id: string;
50
+ status: "open" | "resolved";
51
+ title: string;
52
+ prompt?: string;
53
+ options?: string[];
54
+ recommendation?: string;
55
+ createdAt: string;
56
+ resolvedAt?: string;
57
+ resolution?: string;
58
+ }
59
+
60
+ export interface MissionArtifact {
61
+ kind: string;
62
+ path: string;
63
+ description?: string;
64
+ }
65
+
66
+ export interface MissionReceipt {
67
+ kind: string;
68
+ status: string;
69
+ title: string;
70
+ url: string;
71
+ createdAt: string;
72
+ description?: string;
73
+ }
74
+
75
+ export interface MissionRecord {
76
+ schemaVersion: 1;
77
+ id: string;
78
+ title: string;
79
+ objective: string;
80
+ goal?: MissionGoal;
81
+ budget?: MissionTokenBudget;
82
+ usage?: { tokens: number };
83
+ status: MissionStatus;
84
+ createdAt: string;
85
+ updatedAt: string;
86
+ cwd?: string;
87
+ ownerSessionId?: string;
88
+ runs: MissionRunLink[];
89
+ decisions: MissionDecision[];
90
+ artifacts: MissionArtifact[];
91
+ receipts: MissionReceipt[];
92
+ summary?: string;
93
+ labels?: string[];
94
+ }
95
+
96
+ export interface MissionIndexEntry {
97
+ schemaVersion: 1;
98
+ missionId: string;
99
+ projectRoot: string;
100
+ recordPath: string;
101
+ title: string;
102
+ status: MissionStatus;
103
+ updatedAt: string;
104
+ lastRunId?: string;
105
+ }
106
+
107
+ export interface MissionStoreConfig {
108
+ enabled?: boolean;
109
+ directory?: string;
110
+ globalIndex?: boolean;
111
+ globalIndexDir?: string;
112
+ retainTerminal?: number;
113
+ }
114
+
115
+ export interface MissionStoreLocation {
116
+ projectRoot: string;
117
+ missionDir: string;
118
+ globalIndexDir: string;
119
+ writeGlobalIndex: boolean;
120
+ retainTerminal?: number;
121
+ }
122
+
123
+ // ============================================================================
124
+ // Validation helpers
125
+ // ============================================================================
126
+
127
+ const MISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
128
+
129
+ export function validateMissionId(value: unknown, label = "missionId"): string {
130
+ if (typeof value !== "string" || !MISSION_ID_PATTERN.test(value)) {
131
+ throw new Error(`${label} must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.`);
132
+ }
133
+ return value;
134
+ }
135
+
136
+ function requiredString(value: unknown, label: string): string {
137
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
138
+ return value;
139
+ }
140
+
141
+ function optionalString(value: unknown): string | undefined {
142
+ return typeof value === "string" && value.length > 0 ? value : undefined;
143
+ }
144
+
145
+ function missionStatus(value: unknown, label: string): MissionStatus {
146
+ if (typeof value === "string" && (MISSION_STATUSES as readonly string[]).includes(value)) {
147
+ return value as MissionStatus;
148
+ }
149
+ throw new Error(`${label} must be one of ${MISSION_STATUSES.join(", ")}.`);
150
+ }
151
+
152
+ function parseBudget(value: unknown, label: string): MissionTokenBudget {
153
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object with tokens.`);
154
+ const tokens = (value as { tokens?: unknown }).tokens;
155
+ if (typeof tokens !== "number" || !Number.isInteger(tokens) || tokens <= 0) {
156
+ throw new Error(`${label}.tokens must be a positive integer.`);
157
+ }
158
+ return { tokens };
159
+ }
160
+
161
+ /** Parse + validate a full mission record. */
162
+ export function parseMissionRecord(value: unknown, source = "mission record"): MissionRecord {
163
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
164
+ throw new Error(`${source} must be an object.`);
165
+ }
166
+ const input = value as Record<string, unknown>;
167
+ if (input.schemaVersion !== 1) throw new Error(`${source}.schemaVersion must be 1`);
168
+ const arrays = ["runs", "decisions", "artifacts", "receipts"] as const;
169
+ for (const key of arrays) {
170
+ if (!Array.isArray(input[key])) throw new Error(`${source}.${key} must be an array.`);
171
+ }
172
+ return {
173
+ schemaVersion: 1,
174
+ id: validateMissionId(input.id, `${source}.id`),
175
+ title: requiredString(input.title, `${source}.title`),
176
+ objective: requiredString(input.objective, `${source}.objective`),
177
+ ...(input.goal && typeof input.goal === "object" ? { goal: input.goal as MissionGoal } : {}),
178
+ ...(input.budget ? { budget: parseBudget(input.budget, `${source}.budget`) } : {}),
179
+ ...(input.usage && typeof input.usage === "object" ? { usage: input.usage as { tokens: number } } : {}),
180
+ status: missionStatus(input.status, `${source}.status`),
181
+ createdAt: requiredString(input.createdAt, `${source}.createdAt`),
182
+ updatedAt: requiredString(input.updatedAt, `${source}.updatedAt`),
183
+ ...(optionalString(input.cwd) ? { cwd: input.cwd as string } : {}),
184
+ ...(optionalString(input.ownerSessionId) ? { ownerSessionId: input.ownerSessionId as string } : {}),
185
+ runs: input.runs as MissionRunLink[],
186
+ decisions: input.decisions as MissionDecision[],
187
+ artifacts: input.artifacts as MissionArtifact[],
188
+ receipts: input.receipts as MissionReceipt[],
189
+ ...(optionalString(input.summary) ? { summary: input.summary as string } : {}),
190
+ ...(Array.isArray(input.labels) ? { labels: input.labels.filter((l): l is string => typeof l === "string") } : {}),
191
+ };
192
+ }
193
+
194
+ // ============================================================================
195
+ // Store location — OUR layout: ~/.unipi/missions/<project-hash>/
196
+ // ============================================================================
197
+
198
+ function expandConfiguredPath(raw: string, projectRoot: string): string {
199
+ const trimmed = raw.trim();
200
+ if (trimmed.startsWith("~/")) return path.join(homedir(), trimmed.slice(2));
201
+ if (path.isAbsolute(trimmed)) return trimmed;
202
+ return path.resolve(projectRoot, trimmed);
203
+ }
204
+
205
+ /** Project hash key (sha256 of the resolved root, first 16 hex chars). */
206
+ export function projectHashKey(projectRoot: string): string {
207
+ return createHash("sha256").update(path.resolve(projectRoot)).digest("hex").slice(0, 16);
208
+ }
209
+
210
+ export function resolveMissionStoreLocation(input: {
211
+ projectRoot: string;
212
+ config?: MissionStoreConfig;
213
+ }): MissionStoreLocation {
214
+ const projectRoot = path.resolve(input.projectRoot);
215
+ const unipiDir = path.join(homedir(), ".unipi");
216
+ const defaultMissionDir = path.join(unipiDir, "missions", projectHashKey(projectRoot));
217
+ const missionDir = input.config?.directory
218
+ ? expandConfiguredPath(input.config.directory, projectRoot)
219
+ : defaultMissionDir;
220
+ const globalIndexDir = input.config?.globalIndexDir
221
+ ? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
222
+ : path.join(unipiDir, "missions", "index");
223
+ return {
224
+ projectRoot,
225
+ missionDir,
226
+ globalIndexDir,
227
+ writeGlobalIndex: input.config?.globalIndex !== false,
228
+ ...(input.config?.retainTerminal !== undefined ? { retainTerminal: input.config.retainTerminal } : {}),
229
+ };
230
+ }
231
+
232
+ export function missionRecordPath(location: MissionStoreLocation, missionId: string): string {
233
+ return path.join(location.missionDir, `${validateMissionId(missionId)}.json`);
234
+ }
235
+
236
+ // ============================================================================
237
+ // CRUD
238
+ // ============================================================================
239
+
240
+ function writePrivateAtomicJson(filePath: string, value: unknown): void {
241
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
242
+ const tmp = `${filePath}.tmp-${process.pid}`;
243
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
244
+ fs.renameSync(tmp, filePath);
245
+ }
246
+
247
+ function indexPath(location: MissionStoreLocation, missionId: string): string {
248
+ const key = createHash("sha256").update(`${location.projectRoot}\0${missionId}`).digest("hex");
249
+ return path.join(location.globalIndexDir, `${key}.json`);
250
+ }
251
+
252
+ function writeMission(location: MissionStoreLocation, record: MissionRecord): MissionRecord {
253
+ const validated = parseMissionRecord(record);
254
+ writePrivateAtomicJson(missionRecordPath(location, validated.id), validated);
255
+ if (location.writeGlobalIndex) {
256
+ const lastRunId = validated.runs.at(-1)?.runId;
257
+ const entry: MissionIndexEntry = {
258
+ schemaVersion: 1,
259
+ missionId: validated.id,
260
+ projectRoot: location.projectRoot,
261
+ recordPath: missionRecordPath(location, validated.id),
262
+ title: validated.title,
263
+ status: validated.status,
264
+ updatedAt: validated.updatedAt,
265
+ ...(lastRunId ? { lastRunId } : {}),
266
+ };
267
+ writePrivateAtomicJson(indexPath(location, validated.id), entry);
268
+ }
269
+ return validated;
270
+ }
271
+
272
+ function pruneTerminalMissions(location: MissionStoreLocation, maxTerminal: number): void {
273
+ const terminal = listMissions(location)
274
+ .records.filter((record) => TERMINAL_MISSION_STATUSES.has(record.status))
275
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
276
+ for (const record of terminal.slice(maxTerminal)) {
277
+ try {
278
+ fs.rmSync(missionRecordPath(location, record.id), { force: true });
279
+ if (location.writeGlobalIndex) fs.rmSync(indexPath(location, record.id), { force: true });
280
+ } catch {
281
+ // Retention is best-effort and must never block a launch.
282
+ }
283
+ }
284
+ }
285
+
286
+ export interface MissionCreateInput {
287
+ title: string;
288
+ objective: string;
289
+ goal?: boolean;
290
+ budget?: { tokens: number };
291
+ status?: MissionStatus;
292
+ ownerSessionId?: string;
293
+ labels?: string[];
294
+ }
295
+
296
+ export function createMission(
297
+ location: MissionStoreLocation,
298
+ input: MissionCreateInput,
299
+ now = new Date(),
300
+ retainTerminal = location.retainTerminal ?? DEFAULT_TERMINAL_MISSION_RETENTION,
301
+ ): MissionRecord {
302
+ if (input.goal === true && !input.budget) {
303
+ throw new Error("mission.budget is required when mission.goal is true");
304
+ }
305
+ const createdAt = now.toISOString();
306
+ const record: MissionRecord = {
307
+ schemaVersion: 1,
308
+ id: randomUUID(),
309
+ title: requiredString(input.title, "mission.title").trim(),
310
+ objective: requiredString(input.objective, "mission.objective").trim(),
311
+ ...(input.goal === true ? { goal: { status: "active" as const } } : {}),
312
+ ...(input.budget ? { budget: parseBudget(input.budget, "mission.budget") } : {}),
313
+ ...(input.goal === true ? { usage: { tokens: 0 } } : {}),
314
+ status: input.status ?? "planned",
315
+ createdAt,
316
+ updatedAt: createdAt,
317
+ cwd: location.projectRoot,
318
+ runs: [],
319
+ decisions: [],
320
+ artifacts: [],
321
+ receipts: [],
322
+ ...(input.ownerSessionId ? { ownerSessionId: requiredString(input.ownerSessionId, "mission.ownerSessionId") } : {}),
323
+ ...(input.labels ? { labels: input.labels.filter((l): l is string => typeof l === "string") } : {}),
324
+ };
325
+ const created = writeMission(location, record);
326
+ pruneTerminalMissions(location, retainTerminal);
327
+ return created;
328
+ }
329
+
330
+ export class MissionNotFoundError extends Error {
331
+ readonly code = "MISSION_NOT_FOUND";
332
+
333
+ constructor(missionId: string, missionDir: string) {
334
+ super(`Mission '${missionId}' was not found in mission directory '${missionDir}'. If it was created in another worktree, run the request from that worktree.`);
335
+ this.name = "MissionNotFoundError";
336
+ }
337
+ }
338
+
339
+ export function readMission(location: MissionStoreLocation, missionId: string): MissionRecord {
340
+ const filePath = missionRecordPath(location, missionId);
341
+ let raw: string;
342
+ try {
343
+ raw = fs.readFileSync(filePath, "utf-8");
344
+ } catch (error) {
345
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new MissionNotFoundError(missionId, location.missionDir);
346
+ throw error;
347
+ }
348
+ try {
349
+ return parseMissionRecord(JSON.parse(raw), filePath);
350
+ } catch (error) {
351
+ throw new Error(`Invalid mission file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
352
+ }
353
+ }
354
+
355
+ export interface MissionListResult {
356
+ records: MissionRecord[];
357
+ warnings: string[];
358
+ }
359
+
360
+ export function listMissions(location: MissionStoreLocation): MissionListResult {
361
+ if (!fs.existsSync(location.missionDir)) return { records: [], warnings: [] };
362
+ const records: MissionRecord[] = [];
363
+ const warnings: string[] = [];
364
+ for (const name of fs.readdirSync(location.missionDir).filter((item) => item.endsWith(".json")).sort()) {
365
+ const filePath = path.join(location.missionDir, name);
366
+ try {
367
+ records.push(parseMissionRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")), filePath));
368
+ } catch (error) {
369
+ warnings.push(`Skipped corrupt mission '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
370
+ }
371
+ }
372
+ records.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
373
+ return { records, warnings };
374
+ }
375
+
376
+ export interface MissionUpdateInput {
377
+ status?: MissionStatus;
378
+ summary?: string;
379
+ labels?: string[];
380
+ addRun?: MissionRunLink;
381
+ addDecision?: Omit<MissionDecision, "id" | "createdAt"> & { id?: string };
382
+ resolveDecision?: { id: string; resolution: string };
383
+ addArtifact?: MissionArtifact;
384
+ addReceipt?: MissionReceipt;
385
+ incrementUsage?: { tokens: number };
386
+ }
387
+
388
+ export function updateMission(
389
+ location: MissionStoreLocation,
390
+ missionId: string,
391
+ update: MissionUpdateInput,
392
+ now = new Date(),
393
+ retainTerminal = location.retainTerminal ?? DEFAULT_TERMINAL_MISSION_RETENTION,
394
+ ): MissionRecord {
395
+ const current = readMission(location, missionId);
396
+ const next: MissionRecord = { ...current, updatedAt: now.toISOString() };
397
+
398
+ if (update.status !== undefined) next.status = update.status;
399
+ if (update.summary !== undefined) next.summary = update.summary;
400
+ if (update.labels !== undefined) next.labels = update.labels;
401
+ if (update.addRun) next.runs = [...next.runs, update.addRun];
402
+ if (update.addArtifact) next.artifacts = [...next.artifacts, update.addArtifact];
403
+ if (update.addReceipt) next.receipts = [...next.receipts, update.addReceipt];
404
+ if (update.incrementUsage) {
405
+ const base = next.usage?.tokens ?? 0;
406
+ next.usage = { tokens: base + update.incrementUsage.tokens };
407
+ // Goal continuation: budget exhaustion flips the goal status.
408
+ if (next.goal && next.budget && next.usage.tokens >= next.budget.tokens) {
409
+ next.goal = { ...next.goal, status: "budget-exhausted" };
410
+ }
411
+ }
412
+ if (update.addDecision) {
413
+ next.decisions = [
414
+ ...next.decisions,
415
+ {
416
+ ...update.addDecision,
417
+ id: update.addDecision.id ?? randomUUID().slice(0, 8),
418
+ status: "open",
419
+ createdAt: now.toISOString(),
420
+ } as MissionDecision,
421
+ ];
422
+ }
423
+ if (update.resolveDecision) {
424
+ next.decisions = next.decisions.map((decision) =>
425
+ decision.id === update.resolveDecision!.id && decision.status === "open"
426
+ ? { ...decision, status: "resolved", resolvedAt: now.toISOString(), resolution: update.resolveDecision!.resolution }
427
+ : decision,
428
+ );
429
+ }
430
+
431
+ const saved = writeMission(location, next);
432
+ pruneTerminalMissions(location, retainTerminal);
433
+ return saved;
434
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @pi-unipi/subagents — Model resolver
3
+ *
4
+ * Resolves model strings like "haiku", "sonnet", "anthropic/claude-sonnet-4-6"
5
+ * to actual Model instances using the registry.
6
+ */
7
+
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+
10
+ export interface ModelRegistry {
11
+ find(provider: string, modelId: string): Model<any> | undefined;
12
+ getAll(): Model<any>[];
13
+ getAvailable?(): Model<any>[];
14
+ }
15
+
16
+ /**
17
+ * Resolve a model string to a Model instance.
18
+ * Tries exact match first, then fuzzy match.
19
+ * Returns Model on success, error string on failure.
20
+ */
21
+ export function resolveModel(
22
+ input: string,
23
+ registry: ModelRegistry,
24
+ ): Model<any> | string {
25
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as Array<{
26
+ id: string;
27
+ name?: string;
28
+ provider: string;
29
+ }>;
30
+ const availableSet = new Set(all.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
31
+
32
+ // 1. Exact match: "provider/modelId"
33
+ const slashIdx = input.indexOf("/");
34
+ if (slashIdx !== -1) {
35
+ const provider = input.slice(0, slashIdx);
36
+ const modelId = input.slice(slashIdx + 1);
37
+ if (availableSet.has(input.toLowerCase())) {
38
+ const found = registry.find(provider, modelId);
39
+ if (found) return found;
40
+ }
41
+ }
42
+
43
+ // 2. Fuzzy match
44
+ const query = input.toLowerCase();
45
+ let bestMatch: (typeof all)[number] | undefined;
46
+ let bestScore = 0;
47
+
48
+ for (const m of all) {
49
+ const id = m.id.toLowerCase();
50
+ const name = (m.name ?? m.id).toLowerCase();
51
+ const full = `${m.provider}/${m.id}`.toLowerCase();
52
+
53
+ let score = 0;
54
+ if (id === query || full === query) {
55
+ score = 100;
56
+ } else if (id.includes(query) || full.includes(query)) {
57
+ score = 60 + (query.length / id.length) * 30;
58
+ } else if (name.includes(query)) {
59
+ score = 40 + (query.length / name.length) * 20;
60
+ }
61
+
62
+ if (score > bestScore) {
63
+ bestScore = score;
64
+ bestMatch = m;
65
+ }
66
+ }
67
+
68
+ if (bestMatch && bestScore >= 20) {
69
+ const found = registry.find(bestMatch.provider, bestMatch.id);
70
+ if (found) return found;
71
+ }
72
+
73
+ // 3. No match — return error with available models
74
+ const modelList = all
75
+ .map((m) => ` ${m.provider}/${m.id}`)
76
+ .sort()
77
+ .join("\n");
78
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
79
+ }