@mjasnikovs/pi-task 0.15.2 → 0.16.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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Implementation-turn status widget.
3
+ *
4
+ * The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
5
+ * implementation turn — the longest, most visible part of a run — otherwise shows
6
+ * only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
7
+ * block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
8
+ * tool), driven by the host's own live `ctx.getContextUsage()`.
9
+ *
10
+ * It is event-driven rather than poll-wrapped because the two delivery paths differ:
11
+ *
12
+ * • /task (fire-and-forget): the command returns right after `sendUserMessage`,
13
+ * so the host runs the impl turn AFTER our code is gone — only an `agent_start`
14
+ * handler can pick it up. Armed one-shot: `agent_end` disarms it.
15
+ * • /task-auto (awaited): the caller blocks across `waitForIdle` plus any
16
+ * compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
17
+ * widget, `agent_end` only hides it between turns, and the caller disarms once
18
+ * the whole implementation phase has settled.
19
+ *
20
+ * Only one task runs at a time (single active task), so a single module-level slot
21
+ * is sufficient.
22
+ */
23
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
24
+ export interface ImplWidgetMeta {
25
+ taskId: string;
26
+ title: string;
27
+ label?: string;
28
+ }
29
+ /**
30
+ * Arm the implementation widget just before the spec is handed off. `oneShot`
31
+ * true (fire-and-forget /task) lets `agent_end` disarm it after the single turn;
32
+ * false (awaited /task-auto) keeps it armed until `disarmImplWidget` is called.
33
+ */
34
+ export declare function armImplWidget(meta: ImplWidgetMeta, opts: {
35
+ oneShot: boolean;
36
+ }): void;
37
+ /** Tear down the widget and clear the armed slot (sticky/awaited path). */
38
+ export declare function disarmImplWidget(): void;
39
+ /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
40
+ export declare function setupImplWidget(pi: ExtensionAPI): void;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Implementation-turn status widget.
3
+ *
4
+ * The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
5
+ * implementation turn — the longest, most visible part of a run — otherwise shows
6
+ * only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
7
+ * block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
8
+ * tool), driven by the host's own live `ctx.getContextUsage()`.
9
+ *
10
+ * It is event-driven rather than poll-wrapped because the two delivery paths differ:
11
+ *
12
+ * • /task (fire-and-forget): the command returns right after `sendUserMessage`,
13
+ * so the host runs the impl turn AFTER our code is gone — only an `agent_start`
14
+ * handler can pick it up. Armed one-shot: `agent_end` disarms it.
15
+ * • /task-auto (awaited): the caller blocks across `waitForIdle` plus any
16
+ * compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
17
+ * widget, `agent_end` only hides it between turns, and the caller disarms once
18
+ * the whole implementation phase has settled.
19
+ *
20
+ * Only one task runs at a time (single active task), so a single module-level slot
21
+ * is sufficient.
22
+ */
23
+ import { WIDGET_KEY, WIDGET_REFRESH_MS, buildImplLines } from './widget.js';
24
+ import { setTaskWidget } from '../remote/session-state.js';
25
+ let armed = null;
26
+ let lastLine;
27
+ let timer = null;
28
+ let activeCtx = null;
29
+ /** Map the host's live context usage to the widget snapshot, or undefined when the
30
+ * token count is unknown (e.g. right after compaction, before the next response). */
31
+ function snapshot(ctx) {
32
+ const u = ctx.getContextUsage?.();
33
+ if (!u || u.tokens == null)
34
+ return undefined;
35
+ return { tokens: u.tokens, contextWindow: u.contextWindow, percent: u.percent ?? 0 };
36
+ }
37
+ function render() {
38
+ if (!armed || !activeCtx)
39
+ return;
40
+ const ctx = activeCtx;
41
+ const state = {
42
+ taskId: armed.meta.taskId,
43
+ title: armed.meta.title,
44
+ label: armed.meta.label,
45
+ startedAt: armed.startedAt,
46
+ lastLine,
47
+ contextUsage: snapshot(ctx)
48
+ };
49
+ try {
50
+ ctx.ui.setWidget(WIDGET_KEY, buildImplLines(state, ctx.ui.theme));
51
+ }
52
+ catch {
53
+ /* stale ctx */
54
+ }
55
+ setTaskWidget(buildImplLines(state, undefined)); // un-themed for the wire
56
+ }
57
+ function startTimer() {
58
+ if (timer)
59
+ return;
60
+ render();
61
+ timer = setInterval(render, WIDGET_REFRESH_MS);
62
+ timer.unref?.();
63
+ }
64
+ function stopTimer() {
65
+ if (timer) {
66
+ clearInterval(timer);
67
+ timer = null;
68
+ }
69
+ }
70
+ function clearWidget() {
71
+ try {
72
+ activeCtx?.ui.setWidget(WIDGET_KEY, undefined);
73
+ }
74
+ catch {
75
+ /* stale ctx */
76
+ }
77
+ setTaskWidget(undefined);
78
+ }
79
+ /** One-line summary of a host tool call for the `↳` trailer (best-effort). */
80
+ function formatToolLine(toolName, args) {
81
+ const a = (args ?? {});
82
+ const field = a.path ?? a.file_path ?? a.command ?? a.pattern ?? a.prompt ?? a.query ?? a.url;
83
+ const detail = typeof field === 'string' ? ' ' + field.replace(/\s+/g, ' ').trim() : '';
84
+ return `${toolName}${detail}`;
85
+ }
86
+ /**
87
+ * Arm the implementation widget just before the spec is handed off. `oneShot`
88
+ * true (fire-and-forget /task) lets `agent_end` disarm it after the single turn;
89
+ * false (awaited /task-auto) keeps it armed until `disarmImplWidget` is called.
90
+ */
91
+ export function armImplWidget(meta, opts) {
92
+ armed = { meta, oneShot: opts.oneShot, startedAt: Date.now() };
93
+ lastLine = undefined;
94
+ }
95
+ /** Tear down the widget and clear the armed slot (sticky/awaited path). */
96
+ export function disarmImplWidget() {
97
+ stopTimer();
98
+ clearWidget();
99
+ armed = null;
100
+ activeCtx = null;
101
+ lastLine = undefined;
102
+ }
103
+ /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
104
+ export function setupImplWidget(pi) {
105
+ pi.on('agent_start', (_event, ctx) => {
106
+ if (!armed)
107
+ return;
108
+ activeCtx = ctx;
109
+ lastLine = undefined;
110
+ startTimer();
111
+ });
112
+ pi.on('tool_execution_start', (event, _ctx) => {
113
+ if (!armed)
114
+ return;
115
+ lastLine = formatToolLine(event.toolName, event.args);
116
+ });
117
+ pi.on('agent_end', (_event, _ctx) => {
118
+ if (!armed)
119
+ return;
120
+ stopTimer();
121
+ clearWidget();
122
+ // Fire-and-forget /task: the single impl turn is over, so disarm. Awaited
123
+ // /task-auto leaves the slot armed so the next compaction-resume / steer
124
+ // turn re-shows it; the caller disarms when the phase truly settles.
125
+ if (armed.oneShot) {
126
+ armed = null;
127
+ activeCtx = null;
128
+ lastLine = undefined;
129
+ }
130
+ });
131
+ }
@@ -28,6 +28,11 @@ export declare class TaskRunner {
28
28
  private readonly _onStart;
29
29
  private readonly _planContext;
30
30
  private readonly _fixInstruction;
31
+ /** True when the caller awaits the implementation turn (waitForImplementation):
32
+ * the impl widget stays armed across the whole impl phase (incl. compaction /
33
+ * steer turns) and is disarmed here. False for fire-and-forget /task, where the
34
+ * widget is armed one-shot and its own agent_end disarms it. */
35
+ private readonly _implAwaited;
31
36
  private readonly _abort;
32
37
  private readonly _startedAt;
33
38
  private readonly _widgetState;
@@ -43,7 +48,7 @@ export declare class TaskRunner {
43
48
  */
44
49
  private readonly _timings;
45
50
  private _currentPhaseChildren;
46
- constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string, fixInstruction?: string);
51
+ constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string, fixInstruction?: string, implAwaited?: boolean);
47
52
  get taskId(): string;
