@tt-a1i/openpi 0.1.1 → 0.3.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 (49) hide show
  1. package/README.md +65 -28
  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/bash-policy.ts +219 -42
  23. package/extensions/plan-mode/index.ts +56 -19
  24. package/extensions/setup/index.ts +96 -10
  25. package/extensions/shared/child-session.ts +40 -4
  26. package/extensions/shared/editor-layers.ts +150 -0
  27. package/extensions/shared/setup-config.ts +26 -15
  28. package/extensions/shared/setup-episode-state.ts +7 -0
  29. package/extensions/shared/tool-surface.ts +435 -0
  30. package/extensions/subagents/index.ts +179 -96
  31. package/extensions/subagents/src/manager.ts +13 -11
  32. package/extensions/subagents/src/prompt.ts +7 -7
  33. package/extensions/subagents/src/ui/takeover.ts +231 -133
  34. package/extensions/subagents/src/ui/transcript.ts +252 -37
  35. package/extensions/subagents/src/ui/wait-result.ts +6 -19
  36. package/extensions/suggestions/index.ts +27 -18
  37. package/extensions/tasks/index.ts +63 -18
  38. package/extensions/ui-customization/footer.ts +65 -11
  39. package/extensions/workflows/graph-projection.ts +6 -4
  40. package/extensions/workflows/index.ts +44 -21
  41. package/extensions/workflows/invocation-ledger.ts +8 -2
  42. package/extensions/workflows/model.ts +5 -1
  43. package/extensions/workflows/prompt.ts +10 -40
  44. package/extensions/workflows/replay-safety.ts +9 -8
  45. package/package.json +10 -10
  46. package/skills/subagents/SKILL.md +7 -1
  47. package/skills/workflows/EXAMPLES.md +58 -0
  48. package/skills/workflows/REFERENCE.md +44 -0
  49. package/skills/workflows/SKILL.md +39 -0
@@ -18,6 +18,29 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
18
18
 
19
19
  const MAX_CACHED_WIDTHS_PER_ITEM = 2;
20
20
 
