@pi-unipi/unipi 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +10 -134
  3. package/package.json +23 -23
  4. package/packages/app-bridge/package.json +2 -2
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/autocomplete/package.json +1 -1
  7. package/packages/background-tasks/package.json +2 -2
  8. package/packages/btw/package.json +2 -2
  9. package/packages/compactor/package.json +3 -3
  10. package/packages/core/README.md +4 -1
  11. package/packages/core/index.ts +1 -0
  12. package/packages/core/package.json +1 -1
  13. package/packages/core/src/settings/snapshot.ts +247 -0
  14. package/packages/dream/package.json +2 -2
  15. package/packages/footer/package.json +3 -3
  16. package/packages/fusion/package.json +4 -4
  17. package/packages/info-screen/package.json +2 -2
  18. package/packages/input-shortcuts/package.json +2 -2
  19. package/packages/kanboard/package.json +7 -7
  20. package/packages/long-horizon/README.md +1 -3
  21. package/packages/long-horizon/index.ts +131 -27
  22. package/packages/long-horizon/package.json +2 -2
  23. package/packages/long-horizon/src/owner.ts +33 -1
  24. package/packages/long-horizon/src/session-state.ts +366 -0
  25. package/packages/long-horizon/src/tools/graph.ts +10 -6
  26. package/packages/mcp/package.json +2 -2
  27. package/packages/memory/package.json +3 -3
  28. package/packages/notify/package.json +2 -2
  29. package/packages/skill-registry/package.json +2 -2
  30. package/packages/subagents/package.json +3 -3
  31. package/packages/subagents/src/index.ts +29 -5
  32. package/packages/unipi/bundled.js +950 -464
  33. package/packages/unipi/index.ts +18 -1
  34. package/packages/updater/package.json +2 -2
  35. package/packages/utility/package.json +2 -2
  36. package/packages/watchdog/package.json +3 -3
  37. package/packages/web-api/package.json +2 -2
  38. package/packages/workflow/package.json +2 -2
@@ -37,6 +37,14 @@ export interface OwnerState {
37
37
  readonly revision: number;
38
38
  readonly lease: OwnerLease;
39
39
  readonly updatedAt: string;
40
+ /**
41
+ * UNI-followup: the session that started/adopted this run — state is
42
+ * session-scoped, so these are informational (the other-session warning).
43
+ */
44
+ readonly sessionId?: string;
45
+ readonly sessionName?: string;
46
+ /** Refreshed on restore/bind so "running vs paused" is honest. */
47
+ readonly pid?: number;
40
48
  }
41
49
 
