pi-subagents 0.42.0 → 0.43.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 (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -5
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +2 -2
  5. package/skills/pi-subagents/references/constraints-and-recipes.md +35 -32
  6. package/skills/pi-subagents/references/execution-controls.md +27 -43
  7. package/skills/pi-subagents/references/management-authoring-rpc.md +20 -3
  8. package/skills/pi-subagents/references/prompting-and-roles.md +16 -49
  9. package/src/agents/agent-refinements.ts +624 -0
  10. package/src/agents/agents.ts +0 -2
  11. package/src/agents/proactive-skills.ts +1 -1
  12. package/src/api/delegation.ts +1 -2
  13. package/src/extension/control-notices.ts +2 -2
  14. package/src/extension/fanout-child.ts +46 -22
  15. package/src/extension/index.ts +29 -12
  16. package/src/extension/public-execution.ts +71 -0
  17. package/src/extension/rpc.ts +7 -8
  18. package/src/extension/schemas.ts +17 -17
  19. package/src/extension/tool-description.ts +14 -14
  20. package/src/missions/actions.ts +47 -11
  21. package/src/missions/goal-driver.ts +162 -0
  22. package/src/missions/lifecycle.ts +44 -12
  23. package/src/missions/store.ts +68 -3
  24. package/src/missions/types.ts +25 -3
  25. package/src/missions/workflow-state.ts +77 -0
  26. package/src/profiles/profiles.ts +1 -3
  27. package/src/runs/background/async-execution.ts +3 -0
  28. package/src/runs/background/async-job-tracker.ts +2 -17
  29. package/src/runs/background/control-channel.ts +50 -6
  30. package/src/runs/background/retained-children.ts +68 -0
  31. package/src/runs/background/scheduled-runs.ts +17 -13
  32. package/src/runs/background/steering.ts +7 -5
  33. package/src/runs/background/subagent-runner.ts +10 -4
  34. package/src/runs/foreground/async-steering-action.ts +36 -10
  35. package/src/runs/foreground/chain-clarify.ts +13 -16
  36. package/src/runs/foreground/execution.ts +4 -0
  37. package/src/runs/foreground/subagent-executor.ts +200 -59
  38. package/src/runs/shared/acceptance.ts +154 -9
  39. package/src/runs/shared/subagent-prompt-runtime.ts +94 -25
  40. package/src/shared/types.ts +17 -3
  41. package/src/slash/delegation-adapters.ts +0 -13
  42. package/src/slash/prompt-template-bridge.ts +14 -12
  43. package/src/slash/prompt-workflows.ts +10 -4
  44. package/src/slash/slash-bridge.ts +8 -6
  45. package/src/slash/slash-commands.ts +27 -12
  46. package/src/slash/slash-live-state.ts +4 -2
  47. package/src/tui/fleet-status.ts +8 -4
  48. package/src/tui/fleet.ts +15 -5
  49. package/src/tui/render.ts +13 -31
  50. package/src/workflows/chat-progress.ts +2 -2
  51. package/src/workflows/scripted-workflow.ts +97 -10
  52. package/agents/context-builder.md +0 -46
  53. package/agents/planner.md +0 -56
  54. package/prompts/parallel-context-build.md +0 -55
  55. package/prompts/parallel-handoff-plan.md +0 -61
@@ -14,8 +14,10 @@ import {
14
14
  type MissionStatus,
15
15
  type MissionStoreConfig,
16
16
  type MissionStoreLocation,
17
+ type MissionTokenBudget,
17
18
  type MissionUpdateInput,
18
19
  } from "./types.ts";
20
+ import { missionStatePath } from "./workflow-state.ts";
19
21
  import {
20
22
  createMission,
21
23
  listGlobalMissions,
@@ -40,13 +42,17 @@ export type MissionAction = typeof MISSION_ACTIONS[number];
40
42
 
41
43
  export interface MissionLaunchInput {
42
44
  title: string;
43
- goal?: string;
45
+ objective?: string;
46
+ goal?: true;
47
+ budget?: MissionTokenBudget;
44
48
  labels?: string[];
45
49
  }
46
50
 
47
51
  export interface MissionUpdateToolInput {
48
52
  title?: string;
49
- goal?: string;
53
+ objective?: string;
54
+ goal?: boolean | { paused: boolean };
55
+ budget?: MissionTokenBudget;
50
56
  status?: MissionStatus;
51
57
  summary?: string;
52
58
  labels?: string[];
@@ -101,18 +107,26 @@ export function validateMissionLaunch(value: unknown): MissionLaunchInput {
101
107
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("mission must be an object");
102
108
  const input = value as Record<string, unknown>;
103
109
  for (const key of Object.keys(input)) {
104
- if (key !== "title" && key !== "summary" && key !== "goal" && key !== "labels") throw new Error(`mission.${key} is unknown`);
110
+ if (key !== "title" && key !== "summary" && key !== "objective" && key !== "goal" && key !== "budget" && key !== "labels") throw new Error(`mission.${key} is unknown`);
105
111
  }
106
112
  if (input.title !== undefined && input.summary !== undefined) throw new Error("mission.title and mission.summary cannot both be set");
107
113
  const title = input.title ?? input.summary;
108
114
  if (typeof title !== "string" || !title.trim()) throw new Error("mission.title or mission.summary must be a non-empty string");
109
- if (input.goal !== undefined && (typeof input.goal !== "string" || !input.goal.trim())) throw new Error("mission.goal must be a non-empty string");
115
+ if (input.objective !== undefined && (typeof input.objective !== "string" || !input.objective.trim())) throw new Error("mission.objective must be a non-empty string");
116
+ if (input.goal !== undefined && input.goal !== true) throw new Error("mission.goal must be true when supplied");
117
+ const budget = input.budget;
118
+ if (budget !== undefined && (!budget || typeof budget !== "object" || Array.isArray(budget) || !Number.isSafeInteger((budget as { tokens?: unknown }).tokens) || ((budget as { tokens: number }).tokens < 1))) {
119
+ throw new Error("mission.budget.tokens must be a positive integer");
120
+ }
121
+ if (input.goal === true && budget === undefined) throw new Error("mission.budget is required when mission.goal is true");
110
122
  if (input.labels !== undefined && (!Array.isArray(input.labels) || input.labels.some((label) => typeof label !== "string" || !label.trim()))) {
111
123
  throw new Error("mission.labels must contain only non-empty strings");
112
124
  }
113
125
  return {
114
126
  title: title.trim(),
115
- ...(input.goal !== undefined ? { goal: input.goal.trim() } : {}),
127
+ ...(typeof input.objective === "string" ? { objective: input.objective.trim() } : {}),
128
+ ...(input.goal === true ? { goal: true as const } : {}),
129
+ ...(budget !== undefined ? { budget: { tokens: (budget as { tokens: number }).tokens } } : {}),
116
130
  ...(input.labels !== undefined ? { labels: input.labels.map((label) => label.trim()) } : {}),
117
131
  };
118
132
  }
@@ -163,15 +177,30 @@ function validateMissionUpdate(value: unknown): MissionUpdateInput {
163
177
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("missionUpdate must be an object");
164
178
  const input = value as Record<string, unknown>;
165
179
  for (const key of Object.keys(input)) {
166
- if (!["title", "goal", "status", "summary", "labels", "artifacts", "receipts", "decisions"].includes(key)) throw new Error(`missionUpdate.${key} is unknown`);
180
+ if (!["title", "objective", "goal", "budget", "status", "summary", "labels", "artifacts", "receipts", "decisions"].includes(key)) throw new Error(`missionUpdate.${key} is unknown`);
167
181
  }
168
182
  const update: MissionUpdateInput = {};
169
- for (const field of ["title", "goal", "summary"] as const) {
183
+ for (const field of ["title", "objective", "summary"] as const) {
170
184
  const candidate = input[field];
171
185
  if (candidate === undefined) continue;
172
186
  if (typeof candidate !== "string" || !candidate.trim()) throw new Error(`missionUpdate.${field} must be a non-empty string`);
173
187
  update[field] = candidate.trim();
174
188
  }
189
+ if (input.goal !== undefined) {
190
+ if (typeof input.goal === "boolean") update.goal = input.goal ? { status: "active" } : false;
191
+ else {
192
+ if (!input.goal || typeof input.goal !== "object" || Array.isArray(input.goal) || typeof (input.goal as { paused?: unknown }).paused !== "boolean" || Object.keys(input.goal).some((key) => key !== "paused")) {
193
+ throw new Error("missionUpdate.goal must be boolean or { paused: boolean }");
194
+ }
195
+ update.goal = { status: (input.goal as { paused: boolean }).paused ? "paused" : "active" };
196
+ }
197
+ }
198
+ if (input.budget !== undefined) {
199
+ if (!input.budget || typeof input.budget !== "object" || Array.isArray(input.budget) || !Number.isSafeInteger((input.budget as { tokens?: unknown }).tokens) || (input.budget as { tokens: number }).tokens < 1) {
200
+ throw new Error("missionUpdate.budget.tokens must be a positive integer");
201
+ }
202
+ update.budget = { tokens: (input.budget as { tokens: number }).tokens };
203
+ }
175
204
  if (input.status !== undefined) update.status = validateStatus(input.status, "missionUpdate.status");
176
205
  if (input.labels !== undefined) {
177
206
  if (!Array.isArray(input.labels) || input.labels.some((label) => typeof label !== "string" || !label.trim())) throw new Error("missionUpdate.labels must contain only non-empty strings");
@@ -236,7 +265,11 @@ function refreshLinkedRunStatus(location: MissionStoreLocation, record: MissionR
236
265
  if (updates.length === 0) return { record, warnings };
237
266
  const merged = record.runs.map((run) => updates.find((update) => update.runId === run.runId && update.childIndex === run.childIndex) ?? run);
238
267
  const states = merged.map((run) => run.status);
239
- const status: MissionStatus = states.some((state) => state === "queued" || state === "running" || state === "active")
268
+ const status: MissionStatus = record.status === "completed" || record.status === "failed" || record.status === "cancelled"
269
+ ? record.status
270
+ : record.goal && !states.some((state) => state === "queued" || state === "running" || state === "active")
271
+ ? "active"
272
+ : states.some((state) => state === "queued" || state === "running" || state === "active")
240
273
  ? "active"
241
274
  : states.some((state) => state === "paused")
242
275
  ? "waiting"
@@ -255,9 +288,10 @@ function formatMission(record: MissionRecord): string {
255
288
  `Mission: ${record.id}`,
256
289
  `Title: ${record.title}`,
257
290
  `Status: ${record.status}`,
258
- `Goal: ${record.goal}`,
291
+ `Objective: ${record.objective}`,
259
292
  `Updated: ${record.updatedAt}`,
260
293
  ];
294
+ if (record.goal && record.budget) lines.push(`Goal mode: ${record.goal.status}`, `Budget: ${record.usage?.tokens ?? 0}/${record.budget.tokens} tokens`);
261
295
  if (record.summary) lines.push(`Summary: ${record.summary}`);
262
296
  if (record.labels?.length) lines.push(`Labels: ${record.labels.join(", ")}`);
263
297
  if (record.runs.length) {
@@ -293,7 +327,9 @@ export function handleMissionAction(
293
327
  const mission = validateMissionLaunch(params.mission);
294
328
  const record = createMission(location, {
295
329
  title: mission.title,
296
- goal: mission.goal ?? mission.title,
330
+ objective: mission.objective ?? mission.title,
331
+ ...(mission.goal === true ? { goal: true as const } : {}),
332
+ ...(mission.budget ? { budget: mission.budget } : {}),
297
333
  status: params.missionStatus ? validateStatus(params.missionStatus, "missionStatus") : "planned",
298
334
  ...(mission.labels ? { labels: mission.labels } : {}),
299
335
  ...(ctx.currentSessionId ? { ownerSessionId: ctx.currentSessionId } : {}),
@@ -321,7 +357,7 @@ export function handleMissionAction(
321
357
  }
322
358
  if (action === "mission.show") {
323
359
  const refreshed = refreshLinkedRunStatus(location, readMission(location, requireMissionId(params)));
324
- const lines = [formatMission(refreshed.record)];
360
+ const lines = [formatMission(refreshed.record), `State: ${missionStatePath(location, refreshed.record.id)}`];
325
361
  if (refreshed.warnings.length) lines.push("", ...refreshed.warnings.map((warning) => `Warning: ${warning}`));
326
362
  return textResult(lines.join("\n"), {
327
363
  mode: "management",
@@ -0,0 +1,162 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { RetainedChild } from "../runs/background/retained-children.ts";
4
+ import type { ControlEvent } from "../shared/types.ts";
5
+ import { listMissions, readMission, updateMission } from "./store.ts";
6
+ import type { MissionRecord, MissionRunLink, MissionStoreLocation } from "./types.ts";
7
+ import { missionStatePath } from "./workflow-state.ts";
8
+
9
+ const TERMINAL_MISSION_STATUSES = new Set(["completed", "failed", "cancelled"]);
10
+ const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "active"]);
11
+ const MAX_ACTION_LENGTH = 180;
12
+
13
+ export interface GoalContinuationNotice {
14
+ missionId: string;
15
+ message: string;
16
+ event: ControlEvent;
17
+ }
18
+
19
+ function bounded(value: string): string {
20
+ const normalized = value.replace(/\s+/g, " ").trim();
21
+ return normalized.length > MAX_ACTION_LENGTH ? `${normalized.slice(0, MAX_ACTION_LENGTH - 1)}…` : normalized;
22
+ }
23
+
24
+ function tokenUsage(value: unknown): number | undefined {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
26
+ const total = (value as { total?: unknown }).total;
27
+ return Number.isSafeInteger(total) && (total as number) >= 0 ? total as number : undefined;
28
+ }
29
+
30
+ function readLinkedRun(run: MissionRunLink): MissionRunLink {
31
+ if (!run.asyncDir) return run;
32
+ const statusPath = path.join(run.asyncDir, "status.json");
33
+ if (!fs.existsSync(statusPath)) return run;
34
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as Record<string, unknown>;
35
+ if (typeof status.state !== "string" || !status.state.trim()) throw new Error(`Linked run status '${statusPath}' is missing state`);
36
+ const tokens = tokenUsage(status.totalTokens)
37
+ ?? (Array.isArray(status.steps)
38
+ ? status.steps.reduce((total, step) => {
39
+ if (!step || typeof step !== "object") return total;
40
+ return total + (tokenUsage((step as { tokens?: unknown }).tokens) ?? 0);
41
+ }, 0)
42
+ : undefined);
43
+ return {
44
+ ...run,
45
+ status: status.state,
46
+ ...(!ACTIVE_RUN_STATUSES.has(status.state) && !run.completedAt ? { completedAt: new Date().toISOString() } : {}),
47
+ ...(tokens !== undefined ? { usage: { tokens } } : {}),
48
+ };
49
+ }
50
+
51
+ function refreshGoalMission(location: MissionStoreLocation, record: MissionRecord): MissionRecord {
52
+ const runs = record.runs.map(readLinkedRun);
53
+ const changed = runs.some((run, index) => JSON.stringify(run) !== JSON.stringify(record.runs[index]));
54
+ if (!changed) return record;
55
+ const active = runs.some((run) => run.status && ACTIVE_RUN_STATUSES.has(run.status));
56
+ return updateMission(location, record.id, {
57
+ status: active ? "active" : record.goal ? "active" : record.status,
58
+ addRuns: runs,
59
+ });
60
+ }
61
+
62
+ function readyActionFromValue(value: unknown, pathLabel = "state", depth = 0): string | undefined {
63
+ if (depth > 8 || !value || typeof value !== "object") return undefined;
64
+ if (Array.isArray(value)) {
65
+ for (let index = 0; index < value.length; index += 1) {
66
+ const found = readyActionFromValue(value[index], `${pathLabel}[${index}]`, depth + 1);
67
+ if (found) return found;
68
+ }
69
+ return undefined;
70
+ }
71
+ const input = value as Record<string, unknown>;
72
+ for (const key of ["nextReadyAction", "nextAction"] as const) {
73
+ if (typeof input[key] === "string" && input[key].trim()) return bounded(input[key]);
74
+ }
75
+ if (input.status === "ready") {
76
+ for (const key of ["action", "task", "title", "summary"] as const) {
77
+ if (typeof input[key] === "string" && input[key].trim()) return bounded(input[key]);
78
+ }
79
+ return `Continue ready mission state at ${pathLabel}`;
80
+ }
81
+ for (const [key, child] of Object.entries(input)) {
82
+ const found = readyActionFromValue(child, `${pathLabel}.${key}`, depth + 1);
83
+ if (found) return found;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function missionStateAction(location: MissionStoreLocation, record: MissionRecord): string | undefined {
89
+ const statePath = missionStatePath(location, record.id);
90
+ if (fs.existsSync(statePath)) {
91
+ const action = readyActionFromValue(JSON.parse(fs.readFileSync(statePath, "utf-8")));
92
+ if (action) return action;
93
+ }
94
+ const decision = record.decisions.find((item) => item.status === "open");
95
+ if (decision) return bounded(decision.recommendation ?? `Resolve decision: ${decision.title}`);
96
+ return undefined;
97
+ }
98
+
99
+ function retainedResumeTarget(record: MissionRecord, retainedChildren: RetainedChild[]): RetainedChild | undefined {
100
+ const latestRun = record.runs.at(-1);
101
+ if (!latestRun) return undefined;
102
+ return retainedChildren.find((child) => child.runId === latestRun.runId || child.parentRunId === latestRun.runId);
103
+ }
104
+
105
+ function nextReadyAction(location: MissionStoreLocation, record: MissionRecord, retainedChildren: RetainedChild[]): string {
106
+ const stateAction = missionStateAction(location, record);
107
+ const latestRun = record.runs.at(-1);
108
+ const action = stateAction
109
+ ?? (latestRun?.status === "failed" || latestRun?.status === "paused"
110
+ ? `Inspect linked run ${latestRun.runId} and continue the mission`
111
+ : `Continue objective: ${record.objective}`);
112
+ const retained = retainedResumeTarget(record, retainedChildren);
113
+ return retained
114
+ ? `Resume retained child ${retained.runId} (${retained.agent}) for: ${bounded(action)}`
115
+ : bounded(action);
116
+ }
117
+
118
+ export function collectGoalContinuationNotices(input: {
119
+ location: MissionStoreLocation;
120
+ ownerSessionId: string;
121
+ retainedChildren: RetainedChild[];
122
+ turnId: number;
123
+ now?: number;
124
+ }): GoalContinuationNotice[] {
125
+ const notices: GoalContinuationNotice[] = [];
126
+ const seen = new Set<string>();
127
+ for (const listed of listMissions(input.location).records) {
128
+ if (listed.ownerSessionId !== input.ownerSessionId || !listed.goal || TERMINAL_MISSION_STATUSES.has(listed.status)) continue;
129
+ let record = refreshGoalMission(input.location, readMission(input.location, listed.id));
130
+ if (!record.goal || record.goal.status !== "active" || !record.budget) continue;
131
+ if ((record.usage?.tokens ?? 0) >= record.budget.tokens) {
132
+ record = updateMission(input.location, record.id, { usage: record.usage ?? { tokens: 0 } });
133
+ if (record.goal?.status === "budget-exhausted") continue;
134
+ }
135
+ if (record.runs.some((run) => run.status && ACTIVE_RUN_STATUSES.has(run.status))) continue;
136
+ if (seen.has(record.id) || !record.budget) continue;
137
+ seen.add(record.id);
138
+ const budget = record.budget.tokens;
139
+ const used = record.usage?.tokens ?? 0;
140
+ const remaining = Math.max(0, budget - used);
141
+ const message = [
142
+ `Goal mission needs attention: ${bounded(record.title)}`,
143
+ `Mission: ${record.id}`,
144
+ `Remaining budget: ${remaining} tokens (${used}/${budget} used)`,
145
+ `Next ready action: ${nextReadyAction(input.location, record, input.retainedChildren)}`,
146
+ ].join("\n");
147
+ notices.push({
148
+ missionId: record.id,
149
+ message,
150
+ event: {
151
+ type: "needs_attention",
152
+ to: "needs_attention",
153
+ ts: input.now ?? Date.now(),
154
+ runId: `goal-${record.id}-turn-${input.turnId}`,
155
+ agent: "goal mission",
156
+ message,
157
+ reason: "idle",
158
+ },
159
+ });
160
+ }
161
+ return notices;
162
+ }
@@ -34,14 +34,21 @@ interface PersistedMissionBinding {
34
34
  retainTerminal?: number;
35
35
  }
36
36
 
37
- function workflowGoal(params: MissionLaunchParams): string | undefined {
38
- return params.task?.trim()
37
+ function workflowObjective(params: MissionLaunchParams): string | undefined {
38
+ const objective = params.task?.trim()
39
39
  || params.tasks?.find((task) => task.task?.trim())?.task?.trim()
40
40
  || params.chain?.find((step) => step.task?.trim())?.task?.trim();
41
+ if (objective) return objective;
42
+ for (const step of params.chain ?? []) {
43
+ const parallel = Array.isArray(step.parallel) ? step.parallel : step.parallel ? [step.parallel] : [];
44
+ const task = parallel.find((child) => child.task?.trim())?.task?.trim();
45
+ if (task) return task;
46
+ }
47
+ return undefined;
41
48
  }
42
49
 
43
- function conciseTitle(goal: string): string {
44
- const firstLine = goal.split(/\r?\n/, 1)[0]?.trim() || goal.trim();
50
+ function conciseTitle(objective: string): string {
51
+ const firstLine = objective.split(/\r?\n/, 1)[0]?.trim() || objective.trim();
45
52
  return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
46
53
  }
47
54
 
@@ -51,24 +58,27 @@ export function prepareMissionLaunch(input: {
51
58
  config?: MissionStoreConfig;
52
59
  ownerSessionId?: string;
53
60
  }): MissionLaunchBinding | undefined {
54
- if (input.params.missionId && input.params.mission !== undefined) throw new Error("Use missionId or mission, not both");
61
+ const hasMissionId = input.params.missionId !== undefined;
62
+ if (hasMissionId && input.params.mission !== undefined) throw new Error("Use missionId or mission, not both");
55
63
  if (input.params.mission === false) return undefined;
56
- const goal = workflowGoal(input.params);
64
+ const objective = workflowObjective(input.params);
57
65
  const missionsEnabled = input.config?.enabled !== false;
58
- const shouldCreate = input.params.mission !== undefined || (missionsEnabled && goal !== undefined);
59
- if (!input.params.missionId && !shouldCreate) return undefined;
66
+ const shouldCreate = input.params.mission !== undefined || (missionsEnabled && objective !== undefined);
67
+ if (!hasMissionId && !shouldCreate) return undefined;
60
68
  const location = resolveMissionStoreLocation({ projectRoot: input.projectRoot, ...(input.config ? { config: input.config } : {}) });
61
- if (input.params.missionId) {
69
+ if (hasMissionId) {
62
70
  const missionId = validateMissionId(input.params.missionId);
63
71
  readMission(location, missionId);
64
72
  updateMission(location, missionId, { status: "active" });
65
73
  return { missionId, location, autoCreated: false, announceInContent: true };
66
74
  }
67
75
  const mission = input.params.mission !== undefined ? validateMissionLaunch(input.params.mission) : undefined;
68
- const title = mission?.title || conciseTitle(goal!);
76
+ const title = mission?.title || conciseTitle(objective!);
69
77
  const record = createMission(location, {
70
78
  title,
71
- goal: mission?.goal || goal || title,
79
+ objective: mission?.objective || objective || title,
80
+ ...(mission?.goal === true ? { goal: true as const } : {}),
81
+ ...(mission?.budget ? { budget: mission.budget } : {}),
72
82
  status: "active",
73
83
  ...(mission?.labels ? { labels: mission.labels } : {}),
74
84
  ...(input.ownerSessionId ? { ownerSessionId: input.ownerSessionId } : {}),
@@ -92,7 +102,9 @@ function runStatusForResult(result: AgentToolResult<Details>): string {
92
102
  }
93
103
 
94
104
  function missionStatusForRun(record: MissionRecord, runId: string, runStatus: string): MissionStatus {
105
+ if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") return record.status;
95
106
  if (runStatus === "active" || runStatus === "queued" || runStatus === "running") return "active";
107
+ if (record.goal) return "active";
96
108
  if (runStatus === "paused") return "waiting";
97
109
  const otherActive = record.runs.some((run) => run.runId !== runId && (run.status === "active" || run.status === "queued" || run.status === "running"));
98
110
  if (otherActive) return "active";
@@ -101,6 +113,17 @@ function missionStatusForRun(record: MissionRecord, runId: string, runStatus: st
101
113
  return "failed";
102
114
  }
103
115
 
116
+ function usageForResult(result: AgentToolResult<Details>): { tokens: number } | undefined {
117
+ const tokens = result.details.results.reduce((total, child) => total + child.usage.input + child.usage.output, 0);
118
+ return tokens > 0 ? { tokens } : undefined;
119
+ }
120
+
121
+ function usageFromUnknown(value: unknown): { tokens: number } | undefined {
122
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
123
+ const total = (value as { total?: unknown }).total;
124
+ return Number.isSafeInteger(total) && (total as number) >= 0 ? { tokens: total as number } : undefined;
125
+ }
126
+
104
127
  function artifactsForResult(result: AgentToolResult<Details>): MissionArtifact[] {
105
128
  const artifacts: MissionArtifact[] = [];
106
129
  if (result.details.asyncDir) {
@@ -168,6 +191,7 @@ export function attachMissionToLaunchResult(input: {
168
191
  const runStatus = runStatusForResult(input.result);
169
192
  const current = readMission(input.binding.location, input.binding.missionId);
170
193
  const startedAt = new Date().toISOString();
194
+ const usage = usageForResult(input.result);
171
195
  const run: MissionRunLink = {
172
196
  runId,
173
197
  mode: missionRunModeForResult(input.result.details.mode),
@@ -176,6 +200,7 @@ export function attachMissionToLaunchResult(input: {
176
200
  ...(input.result.details.asyncDir ? { asyncDir: input.result.details.asyncDir } : {}),
177
201
  ...(input.result.details.results.length === 1 && input.result.details.results[0]?.agent ? { agent: input.result.details.results[0].agent } : {}),
178
202
  ...(runStatus !== "active" ? { completedAt: startedAt } : {}),
203
+ ...(usage ? { usage } : {}),
179
204
  };
180
205
  let mission = updateMission(input.binding.location, input.binding.missionId, {
181
206
  status: missionStatusForRun(current, runId, runStatus),
@@ -305,9 +330,16 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
305
330
  }
306
331
  }
307
332
  const summary = typeof event.summary === "string" && event.summary.trim() ? event.summary.slice(0, 2000) : undefined;
333
+ const usage = usageFromUnknown(event.totalTokens)
334
+ ?? (Array.isArray(event.results)
335
+ ? { tokens: event.results.reduce((total, result) => {
336
+ if (!result || typeof result !== "object") return total;
337
+ return total + (usageFromUnknown((result as { tokens?: unknown }).tokens)?.tokens ?? 0);
338
+ }, 0) }
339
+ : undefined);
308
340
  return updateMission(binding.location, binding.missionId, {
309
341
  status: missionStatusForRun(current, runId, runStatus),
310
- addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt }],
342
+ addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
311
343
  addArtifacts: artifacts,
312
344
  ...(summary ? { summary } : {}),
313
345
  });
@@ -12,6 +12,7 @@ import {
12
12
  type MissionArtifactKind,
13
13
  type MissionCreateInput,
14
14
  type MissionDecision,
15
+ type MissionGoal,
15
16
  type MissionIndexEntry,
16
17
  type MissionListResult,
17
18
  type MissionReceipt,
@@ -23,6 +24,8 @@ import {
23
24
  type MissionStatus,
24
25
  type MissionStoreConfig,
25
26
  type MissionStoreLocation,
27
+ type MissionTokenBudget,
28
+ type MissionTokenUsage,
26
29
  type MissionUpdateInput,
27
30
  } from "./types.ts";
28
31
 
@@ -63,6 +66,37 @@ function missionStatus(value: unknown, label: string): MissionStatus {
63
66
  return value as MissionStatus;
64
67
  }
65
68
 
69
+ function positiveTokenCount(value: unknown, label: string): number {
70
+ if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error(`${label} must be a positive integer`);
71
+ return value as number;
72
+ }
73
+
74
+ function nonNegativeTokenCount(value: unknown, label: string): number {
75
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} must be a non-negative integer`);
76
+ return value as number;
77
+ }
78
+
79
+ function parseStoredGoal(value: unknown, label: string): { goal?: MissionGoal; legacyObjective?: string } {
80
+ if (typeof value === "string") return { legacyObjective: requiredString(value, label).trim() };
81
+ return { goal: parseGoal(value, label) };
82
+ }
83
+
84
+ function parseGoal(value: unknown, label: string): MissionGoal {
85
+ const input = asObject(value, label);
86
+ if (input.status !== "active" && input.status !== "paused" && input.status !== "budget-exhausted") throw new Error(`${label}.status is invalid`);
87
+ return { status: input.status };
88
+ }
89
+
90
+ function parseBudget(value: unknown, label: string): MissionTokenBudget {
91
+ const input = asObject(value, label);
92
+ return { tokens: positiveTokenCount(input.tokens, `${label}.tokens`) };
93
+ }
94
+
95
+ function parseUsage(value: unknown, label: string): MissionTokenUsage {
96
+ const input = asObject(value, label);
97
+ return { tokens: nonNegativeTokenCount(input.tokens, `${label}.tokens`) };
98
+ }
99
+
66
100
  function stringArray(value: unknown, label: string): string[] {
67
101
  if (!Array.isArray(value)) throw new Error(`${label} must be an array of non-empty strings`);
68
102
  const result = value.map((item, index) => requiredString(item, `${label}[${index}]`).trim());
@@ -94,6 +128,7 @@ function parseRunLink(value: unknown, label: string): MissionRunLink {
94
128
  ...(optionalString(input.status, `${label}.status`) ? { status: input.status as string } : {}),
95
129
  ...(input.startedAt !== undefined ? { startedAt: timestamp(input.startedAt, `${label}.startedAt`) } : {}),
96
130
  ...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
131
+ ...(input.usage !== undefined ? { usage: parseUsage(input.usage, `${label}.usage`) } : {}),
97
132
  };
98
133
  }
99
134
 
@@ -158,11 +193,20 @@ export function parseMissionRecord(value: unknown, source = "mission record"): M
158
193
  const decisions = input.decisions as unknown[];
159
194
  const artifacts = input.artifacts as unknown[];
160
195
  const receipts = (input.receipts ?? []) as unknown[];
196
+ const parsedGoal = input.goal !== undefined ? parseStoredGoal(input.goal, `${source}.goal`) : {};
197
+ const budget = input.budget !== undefined ? parseBudget(input.budget, `${source}.budget`) : undefined;
198
+ const usage = input.usage !== undefined ? parseUsage(input.usage, `${source}.usage`) : undefined;
199
+ const objective = optionalString(input.objective, `${source}.objective`)?.trim() ?? parsedGoal.legacyObjective;
200
+ if (!objective) throw new Error(`${source}.objective must be a non-empty string`);
201
+ if (parsedGoal.goal && !budget) throw new Error(`${source}.budget is required for a goal mission`);
161
202
  return {
162
203
  schemaVersion: 1,
163
204
  id: validateMissionId(input.id, `${source}.id`),
164
205
  title: requiredString(input.title, `${source}.title`),
165
- goal: requiredString(input.goal, `${source}.goal`),
206
+ objective,
207
+ ...(parsedGoal.goal ? { goal: parsedGoal.goal } : {}),
208
+ ...(budget ? { budget } : {}),
209
+ ...(usage ? { usage } : {}),
166
210
  status: missionStatus(input.status, `${source}.status`),
167
211
  createdAt: timestamp(input.createdAt, `${source}.createdAt`),
168
212
  updatedAt: timestamp(input.updatedAt, `${source}.updatedAt`),
@@ -279,6 +323,7 @@ function pruneTerminalMissions(location: MissionStoreLocation, maxTerminal: numb
279
323
  for (const record of terminal.slice(maxTerminal)) {
280
324
  try {
281
325
  fs.rmSync(missionRecordPath(location, record.id), { force: true });
326
+ fs.rmSync(path.join(location.missionDir, record.id), { recursive: true, force: true });
282
327
  if (location.writeGlobalIndex) fs.rmSync(indexPath(location, record), { force: true });
283
328
  } catch {
284
329
  // Retention is best-effort and must never block a launch.
@@ -292,7 +337,10 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
292
337
  schemaVersion: 1,
293
338
  id: randomUUID(),
294
339
  title: requiredString(input.title, "mission.title").trim(),
295
- goal: requiredString(input.goal, "mission.goal").trim(),
340
+ objective: requiredString(input.objective, "mission.objective").trim(),
341
+ ...(input.goal === true ? { goal: { status: "active" as const } } : {}),
342
+ ...(input.budget ? { budget: parseBudget(input.budget, "mission.budget") } : {}),
343
+ ...(input.goal === true ? { usage: { tokens: 0 } } : {}),
296
344
  status: input.status ?? "planned",
297
345
  createdAt,
298
346
  updatedAt: createdAt,
@@ -304,6 +352,7 @@ export function createMission(location: MissionStoreLocation, input: MissionCrea
304
352
  ...(input.ownerSessionId ? { ownerSessionId: requiredString(input.ownerSessionId, "mission.ownerSessionId") } : {}),
305
353
  ...(input.labels ? { labels: stringArray(input.labels, "mission.labels") } : {}),
306
354
  };
355
+ if (input.goal === true && !input.budget) throw new Error("mission.budget is required when mission.goal is true");
307
356
  const created = writeMission(location, record);
308
357
  pruneTerminalMissions(location, retainTerminal);
309
358
  return created;
@@ -390,6 +439,19 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
390
439
  ...(decision.recommendation ? { recommendation: requiredString(decision.recommendation, "mission.update.addDecisions[].recommendation") } : {}),
391
440
  })),
392
441
  ];
442
+ const budget = update.budget !== undefined ? parseBudget(update.budget, "mission.update.budget") : current.budget;
443
+ const usage = update.usage !== undefined
444
+ ? parseUsage(update.usage, "mission.update.usage")
445
+ : { tokens: runs.reduce((total, run) => total + (run.usage?.tokens ?? 0), 0) };
446
+ let goal = update.goal === false ? undefined : update.goal !== undefined ? parseGoal(update.goal, "mission.update.goal") : current.goal;
447
+ if (goal && !budget) throw new Error("mission.update.budget is required when enabling a goal mission");
448
+ if (goal && budget) {
449
+ goal = usage.tokens >= budget.tokens
450
+ ? { status: "budget-exhausted" }
451
+ : goal.status === "budget-exhausted"
452
+ ? { status: "active" }
453
+ : goal;
454
+ }
393
455
  const next: MissionRecord = {
394
456
  ...current,
395
457
  updatedAt: createdAt,
@@ -398,12 +460,15 @@ export function updateMission(location: MissionStoreLocation, missionId: string,
398
460
  receipts,
399
461
  decisions,
400
462
  ...(update.title !== undefined ? { title: requiredString(update.title, "mission.update.title").trim() } : {}),
401
- ...(update.goal !== undefined ? { goal: requiredString(update.goal, "mission.update.goal").trim() } : {}),
463
+ ...(update.objective !== undefined ? { objective: requiredString(update.objective, "mission.update.objective").trim() } : {}),
464
+ ...(budget ? { budget } : {}),
465
+ ...(goal ? { goal, usage } : {}),
402
466
  ...(update.status !== undefined ? { status: missionStatus(update.status, "mission.update.status") } : {}),
403
467
  ...(update.summary !== undefined ? { summary: requiredString(update.summary, "mission.update.summary") } : {}),
404
468
  ...(update.labels !== undefined ? { labels: stringArray(update.labels, "mission.update.labels") } : {}),
405
469
  ...(update.acceptance !== undefined ? { acceptance: update.acceptance } : {}),
406
470
  };
471
+ if (!goal) delete next.goal;
407
472
  const updated = writeMission(location, next);
408
473
  if (TERMINAL_MISSION_STATUSES.has(updated.status)) pruneTerminalMissions(location, retainTerminal);
409
474
  return updated;
@@ -13,6 +13,19 @@ export type MissionRunMode = "single" | "parallel" | "chain" | "workflow" | "sch
13
13
  export type MissionArtifactKind = "status" | "output" | "patch" | "manifest" | "review" | "note" | "other";
14
14
  export type MissionReceiptKind = "pull_request" | "ci" | "deployment" | "release";
15
15
  export type MissionReceiptStatus = "pending" | "ready" | "succeeded" | "failed";
16
+ export type MissionGoalStatus = "active" | "paused" | "budget-exhausted";
17
+
18
+ export interface MissionGoal {
19
+ status: MissionGoalStatus;
20
+ }
21
+
22
+ export interface MissionTokenBudget {
23
+ tokens: number;
24
+ }
25
+
26
+ export interface MissionTokenUsage {
27
+ tokens: number;
28
+ }
16
29
 
17
30
  export interface MissionRunLink {
18
31
  runId: string;
@@ -23,6 +36,7 @@ export interface MissionRunLink {
23
36
  status?: string;
24
37
  startedAt?: string;
25
38
  completedAt?: string;
39
+ usage?: MissionTokenUsage;
26
40
  }
27
41
 
28
42
  export interface MissionDecision {
@@ -56,7 +70,10 @@ export interface MissionRecord {
56
70
  schemaVersion: 1;
57
71
  id: string;
58
72
  title: string;
59
- goal: string;
73
+ objective: string;
74
+ goal?: MissionGoal;
75
+ budget?: MissionTokenBudget;
76
+ usage?: MissionTokenUsage;
60
77
  status: MissionStatus;
61
78
  createdAt: string;
62
79
  updatedAt: string;
@@ -115,7 +132,9 @@ export interface GlobalMissionListResult {
115
132
 
116
133
  export interface MissionCreateInput {
117
134
  title: string;
118
- goal: string;
135
+ objective: string;
136
+ goal?: boolean;
137
+ budget?: MissionTokenBudget;
119
138
  status?: MissionStatus;
120
139
  labels?: string[];
121
140
  ownerSessionId?: string;
@@ -123,7 +142,10 @@ export interface MissionCreateInput {
123
142
 
124
143
  export interface MissionUpdateInput {
125
144
  title?: string;
126
- goal?: string;
145
+ objective?: string;
146
+ goal?: MissionGoal | false;
147
+ budget?: MissionTokenBudget;
148
+ usage?: MissionTokenUsage;
127
149
  status?: MissionStatus;
128
150
  summary?: string;
129
151
  labels?: string[];