21
+ export const SPINNER_FRAMES = [
22
+ "⠋",
23
+ "⠙",
24
+ "⠹",
25
+ "⠸",
26
+ "⠼",
27
+ "⠴",
28
+ "⠦",
29
+ "⠧",
30
+ "⠇",
31
+ "⠏",
32
+ ] as const;
33
+
34
+ /** Frame cadence, shared with the dashboard and takeover headers. */
35
+ export const SPINNER_INTERVAL_MS = 120;
36
+
37
+ export function spinnerFrame(now: number) {
38
+ const frame = Math.floor(now / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length;
39
+ return SPINNER_FRAMES[
40
+ (frame + SPINNER_FRAMES.length) % SPINNER_FRAMES.length
41
+ ];
42
+ }
43
+
21
44
  /**
22
45
  * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
23
46
  * tabs (and stray escapes) make lines wider than the width we declare to the
@@ -146,10 +169,82 @@ function renderThinking(theme: Theme, text: string, width: number) {
146
169
  return out;
147
170
  }
148
171
 
172
+ function renderToolBody(theme: Theme, name: string, argsPreview?: string) {
173
+ const toolName = sanitizeText(name);
174
+ const preview = summarizeToolArgs(toolName, argsPreview);
175
+ // The `$` form only earns its prompt when there is a command to show; a bare
176
+ // `$ ` would read as an empty shell line.
177
+ if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`);
178
+ return (
179
+ theme.fg("dim", "→ ") +
180
+ theme.fg("toolTitle", toolName) +
181
+ (preview ? theme.fg("dim", ` ${preview}`) : "")
182
+ );
183
+ }
184
+
185
+ function firstOutputPreview(outputPreview?: string) {
186
+ return (
187
+ sanitizeText(outputPreview ?? "")
188
+ .split("\n")
189
+ .find((line) => line.trim()) ?? ""
190
+ );
191
+ }
192
+
193
+ /**
194
+ * One execution owns exactly one glyph column, on its command line. Output
195
+ * lines are plain indented text so a block keeps identical columns from the
196
+ * moment the command starts to the moment it settles.
197
+ */
198
+ export type ToolPhase = "live" | "ok" | "error" | "pending";
199
+
200
+ function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) {
201
+ switch (phase) {
202
+ case "live":
203
+ return theme.fg("warning", spinnerFrame(now));
204
+ case "ok":
205
+ return theme.fg("success", "✓");
206
+ case "error":
207
+ return theme.fg("error", "✗");
208
+ case "pending":
209
+ return theme.fg("dim", "·");
210
+ }
211
+ }
212
+
213
+ /** Command line: `<glyph> $ cmd` for bash, `<glyph> → name args` otherwise. */
214
+ function renderToolLine(
215
+ theme: Theme,
216
+ phase: ToolPhase,
217
+ name: string,
218
+ argsPreview: string | undefined,
219
+ width: number,
220
+ now: number,
221
+ ) {
222
+ return truncateToWidth(
223
+ `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview)}`,
224
+ width,
225
+ );
226
+ }
227
+
228
+ /** Output line: indented under the command, no second glyph. */
229
+ function renderOutputLine(
230
+ theme: Theme,
231
+ isError: boolean,
232
+ outputPreview: string,
233
+ width: number,
234
+ ) {
235
+ const preview = outputPreview || "(no output)";
236
+ const content = isError
237
+ ? theme.fg(outputPreview ? "error" : "dim", preview)
238
+ : theme.fg("dim", preview);
239
+ return truncateToWidth(` ${content}`, width);
240
+ }
241
+
149
242
  function renderAssistantItem(
150
243
  theme: Theme,
151
244
  item: Extract<TranscriptItem, { kind: "assistant" }>,
152
245
  width: number,
246
+ phases: ReadonlyMap<string, ToolPhase>,
247
+ now: number,
153
248
  ) {
154
249
  const out: string[] = [];
155
250
  for (const part of item.parts) {
@@ -164,12 +259,14 @@ function renderAssistantItem(
164
259
  ),
165
260
  );
166
261
  } else if (part.type === "toolCall") {
167
- const preview = summarizeToolArgs(part.name, part.argsPreview);
168
- const line =
169
- theme.fg("muted", "→ ") +
170
- theme.fg("toolTitle", part.name) +
171
- (preview ? theme.fg("dim", ` ${preview}`) : "");
172
- out.push(truncateToWidth(line, width));
262
+ const phase = phases.get(part.toolId) ?? "pending";
263
+ // A live tool is rendered by the live block, which owns the spinner and
264
+ // the streaming output; rendering the call here too would show the same
265
+ // command twice and make the block reflow when the tool settles.
266
+ if (phase === "live") continue;
267
+ out.push(
268
+ renderToolLine(theme, phase, part.name, part.argsPreview, width, now),
269
+ );
173
270
  }
174
271
  }
175
272
  return out;
@@ -179,27 +276,117 @@ function renderToolResultItem(
179
276
  theme: Theme,
180
277
  item: Extract<TranscriptItem, { kind: "toolResult" }>,
181
278
  width: number,
279
+ paired: boolean,
280
+ now: number,
182
281
  ) {
183
- const firstLine =
184
- sanitizeText(item.outputPreview ?? "")
185
- .split("\n")
186
- .find((line) => line.trim()) ?? "";
187
- const label = item.isError
188
- ? theme.fg("error", " error: ")
189
- : theme.fg("dim", " output: ");
190
- return [
191
- truncateToWidth(label + theme.fg("dim", firstLine || "(no output)"), width),
192
- ];
282
+ const preview = firstOutputPreview(item.outputPreview);
283
+ // An orphan result (its call is not the previous item) still needs a glyph:
284
+ // there is no command line above it to carry one.
285
+ if (!paired) {
286
+ return [
287
+ renderToolLine(
288
+ theme,
289
+ item.isError ? "error" : "ok",
290
+ item.name,
291
+ undefined,
292
+ width,
293
+ now,
294
+ ),
295
+ ...(preview
296
+ ? [renderOutputLine(theme, item.isError, preview, width)]
297
+ : []),
298
+ ];
299
+ }
300
+ return [renderOutputLine(theme, item.isError, preview, width)];
301
+ }
302
+
303
+ function isPairedToolResult(
304
+ previous: TranscriptItem | undefined,
305
+ current: TranscriptItem,
306
+ ) {
307
+ if (
308
+ !previous ||
309
+ previous.kind !== "assistant" ||
310
+ current.kind !== "toolResult"
311
+ ) {
312
+ return false;
313
+ }
314
+ const lastPart = previous.parts[previous.parts.length - 1];
315
+ return lastPart?.type === "toolCall" && lastPart.toolId === current.toolId;
193
316
  }