48
53
  get signal(): AbortSignal;
49
54
  /** Return the current widget state, or null if not started. */
@@ -23,6 +23,7 @@ import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
23
23
  import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
24
24
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
25
25
  import { startWidget } from './widget.js';
26
+ import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
26
27
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
27
28
  import { pushNotify } from '../remote/push.js';
28
29
  import { parseVerifyBlock } from './spec-validation.js';
@@ -54,6 +55,11 @@ export class TaskRunner {
54
55
  _onStart;
55
56
  _planContext;
56
57
  _fixInstruction;
58
+ /** True when the caller awaits the implementation turn (waitForImplementation):
59
+ * the impl widget stays armed across the whole impl phase (incl. compaction /
60
+ * steer turns) and is disarmed here. False for fire-and-forget /task, where the
61
+ * widget is armed one-shot and its own agent_end disarms it. */
62
+ _implAwaited;
57
63
  _abort = new AbortController();
58
64
  _startedAt;
59
65
  _widgetState;
@@ -69,7 +75,7 @@ export class TaskRunner {
69
75
  */
70
76
  _timings = [];
71
77
  _currentPhaseChildren = null;
72
- constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction) {
78
+ constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction, implAwaited) {
73
79
  this._ctx = ctx;
74
80
  this._cwd = cwd;
75
81
  this._rawPrompt = rawPrompt;
@@ -78,6 +84,7 @@ export class TaskRunner {
78
84
  this._onStart = onStart;
79
85
  this._planContext = planContext;
80
86
  this._fixInstruction = fixInstruction;
87
+ this._implAwaited = implAwaited ?? false;
81
88
  this._startedAt = Date.now();
82
89
  // We'll populate id/title/phase lazily in run().
83
90
  // Placeholder — real values set in run().
@@ -265,13 +272,30 @@ export class TaskRunner {
265
272
  }
266
273
  async _deliverSpec(ctx) {
267
274
  const spec = this._specForDelivery();
275
+ // Keep the rich status block alive across the implementation turn (the phase
276
+ // widget was disposed at handoff). Awaited (/task-auto) stays armed across all
277
+ // sub-turns and is disarmed here; fire-and-forget (/task) arms one-shot and its
278
+ // own agent_end disarms it after the single turn.
279
+ const meta = {
280
+ taskId: this._widgetState.taskId,
281
+ title: this._widgetState.title,
282
+ label: this._widgetState.label
283
+ };
268
284
  if (this._sendSpec) {
269
- await this._sendSpec(spec);
285
+ armImplWidget(meta, { oneShot: !this._implAwaited });
286
+ try {
287
+ await this._sendSpec(spec);
288
+ }
289
+ finally {
290
+ if (this._implAwaited)
291
+ disarmImplWidget();
292
+ }
270
293
  return;
271
294
  }
272
295
  if (!piApi) {
273
296
  throw new Error('extension not initialised (no ExtensionAPI captured)');
274
297
  }
298
+ armImplWidget(meta, { oneShot: true });
275
299
  if (ctx.isIdle()) {
276
300
  piApi.sendUserMessage(spec);
277
301
  }
@@ -494,7 +518,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
494
518
  if (!interrupted)
495
519
  implError = implementationError(newCtx);
496
520
  }
497
- }, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction);
521
+ }, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction, opts.waitForImplementation);
498
522
  await runner.run();
