@mjasnikovs/pi-task 0.15.2 → 0.16.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.
@@ -109,14 +109,38 @@ export async function startServer(onMessage, getHtml) {
109
109
  }
110
110
  });
111
111
  const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
112
+ // Track every accepted TCP socket so stop() can forcibly destroy lingering
113
+ // keep-alive / WebSocket connections. Without this, httpServer.close() only
114
+ // stops accepting new connections and waits for existing ones to drain — an
115
+ // open browser tab never drains, so the listening server and its sockets
116
+ // stay as active event-loop handles forever. In headless print mode the host
117
+ // exits by natural event-loop drain (it sets process.exitCode and returns,
118
+ // with no process.exit()), so these lingering handles keep the pi process
119
+ // alive indefinitely — which blocks `docker stop` / OS shutdown until the
120
+ // SIGKILL grace timeout fires. A/B-proven: terminating clients + destroying
121
+ // sockets here makes the loop drain in ~2ms instead of never.
122
+ const sockets = new Set();
123
+ httpServer.on('connection', s => {
124
+ sockets.add(s);
125
+ s.on('close', () => sockets.delete(s));
126
+ });
112
127
  const handle = {
113
128
  port,
114
129
  ip,
115
130
  ips,
116
131
  onFirstConnect: null,
117
132
  stop() {
133
+ // Terminate WebSocket clients (immediate close, no drain), then
134
+ // destroy any remaining raw sockets, then close the servers so the
135
+ // event loop has no lingering handles holding the process open.
136
+ for (const ws of wss.clients)
137
+ ws.terminate();
118
138
  wss.close();
139
+ for (const s of sockets)
140
+ s.destroy();
141
+ sockets.clear();
119
142
  httpServer.close();
143
+ httpServer.closeAllConnections?.();
120
144
  }
121
145
  };
122
146
  wss.on('connection', ws => {
@@ -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.1",
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",