194
317
 
195
318
  function renderTranscriptItem(
196
319
  theme: Theme,
197
320
  item: TranscriptItem,
198
321
  width: number,
322
+ context: ItemContext,
323
+ now: number,
199
324
  ) {
200
325
  if (item.kind === "user") return renderUserText(theme, item.text, width);
201
- if (item.kind === "assistant") return renderAssistantItem(theme, item, width);
202
- return renderToolResultItem(theme, item, width);
326
+ if (item.kind === "assistant") {
327
+ return renderAssistantItem(theme, item, width, context.phases, now);
328
+ }
329
+ return renderToolResultItem(theme, item, width, context.paired, now);
330
+ }
331
+
332
+ interface ItemContext {
333
+ readonly phases: ReadonlyMap<string, ToolPhase>;
334
+ readonly paired: boolean;
335
+ /** Cache discriminator: identity plus width is not enough on its own. */
336
+ readonly token: string;
337
+ }
338
+
339
+ /**
340
+ * An item's rendering depends on its neighbours (does a call have its result
341
+ * yet?) and on live state (is the call still running?), so the cache key has to
342
+ * carry that context or a stale glyph would outlive the phase it described.
343
+ */
344
+ function itemContext(
345
+ transcript: ReadonlyArray<TranscriptItem>,
346
+ index: number,
347
+ liveIds: ReadonlySet<string>,
348
+ ): ItemContext {
349
+ const item = transcript[index]!;
350
+ if (item.kind === "user")
351
+ return { phases: new Map(), paired: false, token: "" };
352
+ if (item.kind === "toolResult") {
353
+ const paired = isPairedToolResult(transcript[index - 1], item);
354
+ return { phases: new Map(), paired, token: paired ? "p" : "o" };
355
+ }
356
+
357
+ const phases = new Map<string, ToolPhase>();
358
+ for (const part of item.parts) {
359
+ if (part.type !== "toolCall") continue;
360
+ if (liveIds.has(part.toolId)) {
361
+ phases.set(part.toolId, "live");
362
+ continue;
363
+ }
364
+ const result = findResult(transcript, index, part.toolId);
365
+ phases.set(
366
+ part.toolId,
367
+ result ? (result.isError ? "error" : "ok") : "pending",
368
+ );
369
+ }
370
+ return {
371
+ phases,
372
+ paired: false,
373
+ token: [...phases].map(([id, phase]) => `${id}:${phase}`).join(","),
374
+ };
375
+ }
376
+
377
+ /** The result for a call, if it has already landed later in the transcript. */
378
+ function findResult(
379
+ transcript: ReadonlyArray<TranscriptItem>,
380
+ callIndex: number,
381
+ toolId: string,
382
+ ) {
383
+ for (let index = callIndex + 1; index < transcript.length; index++) {
384
+ const candidate = transcript[index];
385
+ if (candidate?.kind === "toolResult" && candidate.toolId === toolId) {
386
+ return candidate;
387
+ }
388
+ }
389
+ return undefined;
203
390
  }
204
391
 
205
392
  /**
@@ -208,24 +395,43 @@ function renderTranscriptItem(
208
395
  * from their component's invalidate() when Pi changes theme.
209
396
  */
