@tt-a1i/openpi 0.1.1 → 0.2.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 (43) hide show
  1. package/README.md +37 -22
  2. package/SETUP.md +8 -6
  3. package/extensions/ask-user/handoff.ts +5 -1
  4. package/extensions/ask-user/index.ts +44 -0
  5. package/extensions/background-terminals/index.ts +118 -29
  6. package/extensions/background-terminals/src/domain.ts +5 -1
  7. package/extensions/background-terminals/src/manager.ts +2 -1
  8. package/extensions/background-terminals/src/prompt.ts +35 -0
  9. package/extensions/background-terminals/src/result-delivery.ts +76 -3
  10. package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
  11. package/extensions/capabilities/index.ts +198 -0
  12. package/extensions/context-pivot/index.ts +21 -0
  13. package/extensions/cron/index.ts +42 -15
  14. package/extensions/execution-convergence/active-evidence.ts +129 -0
  15. package/extensions/execution-convergence/index.ts +442 -0
  16. package/extensions/execution-convergence/workspace-provenance.ts +338 -0
  17. package/extensions/file-search/index.ts +8 -1
  18. package/extensions/file-search/src/binaries.ts +2 -1
  19. package/extensions/git-info/src/runtime.ts +1 -1
  20. package/extensions/goal/controller.ts +2 -1
  21. package/extensions/goal/index.ts +20 -1
  22. package/extensions/plan-mode/index.ts +12 -0
  23. package/extensions/setup/index.ts +93 -7
  24. package/extensions/shared/child-session.ts +40 -4
  25. package/extensions/shared/setup-config.ts +22 -0
  26. package/extensions/shared/setup-episode-state.ts +7 -0
  27. package/extensions/shared/tool-surface.ts +435 -0
  28. package/extensions/subagents/index.ts +15 -0
  29. package/extensions/subagents/src/manager.ts +13 -11
  30. package/extensions/subagents/src/prompt.ts +1 -1
  31. package/extensions/tasks/index.ts +39 -12
  32. package/extensions/ui-customization/footer.ts +6 -1
  33. package/extensions/workflows/graph-projection.ts +6 -4
  34. package/extensions/workflows/index.ts +16 -1
  35. package/extensions/workflows/invocation-ledger.ts +8 -2
  36. package/extensions/workflows/model.ts +5 -1
  37. package/extensions/workflows/prompt.ts +10 -40
  38. package/extensions/workflows/replay-safety.ts +9 -8
  39. package/package.json +10 -10
  40. package/skills/subagents/SKILL.md +6 -0
  41. package/skills/workflows/EXAMPLES.md +58 -0
  42. package/skills/workflows/REFERENCE.md +44 -0
  43. package/skills/workflows/SKILL.md +39 -0