499
523
  taskId = runner.taskId;
500
524
  }
@@ -643,6 +667,7 @@ async function handleTaskCancel(_args, ctx) {
643
667
  // ─── Entry point ─────────────────────────────────────────────────────────────
644
668
  export function registerTask(pi) {
645
669
  piApi = pi;
670
+ setupImplWidget(pi);
646
671
  registerBridgeCommand(pi, 'task', {
647
672
  description: 'Start a new task. Usage: /task <prompt>',
648
673
  handler: handleTask
@@ -63,4 +63,14 @@ export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetT
63
63
  * (returns a no-op disposer) when there's no UI.
64
64
  */
65
65
  export declare function startAutoLoader(ctx: ExtensionCommandContext, getState: () => AutoLoaderState | null): () => void;
66
+ export interface ImplState {
67
+ taskId: string;
68
+ title: string;
69
+ /** Short display label compressed from `title`; falls back to a truncation when absent. */
70
+ label?: string;
71
+ startedAt: number;
72
+ lastLine?: string;
73
+ contextUsage?: ContextSnapshot;
74
+ }
75
+ export declare function buildImplLines(s: ImplState, theme?: WidgetTheme): string[];
66
76
  export declare function flashTerminalWidget(ctx: ExtensionCommandContext, state: Exclude<TaskState, 'pending' | 'in_progress' | 'completed'>, taskId: string, reason: string | undefined): void;
@@ -170,6 +170,21 @@ export function startAutoLoader(ctx, getState) {
170
170
  setTaskWidget(undefined);
171
171
  };
172
172
  }
173
+ export function buildImplLines(s, theme) {
174
+ const elapsed = formatDuration(Date.now() - s.startedAt);
175
+ const head = `${s.taskId} · ${titleForDisplay(s)}`;
176
+ let detail = `implementing · ${elapsed}`;
177
+ if (s.contextUsage) {
178
+ const ctxDetail = formatContextDetail(s.contextUsage, theme);
179
+ if (ctxDetail)
180
+ detail += ` · ${ctxDetail}`;
181
+ }
182
+ const lines = [head, detail];
183
+ const trailer = lastLineTrailer(s.lastLine, theme);
184
+ if (trailer)
185
+ lines.push(trailer);
186
+ return lines;
187
+ }
173
188
  export function flashTerminalWidget(ctx, state, taskId, reason) {
174
189
  if (!ctx.hasUI)
175
190
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",