@mjasnikovs/pi-task 0.4.4 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { PromptMessage, ServerMessage } from './protocol.js';
3
3
  export interface BridgeState {
4
4
  /** promptId → resolver that settles the remote side of an ask() race. */
@@ -46,6 +46,19 @@ export declare class SessionUI {
46
46
  export declare function publishWidget(key: string, lines: string[] | undefined): void;
47
47
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
48
48
  export declare function publishViewer(title: string, text: string): void;
49
+ /**
50
+ * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
51
+ * for commands that don't need newSession (e.g. /task-resume, /task-list,
52
+ * /task-cancel, /task-auto-cancel).
53
+ *
54
+ * - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
55
+ * - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
56
+ * show a helpful message rather than a confusing TypeError.
57
+ *
58
+ * The shim is replaced by a real ExtensionCommandContext the first time the
59
+ * user runs any /task* or /remote command in the terminal.
60
+ */
61
+ export declare function makeShimmedCtx(ctx: ExtensionContext): ExtensionCommandContext;
49
62
  interface BridgeCommandDef {
50
63
  description: string;
51
64
  handler: (args: string, ctx: ExtensionCommandContext) => unknown;
@@ -55,17 +68,8 @@ interface BridgeCommandDef {
55
68
  * can invoke it. Use in place of pi.registerCommand for task commands. */
56
69
  export declare function registerBridgeCommand(pi: ExtensionAPI, name: string, def: BridgeCommandDef): void;
57
70
  /** Start a new session in response to a remote `/new`. Reads the freshest
58
- * command-capable ctx (currentCtx) at call time and mirrors dispatchRemoteLine's
59
- * guards: a missing, non-command, or stale ctx toasts instead of crashing.
60
- *
61
- * The crash this prevents: a captured ctx goes stale after a *local* /new (a
62
- * replacement we don't initiate, so we never get a withSession to rebind), and
63
- * ctx.newSession() then throws *synchronously* from assertActive — a sync throw
64
- * that a bare `.catch()` on the result would miss. `rebind` runs as withSession
65
- * with the fresh post-replacement ctx. */
66
- /** The richer context the runtime passes to a `newSession` withSession callback
67
- * (it carries sendUserMessage, unlike a plain command ctx). Derived from the
68
- * newSession signature since the package root doesn't re-export the type. */
71
+ * command-capable ctx (currentCtx) at call time. If only a shimmed ctx is
72
+ * available, the newSession shim throws a clear error. */
69
73
  type NewSessionOptions = NonNullable<Parameters<ExtensionCommandContext['newSession']>[0]>;
70
74
  type ReplacedSessionContext = Parameters<NonNullable<NewSessionOptions['withSession']>>[0];
71
75
  export declare function dispatchRemoteNewSession(rebind: (ctx: ReplacedSessionContext) => void): void;
@@ -97,6 +97,33 @@ export function publishNotify(message, level) {
97
97
  export function publishViewer(title, text) {
98
98
  getBridge().broadcast({ type: 'viewer', title, text });
99
99
  }
100
+ // ─── Shimmed ctx ─────────────────────────────────────────────────────────────
101
+ const SHIMMED_MARKER = '__piRemoteShimmed';
102
+ /**
103
+ * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
104
+ * for commands that don't need newSession (e.g. /task-resume, /task-list,
105
+ * /task-cancel, /task-auto-cancel).
106
+ *
107
+ * - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
108
+ * - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
109
+ * show a helpful message rather than a confusing TypeError.
110
+ *
111
+ * The shim is replaced by a real ExtensionCommandContext the first time the
112
+ * user runs any /task* or /remote command in the terminal.
113
+ */
114
+ export function makeShimmedCtx(ctx) {
115
+ const shim = Object.create(ctx);
116
+ shim[SHIMMED_MARKER] = true;
117
+ shim.waitForIdle = async () => {
118
+ while (!ctx.isIdle()) {
119
+ await new Promise(r => setTimeout(r, 100));
120
+ }
121
+ };
122
+ shim.newSession = () => {
123
+ throw new Error('Run /remote in the terminal once to enable /task, /task-auto, and /new from remote.');
124
+ };
125
+ return shim;
126
+ }
100
127
  /** Register a command with pi AND record it in the bridge so remote slash lines
101
128
  * can invoke it. Use in place of pi.registerCommand for task commands. */
102
129
  export function registerBridgeCommand(pi, name, def) {
@@ -115,13 +142,7 @@ export function dispatchRemoteNewSession(rebind) {
115
142
  const b = getBridge();
116
143
  const ctx = b.currentCtx;
117
144
  if (!ctx) {
118
- publishNotify('Start a session before running /new remotely.', 'warning');
119
- return;
120
- }
121
- // A bare event-scoped ctx lacks newSession; a stale command ctx still has it
122
- // but throws on call — the try/catch below covers that case.
123
- if (typeof ctx.waitForIdle !== 'function') {
124
- publishNotify('Session changed locally — run any task command in the terminal once to re-enable remote /new.', 'warning');
145
+ publishNotify('No session context available restart pi and try again.', 'warning');
125
146
  return;
126
147
  }
127
148
  const toastErr = (err) => publishNotify(`/new failed: ${err.message}`, 'error');
@@ -158,14 +179,8 @@ export function dispatchRemoteLine(text, opts) {
158
179
  return true;
159
180
  }
160
181
  if (!b.currentCtx) {
161
- publishNotify('Start a session before running commands remotely.', 'warning');
162
- return true;
163
- }
164
- // Command handlers need a command-capable ctx (waitForIdle/newSession). A bare
165
- // event-scoped ExtensionContext lacks those; if currentCtx is one (e.g. nothing
166
- // command-capable has been captured yet), toast instead of throwing a TypeError.
167
- if (typeof b.currentCtx.waitForIdle !== 'function') {
168
- publishNotify('Session changed locally — run any task command in the terminal once to re-enable remote commands.', 'warning');
182
+ // Shouldn't happen after session_start seeds a shimmed ctx, but guard anyway.
183
+ publishNotify(`/${name}: no session context yet — restart pi.`, 'warning');
169
184
  return true;
170
185
  }
171
186
  // Invoke synchronously so the call happens immediately, but surface both
@@ -1,5 +1,5 @@
1
1
  import { broadcast } from './broadcast.js';
2
- import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession } from './bridge.js';
2
+ import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
3
3
  import { setupEvents } from './events.js';
4
4
  import { HistoryBuffer } from './history.js';
5
5
  import { html } from './ui.js';
@@ -12,20 +12,12 @@ if (!_g.__piRemote)
12
12
  const S = _g.__piRemote;
13
13
  export function registerRemote(pi) {
14
14
  const history = new HistoryBuffer(20);
15
- /** Start the always-on remote server once; later calls return the live handle.
16
- * Plain chat works immediately via S.send; remote slash commands and /new
17
- * bind once any command supplies a command-capable ctx (see dispatchRemoteLine). */
18
15
  async function ensureServer() {
19
16
  if (S.server)
20
17
  return S.server;
21
18
  S.server = await startServer(text => {
22
19
  if (text === '/new') {
23
- // Route through the bridge's freshest command ctx with the same
24
- // crash-safe guards as remote slash commands. The withSession
25
- // rebind keeps S.send pointed at the new session's ctx.
26
20
  dispatchRemoteNewSession(newCtx => {
27
- // newCtx.sendUserMessage bypasses the stale runtime check
28
- // (returns a Promise we deliberately discard).
29
21
  S.send = (msg, opts) => {
30
22
  void (opts ?
31
23
  newCtx.sendUserMessage(msg, opts)
@@ -34,14 +26,8 @@ export function registerRemote(pi) {
34
26
  });
35
27
  return;
36
28
  }
37
- // Slash commands route to the bridge's command registry;
38
- // plain lines fall through to onPlain and go to the agent.
39
29
  dispatchRemoteLine(text, {
40
30
  onPlain: plain => {
41
- // Persist remote-typed messages: they arrive via
42
- // sendUserMessage with source "extension", which the
43
- // interactive input handler skips, so record them
44
- // here for reconnect/history.
45
31
  history.addUserMessage(plain);
46
32
  if (isAgentIdle()) {
47
33
  S.send?.(plain);
@@ -55,18 +41,18 @@ export function registerRemote(pi) {
55
41
  return S.server;
56
42
  }
57
43
  pi.on('session_start', (_event, ctx) => {
58
- // Update shared send with fresh pi on each session (survives /new re-evaluation)
59
44
  S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
60
45
  setupEvents(pi, history, broadcast);
61
- // Always-on: bring the remote server up automatically so no /remote is
62
- // needed. Plain chat flows immediately via S.send; the local widget shows
63
- // the reachable URLs. Run /remote to view the QR or fully enable remote
64
- // slash commands.
65
- // NOTE: we deliberately do NOT seed bridge.currentCtx here. The event ctx
66
- // is the base ExtensionContext (no waitForIdle/newSession), so storing it
67
- // would make a later remote /task throw. currentCtx is only ever set from
68
- // a real command ctx: the /remote handler, registerBridgeCommand, and the
69
- // remote-/new withSession rebind. See dispatchRemoteLine's capability guard.
46
+ // Seed a shimmed ctx so commands that don't need newSession (/task-list,
47
+ // /task-cancel, /task-auto-cancel) work immediately from the remote without
48
+ // any terminal interaction. Only overwrite if null or already shimmed
49
+ // a real command ctx captured from a prior terminal command must survive
50
+ // session_start (it's updated via withSession or registerBridgeCommand,
51
+ // not replaced here).
52
+ const b = getBridge();
53
+ if (!b.currentCtx || b.currentCtx['__piRemoteShimmed'] === true) {
54
+ b.currentCtx = makeShimmedCtx(ctx);
55
+ }
70
56
  void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
71
57
  });
72
58
  pi.on('session_shutdown', (event, _ctx) => {
@@ -92,15 +78,12 @@ export function registerRemote(pi) {
92
78
  }
93
79
  return;
94
80
  }
95
- // Capture this command-capable ctx so remote slash commands and /new
96
- // work (the always-on auto-start can only bind plain chat).
81
+ // Upgrade from shimmed ctx to a real command-capable ctx.
97
82
  getBridge().currentCtx = ctx;
98
83
  try {
99
84
  const server = await ensureServer();
100
- // QR keeps encoding only the primary (Tailscale-preferred) URL.
101
85
  const primaryUrl = `http://${server.ip}:${server.port}`;
102
86
  const qr = await qrLines(primaryUrl);
103
- // Labeled, block-aligned address lines: Tailscale + LAN when present.
104
87
  const addrs = formatAddresses(server.ips, server.port);
105
88
  const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
106
89
  const addrLines = addrs.map(a => a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url);
@@ -110,8 +93,6 @@ export function registerRemote(pi) {
110
93
  const stripAnsi = (s) => s.replace(/\x1b\[[^m]*m/g, '');
111
94
  const visWidth = qr.reduce((max, l) => Math.max(max, stripAnsi(l).length), 0);
112
95
  const overlayWidth = Math.max(visWidth, addrWidth + 4, 36);
113
- // Fire-and-forget: don't await so the command handler returns immediately
114
- // (avoids "Working" lock if user runs /new before dismissing)
115
96
  ctx.ui
116
97
  .custom((_tui, _theme, _kb, done) => ({
117
98
  focused: false,
package/dist/remote/ui.js CHANGED
@@ -5,8 +5,8 @@ export function html(wsUrl) {
5
5
  + `text-anchor="middle" fill="#cba6f7">π</text></svg>`);
6
6
  const iconUrl = `data:image/svg+xml,${iconSvg}`;
7
7
  const manifest = encodeURIComponent(JSON.stringify({
8
- name: 'pi remote',
9
- short_name: 'pi remote',
8
+ name: 'pi-task remote',
9
+ short_name: 'pi-task remote',
10
10
  display: 'standalone',
11
11
  background_color: '#1e1e2e',
12
12
  theme_color: '#1e1e2e',
@@ -19,11 +19,11 @@ export function html(wsUrl) {
19
19
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
20
20
  <meta name="apple-mobile-web-app-capable" content="yes">
21
21
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
22
- <meta name="apple-mobile-web-app-title" content="pi remote">
22
+ <meta name="apple-mobile-web-app-title" content="pi-task remote">
23
23
  <meta name="theme-color" content="#1e1e2e">
24
24
  <link rel="apple-touch-icon" href="${iconUrl}">
25
25
  <link rel="manifest" href="data:application/manifest+json,${manifest}">
26
- <title>pi remote</title>
26
+ <title>pi-task remote</title>
27
27
  <style>
28
28
  :root {
29
29
  --base: #1e1e2e; --mantle: #181825; --crust: #11111b;
@@ -50,7 +50,7 @@ export function html(wsUrl) {
50
50
  #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em; }
51
51
  #header .status { color: var(--subtext0); font-size: 11px; }
52
52
  #chat-log {
53
- flex: 1; overflow-y: auto; padding: 16px;
53
+ flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
54
54
  display: flex; flex-direction: column; gap: 8px;
55
55
  }
56
56
  #chat-log::-webkit-scrollbar { width: 6px; }
@@ -92,9 +92,10 @@ export function html(wsUrl) {
92
92
  .tool-call[open] > summary::before { content: "▼ "; }
93
93
  .tool-call.error > summary { color: var(--red); }
94
94
  .tool-call pre {
95
- padding: 8px 12px; overflow-x: auto; overflow-y: auto;
95
+ padding: 8px 12px; overflow-y: auto;
96
96
  color: var(--subtext1); font-size: 11px; max-height: 280px;
97
97
  border-top: 1px solid var(--surface0);
98
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
98
99
  }
99
100
  .code-block {
100
101
  background: var(--crust); border: 1px solid var(--surface0);
@@ -165,25 +166,48 @@ export function html(wsUrl) {
165
166
  #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
166
167
  color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
167
168
  #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
168
- border-top: 2px solid var(--mauve); padding: 14px; display: none; z-index: 50; }
169
- #prompt-card .q { color: var(--text); margin-bottom: 8px; white-space: pre-wrap; }
169
+ border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
170
+ display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }
171
+ #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;
172
+ text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }
173
+ #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;
174
+ font-size: 15px; line-height: 1.5; }
175
+ #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);
176
+ border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
177
+ #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;
178
+ text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
179
+ #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;
180
+ white-space: pre-wrap; overflow-wrap: anywhere; }
170
181
  #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);
171
- border: 1px solid var(--surface2); border-radius: 6px; padding: 8px; }
172
- #prompt-card .row { display: flex; gap: 8px; margin-top: 8px; }
173
- #prompt-card button { padding: 6px 12px; border-radius: 6px; border: none; cursor: pointer; }
182
+ border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
183
+ font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
184
+ #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: center;
185
+ flex-wrap: wrap; }
186
+ #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
187
+ font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
188
+ #prompt-card button:hover { filter: brightness(1.08); }
189
+ #prompt-card button.primary { background: var(--green); color: var(--crust);
190
+ font-weight: 700; flex: 1; min-width: 160px; }
191
+ #prompt-card button.secondary { background: var(--surface1); color: var(--text);
192
+ flex: 1; min-width: 160px; }
193
+ #prompt-card button.cancel { margin-left: auto; background: transparent; color: var(--subtext0);
194
+ font-size: 12px; font-weight: 500; padding: 8px 10px; }
195
+ #prompt-card button.cancel:hover { color: var(--red); filter: none; }
196
+ #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
174
197
  .toast { position: fixed; top: 12px; right: 12px; padding: 8px 12px; border-radius: 6px;
175
198
  background: var(--surface1); color: var(--text); z-index: 60; }
176
199
  .toast.warning { background: var(--peach); color: var(--crust); }
177
200
  .toast.error { background: var(--red); color: var(--crust); }
178
201
  #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);
179
- border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap; display: none; z-index: 70; }
202
+ border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
203
+ overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
180
204
  #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
181
205
  </style>
182
206
  </head>
183
207
  <body>
184
208
  <div id="context-bar"><div id="context-bar-fill"></div></div>
185
209
  <div id="header">
186
- <span class="title">pi remote</span>
210
+ <span class="title">pi-task remote</span>
187
211
  <span class="status" id="client-status">connecting…</span>
188
212
  </div>
189
213
  <div id="chat-log"></div>
@@ -195,8 +219,13 @@ export function html(wsUrl) {
195
219
  </div>
196
220
  <div id="reconnect-overlay"><span id="reconnect-msg">reconnecting…</span></div>
197
221
  <div id="prompt-card">
222
+ <div class="q-label">pi needs your input</div>
198
223
  <div class="q" id="prompt-q"></div>
199
- <textarea id="prompt-input" rows="2"></textarea>
224
+ <div class="rec-panel" id="prompt-rec" style="display:none">
225
+ <div class="rec-label">Recommended answer</div>
226
+ <div class="rec-text" id="prompt-rec-text"></div>
227
+ </div>
228
+ <textarea id="prompt-input" rows="3" placeholder="Type your answer…" style="display:none"></textarea>
200
229
  <div class="row" id="prompt-buttons"></div>
201
230
  </div>
202
231
  <div id="viewer"><span class="close" id="viewer-close">&#x2715;</span><div id="viewer-body"></div></div>
@@ -220,12 +249,16 @@ export function html(wsUrl) {
220
249
  const statusPanel = document.getElementById('status-panel');
221
250
  const promptCard = document.getElementById('prompt-card');
222
251
  const promptQ = document.getElementById('prompt-q');
252
+ const promptRec = document.getElementById('prompt-rec');
253
+ const promptRecText = document.getElementById('prompt-rec-text');
223
254
  const promptInput = document.getElementById('prompt-input');
224
255
  const promptButtons = document.getElementById('prompt-buttons');
225
256
  const viewer = document.getElementById('viewer');
226
257
  const viewerBody = document.getElementById('viewer-body');
227
258
  document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
228
259
  let activePromptId = null;
260
+ let activeRecommended = '';
261
+ let cancelArmTimer = null;
229
262
  const toolCallMap = {};
230
263
  let currentBubble = null;
231
264
  let streamText = '';
@@ -460,33 +493,93 @@ export function html(wsUrl) {
460
493
  activePromptId = null;
461
494
  promptCard.style.display = 'none';
462
495
  promptInput.value = '';
496
+ promptInput.style.display = 'none';
497
+ promptRec.style.display = 'none';
498
+ if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
463
499
  setEnabled(true);
464
500
  }
465
501
 
466
- function makeBtn(label, bg, onClick) {
502
+ function makeBtn(label, cls, onClick) {
467
503
  const btn = document.createElement('button');
468
504
  btn.textContent = label;
469
- btn.style.background = bg;
505
+ if (cls) btn.className = cls;
470
506
  btn.onclick = onClick;
471
507
  return btn;
472
508
  }
473
509
 
510
+ // "Cancel task" aborts the whole run, so it's deliberately small and needs a
511
+ // two-step confirm: first tap arms it, second tap (within 3s) confirms.
512
+ function makeCancelBtn() {
513
+ const btn = makeBtn('Cancel task', 'cancel', null);
514
+ let armed = false;
515
+ btn.onclick = function () {
516
+ if (armed) { answer(undefined); return; }
517
+ armed = true;
518
+ btn.classList.add('armed');
519
+ btn.textContent = 'Tap again to cancel';
520
+ if (cancelArmTimer) clearTimeout(cancelArmTimer);
521
+ cancelArmTimer = setTimeout(function () {
522
+ armed = false;
523
+ btn.classList.remove('armed');
524
+ btn.textContent = 'Cancel task';
525
+ cancelArmTimer = null;
526
+ }, 3000);
527
+ };
528
+ return btn;
529
+ }
530
+
531
+ function renderButtons(buttons) {
532
+ promptButtons.innerHTML = '';
533
+ for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
534
+ promptButtons.appendChild(makeCancelBtn());
535
+ }
536
+
537
+ // Manual-entry view: editable textarea + Submit, reachable from the
538
+ // recommendation view via "Answer manually".
539
+ function showManualEntry(prefill) {
540
+ promptRec.style.display = 'none';
541
+ promptInput.style.display = 'block';
542
+ promptInput.value = prefill || '';
543
+ renderButtons([
544
+ makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); }),
545
+ makeBtn('← Back', 'secondary', function () { showRecommendation(); })
546
+ ]);
547
+ promptInput.focus();
548
+ }
549
+
550
+ // Recommendation view (Mode A): show the suggested answer and let the user
551
+ // accept it in one tap or switch to manual entry.
552
+ function showRecommendation() {
553
+ promptInput.style.display = 'none';
554
+ promptRec.style.display = 'block';
555
+ renderButtons([
556
+ makeBtn('✓ Accept recommended', 'primary', function () { answer(''); }),
557
+ makeBtn('✎ Answer manually', 'secondary', function () { showManualEntry(activeRecommended); })
558
+ ]);
559
+ }
560
+
474
561
  function showPrompt(msg) {
475
562
  activePromptId = msg.id;
476
563
  promptQ.textContent = msg.question;
477
- promptInput.value = msg.recommended || '';
478
- promptButtons.innerHTML = '';
479
- promptButtons.appendChild(makeBtn('Submit', 'var(--green)', function () { answer(promptInput.value); }));
564
+ activeRecommended = msg.recommended || '';
480
565
  if (msg.recommended) {
481
- promptButtons.appendChild(makeBtn('Accept', 'var(--blue)', function () { answer(''); }));
482
- }
483
- if (msg.allowSkip) {
484
- promptButtons.appendChild(makeBtn('Skip', 'var(--surface2)', function () { answer(''); }));
566
+ // Mode A: there's a recommendation lead with "Accept recommended".
567
+ promptRecText.textContent = msg.recommended;
568
+ showRecommendation();
569
+ } else {
570
+ // Mode B: no recommendation — the user must type an answer (or skip).
571
+ promptRec.style.display = 'none';
572
+ promptInput.style.display = 'block';
573
+ promptInput.value = '';
574
+ const buttons = [makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); })];
575
+ if (msg.allowSkip) {
576
+ buttons.push(makeBtn('Skip question', 'secondary', function () { answer(''); }));
577
+ }
578
+ renderButtons(buttons);
579
+ promptInput.focus();
485
580
  }
486
- promptButtons.appendChild(makeBtn('Cancel task', 'var(--red)', function () { answer(undefined); }));
487
581
  promptCard.style.display = 'block';
488
582
  setEnabled(false);
489
- promptInput.focus();
490
583
  }
491
584
 
492
585
  function handleMsg(msg) {
@@ -59,15 +59,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
59
59
  drainJsonEvents(lineBuffer + '\n', opts);
60
60
  lineBuffer = '';
61
61
  }
62
- let text;
63
- if (isJsonEvents) {
64
- const extracted = (finalText || textDeltaAccum).trim();
65
- // When json-events mode extracts no text but stderr has content,
66
- // surface the stderr as the text so the caller sees the real error
67
- // instead of the generic "X child produced no output". Without this,
68
- // a model API crash that exits 0 with an error on stderr is invisible.
69
- text = extracted.length > 0 ? extracted : (stderr.trim() || undefined);
70
- }
62
+ const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
71
63
  resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
72
64
  });
73
65
  proc.on('error', () => {
@@ -49,12 +49,19 @@ export declare class TaskRunner {
49
49
  cancel(): void;
50
50
  /** Execute the full task lifecycle. */
51
51
  run(): Promise<void>;
52
+ /** Stop the phase widget — clearing both the terminal and remote surfaces —
53
+ * exactly once. Nulling the disposer makes repeat calls no-ops, so the
54
+ * failure flash that handleFailure sets after the catch isn't wiped by the
55
+ * finally block's call. */
56
+ private _disposeWidget;
52
57
  private _deliverSpec;
53
58
  }
54
59
  export interface RunSingleTaskOptions {
55
60
  /** Await the session going idle after the spec is delivered, so the caller
56
61
  * blocks until the agent has implemented it. Default false. */
57
62
  waitForImplementation?: boolean;
63
+ /** Resume an existing task by ID instead of starting a new one. */
64
+ resumeId?: string;
58
65
  /** Test seam: spawn function forwarded to TaskRunner. */
59
66
  spawnFn?: SpawnFn;
60
67
  }
@@ -20,8 +20,8 @@ import * as path from 'node:path';
20
20
  import { PHASES, postCommitPhase } from './phases.js';
21
21
  import { handleFailure } from './failure-classifier.js';
22
22
  import { PHASE_INDEX, PHASE_ORDER, allocateTaskId, ensureTasksDir, normaliseTaskId, parseFrontMatter, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile, extractSection, RESUMABLE_STATES } from './task-file.js';
23
- import { startWidget, WIDGET_KEY } from './widget.js';
24
- import { publishViewer, publishNotify, registerBridgeCommand } from '../remote/bridge.js';
23
+ import { startWidget } from './widget.js';
24
+ import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
25
25
  import { parseVerifyBlock } from './parsers.js';
26
26
  import { formatTimings } from './timings.js';
27
27
  // ─── Module-level state ──────────────────────────────────────────────────────
@@ -211,19 +211,13 @@ export class TaskRunner {
211
211
  if (parseVerifyBlock(this._pc.spec) === null)
212
212
  throw new Error('no_verify_block');
213
213
  await updateTaskFrontMatter(cwd, id, { state: 'completed', phase: 'done' });
214
- this._stopWidget?.();
215
- try {
216
- ctx.ui.setWidget(WIDGET_KEY, undefined);
217
- }
218
- catch {
219
- /* stale ctx */
220
- }
214
+ this._disposeWidget();
221
215
  await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
222
216
  await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
223
217
  await this._deliverSpec(ctx);
224
218
  }
225
219
  catch (err) {
226
- this._stopWidget?.();
220
+ this._disposeWidget();
227
221
  // Persist whatever timings we collected so failed runs are still
228
222
  // useful for analysis. Best-effort — never mask the original error.
229
223
  if (this._timings.length > 0) {
@@ -237,10 +231,18 @@ export class TaskRunner {
237
231
  await handleFailure(err, ctx, cwd, id, this._abort.signal.aborted);
238
232
  }
239
233
  finally {
240
- this._stopWidget?.();
234
+ this._disposeWidget();
241
235
  clearActiveTask(this);
242
236
  }
243
237
  }
238
+ /** Stop the phase widget — clearing both the terminal and remote surfaces —
239
+ * exactly once. Nulling the disposer makes repeat calls no-ops, so the
240
+ * failure flash that handleFailure sets after the catch isn't wiped by the
241
+ * finally block's call. */
242
+ _disposeWidget() {
243
+ this._stopWidget?.();
244
+ this._stopWidget = null;
245
+ }
244
246
  async _deliverSpec(ctx) {
245
247
  if (this._sendSpec) {
246
248
  await this._sendSpec(this._pc.spec);
@@ -272,7 +274,8 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
272
274
  const result = await ctx.newSession({
273
275
  withSession: async (newCtx) => {
274
276
  freshCtx = newCtx;
275
- const runner = new TaskRunner(newCtx, cwd, rawPrompt, undefined, async (spec) => {
277
+ getBridge().currentCtx = newCtx; // keep remote dispatch ctx fresh across session replacement
278
+ const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
276
279
  await newCtx.sendUserMessage(spec);
277
280
  if (opts.waitForImplementation)
278
281
  await newCtx.waitForIdle();
@@ -390,8 +393,10 @@ async function handleTaskResume(args, ctx) {
390
393
  }
391
394
  id = candidates[0].id;
392
395
  }
393
- const runner = new TaskRunner(ctx, cwd, '', id);
394
- await runner.run();
396
+ const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id });
397
+ if (sessionCancelled) {
398
+ ctx.ui.notify('Could not start a fresh session for /task-resume.', 'warning');
399
+ }
395
400
  }
396
401
  // eslint-disable-next-line @typescript-eslint/require-await
397
402
  async function handleTaskCancel(_args, ctx) {
@@ -177,23 +177,25 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
177
177
  // is gone, but 4 concurrent streams still split the one GPU and slow
178
178
  // each other ~4x (context worker measured 27s solo vs 128s under load),
179
179
  // so summed-but-fast (~100s) beats max-of-slowed (~130s).
180
+ // Every worker runs /no_think (below), so sequential is the faster regime.
180
181
  // Do NOT switch this back to Promise.all without re-running that A/B.
181
182
  //
182
- // NOTE: `/no_think` is intentionally NOT applied to research workers.
183
- // Several local reasoning models (Qwen3.6, DeepSeek-R1) ignore the
184
- // directive they still emit thinking but the confused stopping logic
185
- // causes them to burn their entire output budget on tool calls without
186
- // producing text. Letting the model think freely produces proper output.
187
- // Result order (files, apis, context, tooling) is preserved for assembly.
183
+ // `/no_think` is the big win: these are agentic exploration loops, and on a
184
+ // reasoning model the child would otherwise emit a full <think> trace at
185
+ // every tool step ("let me read X next…") — the single largest decode sink
186
+ // in the pipeline. Stripping it cut each worker's decode 3-8x in the A/B.
187
+ // The worker still calls as many tools as it wants; it just stops narrating
188
+ // between them. See appendNoThink. Result order (files, apis, context,
189
+ // tooling) is preserved for assembly.
188
190
  const workerSpecs = [
189
191
  {
190
192
  label: 'worker:files',
191
- prompt: promptHeader + RESEARCH_FILES_PROMPT(refined)
193
+ prompt: appendNoThink(promptHeader + RESEARCH_FILES_PROMPT(refined))
192
194
  },
193
- { label: 'worker:apis', prompt: promptHeader + RESEARCH_APIS_PROMPT(refined) },
195
+ { label: 'worker:apis', prompt: appendNoThink(promptHeader + RESEARCH_APIS_PROMPT(refined)) },
194
196
  {
195
197
  label: 'worker:context',
196
- prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
198
+ prompt: appendNoThink(promptHeader + RESEARCH_CONTEXT_PROMPT(refined)),
197
199
  // Context owns architectural understanding, not path discovery —
198
200
  // FILES handles that. Dropping `find`/`ls` keeps the worker from
199
201
  // spawning long enumeration loops whose output then inflates
@@ -202,7 +204,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
202
204
  },
203
205
  {
204
206
  label: 'worker:tooling',
205
- prompt: promptHeader + RESEARCH_TOOLING_PROMPT(refined)
207
+ prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(refined))
206
208
  }
207
209
  ];
208
210
  const workerResults = [];
@@ -229,7 +231,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
229
231
  throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
230
232
  }
231
233
  if (result.text.trim().length === 0) {
232
- throw new Error(`Research ${name} worker produced no output (exit ${result.exitCode}, ${result.waitMs}ms wait + ${result.workMs}ms work${result.stderr ? ': ' + result.stderr.slice(-300) : ''})`);
234
+ throw new Error(`Research ${name} worker produced no output`);
233
235
  }
234
236
  }
235
237
  return `FILES\n${files.text}\n\nAPIS\n${apis.text}\n\nCONTEXT\n${context.text}\n\nTOOLING\n${tooling.text}`;
@@ -106,7 +106,16 @@ export function startWidget(ctx, getState) {
106
106
  render();
107
107
  const timer = setInterval(render, WIDGET_REFRESH_MS);
108
108
  timer.unref?.();
109
- return () => clearInterval(timer);
109
+ return () => {
110
+ clearInterval(timer);
111
+ try {
112
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
113
+ }
114
+ catch {
115
+ /* stale ctx */
116
+ }
117
+ publishWidget(WIDGET_KEY, undefined);
118
+ };
110
119
  }
111
120
  export function buildAutoLoaderLines(s, theme) {
112
121
  const elapsed = formatDuration(Date.now() - s.startedAt);
@@ -1,5 +1,6 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
3
+ import { LoopDetector } from '../task/loop-detector.js';
3
4
  // `--mode json` makes pi emit structured events as they happen instead of
4
5
  // buffering the assistant text and flushing on exit. That matters for the
5
6
  // wait/work timing split: in text mode the first stdout chunk only arrives at
@@ -14,12 +15,17 @@ export async function runWorker(input) {
14
15
  const invocation = getPiInvocation([...childArgs, input.prompt]);
15
16
  const tStart = Date.now();
16
17
  let tFirstByte = null;
17
- const result = await runChildDefault(invocation, input.cwd, input.signal, { mode: 'json-events', onFirstByte: () => (tFirstByte = Date.now()) }, input.spawn);
18
+ const loopDetector = new LoopDetector(20, 5);
19
+ const result = await runChildDefault(invocation, input.cwd, input.signal, {
20
+ mode: 'json-events',
21
+ onFirstByte: () => (tFirstByte = Date.now()),
22
+ onToolCall: (call) => loopDetector.record(call)
23
+ }, input.spawn);
18
24
  const tEnd = Date.now();
19
25
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
20
26
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
21
27
  return {
22
- text: result.text || '',
28
+ text: result.text ?? '',
23
29
  exitCode: result.exitCode,
24
30
  stderr: result.stderr.trim(),
25
31
  aborted: result.aborted,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.4.4",
3
+ "version": "0.5.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",