@mjasnikovs/pi-task 0.17.22 → 0.17.23

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.
@@ -65,6 +65,14 @@ export declare class SessionUI {
65
65
  private askLocal;
66
66
  }
67
67
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
68
+ /**
69
+ * Abort the running agent turn — the browser Stop button. `abort()` lives on the
70
+ * base ExtensionContext ("Abort the current agent operation") so it works for a
71
+ * host chat turn, a /task phase turn, or a /task-auto run alike. The shimmed ctx
72
+ * inherits it from the real event ctx via its prototype, so this is safe even
73
+ * before the user has run a command in the terminal. No-op when nothing is running.
74
+ */
75
+ export declare function interruptAgent(): void;
68
76
  /**
69
77
  * Mirror a task lifecycle notice (the kind pi-task shows on the terminal via
70
78
  * ctx.ui.notify) to connected remote viewers. Task failures and other
@@ -108,6 +108,16 @@ export class SessionUI {
108
108
  export function publishNotify(message, level) {
109
109
  getBridge().broadcast({ type: 'notify', message, level });
110
110
  }
111
+ /**
112
+ * Abort the running agent turn — the browser Stop button. `abort()` lives on the
113
+ * base ExtensionContext ("Abort the current agent operation") so it works for a
114
+ * host chat turn, a /task phase turn, or a /task-auto run alike. The shimmed ctx
115
+ * inherits it from the real event ctx via its prototype, so this is safe even
116
+ * before the user has run a command in the terminal. No-op when nothing is running.
117
+ */
118
+ export function interruptAgent() {
119
+ getBridge().currentCtx?.abort();
120
+ }
111
121
  /**
112
122
  * Mirror a task lifecycle notice (the kind pi-task shows on the terminal via
113
123
  * ctx.ui.notify) to connected remote viewers. Task failures and other
@@ -1,18 +1,24 @@
1
1
  import { setAgentIdle } from './state.js';
2
2
  import { publishNotify } from './bridge.js';
3
- import { agentStart, appendText, textEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
3
+ import { agentStart, appendText, textEnd, appendThinking, thinkingEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
4
4
  /** Mirror pi agent events into the authoritative SessionState. Each handler
5
5
  * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
6
6
  export function setupEvents(pi) {
7
- pi.on('agent_start', (_event, _ctx) => {
7
+ pi.on('agent_start', (_event, ctx) => {
8
8
  setAgentIdle(false);
9
- agentStart();
9
+ agentStart(ctx.model?.name);
10
10
  });
11
11
  pi.on('message_update', (event, _ctx) => {
12
12
  const ae = event.assistantMessageEvent;
13
13
  if (ae.type === 'text_delta' && 'delta' in ae && typeof ae.delta === 'string') {
14
14
  appendText(ae.delta);
15
15
  }
16
+ else if (ae.type === 'thinking_delta' && 'delta' in ae && typeof ae.delta === 'string') {
17
+ appendThinking(ae.delta);
18
+ }
19
+ else if (ae.type === 'thinking_end') {
20
+ thinkingEnd();
21
+ }
16
22
  else if (ae.type === 'error') {
17
23
  const errorMessage = 'error' in ae && ae.error && typeof ae.error.errorMessage === 'string' ?
18
24
  ae.error.errorMessage
@@ -43,7 +49,7 @@ export function setupEvents(pi) {
43
49
  });
44
50
  pi.on('agent_end', (_event, ctx) => {
45
51
  setAgentIdle(true);
46
- agentEnd(ctx.getContextUsage());
52
+ agentEnd(ctx.getContextUsage(), ctx.model?.name);
47
53
  // Deliberately no push: agent_end fires on EVERY host turn — every chat
48
54
  // reply, every internal phase turn inside /task, and every internal /task
49
55
  // run inside /task-auto — so a "Task finished" push here floods the device.
@@ -2,6 +2,12 @@ export interface TextPart {
2
2
  kind: 'text';
3
3
  text: string;
4
4
  }
5
+ export interface ThinkingPart {
6
+ kind: 'thinking';
7
+ text: string;
8
+ /** false while thinking deltas are still streaming; true once thinking_end fires. */
9
+ done?: boolean;
10
+ }
5
11
  export interface ToolPart {
6
12
  kind: 'tool';
7
13
  toolCallId: string;
@@ -14,7 +20,7 @@ export interface ToolPart {
14
20
  /** Wall-clock duration once finished, shown dimly in the tool summary. */
15
21
  elapsedMs?: number;
16
22
  }
17
- export type Part = TextPart | ToolPart;
23
+ export type Part = TextPart | ThinkingPart | ToolPart;
18
24
  export interface Turn {
19
25
  role: 'user' | 'assistant' | 'system';
20
26
  /** User text, error text, or a system note. Assistant content lives in `parts`. */
@@ -22,6 +28,8 @@ export interface Turn {
22
28
  /** Ordered assistant content (text + tools). */
23
29
  parts?: Part[];
24
30
  error?: boolean;
31
+ /** Epoch ms when the turn was committed — the client renders a dim HH:MM. */
32
+ ts?: number;
25
33
  }
26
34
  export declare class HistoryBuffer {
27
35
  private entries;
@@ -24,7 +24,7 @@ export class HistoryBuffer {
24
24
  return [...this.entries];
25
25
  }
26
26
  _push(entry) {
27
- this.entries.push(entry);
27
+ this.entries.push({ ts: Date.now(), ...entry });
28
28
  if (this.entries.length > this.limit) {
29
29
  this.entries.shift();
30
30
  }
@@ -10,10 +10,27 @@ export interface PromptResolvedMessage {
10
10
  type: 'prompt_resolved';
11
11
  id: string;
12
12
  }
13
- /** The single task-widget slot. `lines: null` clears it. */
13
+ /** Structured task-widget payload lets the browser render a real progress bar,
14
+ * phase badge and elapsed clock instead of reflowing the terminal's text lines.
15
+ * Always sent ALONGSIDE `lines`, which stays the authoritative fallback. */
16
+ export interface WidgetData {
17
+ /** Head line, e.g. "TASK_0003 · auth routes" or "/task-auto · building". */
18
+ title: string;
19
+ /** Current phase/stage label, e.g. "refine", "implementing", "enforcing guidelines". */
20
+ phase: string;
21
+ /** Progress numerator (current step) — omitted for stages without numbering. */
22
+ done?: number;
23
+ /** Progress denominator (total steps) — omitted for stages without numbering. */
24
+ total?: number;
25
+ /** Pre-formatted elapsed clock, e.g. "2:14". */
26
+ elapsed?: string;
27
+ }
28
+ /** The single task-widget slot. `lines: null` clears it. `data` carries the
29
+ * structured view (null when clearing or when a producer only has text). */
14
30
  export interface WidgetMessage {
15
31
  type: 'widget';
16
32
  lines: string[] | null;
33
+ data?: WidgetData | null;
17
34
  }
18
35
  export interface NotifyMessage {
19
36
  type: 'notify';
@@ -58,5 +75,9 @@ export interface ClientPromptAnswer {
58
75
  id: string;
59
76
  value: string | undefined;
60
77
  }
61
- export type ClientMessage = ClientChatMessage | ClientPromptAnswer;
78
+ /** Stop the running agent turn (the browser Stop button). */
79
+ export interface ClientInterrupt {
80
+ type: 'interrupt';
81
+ }
82
+ export type ClientMessage = ClientChatMessage | ClientPromptAnswer | ClientInterrupt;
62
83
  export declare function isClientMessage(x: unknown): x is ClientMessage;
@@ -6,6 +6,8 @@ export function isClientMessage(x) {
6
6
  const m = x;
7
7
  if (m.type === 'message')
8
8
  return typeof m.text === 'string';
9
+ if (m.type === 'interrupt')
10
+ return true;
9
11
  if (m.type === 'prompt_answer') {
10
12
  return typeof m.id === 'string' && (m.value === undefined || typeof m.value === 'string');
11
13
  }
@@ -1,5 +1,5 @@
1
1
  import { getConfig } from '../config/config.js';
2
- import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
2
+ import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent } from './bridge.js';
3
3
  import { setupEvents } from './events.js';
4
4
  import { reset, addUserTurn } from './session-state.js';
5
5
  import { html } from './ui.js';
@@ -33,11 +33,15 @@ export function registerRemote(pi) {
33
33
  S.send?.(plain);
34
34
  }
35
35
  else {
36
- S.send?.(plain, { deliverAs: 'followUp' });
36
+ // Mid-run: steer the live turn (inject the message into the
37
+ // current generation) rather than queueing it for after, so a
38
+ // remote nudge lands immediately — matching the composer's
39
+ // "delivered mid-run" affordance.
40
+ S.send?.(plain, { deliverAs: 'steer' });
37
41
  }
38
42
  }
39
43
  });
40
- }, wsUrl => html(wsUrl));
44
+ }, wsUrl => html(wsUrl), interruptAgent);
41
45
  // Hands-off HTTPS: point Tailscale serve at our port so phones get a