@@ -0,0 +1,435 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * OpenPI-owned model tools, grouped by the extension that owns their runtime
5
+ * state. Ordinary parent sessions add no resident OpenPI tool. Explicit user
6
+ * intent can reveal the capability gateway or load one capability group;
7
+ * capability-owned tools remain registered but are projected only after their
8
+ * group is loaded. Lifecycle tools add a second resource/mode-state gate inside
9
+ * that loaded group.
10
+ */
11
+ export const OPENPI_TOOL_SURFACE = {
12
+ capabilities: {
13
+ entry: ["openpi_load_tools"],
14
+ deferred: [],
15
+ },
16
+ fileSearch: {
17
+ entry: ["fd", "rg"],
18
+ deferred: [],
19
+ },
20
+ subagents: {
21
+ entry: ["subagent_spawn"],
22
+ deferred: [
23
+ "subagent_wait",
24
+ "subagent_cancel",
25
+ "subagent_send",
26
+ "subagent_check",
27
+ "subagent_list",
28
+ ],
29
+ },
30
+ workflows: {
31
+ entry: ["workflow"],
32
+ deferred: ["workflow_stop", "workflow_status"],
33
+ },
34
+ background: {
35
+ entry: ["bg_start"],
36
+ deferred: ["bg_status", "bg_list", "bg_kill", "bg_watch"],
37
+ },
38
+ tasks: {
39
+ entry: ["tasks_add"],
40
+ deferred: ["tasks_update", "tasks_list"],
41
+ },
42
+ goal: {
43
+ entry: ["create_goal"],
44
+ deferred: ["get_goal", "update_goal"],
45
+ },
46
+ interaction: {
47
+ entry: [],
48
+ deferred: ["ask_user", "human_handoff"],
49
+ },
50
+ plan: {
51
+ entry: [],
52
+ deferred: ["plan_ready"],
53
+ },
54
+ setup: {
55
+ entry: [],
56
+ deferred: ["configure_my_pi_setup"],
57
+ },
58
+ context: {
59
+ entry: [],
60
+ deferred: ["context_pivot"],
61
+ },
62
+ } as const;
63
+
64
+ export type OpenPiToolOwner = keyof typeof OPENPI_TOOL_SURFACE;
65
+
66
+ export const OPENPI_CAPABILITY_GROUPS = {
67
+ search: {
68
+ owners: ["fileSearch"],
69
+ summary: "Fast structured file and content search with fd and rg.",
70
+ },
71
+ delegate: {
72
+ owners: ["subagents"],
73
+ summary: "Spawn and manage isolated in-process Pi subagents.",
74
+ },
75
+ workflow: {
76
+ owners: ["workflows"],
77
+ summary: "Run replay-safe multi-stage workflows.",
78
+ },
79
+ background: {
80
+ owners: ["background"],
81
+ summary: "Start and manage long-running background terminals.",
82
+ },
83
+ session: {
84
+ owners: ["tasks", "goal"],
85
+ summary:
86
+ "Track explicit session tasks and persistent user-requested goals.",
87
+ },
88
+ } as const satisfies Record<
89
+ string,
90
+ { owners: readonly OpenPiToolOwner[]; summary: string }
91
+ >;
92
+
93
+ export type OpenPiCapability = keyof typeof OPENPI_CAPABILITY_GROUPS;
94
+
95
+ export const OPENPI_CAPABILITY_NAMES = Object.keys(
96
+ OPENPI_CAPABILITY_GROUPS,
97
+ ) as OpenPiCapability[];
98
+
99
+ export const DEFAULT_OPENPI_ACTIVE_TOOL_NAMES: readonly string[] = [];
100
+
101
+ export const OPENPI_TOOL_SURFACE_NAMES = Object.values(
102
+ OPENPI_TOOL_SURFACE,
103
+ ).flatMap(({ entry, deferred }) => [...entry, ...deferred]);
104
+
105
+ interface ActiveToolSurface {
106
+ events?: {
107
+ emit(channel: string, data: unknown): void;
108
+ on(channel: string, handler: (data: unknown) => void): () => void;
109
+ };
110
+ getActiveTools(): string[];
111
+ getAllTools?(): {
112
+ name: string;
113
+ sourceInfo?: { path: string; source?: string };
114
+ }[];
115
+ setActiveTools(names: string[]): void;
116
+ }
117
+
118
+ interface OwnedToolPatch {
119
+ enable?: readonly string[];
120
+ disable?: readonly string[];
121
+ }
122
+
123
+ interface ToolSurfaceState {
124
+ loaded: Set<OpenPiCapability>;
125
+ desiredByOwner: Map<OpenPiToolOwner, Set<string>>;
126
+ sourceByOwner: Map<OpenPiToolOwner, string>;
127
+ managedOwners: Set<OpenPiToolOwner>;
128
+ knownAvailable: Set<string>;
129
+ subscribed: boolean;
130
+ }
131
+
132
+ const OWNER_SOURCE_PATHS = {
133
+ capabilities: fileURLToPath(
134
+ new URL("../capabilities/index.ts", import.meta.url),
135
+ ),
136
+ fileSearch: fileURLToPath(
137
+ new URL("../file-search/index.ts", import.meta.url),
138
+ ),
139
+ subagents: fileURLToPath(new URL("../subagents/index.ts", import.meta.url)),
140
+ workflows: fileURLToPath(new URL("../workflows/index.ts", import.meta.url)),
141
+ background: fileURLToPath(
142
+ new URL("../background-terminals/index.ts", import.meta.url),
143
+ ),
144
+ tasks: fileURLToPath(new URL("../tasks/index.ts", import.meta.url)),
145
+ goal: fileURLToPath(new URL("../goal/index.ts", import.meta.url)),
146
+ interaction: fileURLToPath(new URL("../ask-user/index.ts", import.meta.url)),
147
+ plan: fileURLToPath(new URL("../plan-mode/index.ts", import.meta.url)),
148
+ setup: fileURLToPath(new URL("../setup/index.ts", import.meta.url)),
149
+ context: fileURLToPath(new URL("../context-pivot/index.ts", import.meta.url)),
150
+ } as const satisfies Record<OpenPiToolOwner, string>;
151
+
152
+ const states = new WeakMap<object, ToolSurfaceState>();
153
+ export const OPENPI_CAPABILITY_STATE_CHANNEL = "openpi:capability-state";
154
+
155
+ function initialDesiredByOwner() {
156
+ return new Map<OpenPiToolOwner, Set<string>>(
157
+ (Object.keys(OPENPI_TOOL_SURFACE) as OpenPiToolOwner[]).map((owner) => [
158
+ owner,
159
+ new Set<string>(
160
+ owner === "capabilities" ? [] : OPENPI_TOOL_SURFACE[owner].entry,
161
+ ),
162
+ ]),
163
+ );
164
+ }
165
+
166
+ function newState(): ToolSurfaceState {
167
+ return {
168
+ loaded: new Set<OpenPiCapability>(),
169
+ desiredByOwner: initialDesiredByOwner(),
170
+ sourceByOwner: new Map<OpenPiToolOwner, string>(),
171
+ managedOwners: new Set<OpenPiToolOwner>(),
172
+ knownAvailable: new Set<string>(),
173
+ subscribed: false,
174
+ };
175
+ }
176
+
177
+ function capabilityStateFromEvent(data: unknown) {
178
+ if (typeof data !== "object" || data === null) return undefined;
179
+ const loaded = (data as { loaded?: unknown }).loaded;
180
+ if (!Array.isArray(loaded)) return undefined;
181
+ if (
182
+ loaded.some(
183
+ (name) =>
184
+ typeof name !== "string" ||
185
+ !OPENPI_CAPABILITY_NAMES.includes(name as OpenPiCapability),
186
+ )
187
+ ) {
188
+ return undefined;
189
+ }
190
+ return loaded as OpenPiCapability[];
191
+ }
192
+
193
+ function subscribeToCapabilityState(
194
+ pi: ActiveToolSurface,
195
+ state: ToolSurfaceState,
196
+ ) {
197
+ if (
198
+ state.subscribed ||
199
+ typeof pi.events?.on !== "function" ||
200
+ typeof pi.events?.emit !== "function"
201
+ ) {
202
+ return;
203
+ }
204
+ state.subscribed = true;
205
+ pi.events.on(OPENPI_CAPABILITY_STATE_CHANNEL, (data) => {
206
+ const loaded = capabilityStateFromEvent(data);
207
+ if (!loaded) return;
208
+ state.loaded = new Set(loaded);
209
+ reconcileManagedOwners(pi, state);
210
+ });
211
+ }
212
+
213
+ function stateFor(pi: ActiveToolSurface) {
214
+ let state = states.get(pi);
215
+ if (!state) {
216
+ state = newState();
217
+ if (
218
+ typeof pi.events?.on !== "function" ||
219
+ typeof pi.events?.emit !== "function"
220
+ ) {
221
+ state.loaded = new Set(OPENPI_CAPABILITY_NAMES);
222
+ }
223
+ states.set(pi, state);
224
+ }
225
+ return state;
226
+ }
227
+
228
+ function capabilityForOwner(owner: OpenPiToolOwner) {
229
+ return OPENPI_CAPABILITY_NAMES.find((capability) =>
230
+ OPENPI_CAPABILITY_GROUPS[capability].owners.includes(owner as never),
231
+ );
232
+ }
233
+
234
+ function ownerIsVisible(state: ToolSurfaceState, owner: OpenPiToolOwner) {
235
+ const capability = capabilityForOwner(owner);
236
+ return capability === undefined || state.loaded.has(capability);
237
+ }
238
+
239
+ function ownedToolNames(owner: OpenPiToolOwner) {
240
+ return [
241
+ ...OPENPI_TOOL_SURFACE[owner].entry,
242
+ ...OPENPI_TOOL_SURFACE[owner].deferred,
243
+ ] as readonly string[];
244
+ }
245
+
246
+ function availableOwnedToolNames(
247
+ pi: ActiveToolSurface,
248
+ state: ToolSurfaceState,
249
+ owner: OpenPiToolOwner,
250
+ ) {
251
+ for (const name of pi.getActiveTools()) state.knownAvailable.add(name);
252
+ const reportedTools = pi.getAllTools?.();
253
+ const owned = new Set<string>(ownedToolNames(owner));
254
+ if (!reportedTools || reportedTools.length === 0) {
255
+ return new Set([...state.knownAvailable].filter((name) => owned.has(name)));
256
+ }
257
+ if (reportedTools.every((tool) => tool.sourceInfo === undefined)) {
258
+ return new Set(
259
+ reportedTools.map(({ name }) => name).filter((name) => owned.has(name)),
260
+ );
261
+ }
262
+
263
+ const expectedSource =
264
+ state.sourceByOwner.get(owner) ?? OWNER_SOURCE_PATHS[owner];
265
+ return new Set(
266
+ reportedTools
267
+ .filter(
268
+ (tool) =>
269
+ owned.has(tool.name) && tool.sourceInfo?.path === expectedSource,
270
+ )
271
+ .map(({ name }) => name),
272
+ );
273
+ }
274
+
275
+ function projectedOwnerTools(
276
+ pi: ActiveToolSurface,
277
+ state: ToolSurfaceState,
278
+ owner: OpenPiToolOwner,
279
+ ) {
280
+ const available = availableOwnedToolNames(pi, state, owner);
281
+ const owned = ownedToolNames(owner);
282
+ const ownedAvailable = new Set(owned.filter((name) => available.has(name)));
283
+ const desired = state.desiredByOwner.get(owner)!;
284
+ const visible = ownerIsVisible(state, owner);
285
+ const next = pi
286
+ .getActiveTools()
287
+ .filter(
288
+ (name) => !ownedAvailable.has(name) || (visible && desired.has(name)),
289
+ );
290
+ const nextSet = new Set(next);
291
+
292
+ if (visible) {
293
+ for (const name of owned) {
294
+ if (desired.has(name) && !nextSet.has(name) && available.has(name)) {
295
+ next.push(name);
296
+ nextSet.add(name);
297
+ }
298
+ }
299
+ }
300
+
301
+ return next;
302
+ }
303
+
304
+ function applyActiveTools(pi: ActiveToolSurface, next: string[]) {
305
+ const active = pi.getActiveTools();
306
+ if (
307
+ active.length === next.length &&
308
+ active.every((name, index) => name === next[index])
309
+ ) {
310
+ return false;
311
+ }
312
+ pi.setActiveTools(next);
313
+ return true;
314
+ }
315
+
316
+ function reconcileOwner(
317
+ pi: ActiveToolSurface,
318
+ state: ToolSurfaceState,
319
+ owner: OpenPiToolOwner,
320
+ ) {
321
+ return applyActiveTools(pi, projectedOwnerTools(pi, state, owner));
322
+ }
323
+
324
+ function reconcileManagedOwners(
325
+ pi: ActiveToolSurface,
326
+ state: ToolSurfaceState,
327
+ ) {
328
+ let changed = false;
329
+ for (const owner of state.managedOwners) {
330
+ changed = reconcileOwner(pi, state, owner) || changed;
331
+ }
332
+ return changed;
333
+ }
334
+
335
+ /** Reset one bound Pi Session to the minimal parent surface. */
336
+ export function resetOpenPiToolSurface(
337
+ pi: ActiveToolSurface,
338
+ sourceByOwner: Readonly<Partial<Record<OpenPiToolOwner, string>>> = {},
339
+ ) {
340
+ const state = newState();
341
+ for (const [owner, source] of Object.entries(sourceByOwner)) {
342
+ if (source) state.sourceByOwner.set(owner as OpenPiToolOwner, source);
343
+ }
344
+ states.set(pi, state);
345
+ state.managedOwners.add("capabilities");
346
+ const changed = reconcileOwner(pi, state, "capabilities");
347
+ pi.events?.emit(OPENPI_CAPABILITY_STATE_CHANNEL, { loaded: [] });
348
+ return changed;
349
+ }
350
+
351
+ export function getLoadedOpenPiCapabilities(pi: ActiveToolSurface) {
352
+ return OPENPI_CAPABILITY_NAMES.filter((capability) =>
353
+ stateFor(pi).loaded.has(capability),
354
+ );
355
+ }
356
+
357
+ /**
358
+ * Load capability groups monotonically for this Session. There is deliberately
359
+ * no unload operation: a stable surface is more cache-friendly and easier for
360
+ * the model to reason about than tools that repeatedly disappear and return.
361
+ */
362
+ export function loadOpenPiCapabilities(
363
+ pi: ActiveToolSurface,
364
+ capabilities: readonly OpenPiCapability[],
365
+ ) {
366
+ const invalid = capabilities.filter(
367
+ (capability) => !OPENPI_CAPABILITY_NAMES.includes(capability),
368
+ );
369
+ if (invalid.length > 0) {
370
+ throw new Error(
371
+ `Unknown OpenPI ${invalid.length === 1 ? "capability" : "capabilities"}: ${invalid.map((name) => JSON.stringify(name)).join(", ")}.`,
372
+ );
373
+ }
374
+
375
+ const state = stateFor(pi);
376
+ const before = pi.getActiveTools();
377
+ const newlyLoaded: OpenPiCapability[] = [];
378
+ for (const capability of capabilities) {
379
+ if (!state.loaded.has(capability)) {
380
+ state.loaded.add(capability);
381
+ newlyLoaded.push(capability);
382
+ }
383
+ }
384
+ reconcileManagedOwners(pi, state);
385
+ pi.events?.emit(OPENPI_CAPABILITY_STATE_CHANNEL, {
386
+ loaded: getLoadedOpenPiCapabilities(pi),
387
+ });
388
+ const beforeSet = new Set(before);
389
+ return {
390
+ newlyLoaded,
391
+ loaded: getLoadedOpenPiCapabilities(pi),
392
+ activatedTools: pi.getActiveTools().filter((name) => !beforeSet.has(name)),
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Record one owner's desired tools and reconcile them through both gates:
398
+ * capability loaded first, then the owner's authoritative resource/mode state.
399
+ * Foreign and Pi-native tools are always preserved from the latest active list.
400
+ */
401
+ export function patchOwnedTools(
402
+ pi: ActiveToolSurface,
403
+ owner: OpenPiToolOwner,
404
+ patch: OwnedToolPatch,
405
+ ) {
406
+ const owned = [
407
+ ...OPENPI_TOOL_SURFACE[owner].entry,
408
+ ...OPENPI_TOOL_SURFACE[owner].deferred,
409
+ ] as readonly string[];
410
+ const ownedSet = new Set(owned);
411
+ const enable = new Set(patch.enable ?? []);
412
+ const disable = new Set(patch.disable ?? []);
413
+
414
+ for (const name of [...enable, ...disable]) {
415
+ if (!ownedSet.has(name)) {
416
+ throw new Error(`${owner} does not own tool ${JSON.stringify(name)}.`);
417
+ }
418
+ }
419
+ for (const name of enable) {
420
+ if (disable.has(name)) {
421
+ throw new Error(
422
+ `${owner} cannot enable and disable tool ${JSON.stringify(name)} in one patch.`,
423
+ );
424
+ }
425
+ }
426
+
427
+ const state = stateFor(pi);
428
+ state.managedOwners.add(owner);
429
+ if (capabilityForOwner(owner)) subscribeToCapabilityState(pi, state);
430
+ const desired = state.desiredByOwner.get(owner)!;
431
+ for (const name of enable) state.knownAvailable.add(name);
432
+ for (const name of disable) desired.delete(name);
433
+ for (const name of enable) desired.add(name);
434
+ return reconcileOwner(pi, state, owner);
435
+ }
@@ -65,6 +65,10 @@ import {
65
65
  hasActivity,
66
66
  unreadActivityCounts,
67
67
  } from "../shared/activity-status.ts";
68
+ import {
69
+ OPENPI_TOOL_SURFACE,
70
+ patchOwnedTools,
71
+ } from "../shared/tool-surface.ts";
68
72
  import { formatContextUtilization } from "./src/format.ts";
69
73
  import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
70
74
  import {
@@ -198,6 +202,14 @@ export default function (pi: ExtensionAPI) {
198
202
  let requestWidgetRender: (() => void) | undefined;
199
203
  let dashboardOpen = false;
200
204
  const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
205
+ const hideLifecycleTools = () =>
206
+ patchOwnedTools(pi, "subagents", {
207
+ disable: OPENPI_TOOL_SURFACE.subagents.deferred,
208
+ });
209
+ const showLifecycleTools = () =>
210
+ patchOwnedTools(pi, "subagents", {
211
+ enable: OPENPI_TOOL_SURFACE.subagents.deferred,
212
+ });
201
213
 
202
214
  const getRuntime = () => (runtime ??= createSubagentRuntime());
203
215
 
@@ -408,6 +420,7 @@ export default function (pi: ExtensionAPI) {
408
420
 
409
421
  pi.on("session_start", (_event, ctx) => {
410
422
  refreshAgentTypes(ctx.cwd, ctx.isProjectTrusted());
423
+ hideLifecycleTools();
411
424
  sessionContext = ctx;
412
425
  settledAcknowledgedAt = 0;
413
426
  if (ctx.hasUI) ui = ctx.ui;
@@ -681,6 +694,8 @@ export default function (pi: ExtensionAPI) {
681
694
  throw error;
682
695
  }
683
696
 
697
+ showLifecycleTools();
698
+
684
699
  return {
685
700
  content: [
686
701
  {
@@ -205,7 +205,8 @@ const makeManager = Effect.gen(function* () {
205
205
  let reservedBtw = 0;
206
206
  let disposed = false;
207
207
  let onSettled:
208
- ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined;
208
+ | ((snap: SubagentSnapshot, consumed: boolean) => void)
209
+ | undefined;
209
210
 
210
211
  const notify = (id?: string) => {
211
212
  const waiters = changeWaiters;
@@ -636,16 +637,17 @@ const makeManager = Effect.gen(function* () {
636
637
  pruneSettled();
637
638
  }),
638
639
  ),
639
- Effect.map((): ReadonlyArray<CancelResult> =>
640
- unique.map((id) => {
641
- const snapshot = entries.get(id)?.snapshot;
642
- return {
643
- id,
644
- title: snapshot?.title ?? "?",
645
- status: snapshot?.status ?? "error",
646
- cancelled: runningIds.includes(id),
647
- };
648
- }),
640
+ Effect.map(
641
+ (): ReadonlyArray<CancelResult> =>
642
+ unique.map((id) => {
643
+ const snapshot = entries.get(id)?.snapshot;
644
+ return {
645
+ id,
646
+ title: snapshot?.title ?? "?",
647
+ status: snapshot?.status ?? "error",
648
+ cancelled: runningIds.includes(id),
649
+ };
650
+ }),
649
651
  ),
650
652
  );
651
653
  });
@@ -75,7 +75,7 @@ export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
75
75
  workingDir:
76
76
  "Trusted working directory for the autonomous child (default: current working directory)",
77
77
  isolation:
78
- 'Set to "worktree" to run this child in its own git worktree on its own branch, branched from HEAD. Use it whenever children may edit the same files or stage changes concurrently — without it, parallel children share one checkout and one git index, so their edits and `git add`s overwrite each other. The child should COMMIT its work. A direct child can receive later subagent_send turns, so its checkout lives with that child Session. On retirement it is reclaimed only when a bounded inspection proves it empty; commits, dirty/untracked/ignored files, detached HEAD, timeout, or Git failure preserve it. Requires a git repository, and the checkout starts clean, so anything gitignored (build output, .env) will not be there.',
78
+ 'Set to "worktree" for concurrent writers and tell the child to commit. Requires Git and a clean checkout. Read the subagents Skill for lifecycle, merge location, and costs.',
79
79
  model:
80
80
  'Optional model override, as "provider/model-id" or a bare id resolved against the current provider. Precedence: explicit spawn model > selected type file model > configured built-in role model > parent model. Never guess a model name.',
81
81
  reasoningEffort:
@@ -6,6 +6,10 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { Key, Text } from "@earendil-works/pi-tui";
8
8
  import { Type } from "typebox";
9
+ import {
10
+ OPENPI_TOOL_SURFACE,
11
+ patchOwnedTools,
12
+ } from "../shared/tool-surface.ts";
9
13
  import {
10
14
  TASKS_ENTRY_TYPE,
11
15
  TASKS_LIMITS,
@@ -97,6 +101,14 @@ export default function sessionTasks(pi: ExtensionAPI) {
97
101
  let taskWidgetExpanded = false;
98
102
  let ui: ExtensionContext["ui"] | undefined;
99
103
  let uiMode: ExtensionContext["mode"] | undefined;
104
+ const hideLifecycleTools = () =>
105
+ patchOwnedTools(pi, "tasks", {
106
+ disable: OPENPI_TOOL_SURFACE.tasks.deferred,
107
+ });
108
+ const showLifecycleTools = () =>
109
+ patchOwnedTools(pi, "tasks", {
110
+ enable: OPENPI_TOOL_SURFACE.tasks.deferred,
111
+ });
100
112
 
101
113
  const snapshot = () => tasks.snapshot();
102
114
 
@@ -199,13 +211,15 @@ export default function sessionTasks(pi: ExtensionAPI) {
199
211
  items,
200
212
  total: snapshot().items.length,
201
213
  revision: snapshot().revision,
202
- // From the live snapshot, not `items`: a tools_update carries only the one
203
- // row it touched, and a header counted from that would claim the batch is
204
- // a single task.
205
214
  counts: taskCounts(snapshot().items),
206
215
  ...(batchClosed ? { batchClosed: true } : {}),
207
216
  });
208
217
 
218
+ const mutationResultText = (summary: string) => {
219
+ const current = snapshot();
220
+ return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`;
221
+ };
222
+
209
223
  const registerTools = () => {
210
224
  if (toolsRegistered || conflict) return;
211
225
  toolsRegistered = true;
@@ -218,6 +232,7 @@ export default function sessionTasks(pi: ExtensionAPI) {
218
232
  "Add stable work-intent items to the current session tasks",
219
233
  promptGuidelines: [
220
234
  "Use tasks_add only for work spanning multiple agent runs or user turns, or when the user explicitly provides a task list; do not use it as a per-step scratchpad within one run.",
235
+ "Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.",
221
236
  "Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.",
222
237
  ],
223
238
  parameters: Type.Object({
@@ -239,14 +254,17 @@ export default function sessionTasks(pi: ExtensionAPI) {
239
254
  assertAvailable();
240
255
  const mutation = applyTaskAdd(snapshot(), params.items);
241
256
  persistThenCommit(mutation.snapshot);
257
+ showLifecycleTools();
242
258
  return Promise.resolve({
243
259
  content: [
244
260
  {
245
261
  type: "text" as const,
246
- text: `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
262
+ text: mutationResultText(
263
+ `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
264
+ ),
247
265
  },
248
266
  ],
249
- details: toolDetails("add", mutation.items),
267
+ details: toolDetails("add", snapshot().items),
250
268
  });
251
269
  },
252
270
  renderCall(args, theme) {
@@ -273,7 +291,9 @@ export default function sessionTasks(pi: ExtensionAPI) {
273
291
  description: `${TOOL_PURPOSE} Patch one task item by numeric ID. blocked, done, and dropped status changes require a fresh note explaining the blocker, observable evidence, or drop reason.`,
274
292
  promptSnippet: "Update one session task item by stable ID",
275
293
  promptGuidelines: [
276
- "Keep tasks_update status current when tracked work materially changes, but avoid ceremonial status churn.",
294
+ "Immediately after each tracked item reaches a real outcome, call tasks_update to set done, blocked, or dropped before moving to the next tracked item.",
295
+ "Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.",
296
+ "A commit, passing test, or authorization is task-scoped evidence only; it does not by itself prove a task is done or identify which task to update.",
277
297
  "Before setting a task item to done, include a note citing an observable check, artifact, commit, tool result, or user confirmation; Tasks record this claim but do not verify it.",
278
298
  ],
279
299
  parameters: Type.Object({
@@ -305,18 +325,21 @@ export default function sessionTasks(pi: ExtensionAPI) {
305
325
  );
306
326
  const mutation = applyTaskUpdate(before, params);
307
327
  const changed = persistThenCommit(mutation.snapshot);
328
+ if (changed && closesBatch) hideLifecycleTools();
308
329
  return Promise.resolve({
309
330
  content: [
310
331
  {
311
332
  type: "text" as const,
312
- text: changed
313
- ? closesBatch
314
- ? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
315
- : `Updated T${params.id}.`
316
- : `T${params.id} already has that state; no update recorded.`,
333
+ text: mutationResultText(
334
+ changed
335
+ ? closesBatch
336
+ ? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
337
+ : `Updated T${params.id}.`
338
+ : `T${params.id} already has that state; no update recorded.`,
339
+ ),
317
340
  },
318
341
  ],
319
- details: toolDetails("update", mutation.items, closesBatch),
342
+ details: toolDetails("update", snapshot().items, closesBatch),
320
343
  });
321
344
  },
322
345
  renderCall(args, theme) {
@@ -448,12 +471,16 @@ export default function sessionTasks(pi: ExtensionAPI) {
448
471
  taskWidgetVisible = true;
449
472
  taskWidgetExpanded = false;
450
473
  registerTools();
474
+ if (hasActionableTasks()) showLifecycleTools();
475
+ else hideLifecycleTools();
451
476
  notifyProblem(ctx);
452
477
  updateTaskWidget(ctx);
453
478
  });
454
479
 
455
480
  pi.on("session_tree", (_event, ctx) => {
456
481
  restore(ctx);
482
+ if (hasActionableTasks()) showLifecycleTools();
483
+ else hideLifecycleTools();
457
484
  taskWidgetExpanded = false;
458
485
  coldRun = true;
459
486
  activeRun = false;
@@ -72,7 +72,12 @@ const MONO_COLORS: readonly PowerlineColors[] = [
72
72
  ];
73
73
 
74
74
  export type SegmentTone =
75
- "text" | "muted" | "dim" | "warning" | "error" | "accent";
75
+ | "text"
76
+ | "muted"
77
+ | "dim"
78
+ | "warning"
79
+ | "error"
80
+ | "accent";
76
81
 
77
82
  export interface FooterSegment {
78
83
  readonly id: FooterItem;
@@ -140,10 +140,12 @@ function findCycles(
140
140
  if (!indexes.has(node.callId)) visit(node.callId);
141
141
  }
142
142
  cycles.sort((left, right) => order.get(left[0]!)! - order.get(right[0]!)!);
143
- return cycles.map((callIds): WorkflowGraphDiagnostic => ({
144
- code: "cycle",
145
- callIds,
146
- }));
143
+ return cycles.map(
144
+ (callIds): WorkflowGraphDiagnostic => ({
145
+ code: "cycle",
146
+ callIds,
147
+ }),
148
+ );
147
149
  }
148
150
 
149
151
  export function projectWorkflowGraph<Record extends WorkflowGraphRecord>(