210
397
  export class TranscriptRenderer {
211
- private itemCache = new WeakMap<TranscriptItem, Map<number, string[]>>();
398
+ private itemCache = new WeakMap<TranscriptItem, Map<string, string[]>>();
212
399
 
213
- render(snap: SubagentSnapshot, width: number, theme: Theme) {
400
+ render(
401
+ snap: SubagentSnapshot,
402
+ width: number,
403
+ theme: Theme,
404
+ options?: { readonly now?: number },
405
+ ) {
214
406
  const out: string[] = [];
407
+ const now = options?.now ?? Date.now();
408
+ const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId));
215
409
 
216
- for (const item of snap.transcript) {
217
- const cached = this.itemCache.get(item)?.get(width);
218
- const lines = cached ?? renderTranscriptItem(theme, item, width);
410
+ for (let index = 0; index < snap.transcript.length; index++) {
411
+ const item = snap.transcript[index];
412
+ const context = itemContext(snap.transcript, index, liveIds);
413
+ const key = `${width}|${context.token}`;
414
+ const cached = this.itemCache.get(item)?.get(key);
415
+ const lines =
416
+ cached ?? renderTranscriptItem(theme, item, width, context, now);
219
417
  if (!cached) {
220
- const widths = this.itemCache.get(item) ?? new Map<number, string[]>();
418
+ const widths = this.itemCache.get(item) ?? new Map<string, string[]>();
221
419
  if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) {
222
420
  const oldestWidth = widths.keys().next().value;
223
421
  if (oldestWidth !== undefined) widths.delete(oldestWidth);
224
422
  }
225
- widths.set(width, lines);
423
+ widths.set(key, lines);
226
424
  this.itemCache.set(item, widths);
227
425
  }
228
- if (lines.length > 0) out.push(...lines, "");
426
+ if (lines.length > 0) {
427
+ if (
428
+ out.length > 0 &&
429
+ !isPairedToolResult(snap.transcript[index - 1], item)
430
+ ) {
431
+ out.push("");
432
+ }
433
+ out.push(...lines);
434
+ }
229
435
  }
230
436
  while (out.length > 0 && out[out.length - 1] === "") out.pop();
231
437
 
@@ -239,19 +445,22 @@ export class TranscriptRenderer {
239
445
  if (out.length === before + 1) out.pop();
240
446
  }
241
447
 
242
- // Live tool executions (present until the ToolEnd lands in the transcript).
448
+ // Live tool executions. The manager drops a live entry when its ToolEnd
449
+ // lands, and the transcript's call line then takes over with the settled
450
+ // glyph in the same column, so the block never reflows.
243
451
  for (const tool of snap.liveTools) {
244
452
  if (out.length > 0) out.push("");
245
- const marker = tool.done
453
+ const phase: ToolPhase = tool.done
246
454
  ? tool.isError
247
- ? theme.fg("error", "error")
248
- : theme.fg("success", "done")
249
- : theme.fg("warning", "running");
250
- const args = summarizeToolArgs(tool.name, tool.argsPreview);
251
- let line = `${theme.fg("toolTitle", tool.name)}${args ? theme.fg("dim", ` ${args}`) : ""} · ${marker}`;
252
- const preview = tool.outputPreview && compactPreview(tool.outputPreview);
253
- if (preview) line += theme.fg("dim", ` · ${preview}`);
254
- out.push(truncateToWidth(line, width));
455
+ ? "error"
456
+ : "ok"
457
+ : "live";
458
+ out.push(
459
+ renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now),
460
+ );
461
+ const preview = firstOutputPreview(tool.outputPreview);
462
+ if (preview)
463
+ out.push(renderOutputLine(theme, !!tool.isError, preview, width));
255
464
  }
256
465
 
257
466
  // Queued steering/follow-up messages: show them immediately so Enter
@@ -288,6 +497,12 @@ export function buildTranscriptLines(
288
497
  width: number,
289
498
  theme: Theme,
290
499
  renderer?: TranscriptRenderer,
500
+ options?: { readonly now?: number },
291
501
  ) {
292
- return (renderer ?? new TranscriptRenderer()).render(snap, width, theme);
502
+ return (renderer ?? new TranscriptRenderer()).render(
503
+ snap,
504
+ width,
505
+ theme,
506
+ options,
507
+ );
293
508
  }