42
46
  // secure context. Best-effort — any failure degrades to the http URL.
43
47
  S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
@@ -28,5 +28,5 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
28
28
  * Tailscale line uses the MagicDNS host when known (resolves to the same node,
29
29
  * but is what SSH and webpush certs need), falling back to the raw IP. */
30
30
  export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: string): AddressLine[];
31
- export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
31
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void): Promise<ServerHandle>;
32
32
  export {};
@@ -64,7 +64,7 @@ async function findPort(start, max) {
64
64
  }
65
65
  throw new Error(`No free port found in range ${start}–${start + max - 1}`);
66
66
  }
67
- export async function startServer(onMessage, getHtml) {
67
+ export async function startServer(onMessage, getHtml, onInterrupt) {
68
68
  const port = await findPort(8800, 100);
69
69
  const ips = getLocalIPs();
70
70
  const ip = ips.primary;
@@ -159,6 +159,10 @@ export async function startServer(onMessage, getHtml) {
159
159
  }
160
160
  if (!isClientMessage(msg))
161
161
  return;
162
+ if (msg.type === 'interrupt') {
163
+ onInterrupt?.();
164
+ return;
165
+ }
162
166
  if (msg.type === 'prompt_answer') {
163
167
  answerPrompt(msg.id, msg.value);
164
168
  return;
@@ -1,6 +1,6 @@
1
1
  import { HistoryBuffer } from './history.js';
2
2
  import type { Turn, Part } from './history.js';
3
- import type { ContextUsage, PromptMessage } from './protocol.js';
3
+ import type { ContextUsage, PromptMessage, WidgetData } from './protocol.js';
4
4
  export interface LiveTurn {
5
5
  /** Ordered assistant content (text segments + tool calls) for this run. */
6
6
  parts: Part[];
@@ -14,16 +14,21 @@ export interface SnapshotMessage {
14
14
  live: LiveTurn | null;
15
15
  agentRunning: boolean;
16
16
  taskWidget: string[] | null;
17
+ taskWidgetData: WidgetData | null;
17
18
  prompt: PromptMessage | null;
18
19
  context: ContextUsage | null;
20
+ model: string | null;
19
21
  }
20
22
  interface SessionState {
21
23
  history: HistoryBuffer;
22
24
  live: LiveTurn | null;
23
25
  agentRunning: boolean;
24
26
  taskWidget: string[] | null;
27
+ taskWidgetData: WidgetData | null;
25
28
  prompt: PromptMessage | null;
26
29
  context: ContextUsage | null;
30
+ /** Human-readable active model name (e.g. "Qwen3.6 27B"), for the header chip. */
31
+ model: string | null;
27
32
  /** tool start timestamps (ms), keyed by toolCallId — kept off the serialized
28
33
  * parts so it never reaches the client; used only to compute elapsedMs. */
29
34
  toolStarts: Record<string, number>;
@@ -33,20 +38,26 @@ interface SessionState {
33
38
  export declare function getState(): SessionState;
34
39
  /** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
35
40
  export declare function _setSink(fn: (msg: unknown) => void): void;
36
- export declare function agentStart(): void;
41
+ export declare function agentStart(model?: string): void;
37
42
  export declare function appendText(delta: string): void;
38
43
  export declare function textEnd(): void;
44
+ /** Accumulate a reasoning/thinking delta into the current thinking part (a new
45
+ * part opens when the previous one is closed or the last part isn't thinking).
46
+ * Stored as a part kind so a reconnect snapshot replays the collapsed block. */
47
+ export declare function appendThinking(delta: string): void;
48
+ export declare function thinkingEnd(): void;
39
49
  export declare function startTool(toolCallId: string, toolName: string, args: unknown): void;
40
50
  export declare function updateTool(toolCallId: string, partialResult: unknown): void;
41
51
  export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
42
- export declare function agentEnd(context: ContextUsage): void;
52
+ export declare function agentEnd(context: ContextUsage, model?: string): void;
43
53
  export declare function addUserTurn(text: string): void;
44
54
  export declare function addError(message: string): void;
45
55
  /** A persistent inline note (committed to the transcript so it survives reconnect)
46
56
  * plus a live delta so connected clients render it immediately. */
47
57
  export declare function addSystemNote(text: string): void;
48
- /** The single task-widget slot. Empty/undefined lines clear it. */
49
- export declare function setTaskWidget(lines: string[] | null | undefined): void;
58
+ /** The single task-widget slot. Empty/undefined lines clear it. `data` is the
59
+ * structured view rendered by the browser; the lines remain the fallback. */
60
+ export declare function setTaskWidget(lines: string[] | null | undefined, data?: WidgetData | null): void;
50
61
  export declare function setPrompt(prompt: PromptMessage): void;
51
62
  export declare function clearPrompt(id: string): void;
52
63
  export declare function setContext(context: ContextUsage): void;
@@ -18,8 +18,10 @@ function fresh() {
18
18
  live: null,
19
19
  agentRunning: false,
20
20
  taskWidget: null,
21
+ taskWidgetData: null,
21
22
  prompt: null,
22
23
  context: null,
24
+ model: null,
23
25
  toolStarts: {},
24
26
  sink: wsBroadcast
25
27
  };
@@ -39,11 +41,13 @@ function ensureLive(s) {
39
41
  return s.live;
40
42
  }
41
43
  // ─── Mutators ────────────────────────────────────────────────────────────────
42
- export function agentStart() {
44
+ export function agentStart(model) {
43
45
  const s = getState();
44
46
  s.live = { parts: [], textOpen: false };
45
47
  s.agentRunning = true;
46
- s.sink({ type: 'agent_start' });
48
+ if (model)
49
+ s.model = model;
50
+ s.sink({ type: 'agent_start', model: s.model });
47
51
  }
48
52
  export function appendText(delta) {
49
53
  const s = getState();
@@ -64,6 +68,29 @@ export function textEnd() {
64
68
  s.live.textOpen = false; // next text starts a new segment
65
69
  s.sink({ type: 'text_end' });
66
70
  }
71
+ /** Accumulate a reasoning/thinking delta into the current thinking part (a new
72
+ * part opens when the previous one is closed or the last part isn't thinking).
73
+ * Stored as a part kind so a reconnect snapshot replays the collapsed block. */
74
+ export function appendThinking(delta) {
75
+ const s = getState();
76
+ const live = ensureLive(s);
77
+ const last = live.parts[live.parts.length - 1];
78
+ if (last && last.kind === 'thinking' && !last.done) {
79
+ last.text += delta;
80
+ }
81
+ else {
82
+ live.parts.push({ kind: 'thinking', text: delta, done: false });
83
+ }
84
+ live.textOpen = false; // a following text delta begins a fresh bubble
85
+ s.sink({ type: 'thinking_delta', delta });
86
+ }
87
+ export function thinkingEnd() {
88
+ const s = getState();
89
+ const last = s.live?.parts[s.live.parts.length - 1];
90
+ if (last && last.kind === 'thinking')
91
+ last.done = true;
92
+ s.sink({ type: 'thinking_end' });
93
+ }
67
94
  export function startTool(toolCallId, toolName, args) {
68
95
  const s = getState();
69
96
  const live = ensureLive(s);
@@ -114,14 +141,16 @@ export function endTool(toolCallId, toolName, result, isError) {
114
141
  }
115
142
  s.sink({ type: 'tool_end', toolCallId, toolName, result, isError, elapsedMs });
116
143
  }
117
- export function agentEnd(context) {
144
+ export function agentEnd(context, model) {
118
145
  const s = getState();
119
146
  if (s.live)
120
147
  s.history.addAssistantTurn(s.live.parts);
121
148
  s.live = null;
122
149
  s.agentRunning = false;
123
150
  s.context = context;
124
- s.sink({ type: 'agent_end', contextUsage: context });
151
+ if (model)
152
+ s.model = model;
153
+ s.sink({ type: 'agent_end', contextUsage: context, model: s.model });
125
154
  }
126
155
  export function addUserTurn(text) {
127
156
  const s = getState();
@@ -142,11 +171,13 @@ export function addSystemNote(text) {
142
171
  s.history.addSystemNote(text);
143
172
  s.sink({ type: 'system_note', text });
144
173
  }
145
- /** The single task-widget slot. Empty/undefined lines clear it. */
146
- export function setTaskWidget(lines) {
174
+ /** The single task-widget slot. Empty/undefined lines clear it. `data` is the
175
+ * structured view rendered by the browser; the lines remain the fallback. */
176
+ export function setTaskWidget(lines, data) {
147
177
  const s = getState();
148
178
  s.taskWidget = lines && lines.length ? lines : null;
149
- s.sink({ type: 'widget', lines: s.taskWidget });
179
+ s.taskWidgetData = s.taskWidget ? (data ?? null) : null;
180
+ s.sink({ type: 'widget', lines: s.taskWidget, data: s.taskWidgetData });
150
181
  }
151
182
  export function setPrompt(prompt) {
152
183
  const s = getState();
@@ -170,8 +201,11 @@ export function reset() {
170
201
  s.live = null;
171
202
  s.agentRunning = false;
172
203
  s.taskWidget = null;
204
+ s.taskWidgetData = null;
173
205
  s.prompt = null;
174
206
  s.context = null;
207
+ // Deliberately keep s.model: the active model doesn't change across a /new,
208
+ // so the header chip needn't blank and re-fill on every session switch.
175
209
  s.sink({ type: 'reset' });
176
210
  }
177
211
  /** Serialize the whole state for a (re)connecting client. */
@@ -183,7 +217,9 @@ export function snapshot() {
183
217
  live: s.live ? { parts: [...s.live.parts], textOpen: s.live.textOpen } : null,
184
218
  agentRunning: s.agentRunning,
185
219
  taskWidget: s.taskWidget,
220
+ taskWidgetData: s.taskWidgetData,
186
221
  prompt: s.prompt,
187
- context: s.context
222
+ context: s.context,
223
+ model: s.model
188
224
  };
189
225
  }
@@ -156,8 +156,12 @@ export function renderModule() {
156
156
  for (var k = 0; k < blocks.length; k++) {
157
157
  var b = blocks[k];
158
158
  if (b.type === 'code') {
159
- out += '<div class="code-block">'
160
- + (b.lang ? '<div class="code-lang">' + escHtml(b.lang) + '</div>' : '')
159
+ // Header row carries the language label and a copy button (wired by
160
+ // event delegation in clientScript it reads the <code> textContent).
161
+ out += '<div class="code-block"><div class="code-head">'
162
+ + '<div class="code-lang">' + (b.lang ? escHtml(b.lang) : '') + '</div>'
163
+ + '<button class="copy-btn" type="button" aria-label="Copy code">Copy</button>'
164
+ + '</div>'
161
165
  + '<pre><code>' + syntaxHighlight(b.content, b.lang) + '</code></pre></div>';
162
166
  } else {
163
167
  out += renderProse(b.content);