42
50
  export interface OwnerHistoryEntry {
@@ -57,7 +65,9 @@ export interface OwnerSnapshot {
57
65
  export interface OwnerCoordinatorDeps {
58
66
  /** Absolute path of the state file (usually under .unipi/long-horizon/). */
59
67
  statePath(): string;
60
- now?(): number;
68
+ now?(): number
69
+ /** The current session's identity, stamped onto activated/adopted owners. */
70
+ session?(): { id?: string; name?: string; pid?: number };
61
71
  /** Best-effort observer; failures never roll back a committed transition. */
62
72
  onChange?(snapshot: OwnerSnapshot, event: OwnerEvent): void;
63
73
  }
@@ -130,6 +140,7 @@ export class OwnerCoordinator {
130
140
  if (this.active) return undefined;
131
141
  const ownerId = randomUUID();
132
142
  const now = new Date(this.deps.now?.() ?? Date.now()).toISOString();
143
+ const session = this.deps.session?.();
133
144
  const owner: OwnerState = Object.freeze({
134
145
  ownerId,
135
146
  kind,
@@ -138,6 +149,9 @@ export class OwnerCoordinator {
138
149
  revision: 0,
139
150
  lease: Object.freeze({ ownerId, generation: 0 }),
140
151
  updatedAt: now,
152
+ ...(session?.id ? { sessionId: session.id } : {}),
153
+ ...(session?.name ? { sessionName: session.name } : {}),
154
+ ...(session?.pid ? { pid: session.pid } : {}),
141
155
  });
142
156
  this.active = owner;
143
157
  return this.commit({ type: "activated", owner });
@@ -229,6 +243,24 @@ export class OwnerCoordinator {
229
243
  // Corrupt state is treated as absent: repair, don't resurrect.
230
244
  }
231
245
  }
246
+ // UNI-followup: adopt (stamp the session id on a legacy owner with none)
247
+ // and refresh the pid — the owning process is here again. An owner stamped
248
+ // with ANOTHER session id never happens on disk (state is session-scoped).
249
+ const session = this.deps.session?.();
250
+ const refresh = (owner: OwnerState | undefined): OwnerState | undefined => {
251
+ if (!owner) return owner;
252
+ // Adopt: a legacy owner without a session stamp belongs to whoever
253
+ // restores it first (the continued-session rule gated this already).
254
+ const stamp = owner.sessionId === undefined && session?.id ? { sessionId: session.id } : {};
255
+ const nameInfo = !owner.sessionName && session?.name ? { sessionName: session.name } : {};
256
+ const pidInfo = session?.pid !== undefined && owner.pid !== session.pid ? { pid: session.pid } : {};
257
+ if (!("sessionId" in stamp) && !("sessionName" in nameInfo) && !("pid" in pidInfo)) return owner;
258
+ const next = this.withOwner(owner, { ...stamp, ...nameInfo, ...pidInfo });
259
+ this.persist();
260
+ return next;
261
+ };
262
+ if (this.active) this.active = refresh(this.active);
263
+ if (this.parked) this.parked = refresh(this.parked);
232
264
  const snapshot = this.snapshot();
233
265
  this.deps.onChange?.(snapshot, { type: "restored", snapshot });
234
266
  return snapshot;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Session-scoped long-horizon state — a run belongs to the session that
3
+ * started it (UNI-followup): every session reads/writes
4
+ * `~/.unipi/workspace/<id>/state/long-horizon/sessions/<sessionId>/`, so a
5
+ * `/new` or app "New chat" starts clean instead of adopting (and waking)
6
+ * another session's graph.
7
+ *
8
+ * Pure helpers here (testable without pi): path layout, the "is this a
9
+ * continued session" rule, adoption of legacy pre-split state, sibling
10
+ * scans for the one-line warning and the read-only history aggregate, and
11
+ * the >30-days housekeeping prune.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** Files (and the ralph dir) that make up one session's long-horizon state. */
18
+ export const LH_SESSION_FILES = ["state.json", "goal.json", "graph.json"] as const;
19
+
20
+ /** `<workspace state>/long-horizon/sessions/<sessionId>/` — one dir per session. */
21
+ export function sessionStateDir(workspaceStateDir: string, sessionId: string): string {
22
+ return join(workspaceStateDir, "sessions", sessionId);
23
+ }
24
+
25
+ /** The pre-split workspace-level dir (legacy, adopted once by a continued session). */
26
+ export function legacyWorkspaceDir(workspaceStateDir: string): string {
27
+ return workspaceStateDir;
28
+ }
29
+
30
+ /** The older in-repo legacy dir (`<cwd>/.unipi/long-horizon`). */
31
+ export function legacyInRepoDir(cwd: string): string {
32
+ return join(cwd, ".unipi", "long-horizon");
33
+ }
34
+
35
+ /**
36
+ * A continued session keeps what its project was doing: `resume`/`reload`
37
+ * explicitly, and a bare `startup` counts when the session file already has
38
+ * entries (pi -r replays them). `new`/`fork` and an entry-less startup start
39
+ * clean and never adopt.
40
+ */
41
+ export function isContinuedSession(reason: string | undefined, priorEntryCount: number): boolean {
42
+ if (reason === "resume" || reason === "reload") return true;
43
+ if (reason === "new" || reason === "fork") return false;
44
+ return priorEntryCount > 0;
45
+ }
46
+
47
+ /**
48
+ * Does THIS session's own transcript prove it owned the legacy run? The
49
+ * entries (stringified) must contain, for a graph/swarm owner, the legacy
50
+ * graph's `current.graphId`; for a goal owner, the goal's id or objective;
51
+ * for a ralph owner, the loop name. Without that, an unrelated `pi -r` of
52
+ * some other session must NOT adopt (and then drive) the legacy run — that
53
+ * would re-introduce exactly the cross-session wake bug. The sibling-owner
54
+ * note still tells the user where the run lives.
55
+ */
56
+ export function legacyOwnershipEvidence(entriesJson: string, legacyDirs: string[]): boolean {
57
+ if (!entriesJson) return false;
58
+ for (const dir of legacyDirs) {
59
+ const state = readOwnerState(join(dir, "state.json"));
60
+ const owner = state?.active ?? state?.parked;
61
+ const kind = owner?.kind;
62
+ if (kind === "graph" || kind === "swarm") {
63
+ try {
64
+ const graph = JSON.parse(readJsonFile(join(dir, "graph.json"))) as { current?: { graphId?: string } };
65
+ const graphId = graph?.current?.graphId;
66
+ if (graphId && entriesJson.includes(graphId)) return true;
67
+ } catch {
68
+ // No/bad graph.json: no graph evidence from this dir.
69
+ }
70
+ } else if (kind === "goal") {
71
+ try {
72
+ const goal = JSON.parse(readJsonFile(join(dir, "goal.json"))) as { goalId?: string; objective?: string };
73
+ if ((goal?.goalId && entriesJson.includes(goal.goalId)) || (goal?.objective && entriesJson.includes(goal.objective))) return true;
74
+ } catch {
75
+ // No/bad goal.json.
76
+ }
77
+ } else if (kind === "ralph") {
78
+ const loopName = owner?.label;
79
+ if (loopName && entriesJson.includes(loopName)) return true;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Move legacy state (workspace-level and in-repo) into the session dir.
87
+ * Only files the session dir lacks are moved; rename is atomic; the first
88
+ * continued session wins (the source is gone afterwards). Returns what moved.
89
+ */
90
+ export function adoptLegacyState(sessionDir: string, sources: string[], files: readonly string[]): string[] {
91
+ const moved: string[] = [];
92
+ if (existsSync(join(sessionDir, "state.json"))) return moved;
93
+ try {
94
+ mkdirSync(sessionDir, { recursive: true });
95
+ } catch {
96
+ return moved;
97
+ }
98
+ for (const source of sources) {
99
+ for (const file of files) {
100
+ const from = join(source, file);
101
+ const to = join(sessionDir, file);
102
+ if (!existsSync(to) && existsSync(from)) {
103
+ try {
104
+ renameSync(from, to);
105
+ moved.push(from);
106
+ } catch {
107
+ // A failed move just means this session starts fresh at `to`.
108
+ }
109
+ }
110
+ }
111
+ // The ralph task-file dir moves whole.
112
+ const fromDir = join(source, "ralph");
113
+ const toDir = join(sessionDir, "ralph");
114
+ if (!existsSync(toDir) && existsSync(fromDir)) {
115
+ try {
116
+ renameSync(fromDir, toDir);
117
+ moved.push(fromDir);
118
+ } catch {
119
+ // Same: best-effort.
120
+ }
121
+ }
122
+ }
123
+ return moved;
124
+ }
125
+
126
+ // ── sibling scans ────────────────────────────────────────────────────────────
127
+
128
+ export interface SiblingGraphOwner {
129
+ readonly sessionId: string;
130
+ readonly kind: "graph" | "swarm";
131
+ readonly label: string;
132
+ /** Set when the state declares one (best-effort; the session file's name). */
133
+ readonly sessionName?: string;
134
+ /** The owner's pid when stamped (running vs paused in the warning). */
135
+ readonly pid?: number;
136
+ readonly updatedAt: string;
137
+ }
138
+
139
+ interface OwnerLike {
140
+ active?: { kind?: string; label?: string; sessionId?: string; pid?: number };
141
+ parked?: { kind?: string; label?: string; sessionId?: string; pid?: number };
142
+ updatedAt?: string;
143
+ }
144
+
145
+ function readOwnerState(path: string): OwnerLike | undefined {
146
+ if (!existsSync(path)) return undefined;
147
+ try {
148
+ const parsed = JSON.parse(readJsonFile(path)) as OwnerLike;
149
+ return parsed && typeof parsed === "object" ? parsed : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ /** Shim so this module needs no core import for one read. */
156
+ function readJsonFile(path: string): string {
157
+ return readFileSync(path, "utf8");
158
+ }
159
+
160
+ /**
161
+ * Sibling sessions holding an active/parked graph|swarm owner that is NOT
162
+ * this session's. `labelsById` maps session ids to names (best-effort).
163
+ */
164
+ export function scanSiblingGraphOwners(
165
+ sessionsDir: string,
166
+ ownSessionId: string,
167
+ ...legacyDirs: (string | undefined)[]
168
+ ): SiblingGraphOwner[] {
169
+ const out: SiblingGraphOwner[] = [];
170
+ const push = (sessionId: string, state: OwnerLike | undefined, sessionName?: string, ownDir = true): void => {
171
+ for (const slot of ["active", "parked"] as const) {
172
+ const owner = state?.[slot];
173
+ if (!owner || (owner.kind !== "graph" && owner.kind !== "swarm")) continue;
174
+ // A sibling dir whose owner belongs to yet another session is skipped;
175
+ // legacy owners keep their real session id (the note needs it).
176
+ if (ownDir && owner.sessionId && owner.sessionId !== sessionId) continue;
177
+ out.push({
178
+ sessionId,
179
+ kind: owner.kind === "swarm" ? "swarm" : "graph",
180
+ label: owner.label ?? "",
181
+ ...(sessionName !== undefined ? { sessionName } : {}),
182
+ ...(owner.pid !== undefined ? { pid: owner.pid } : {}),
183
+ updatedAt: state?.updatedAt ?? "",
184
+ });
185
+ break;
186
+ }
187
+ };
188
+ try {
189
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
190
+ if (!entry.isDirectory() || entry.name === ownSessionId) continue;
191
+ const state = readOwnerState(join(sessionsDir, entry.name, "state.json"));
192
+ // An owner stamped with OUR session id lives in our dir, not here.
193
+ if (state?.active?.sessionId === ownSessionId || state?.parked?.sessionId === ownSessionId) continue;
194
+ push(entry.name, state);
195
+ }
196
+ } catch {
197
+ // No sessions dir yet: fall through to the legacy scan.
198
+ }
199
+ // Leftover legacy state (pre-split workspace-level AND the older in-repo
200
+ // dir): warn the same way — the user should /resume the owning session.
201
+ for (const legacyDir of legacyDirs) {
202
+ if (!legacyDir) continue;
203
+ const state = readOwnerState(join(legacyDir, "state.json"));
204
+ if (state && state.active?.sessionId !== ownSessionId && state.parked?.sessionId !== ownSessionId) {
205
+ push("(legacy)", state, undefined, false);
206
+ }
207
+ }
208
+ return out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
209
+ }
210
+
211
+ /** `process.kill(pid, 0)` — the stamped owner process is alive. */
212
+ export function pidAlive(pid: number | undefined): boolean {
213
+ if (pid === undefined || pid <= 0) return false;
214
+ try {
215
+ process.kill(pid, 0);
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /** The one-line ui.notify note for a sibling graph owner (most recent wins). */
223
+ export function graphOwnerNote(owner: SiblingGraphOwner | undefined): string | undefined {
224
+ if (!owner) return undefined;
225
+ const name = owner.sessionName ?? (owner.sessionId !== "(workspace)" ? owner.sessionId.slice(0, 12) : "another session");
226
+ if (pidAlive(owner.pid)) {
227
+ return `Agent ${owner.kind} "${owner.label}" is running in another session (${name}) — /resume it to follow it.`;
228
+ }
229
+ return `Agent ${owner.kind} "${owner.label}" is paused in another session (${name}) — /resume it to continue.`;
230
+ }
231
+
232
+ // ── history aggregate + read-only sibling results ────────────────────────────
233
+
234
+ /** A graph file: `{ v, history: [...], current?: graph }`. */
235
+ export interface GraphFile {
236
+ history: Array<Record<string, unknown> & { graphId?: string; createdAt?: number }>;
237
+ current?: Record<string, unknown> & { graphId?: string; createdAt?: number };
238
+ }
239
+
240
+ function readGraphFile(path: string): GraphFile | undefined {
241
+ if (!existsSync(path)) return undefined;
242
+ try {
243
+ const parsed = JSON.parse(readJsonFile(path)) as GraphFile;
244
+ if (!parsed || !Array.isArray(parsed.history)) return undefined;
245
+ return parsed;
246
+ } catch {
247
+ return undefined;
248
+ }
249
+ }
250
+
251
+ export function graphUpdatedAt(graph: Record<string, unknown>): number {
252
+ const at = graph.updatedAt ?? graph.createdAt;
253
+ return typeof at === "number" ? at : 0;
254
+ }
255
+
256
+ function isClosed(graph: Record<string, unknown>): boolean {
257
+ return !!(graph.finish || graph.abandoned);
258
+ }
259
+
260
+ /**
261
+ * Earlier graphs across the project: the caller's own list plus siblings'
262
+ * finished graphs (their `history` and a closed `current` — never another
263
+ * session's OPEN graph). Deduped by graphId, newest first.
264
+ */
265
+ export function aggregateGraphHistory(
266
+ own: Array<Record<string, unknown> & { graphId?: string }>,
267
+ sessionsDir: string,
268
+ ownSessionId: string,
269
+ ): Array<Record<string, unknown> & { graphId?: string }> {
270
+ const byId = new Map<string, Record<string, unknown>>();
271
+ for (const graph of own) {
272
+ const id = graph.graphId;
273
+ if (typeof id === "string") byId.set(id, graph);
274
+ }
275
+ try {
276
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
277
+ if (!entry.isDirectory() || entry.name === ownSessionId) continue;
278
+ const file = readGraphFile(join(sessionsDir, entry.name, "graph.json"));
279
+ if (!file) continue;
280
+ for (const graph of file.history) {
281
+ if (typeof graph.graphId === "string" && !byId.has(graph.graphId)) byId.set(graph.graphId, graph);
282
+ }
283
+ if (file.current && isClosed(file.current) && typeof file.current.graphId === "string" && !byId.has(file.current.graphId)) {
284
+ byId.set(file.current.graphId, file.current);
285
+ }
286
+ }
287
+ } catch {
288
+ // No sessions dir: only the engine's own history.
289
+ }
290
+ return [...byId.values()].sort((a, b) => graphUpdatedAt(b) - graphUpdatedAt(a));
291
+ }
292
+
293
+ /**
294
+ * Find a work item (by work id) inside a specific sibling graph (history or
295
+ * closed current) — read-only, for the Progress sheet's Full result.
296
+ */
297
+ export function findSiblingWork(
298
+ sessionsDir: string,
299
+ ownSessionId: string,
300
+ graphId: string,
301
+ workId: string,
302
+ ): { work: Record<string, unknown>; graph: Record<string, unknown> } | undefined {
303
+ try {
304
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
305
+ if (!entry.isDirectory() || entry.name === ownSessionId) continue;
306
+ const file = readGraphFile(join(sessionsDir, entry.name, "graph.json"));
307
+ if (!file) continue;
308
+ const candidates = [...file.history, ...(file.current && isClosed(file.current) ? [file.current] : [])];
309
+ const graph = candidates.find((g) => g.graphId === graphId);
310
+ if (!graph) continue;
311
+ const work = (graph["work"] as Array<Record<string, unknown>> | undefined)?.find((w) => w.workId === workId);
312
+ if (work) return { work, graph };
313
+ }
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ // ── housekeeping ─────────────────────────────────────────────────────────────
321
+
322
+ const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
323
+
324
+ /**
325
+ * Remove sibling session dirs with no active/parked owner, no open graph and
326
+ * mtime older than 30 days. Best-effort, never throws.
327
+ */
328
+ export function pruneSiblingSessionDirs(sessionsDir: string, ownSessionId: string, now: number): string[] {
329
+ const pruned: string[] = [];
330
+ try {
331
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
332
+ if (!entry.isDirectory() || entry.name === ownSessionId) continue;
333
+ const dir = join(sessionsDir, entry.name);
334
+ try {
335
+ if (now - statSync(dir).mtimeMs <= PRUNE_AGE_MS) continue;
336
+ const state = readOwnerState(join(dir, "state.json"));
337
+ if (state?.active || state?.parked) continue;
338
+ const file = readGraphFile(join(dir, "graph.json"));
339
+ if (file?.current && !isClosed(file.current)) continue;
340
+ rmSync(dir, { recursive: true, force: true });
341
+ pruned.push(entry.name);
342
+ } catch {
343
+ // One bad dir never blocks the rest.
344
+ }
345
+ }
346
+ } catch {
347
+ // No sessions dir: nothing to prune.
348
+ }
349
+ return pruned;
350
+ }
351
+
352
+ /** The most recent mtime across a dir's own entries (best-effort). */
353
+ export function dirMtime(dir: string): number {
354
+ try {
355
+ return statSync(dir).mtimeMs;
356
+ } catch {
357
+ return 0;
358
+ }
359
+ }
360
+
361
+ /** Whether a session dir has any long-horizon state yet. */
362
+ export function sessionHasState(sessionDir: string): boolean {
363
+ return existsSync(join(sessionDir, "state.json")) || existsSync(join(sessionDir, "goal.json")) || existsSync(join(sessionDir, "graph.json"));
364
+ }
365
+
366
+ export { existsSync };
@@ -35,6 +35,7 @@ export const GRAPH_ORCHESTRATION_PROMPT = `<orchestration_mode>
35
35
  Graph Mode is active for this session. You are the main-agent supervisor beside the data path.
36
36
  - Use update_agent_graph to schedule bounded work onto child subagents when a graph materially improves the task. The host runs the work; do not call run_subagent for graph work.
37
37
  - Each add_work item needs a self-contained instruction (the child does not see this conversation). Give items a short work_id so later work can name them.
38
+ - Each item's profile is your choice: pick per item (a read-only investigation uses subagent_explore; write-capable work uses subagent_general or a custom profile). view_agent_graph lists the profiles available right now; a profile you name that does not exist is refused.
38
39
  - Dependencies are input_ids: work ids (or result ids r-<work_id>) of this graph whose committed results the item consumes. Work waits until its inputs complete; the host attaches bounded upstream handoffs, so do not restate upstream conclusions in the instruction. You may declare the whole DAG in one update.
39
40
  - Use view_agent_graph to observe work status, operators, waits, failures and committed result ids.
40
41
  - After scheduling, call yield_agent_graph instead of polling, sleeping, or writing a waiting message. Yield ends only the current supervisor turn; the host wakes you at the next durable checkpoint (failure, nothing runnable, or everything settled) and the graph stays open.
@@ -55,7 +56,7 @@ Swarm Mode is active for this session. Treat the durable asynchronous agent grap
55
56
  - Perform only the lightweight exploration needed to establish boundaries before dispatch.
56
57
  - Make every item bounded and self-contained with an explicit scope, expected output, and constraints.
57
58
  - Avoid overlapping writes. Prefer read-only investigation (profile subagent_explore) unless isolated workspaces are available.
58
- - Schedule all independent items together with one update_agent_graph add_work (pick each item's profile). The host runs them; do not call run_subagent for swarm items.
59
+ - Schedule all independent items together with one update_agent_graph add_work (pick each item's profile — view_agent_graph lists the profiles available right now; a read-only investigation uses subagent_explore, write-capable work uses subagent_general or a custom profile). The host runs them; do not call run_subagent for swarm items.
59
60
  - After scheduling, call yield_agent_graph. Do not poll, sleep, watch child logs, or wait synchronously; the host will wake you only when work needs attention or the whole swarm is settled.
60
61
  - On wake, call agent_swarm_status for compact statuses only. Read agent_output only for completed final results or narrow failed-item diagnosis.
61
62
  - Replace failed work with update_agent_graph using replaces=<failed work id> so the failed item no longer keeps the swarm in needs_attention. When all useful work is settled, finish the graph (operation=finish, result_ids, reason), deduplicate, verify, and semantically synthesize the results.
@@ -86,7 +87,7 @@ export interface GraphToolDeps {
86
87
  }
87
88
 
88
89
  /** Compact, model-facing view of one graph. */
89
- export function graphView(engine: GraphEngine, graph: GraphState, options: { history?: boolean } = {}): Record<string, unknown> {
90
+ export function graphView(engine: GraphEngine, graph: GraphState, options: { history?: boolean; profiles?: readonly string[] } = {}): Record<string, unknown> {
90
91
  const status = (work: GraphWork) => engine.viewStatus(graph, work);
91
92
  const live = graph.work.filter((work) => work.status === "queued" || work.status === "running");
92
93
  const terminal = graph.work.filter((work) => work.status !== "queued" && work.status !== "running");
@@ -98,6 +99,9 @@ export function graphView(engine: GraphEngine, graph: GraphState, options: { his
98
99
  revision: graph.revision,
99
100
  state: graph.finish ? "finished" : graph.abandoned ? "stopped" : "open",
100
101
  counts: countStatuses(graph, status),
102
+ ...(options.profiles !== undefined && options.profiles.length > 0
103
+ ? { available_profiles: [...options.profiles] }
104
+ : {}),
101
105
  work: shown.map((work) => ({
102
106
  work_id: work.workId,
103
107
  status: status(work),
@@ -161,7 +165,7 @@ export function swarmStatus(engine: GraphEngine, graph: GraphState): Record<stri
161
165
  const workSchema = Type.Object({
162
166
  work_id: Type.Optional(Type.String({ description: "Short stable id for this work (e.g. scan-api). Later work names it in input_ids; its result id is r-<work_id>. Auto-assigned when omitted." })),
163
167
  instruction: Type.String({ description: "Bounded, self-contained instruction: scope, expected output, constraints. The child does not see this conversation." }),
164
- profile: Type.Optional(Type.String({ description: `Subagent profile for new work (default ${DEFAULT_PROFILE}; subagent_explore for read-only investigation).` })),
168
+ profile: Type.Optional(Type.String({ description: `Subagent profile for this work (default ${DEFAULT_PROFILE}). Pick per work item: subagent_explore for read-only investigation, subagent_general for write-capable work on your model, or any custom profile listed in the Subagents section of your prompt. view_agent_graph reports the profiles available right now.` })),
165
169
  operator_id: Type.Optional(Type.String({ description: "Follow-up work on an EXISTING operator (from view_agent_graph): resumes that child's session. Omit for new work." })),
166
170
  input_ids: Type.Optional(Type.Array(Type.String(), { description: `Work ids or result ids (r-<work_id>) of THIS graph whose committed results this work consumes (its dependencies). Max ${MAX_INPUT_IDS}.` })),
167
171
  selected_result_inputs: Type.Optional(
@@ -270,11 +274,11 @@ export function registerGraphTools(pi: ExtensionAPI, deps: GraphToolDeps): void
270
274
  const graph = p.graph_id ? engine.graph(p.graph_id) : engine.current();
271
275
  if (!graph) {
272
276
  if (p.history && engine.history().length > 0) {
273
- return textResult(JSON.stringify({ current: null, history: engine.history().slice(0, 10).map((past) => ({ graph_id: past.graphId, task: past.task, state: past.finish ? "finished" : "stopped", selected_result_ids: past.finish?.resultIds ?? [] })) }, null, 2));
277
+ return textResult(JSON.stringify({ current: null, profiles: deps.profiles(), history: engine.history().slice(0, 10).map((past) => ({ graph_id: past.graphId, task: past.task, state: past.finish ? "finished" : "stopped", selected_result_ids: past.finish?.resultIds ?? [] })) }, null, 2));
274
278
  }
275
- return textResult(p.graph_id ? `No graph ${p.graph_id}.` : "No agent graph yet — schedule work with update_agent_graph operation=add_work.");
279
+ return textResult(p.graph_id ? `No graph ${p.graph_id}. Available profiles: ${deps.profiles().join(", ") || "none"}.` : `No agent graph yet — schedule work with update_agent_graph operation=add_work. Available profiles: ${deps.profiles().join(", ") || "none"}.`);
276
280
  }
277
- return textResult(JSON.stringify(graphView(engine, graph, { history: p.history === true }), null, 2));
281
+ return textResult(JSON.stringify(graphView(engine, graph, { history: p.history === true, profiles: deps.profiles() }), null, 2));
278
282
  },
279
283
  });
280
284
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "3.0.0"
33
+ "@pi-unipi/core": "3.0.1"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Persistent cross-session memory on MemPalace (auto-installed): daemon-routed writes, shared palace with other agents, for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -44,8 +44,8 @@
44
44
  "README.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@pi-unipi/core": "3.0.0",
48
- "@pi-unipi/info-screen": "3.0.0",
47
+ "@pi-unipi/core": "3.0.1",
48
+ "@pi-unipi/info-screen": "3.0.1",
49
49
  "js-yaml": "^4.1.0"
50
50
  },
51
51
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "3.0.0",
37
+ "@pi-unipi/core": "3.0.1",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/skill-registry",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Skill registry for pi — per-project skill on/off, a skill vault, and jev-judged skill exposure",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "main": "index.ts",
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0"
36
+ "@pi-unipi/core": "3.0.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Subagents for UniPi — Devin-model independent child agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,8 +9,8 @@
9
9
  "test": "npx tsx --test src/__tests__/*.test.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@pi-unipi/core": "3.0.0",
13
- "@pi-unipi/utility": "3.0.0",
12
+ "@pi-unipi/core": "3.0.1",
13
+ "@pi-unipi/utility": "3.0.1",
14
14
  "@earendil-works/pi-agent-core": "^0.87.1",
15
15
  "acorn": "8.18.0"
16
16
  },
@@ -5,8 +5,10 @@
5
5
  * reader registration for the shared core `read_subagent` tool.
6
6
  * Profiles: built-ins `subagent_explore` / `subagent_general` plus custom
7
7
  * markdown agents from ~/.unipi/config/agents and <workspace>/.unipi/config/agents.
8
+ * The agent files are re-read at SPAWN time, so a profile created mid-turn
9
+ * works immediately (no reload, no next prompt).
8
10
  * Config `subagents`: enabled, defaultModel, defaultThinking, maxConcurrent —
9
- * re-read every turn, so settings and agent files apply without a restart.
11
+ * re-read every turn, so settings apply without a restart.
10
12
  *
11
13
  * TUI (Devin parity): spawn card with a live tail, `Subagent "…" completed`
12
14
  * lines, the Subagents tab of the shared work tray (core/src/work/tray.ts —
@@ -240,6 +242,24 @@ export default function subagents(pi: ExtensionAPI, deps?: { manager?: SubagentM
240
242
  return loaded.warnings;
241
243
  }
242
244
 
245
+ /**
246
+ * The profiles as of NOW, re-read from disk.
247
+ *
248
+ * The turn-start snapshot goes stale the moment an agent file is created or
249
+ * edited: `before_agent_start` fires per PROMPT, so a profile written
250
+ * mid-turn stays invisible until the next user message — and `/reload` (or
251
+ * any new prompt) is what people reach for. Re-reading on every spawn costs
252
+ * two readdirs, while a child pi costs a whole process: read every time.
253
+ */
254
+ function currentProfiles(cwd: string): AgentProfile[] {
255
+ try {
256
+ profiles = loadProfiles(cwd).profiles;
257
+ } catch {
258
+ /* keep the last known list */
259
+ }
260
+ return profiles;
261
+ }
262
+
243
263
  /** reload + tool sync + warnings (runtime only — not during loading). */
244
264
  function refresh(cwd: string, ctx?: ExtensionContext): void {
245
265
  // Warnings surface once per distinct message per process.
@@ -483,7 +503,9 @@ export default function subagents(pi: ExtensionAPI, deps?: { manager?: SubagentM
483
503
  const cwd = ctx.cwd ?? process.cwd();
484
504
  const resumeId = params.resume?.trim() || undefined;
485
505
  const profileName = resumeId !== undefined ? (manager.record(resumeId)?.profile ?? params.profile) : params.profile;
486
- const profile = profiles.find((p) => p.id === profileName);
506
+ // Re-read: a profile created or edited earlier in this turn is not in the
507
+ // turn-start snapshot yet.
508
+ const profile = currentProfiles(cwd).find((p) => p.id === profileName);
487
509
  if (!profile) {
488
510
  return textResult(`Unknown profile "${params.profile}". Available: ${profiles.map((p) => p.id).join(", ")}`, { owner: "subagents", status: "failed", title: params.title }, true);
489
511
  }
@@ -644,15 +666,17 @@ export default function subagents(pi: ExtensionAPI, deps?: { manager?: SubagentM
644
666
  // UNI-280: the Agent Graph (long-horizon) runs its work through this port.
645
667
  // Background-only; the graph owns completion (no completion notice here).
646
668
  const graphPort: GraphRunPort = {
647
- profiles: () => (enabled && canSpawn() ? profiles.map((p) => p.id) : []),
669
+ // Fresh list: the graph validates a work item's profile against this, and
670
+ // a profile created earlier in this turn must be accepted, not refused.
671
+ profiles: () => (enabled && canSpawn() ? currentProfiles(uiCtx?.cwd ?? process.cwd()).map((p) => p.id) : []),
648
672
  start: (request) => {
649
673
  const ctx = uiCtx;
650
674
  if (ctx === undefined) return { error: "subagents are not ready (no session)", retryable: true };
651
675
  if (!enabled || !canSpawn()) return { error: "subagents are disabled in this session" };
676
+ const cwd = ctx.cwd ?? process.cwd();
652
677
  const resumeProfile = request.resume !== undefined ? manager.record(request.resume)?.profile : undefined;
653
- const profile = profiles.find((p) => p.id === (resumeProfile ?? request.profile));
678
+ const profile = currentProfiles(cwd).find((p) => p.id === (resumeProfile ?? request.profile));
654
679
  if (!profile) return { error: `unknown subagent profile "${request.profile}" (available: ${profiles.map((p) => p.id).join(", ")})` };
655
- const cwd = ctx.cwd ?? process.cwd();
656
680
  const start = manager.start({
657
681
  title: request.title,
658
682
  task: request.task,