@@ -6,17 +6,16 @@ import {
6
6
  import { Markdown, Text } from "@earendil-works/pi-tui";
7
7
  import { sanitizeText } from "./transcript.ts";
8
8
 
9
- const COLLAPSED_MAX_LINES = 12;
10
9
  const MAX_STATUS_ROWS = 4;
11
10
 
12
11
  export interface WaitResultItem {
13
- id: string;
14
- title?: string;
15
- status?: string;
12
+ readonly id: string;
13
+ readonly title?: string;
14
+ readonly status?: string;
16
15
  }
17
16
 
18
17
  export interface WaitResultDetails {
19
- results?: WaitResultItem[];
18
+ readonly results?: readonly WaitResultItem[];
20
19
  }
21
20
 
22
21
  export function buildWaitResultPreview(
@@ -46,23 +45,11 @@ export function buildWaitResultPreview(
46
45
  lines.push(theme.fg("dim", ` … ${results.length - MAX_STATUS_ROWS} more`));
47
46
  }
48
47
 
49
- const cleanLines = sanitizeText(content)
50
- .split("\n")
51
- .map((line) => line.trimEnd())
52
- .filter((line) => line.trim() && line.trim() !== "---");
53
- const leadingHeader = cleanLines[0]?.startsWith("## ") ? 1 : 0;
54
- const body = cleanLines.slice(leadingHeader);
55
- const available = Math.max(1, COLLAPSED_MAX_LINES - lines.length - 1);
56
- for (const line of body.slice(0, available)) {
57
- lines.push(theme.fg("toolOutput", line));
58
- }
59
-
60
- const hidden = Math.max(0, body.length - available);
61
- if (hidden > 0) {
48
+ if (content.trim()) {
62
49
  lines.push(
63
50
  theme.fg(
64
51
  "dim",
65
- `… ${hidden} more line${hidden === 1 ? "" : "s"} · ${keyHint("app.tools.expand", "to expand")}`,
52
+ `Results passed to main agent · ${keyHint("app.tools.expand", "to expand")}`,
66
53
  ),
67
54
  );
68
55
  }
@@ -1,8 +1,11 @@
1
- import {
2
- CustomEditor,
3
- type ExtensionAPI,
4
- type ExtensionContext,
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
5
4
  } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ registerEditorLayer,
7
+ removeEditorLayer,
8
+ } from "../shared/editor-layers.ts";
6
9
  import {
7
10
  loadSetupConfig,
8
11
  SETUP_CONFIG_CHANGED_CHANNEL,
@@ -51,6 +54,7 @@ export default function (pi: ExtensionAPI) {
51
54
  let sessionActive = false;
52
55
  let statusContext: ExtensionContext | undefined;
53
56
  let requestEditorRender: (() => void) | undefined;
57
+ let editorLayerRegistered = false;
54
58
 
55
59
  const updateStatus = () => {
56
60
  statusContext?.ui.setStatus(
@@ -72,21 +76,22 @@ export default function (pi: ExtensionAPI) {
72
76
 
73
77
  const installSuggestionEditor = (ctx: ExtensionContext) => {
74
78
  if (ctx.mode !== "tui") return;
75
- const previous = ctx.ui.getEditorComponent();
76
- ctx.ui.setEditorComponent((tui, theme, keybindings) => {
77
- const base =
78
- previous?.(tui, theme, keybindings) ??
79
- new CustomEditor(tui, theme, keybindings);
80
- requestEditorRender = () => tui.requestRender();
81
- return new NextActionSuggestionEditor(
82
- base,
83
- keybindings,
84
- suggestionState,
85
- cancelPrediction,
86
- requestEditorRender,
87
- (text) => ctx.ui.theme.fg("dim", text),
88
- );
79
+ registerEditorLayer(pi, ctx, {
80
+ id: "suggestions",
81
+ order: 200,
82
+ wrap: (base, tui, _theme, keybindings) => {
83
+ requestEditorRender = () => tui.requestRender();
84
+ return new NextActionSuggestionEditor(
85
+ base,
86
+ keybindings,
87
+ suggestionState,
88
+ cancelPrediction,
89
+ requestEditorRender,
90
+ (text) => ctx.ui.theme.fg("dim", text),
91
+ );
92
+ },
89
93
  });
94
+ editorLayerRegistered = true;
90
95
  };
91
96
 
92
97
  pi.on("session_start", (_event, ctx) => {
@@ -160,6 +165,10 @@ export default function (pi: ExtensionAPI) {
160
165
  pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, cancelPrediction);
161
166
 
162
167
  pi.on("session_shutdown", async () => {
168
+ if (editorLayerRegistered) {
169
+ removeEditorLayer(pi, "suggestions");
170
+ editorLayerRegistered = false;
171
+ }
163
172
  sessionActive = false;
164
173
  runBoundary.reset();
165
174
  const task = activePrediction?.task;
@@ -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,
@@ -95,8 +99,18 @@ export default function sessionTasks(pi: ExtensionAPI) {
95
99
  let notifiedProblem: string | undefined;
96
100
  let taskWidgetVisible = true;
97
101
  let taskWidgetExpanded = false;
102
+ let taskWidgetMounted = false;
103
+ let requestTaskWidgetRender: (() => void) | undefined;
98
104
  let ui: ExtensionContext["ui"] | undefined;
99
105
  let uiMode: ExtensionContext["mode"] | undefined;
106
+ const hideLifecycleTools = () =>
107
+ patchOwnedTools(pi, "tasks", {
108
+ disable: OPENPI_TOOL_SURFACE.tasks.deferred,
109
+ });
110
+ const showLifecycleTools = () =>
111
+ patchOwnedTools(pi, "tasks", {
112
+ enable: OPENPI_TOOL_SURFACE.tasks.deferred,
113
+ });
100
114
 
101
115
  const snapshot = () => tasks.snapshot();
102
116
 
@@ -112,22 +126,36 @@ export default function sessionTasks(pi: ExtensionAPI) {
112
126
 
113
127
  const updateTaskWidget = (ctx?: ExtensionContext) => {
114
128
  if (ctx?.hasUI) {
129
+ if (ui !== ctx.ui) {
130
+ taskWidgetMounted = false;
131
+ requestTaskWidgetRender = undefined;
132
+ }
115
133
  ui = ctx.ui;
116
134
  uiMode = ctx.mode;
117
135
  }
118
136
  if (!ui || uiMode !== "tui") return false;
119
- const current = snapshot();
120
137
  const shown =
121
138
  taskWidgetVisible && !problemMessage() && hasActionableTasks();
122
139
  if (!shown) {
140
+ if (!taskWidgetMounted) return false;
123
141
  ui.setWidget(TASK_WIDGET_KEY, undefined);
142
+ taskWidgetMounted = false;
143
+ requestTaskWidgetRender = undefined;
124
144
  return false;
125
145
  }
126
- ui.setWidget(TASK_WIDGET_KEY, (_tui, theme) => ({
127
- render: (width) =>
128
- renderTaskWidget(current, theme, width, taskWidgetExpanded),
129
- invalidate() {},
130
- }));
146
+ if (taskWidgetMounted) {
147
+ requestTaskWidgetRender?.();
148
+ return true;
149
+ }
150
+ ui.setWidget(TASK_WIDGET_KEY, (tui, theme) => {
151
+ requestTaskWidgetRender = () => tui.requestRender();
152
+ return {
153
+ render: (width) =>
154
+ renderTaskWidget(snapshot(), theme, width, taskWidgetExpanded),
155
+ invalidate() {},
156
+ };
157
+ });
158
+ taskWidgetMounted = true;
131
159
  return true;
132
160
  };
133
161
 
@@ -199,13 +227,15 @@ export default function sessionTasks(pi: ExtensionAPI) {
199
227
  items,
200
228
  total: snapshot().items.length,
201
229
  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
230
  counts: taskCounts(snapshot().items),
206
231
  ...(batchClosed ? { batchClosed: true } : {}),
207
232
  });
208
233
 
234
+ const mutationResultText = (summary: string) => {
235
+ const current = snapshot();
236
+ return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`;
237
+ };
238
+
209
239
  const registerTools = () => {
210
240
  if (toolsRegistered || conflict) return;
211
241
  toolsRegistered = true;
@@ -218,6 +248,7 @@ export default function sessionTasks(pi: ExtensionAPI) {
218
248
  "Add stable work-intent items to the current session tasks",
219
249
  promptGuidelines: [
220
250
  "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.",
251
+ "Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.",
221
252
  "Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.",
222
253
  ],
223
254
  parameters: Type.Object({
@@ -239,14 +270,17 @@ export default function sessionTasks(pi: ExtensionAPI) {
239
270
  assertAvailable();
240
271
  const mutation = applyTaskAdd(snapshot(), params.items);
241
272
  persistThenCommit(mutation.snapshot);
273
+ showLifecycleTools();
242
274
  return Promise.resolve({
243
275
  content: [
244
276
  {
245
277
  type: "text" as const,
246
- text: `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
278
+ text: mutationResultText(
279
+ `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
280
+ ),
247
281
  },
248
282
  ],
249
- details: toolDetails("add", mutation.items),
283
+ details: toolDetails("add", snapshot().items),
250
284
  });
251
285
  },
252
286
  renderCall(args, theme) {
@@ -273,7 +307,9 @@ export default function sessionTasks(pi: ExtensionAPI) {
273
307
  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
308
  promptSnippet: "Update one session task item by stable ID",
275
309
  promptGuidelines: [
276
- "Keep tasks_update status current when tracked work materially changes, but avoid ceremonial status churn.",
310
+ "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.",
311
+ "Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.",
312
+ "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
313
  "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
314
  ],
279
315
  parameters: Type.Object({
@@ -305,18 +341,21 @@ export default function sessionTasks(pi: ExtensionAPI) {
305
341
  );
306
342
  const mutation = applyTaskUpdate(before, params);
307
343
  const changed = persistThenCommit(mutation.snapshot);
344
+ if (changed && closesBatch) hideLifecycleTools();
308
345
  return Promise.resolve({
309
346
  content: [
310
347
  {
311
348
  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.`,
349
+ text: mutationResultText(
350
+ changed
351
+ ? closesBatch
352
+ ? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
353
+ : `Updated T${params.id}.`
354
+ : `T${params.id} already has that state; no update recorded.`,
355
+ ),
317
356
  },
318
357
  ],
319
- details: toolDetails("update", mutation.items, closesBatch),
358
+ details: toolDetails("update", snapshot().items, closesBatch),
320
359
  });
321
360
  },
322
361
  renderCall(args, theme) {
@@ -448,12 +487,16 @@ export default function sessionTasks(pi: ExtensionAPI) {
448
487
  taskWidgetVisible = true;
449
488
  taskWidgetExpanded = false;
450
489
  registerTools();
490
+ if (hasActionableTasks()) showLifecycleTools();
491
+ else hideLifecycleTools();
451
492
  notifyProblem(ctx);
452
493
  updateTaskWidget(ctx);
453
494
  });
454
495
 
455
496
  pi.on("session_tree", (_event, ctx) => {
456
497
  restore(ctx);
498
+ if (hasActionableTasks()) showLifecycleTools();
499
+ else hideLifecycleTools();
457
500
  taskWidgetExpanded = false;
458
501
  coldRun = true;
459
502
  activeRun = false;
@@ -492,6 +535,8 @@ export default function sessionTasks(pi: ExtensionAPI) {
492
535
  } catch {
493
536
  // The interactive UI may already be disposed.
494
537
  }
538
+ taskWidgetMounted = false;
539
+ requestTaskWidgetRender = undefined;
495
540
  ui = undefined;
496
541
  uiMode = undefined;
497
542
  });