@mjasnikovs/pi-task 0.4.4 → 0.6.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,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
- import type { PromptMessage, ServerMessage } from './protocol.js';
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type { ContextUsage, PromptMessage, ServerMessage } from './protocol.js';
3
3
  export interface BridgeState {
4
4
  /** promptId → resolver that settles the remote side of an ask() race. */
5
5
  pending: Map<string, (value: string | undefined) => void>;
@@ -7,6 +7,8 @@ export interface BridgeState {
7
7
  activePrompt: PromptMessage | null;
8
8
  /** Last lines pushed per widget key (replayed to late joiners). */
9
9
  activeWidgets: Map<string, string[]>;
10
+ /** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
11
+ lastContextUsage: ContextUsage | null;
10
12
  nextId: number;
11
13
  /** Command name → handler, populated as pi-task registers its commands. */
12
14
  commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
@@ -46,6 +48,19 @@ export declare class SessionUI {
46
48
  export declare function publishWidget(key: string, lines: string[] | undefined): void;
47
49
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
48
50
  export declare function publishViewer(title: string, text: string): void;
51
+ /**
52
+ * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
53
+ * for commands that don't need newSession (e.g. /task-resume, /task-list,
54
+ * /task-cancel, /task-auto-cancel).
55
+ *
56
+ * - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
57
+ * - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
58
+ * show a helpful message rather than a confusing TypeError.
59
+ *
60
+ * The shim is replaced by a real ExtensionCommandContext the first time the
61
+ * user runs any /task* or /remote command in the terminal.
62
+ */
63
+ export declare function makeShimmedCtx(ctx: ExtensionContext): ExtensionCommandContext;
49
64
  interface BridgeCommandDef {
50
65
  description: string;
51
66
  handler: (args: string, ctx: ExtensionCommandContext) => unknown;
@@ -55,17 +70,8 @@ interface BridgeCommandDef {
55
70
  * can invoke it. Use in place of pi.registerCommand for task commands. */
56
71
  export declare function registerBridgeCommand(pi: ExtensionAPI, name: string, def: BridgeCommandDef): void;
57
72
  /** 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. */
73
+ * command-capable ctx (currentCtx) at call time. If only a shimmed ctx is
74
+ * available, the newSession shim throws a clear error. */
69
75
  type NewSessionOptions = NonNullable<Parameters<ExtensionCommandContext['newSession']>[0]>;
70
76
  type ReplacedSessionContext = Parameters<NonNullable<NewSessionOptions['withSession']>>[0];
71
77
  export declare function dispatchRemoteNewSession(rebind: (ctx: ReplacedSessionContext) => void): void;
@@ -6,6 +6,7 @@ export function getBridge() {
6
6
  pending: new Map(),
7
7
  activePrompt: null,
8
8
  activeWidgets: new Map(),
9
+ lastContextUsage: null,
9
10
  nextId: 0,
10
11
  commands: new Map(),
11
12
  currentCtx: null,
@@ -97,6 +98,33 @@ export function publishNotify(message, level) {
97
98
  export function publishViewer(title, text) {
98
99
  getBridge().broadcast({ type: 'viewer', title, text });
99
100
  }
101
+ // ─── Shimmed ctx ─────────────────────────────────────────────────────────────
102
+ const SHIMMED_MARKER = '__piRemoteShimmed';
103
+ /**
104
+ * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
105
+ * for commands that don't need newSession (e.g. /task-resume, /task-list,
106
+ * /task-cancel, /task-auto-cancel).
107
+ *
108
+ * - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
109
+ * - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
110
+ * show a helpful message rather than a confusing TypeError.
111
+ *
112
+ * The shim is replaced by a real ExtensionCommandContext the first time the
113
+ * user runs any /task* or /remote command in the terminal.
114
+ */
115
+ export function makeShimmedCtx(ctx) {
116
+ const shim = Object.create(ctx);
117
+ shim[SHIMMED_MARKER] = true;
118
+ shim.waitForIdle = async () => {
119
+ while (!ctx.isIdle()) {
120
+ await new Promise(r => setTimeout(r, 100));
121
+ }
122
+ };
123
+ shim.newSession = () => {
124
+ throw new Error('Run /remote in the terminal once to enable /task, /task-auto, and /new from remote.');
125
+ };
126
+ return shim;
127
+ }
100
128
  /** Register a command with pi AND record it in the bridge so remote slash lines
101
129
  * can invoke it. Use in place of pi.registerCommand for task commands. */
102
130
  export function registerBridgeCommand(pi, name, def) {
@@ -115,13 +143,7 @@ export function dispatchRemoteNewSession(rebind) {
115
143
  const b = getBridge();
116
144
  const ctx = b.currentCtx;
117
145
  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');
146
+ publishNotify('No session context available restart pi and try again.', 'warning');
125
147
  return;
126
148
  }
127
149
  const toastErr = (err) => publishNotify(`/new failed: ${err.message}`, 'error');
@@ -158,14 +180,8 @@ export function dispatchRemoteLine(text, opts) {
158
180
  return true;
159
181
  }
160
182
  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');
183
+ // Shouldn't happen after session_start seeds a shimmed ctx, but guard anyway.
184
+ publishNotify(`/${name}: no session context yet — restart pi.`, 'warning');
169
185
  return true;
170
186
  }
171
187
  // Invoke synchronously so the call happens immediately, but surface both
@@ -1,4 +1,5 @@
1
1
  import { setAgentIdle } from './state.js';
2
+ import { getBridge } from './bridge.js';
2
3
  export function setupEvents(pi, history, broadcastFn) {
3
4
  let currentText = '';
4
5
  const currentTools = [];
@@ -67,6 +68,8 @@ export function setupEvents(pi, history, broadcastFn) {
67
68
  pi.on('agent_end', (_event, ctx) => {
68
69
  const contextUsage = ctx.getContextUsage();
69
70
  setAgentIdle(true);
71
+ // Remember it so a browser that connects mid-session gets its bar seeded.
72
+ getBridge().lastContextUsage = contextUsage;
70
73
  history.addAssistantTurn(currentText, [...currentTools]);
71
74
  broadcastFn({ type: 'agent_end', contextUsage });
72
75
  currentText = '';
@@ -24,10 +24,21 @@ export interface ViewerMessage {
24
24
  title: string;
25
25
  text: string;
26
26
  }
27
+ export interface ContextUsage {
28
+ tokens?: number;
29
+ contextWindow?: number;
30
+ percent?: number;
31
+ }
32
+ /** Seeds the context-usage bar for a freshly-connected client (the live value is
33
+ * otherwise only carried on agent_end). */
34
+ export interface ContextMessage {
35
+ type: 'context';
36
+ contextUsage: ContextUsage;
37
+ }
27
38
  /** Server → browser messages added by the integration. The existing
28
39
  * history / text_delta / tool_* / agent_* / client_count / user_message messages are
29
40
  * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
30
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage;
41
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
31
42
  /** Browser → server messages. */
32
43
  export interface ClientChatMessage {
33
44
  type: 'message';
@@ -1,31 +1,24 @@
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';
6
6
  import { qrLines } from './qr.js';
7
7
  import { startServer, formatAddresses } from './server.js';
8
+ import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls } from './tailscale.js';
8
9
  import { isAgentIdle } from './state.js';
9
10
  const _g = globalThis;
10
11
  if (!_g.__piRemote)
11
- _g.__piRemote = { server: null, send: null };
12
+ _g.__piRemote = { server: null, send: null, serveResult: null };
12
13
  const S = _g.__piRemote;
13
14
  export function registerRemote(pi) {
14
15
  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
16
  async function ensureServer() {
19
17
  if (S.server)
20
18
  return S.server;
21
19
  S.server = await startServer(text => {
22
20
  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
21
  dispatchRemoteNewSession(newCtx => {
27
- // newCtx.sendUserMessage bypasses the stale runtime check
28
- // (returns a Promise we deliberately discard).
29
22
  S.send = (msg, opts) => {
30
23
  void (opts ?
31
24
  newCtx.sendUserMessage(msg, opts)
@@ -34,14 +27,8 @@ export function registerRemote(pi) {
34
27
  });
35
28
  return;
36
29
  }
37
- // Slash commands route to the bridge's command registry;
38
- // plain lines fall through to onPlain and go to the agent.
39
30
  dispatchRemoteLine(text, {
40
31
  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
32
  history.addUserMessage(plain);
46
33
  if (isAgentIdle()) {
47
34
  S.send?.(plain);
@@ -52,28 +39,35 @@ export function registerRemote(pi) {
52
39
  }
53
40
  });
54
41
  }, wsUrl => html(wsUrl), () => history.getEntries());
42
+ // Hands-off HTTPS: point Tailscale serve at our port so phones get a
43
+ // secure context. Best-effort — any failure degrades to the http URL.
44
+ S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
55
45
  return S.server;
56
46
  }
57
47
  pi.on('session_start', (_event, ctx) => {
58
- // Update shared send with fresh pi on each session (survives /new re-evaluation)
59
48
  S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
60
49
  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.
50
+ // Seed a shimmed ctx so commands that don't need newSession (/task-list,
51
+ // /task-cancel, /task-auto-cancel) work immediately from the remote without
52
+ // any terminal interaction. Only overwrite if null or already shimmed
53
+ // a real command ctx captured from a prior terminal command must survive
54
+ // session_start (it's updated via withSession or registerBridgeCommand,
55
+ // not replaced here).
56
+ const b = getBridge();
57
+ if (!b.currentCtx
58
+ || b.currentCtx['__piRemoteShimmed'] === true) {
59
+ b.currentCtx = makeShimmedCtx(ctx);
60
+ }
70
61
  void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
71
62
  });
72
63
  pi.on('session_shutdown', (event, _ctx) => {
73
64
  if (event.reason === 'quit') {
74
65
  if (S.server) {
66
+ const port = S.server.port;
75
67
  S.server.stop();
76
68
  S.server = null;
69
+ S.serveResult = null;
70
+ void teardownTailscaleServe(port).catch(() => { });
77
71
  }
78
72
  S.send = null;
79
73
  }
@@ -83,8 +77,11 @@ export function registerRemote(pi) {
83
77
  handler: async (args, ctx) => {
84
78
  if (args.trim() === 'stop') {
85
79
  if (S.server) {
80
+ const port = S.server.port;
86
81
  S.server.stop();
87
82
  S.server = null;
83
+ S.serveResult = null;
84
+ void teardownTailscaleServe(port).catch(() => { });
88
85
  ctx.ui.notify('Remote server stopped', 'info');
89
86
  }
90
87
  else {
@@ -92,26 +89,27 @@ export function registerRemote(pi) {
92
89
  }
93
90
  return;
94
91
  }
95
- // Capture this command-capable ctx so remote slash commands and /new
96
- // work (the always-on auto-start can only bind plain chat).
92
+ // Upgrade from shimmed ctx to a real command-capable ctx.
97
93
  getBridge().currentCtx = ctx;
98
94
  try {
99
95
  const server = await ensureServer();
100
- // QR keeps encoding only the primary (Tailscale-preferred) URL.
101
- const primaryUrl = `http://${server.ip}:${server.port}`;
96
+ const httpPrimary = `http://${server.ip}:${server.port}`;
97
+ const result = S.serveResult ?? { state: 'unavailable' };
98
+ const plan = planRemoteUrls(httpPrimary, result);
99
+ const primaryUrl = plan.primaryUrl;
102
100
  const qr = await qrLines(primaryUrl);
103
- // Labeled, block-aligned address lines: Tailscale + LAN when present.
104
- const addrs = formatAddresses(server.ips, server.port);
101
+ const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
105
102
  const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
106
- const addrLines = addrs.map(a => a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url);
103
+ const addrLines = [
104
+ ...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
105
+ ...plan.hintLines
106
+ ];
107
107
  const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
108
108
  if (ctx.mode === 'tui') {
109
109
  // eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
110
110
  const stripAnsi = (s) => s.replace(/\x1b\[[^m]*m/g, '');
111
111
  const visWidth = qr.reduce((max, l) => Math.max(max, stripAnsi(l).length), 0);
112
112
  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
113
  ctx.ui
116
114
  .custom((_tui, _theme, _kb, done) => ({
117
115
  focused: false,
@@ -95,6 +95,9 @@ export async function startServer(onMessage, getHtml, getHistory) {
95
95
  }
96
96
  if (bridge.activePrompt)
97
97
  sendTo(ws, bridge.activePrompt);
98
+ if (bridge.lastContextUsage) {
99
+ sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
100
+ }
98
101
  broadcast({ type: 'client_count', count: clientCount() });
99
102
  ws.on('message', data => {
100
103
  let msg;
@@ -0,0 +1,45 @@
1
+ /** Injectable command runner so serve orchestration is unit-testable without a real CLI. */
2
+ export interface Run {
3
+ (cmd: string, args: string[]): Promise<{
4
+ stdout: string;
5
+ stderr: string;
6
+ exitCode: number;
7
+ }>;
8
+ }
9
+ export type ServeResult = {
10
+ state: 'served';
11
+ url: string;
12
+ } | {
13
+ state: 'foreign-conflict';
14
+ host: string;
15
+ } | {
16
+ state: 'certs-disabled';
17
+ host: string;
18
+ } | {
19
+ state: 'unavailable';
20
+ };
21
+ export interface RemoteUrlPlan {
22
+ /** URL to encode in the QR and announce; the https one when serve is live. */
23
+ primaryUrl: string;
24
+ /** Labeled URL lines to add to the address list (e.g. the HTTPS URL when served). */
25
+ urlLines: {
26
+ label: string;
27
+ url: string;
28
+ }[];
29
+ /** Extra lines to print under the URLs (empty when nothing to suggest). */
30
+ hintLines: string[];
31
+ }
32
+ /** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
33
+ * or undefined when there is no such handler / the output isn't valid serve JSON
34
+ * (e.g. the literal "No serve config"). */
35
+ export declare function serve443Target(json: string): string | undefined;
36
+ /** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
37
+ * when needed and never touches a serve config we didn't create. Best-effort:
38
+ * returns a non-served state rather than throwing. */
39
+ export declare function ensureTailscaleServe(port: number, run?: Run): Promise<ServeResult>;
40
+ /** Tear down the :443 serve handler ONLY if it currently points at our port.
41
+ * Best-effort; callers should additionally `.catch()` since it may run during
42
+ * shutdown. */
43
+ export declare function teardownTailscaleServe(port: number, run?: Run): Promise<void>;
44
+ /** Pure: pick the primary URL and any hint lines from a serve result. */
45
+ export declare function planRemoteUrls(httpPrimary: string, result: ServeResult): RemoteUrlPlan;
@@ -0,0 +1,108 @@
1
+ import { execFile } from 'node:child_process';
2
+ const defaultRun = (cmd, args) => new Promise(resolve => {
3
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
4
+ const exitCode = err && typeof err.code === 'number' ?
5
+ err.code
6
+ : err ? 1
7
+ : 0;
8
+ resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
9
+ });
10
+ });
11
+ /** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
12
+ * or undefined when there is no such handler / the output isn't valid serve JSON
13
+ * (e.g. the literal "No serve config"). */
14
+ export function serve443Target(json) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(json);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ const web = parsed
23
+ .Web;
24
+ if (!web)
25
+ return undefined;
26
+ for (const key of Object.keys(web)) {
27
+ if (key.endsWith(':443'))
28
+ return web[key]?.Handlers?.['/']?.Proxy;
29
+ }
30
+ return undefined;
31
+ }
32
+ /** Tailscale MagicDNS name (trailing dot stripped), or undefined if the daemon
33
+ * isn't reachable / has no name. */
34
+ async function tailscaleHost(run) {
35
+ const status = await run('tailscale', ['status', '--json']);
36
+ if (status.exitCode !== 0)
37
+ return undefined;
38
+ try {
39
+ const parsed = JSON.parse(status.stdout);
40
+ return (parsed.Self?.DNSName ?? '').replace(/\.$/, '') || undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
47
+ * when needed and never touches a serve config we didn't create. Best-effort:
48
+ * returns a non-served state rather than throwing. */
49
+ export async function ensureTailscaleServe(port, run = defaultRun) {
50
+ const host = await tailscaleHost(run);
51
+ if (!host)
52
+ return { state: 'unavailable' };
53
+ const ours = `http://127.0.0.1:${port}`;
54
+ const serve = await run('tailscale', ['serve', 'status', '--json']);
55
+ const target = serve443Target(serve.stdout);
56
+ if (target === ours)
57
+ return { state: 'served', url: `https://${host}` };
58
+ if (target !== undefined)
59
+ return { state: 'foreign-conflict', host };
60
+ // No :443 handler yet — check cert capability before trying to create one.
61
+ const cert = await run('tailscale', ['cert']);
62
+ if (/HTTPS cert support is not enabled/i.test(cert.stdout + cert.stderr)) {
63
+ return { state: 'certs-disabled', host };
64
+ }
65
+ const set = await run('tailscale', ['serve', '--bg', '--https=443', ours]);
66
+ if (set.exitCode === 0)
67
+ return { state: 'served', url: `https://${host}` };
68
+ return { state: 'certs-disabled', host };
69
+ }
70
+ /** Tear down the :443 serve handler ONLY if it currently points at our port.
71
+ * Best-effort; callers should additionally `.catch()` since it may run during
72
+ * shutdown. */
73
+ export async function teardownTailscaleServe(port, run = defaultRun) {
74
+ const serve = await run('tailscale', ['serve', 'status', '--json']);
75
+ if (serve443Target(serve.stdout) === `http://127.0.0.1:${port}`) {
76
+ await run('tailscale', ['serve', '--https=443', 'off']);
77
+ }
78
+ }
79
+ /** Pure: pick the primary URL and any hint lines from a serve result. */
80
+ export function planRemoteUrls(httpPrimary, result) {
81
+ switch (result.state) {
82
+ case 'served':
83
+ return {
84
+ primaryUrl: result.url,
85
+ urlLines: [{ label: 'HTTPS', url: result.url }],
86
+ hintLines: []
87
+ };
88
+ case 'foreign-conflict':
89
+ return {
90
+ primaryUrl: httpPrimary,
91
+ urlLines: [],
92
+ hintLines: [
93
+ 'HTTPS: port 443 is already used by another tailscale serve config; not touching it.',
94
+ ' Free it (tailscale serve --https=443 off) to enable phone notifications.'
95
+ ]
96
+ };
97
+ case 'certs-disabled':
98
+ return {
99
+ primaryUrl: httpPrimary,
100
+ urlLines: [],
101
+ hintLines: [
102
+ 'HTTPS (for phone notifications): enable HTTPS in the Tailscale admin console, then restart the remote.'
103
+ ]
104
+ };
105
+ case 'unavailable':
106
+ return { primaryUrl: httpPrimary, urlLines: [], hintLines: [] };
107
+ }
108
+ }
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;
@@ -38,7 +38,7 @@ export function html(wsUrl) {
38
38
  font-family: ui-monospace, monospace; height: 100dvh;
39
39
  display: flex; flex-direction: column; overflow: hidden;
40
40
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
41
- env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
41
+ 0px env(safe-area-inset-left, 0px);
42
42
  }
43
43
  #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }
44
44
  #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }
@@ -47,10 +47,29 @@ export function html(wsUrl) {
47
47
  display: flex; justify-content: space-between; align-items: center;
48
48
  font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);
49
49
  }
50
- #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em; }
51
- #header .status { color: var(--subtext0); font-size: 11px; }
50
+ #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
51
+ position: relative; animation: glitch 5s steps(1) infinite; }
52
+ @keyframes glitch {
53
+ 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }
54
+ 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }
55
+ 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }
56
+ 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }
57
+ 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }
58
+ }
59
+ @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }
60
+ #header .status { color: var(--subtext0); font-size: 11px; display: inline-flex; align-items: center; gap: 5px; }
61
+ #header .cdot { color: var(--yellow); }
62
+ #header .cdot.up { color: var(--green); }
63
+ #header .cdot.down { color: var(--red); }
64
+ #header .hgroup { display: flex; align-items: center; gap: 10px; }
65
+ #bell {
66
+ background: none; border: none; color: var(--subtext1); cursor: pointer;
67
+ font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;
68
+ }
69
+ #bell:hover { color: var(--text); }
70
+ #bell.on { color: var(--mauve); }
52
71
  #chat-log {
53
- flex: 1; overflow-y: auto; padding: 16px;
72
+ flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
54
73
  display: flex; flex-direction: column; gap: 8px;
55
74
  }
56
75
  #chat-log::-webkit-scrollbar { width: 6px; }
@@ -67,17 +86,11 @@ export function html(wsUrl) {
67
86
  max-width: 100%; border: 1px solid var(--red); font-size: 12px;
68
87
  }
69
88
  .bubble.thinking {
70
- display: flex; gap: 5px; align-items: center; padding: 12px 14px;
89
+ display: flex; gap: 5px; align-items: center; padding: 10px 14px;
71
90
  }
72
- .bubble.thinking .dot {
73
- width: 7px; height: 7px; border-radius: 50%; background: var(--subtext0);
74
- animation: thinking-bounce 1.2s infinite ease-in-out both;
75
- }
76
- .bubble.thinking .dot:nth-child(1) { animation-delay: -0.24s; }
77
- .bubble.thinking .dot:nth-child(2) { animation-delay: -0.12s; }
78
- @keyframes thinking-bounce {
79
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
80
- 40% { transform: scale(1); opacity: 1; }
91
+ .bubble.thinking .spinner {
92
+ color: var(--mauve); font-size: 15px; line-height: 1;
93
+ font-family: ui-monospace, monospace;
81
94
  }
82
95
  .tool-call {
83
96
  background: var(--crust); border-radius: 6px; align-self: flex-start;
@@ -86,15 +99,17 @@ export function html(wsUrl) {
86
99
  .tool-call summary {
87
100
  padding: 6px 10px; color: var(--subtext1); cursor: pointer;
88
101
  user-select: none; list-style: none;
102
+ overflow-wrap: anywhere; word-break: break-word;
89
103
  }
90
104
  .tool-call summary::-webkit-details-marker { display: none; }
91
105
  .tool-call summary::before { content: "▶ "; }
92
106
  .tool-call[open] > summary::before { content: "▼ "; }
93
107
  .tool-call.error > summary { color: var(--red); }
94
108
  .tool-call pre {
95
- padding: 8px 12px; overflow-x: auto; overflow-y: auto;
109
+ padding: 8px 12px; overflow-y: auto;
96
110
  color: var(--subtext1); font-size: 11px; max-height: 280px;
97
111
  border-top: 1px solid var(--surface0);
112
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
98
113
  }
99
114
  .code-block {
100
115
  background: var(--crust); border: 1px solid var(--surface0);
@@ -115,7 +130,7 @@ export function html(wsUrl) {
115
130
  .hl-num { color: var(--blue); }
116
131
  .hl-fn { color: var(--yellow); }
117
132
  #input-bar {
118
- background: var(--mantle); padding: 10px 16px;
133
+ background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
119
134
  display: flex; gap: 8px; flex-shrink: 0;
120
135
  border-top: 1px solid var(--surface0);
121
136
  position: relative;
@@ -165,26 +180,53 @@ export function html(wsUrl) {
165
180
  #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
166
181
  color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
167
182
  #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; }
183
+ border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
184
+ display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }
185
+ #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;
186
+ text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }
187
+ #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;
188
+ font-size: 15px; line-height: 1.5; }
189
+ #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);
190
+ border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
191
+ #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;
192
+ text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
193
+ #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;
194
+ white-space: pre-wrap; overflow-wrap: anywhere; }
170
195
  #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; }
174
- .toast { position: fixed; top: 12px; right: 12px; padding: 8px 12px; border-radius: 6px;
196
+ border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
197
+ font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
198
+ #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: center;
199
+ flex-wrap: wrap; }
200
+ #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
201
+ font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
202
+ #prompt-card button:hover { filter: brightness(1.08); }
203
+ #prompt-card button.primary { background: var(--green); color: var(--crust);
204
+ font-weight: 700; flex: 1; min-width: 160px; }
205
+ #prompt-card button.secondary { background: var(--surface1); color: var(--text);
206
+ flex: 1; min-width: 160px; }
207
+ #prompt-card button.cancel { margin-left: auto; background: transparent; color: var(--subtext0);
208
+ font-size: 12px; font-weight: 500; padding: 8px 10px; }
209
+ #prompt-card button.cancel:hover { color: var(--red); filter: none; }
210
+ #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
211
+ .toast { position: fixed; top: 12px; right: 12px; max-width: calc(100vw - 24px);
212
+ padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
175
213
  background: var(--surface1); color: var(--text); z-index: 60; }
176
214
  .toast.warning { background: var(--peach); color: var(--crust); }
177
215
  .toast.error { background: var(--red); color: var(--crust); }
178
216
  #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; }
217
+ border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
218
+ overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
180
219
  #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
181
220
  </style>
182
221
  </head>
183
222
  <body>
184
223
  <div id="context-bar"><div id="context-bar-fill"></div></div>
185
224
  <div id="header">
186
- <span class="title">pi remote</span>
187
- <span class="status" id="client-status">connecting…</span>
225
+ <span class="title">pi-task remote</span>
226
+ <div class="hgroup">
227
+ <span class="status" id="client-status"><span class="cdot" id="conn-dot">&#x25CB;</span><span id="client-label">connecting&#x2026;</span></span>
228
+ <button id="bell" aria-label="Toggle notifications" title="Notifications">&#x25EF;</button>
229
+ </div>
188
230
  </div>
189
231
  <div id="chat-log"></div>
190
232
  <div id="status-panel"></div>
@@ -195,8 +237,13 @@ export function html(wsUrl) {
195
237
  </div>
196
238
  <div id="reconnect-overlay"><span id="reconnect-msg">reconnecting…</span></div>
197
239
  <div id="prompt-card">
240
+ <div class="q-label">pi needs your input</div>
198
241
  <div class="q" id="prompt-q"></div>
199
- <textarea id="prompt-input" rows="2"></textarea>
242
+ <div class="rec-panel" id="prompt-rec" style="display:none">
243
+ <div class="rec-label">Recommended answer</div>
244
+ <div class="rec-text" id="prompt-rec-text"></div>
245
+ </div>
246
+ <textarea id="prompt-input" rows="3" placeholder="Type your answer…" style="display:none"></textarea>
200
247
  <div class="row" id="prompt-buttons"></div>
201
248
  </div>
202
249
  <div id="viewer"><span class="close" id="viewer-close">&#x2715;</span><div id="viewer-body"></div></div>
@@ -213,19 +260,33 @@ export function html(wsUrl) {
213
260
  const inputEl = document.getElementById('input');
214
261
  const sendBtn = document.getElementById('send');
215
262
  const contextFill = document.getElementById('context-bar-fill');
216
- const clientStatus = document.getElementById('client-status');
263
+ function setContextBar(usage) {
264
+ if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
265
+ }
266
+ const connDot = document.getElementById('conn-dot');
267
+ const clientLabel = document.getElementById('client-label');
268
+ // state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
269
+ function setConn(state, label) {
270
+ connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
271
+ connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
272
+ if (label !== undefined) clientLabel.textContent = label;
273
+ }
217
274
  const reconnectOverlay = document.getElementById('reconnect-overlay');
218
275
  const reconnectMsg = document.getElementById('reconnect-msg');
219
276
  const cmdSuggestions = document.getElementById('cmd-suggestions');
220
277
  const statusPanel = document.getElementById('status-panel');
221
278
  const promptCard = document.getElementById('prompt-card');
222
279
  const promptQ = document.getElementById('prompt-q');
280
+ const promptRec = document.getElementById('prompt-rec');
281
+ const promptRecText = document.getElementById('prompt-rec-text');
223
282
  const promptInput = document.getElementById('prompt-input');
224
283
  const promptButtons = document.getElementById('prompt-buttons');
225
284
  const viewer = document.getElementById('viewer');
226
285
  const viewerBody = document.getElementById('viewer-body');
227
286
  document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
228
287
  let activePromptId = null;
288
+ let activeRecommended = '';
289
+ let cancelArmTimer = null;
229
290
  const toolCallMap = {};
230
291
  let currentBubble = null;
231
292
  let streamText = '';
@@ -411,16 +472,28 @@ export function html(wsUrl) {
411
472
  }
412
473
 
413
474
  let thinkingEl = null;
475
+ let spinTimer = null;
476
+ let spinIdx = 0;
477
+ const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
414
478
  function showThinking() {
415
479
  if (!thinkingEl) {
416
480
  thinkingEl = document.createElement('div');
417
481
  thinkingEl.className = 'bubble assistant thinking';
418
- thinkingEl.innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
482
+ thinkingEl.innerHTML = '<span class="spinner"></span>';
483
+ }
484
+ if (!spinTimer) {
485
+ var sp = thinkingEl.firstChild;
486
+ sp.textContent = SPIN[spinIdx % SPIN.length];
487
+ spinTimer = setInterval(function () {
488
+ spinIdx = (spinIdx + 1) % SPIN.length;
489
+ sp.textContent = SPIN[spinIdx];
490
+ }, 90);
419
491
  }
420
492
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
421
493
  scrollBottom();
422
494
  }
423
495
  function hideThinking() {
496
+ if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
424
497
  if (thinkingEl) thinkingEl.remove();
425
498
  }
426
499
 
@@ -450,6 +523,61 @@ export function html(wsUrl) {
450
523
  setTimeout(function () { t.remove(); }, 4000);
451
524
  }
452
525
 
526
+ const bell = document.getElementById('bell');
527
+ const NOTIFY_KEY = 'piRemoteNotify';
528
+
529
+ function notifyEnabled() {
530
+ return localStorage.getItem(NOTIFY_KEY) === '1'
531
+ && typeof Notification !== 'undefined'
532
+ && Notification.permission === 'granted';
533
+ }
534
+
535
+ function updateBell() {
536
+ // ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
537
+ var on = notifyEnabled();
538
+ bell.textContent = on ? '\\u25C9' : '\\u25EF';
539
+ bell.classList.toggle('on', on);
540
+ }
541
+
542
+ // Why notifications can't be enabled here, or null if they can.
543
+ function notifyEnvIssue() {
544
+ if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
545
+ if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
546
+ const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
547
+ const standalone = navigator.standalone === true
548
+ || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
549
+ if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
550
+ return null;
551
+ }
552
+
553
+ bell.addEventListener('click', function () {
554
+ // Turning OFF always works regardless of environment.
555
+ if (localStorage.getItem(NOTIFY_KEY) === '1') {
556
+ localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
557
+ }
558
+ const issue = notifyEnvIssue();
559
+ if (issue) { showToast(issue, 'warning'); return; }
560
+ Notification.requestPermission().then(function (perm) {
561
+ if (perm === 'granted') { localStorage.setItem(NOTIFY_KEY, '1'); }
562
+ else { showToast('Notifications blocked in browser settings.', 'warning'); }
563
+ updateBell();
564
+ });
565
+ });
566
+
567
+ // Fire a browser notification, but only when armed, in a secure context,
568
+ // and the tab is backgrounded (foreground already has the in-page UI).
569
+ function notify(title, body, tag) {
570
+ if (!notifyEnabled()) return;
571
+ if (!window.isSecureContext) return;
572
+ if (!document.hidden) return;
573
+ try {
574
+ const n = new Notification(title, { body: body || '', tag: tag });
575
+ n.onclick = function () { window.focus(); n.close(); };
576
+ } catch (e) { /* constructor unsupported (e.g. iOS without SW) */ }
577
+ }
578
+
579
+ updateBell();
580
+
453
581
  function answer(value) {
454
582
  if (activePromptId === null) return;
455
583
  ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
@@ -460,33 +588,93 @@ export function html(wsUrl) {
460
588
  activePromptId = null;
461
589
  promptCard.style.display = 'none';
462
590
  promptInput.value = '';
591
+ promptInput.style.display = 'none';
592
+ promptRec.style.display = 'none';
593
+ if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
463
594
  setEnabled(true);
464
595
  }
465
596
 
466
- function makeBtn(label, bg, onClick) {
597
+ function makeBtn(label, cls, onClick) {
467
598
  const btn = document.createElement('button');
468
599
  btn.textContent = label;
469
- btn.style.background = bg;
600
+ if (cls) btn.className = cls;
470
601
  btn.onclick = onClick;
471
602
  return btn;
472
603
  }
473
604
 
605
+ // "Cancel task" aborts the whole run, so it's deliberately small and needs a
606
+ // two-step confirm: first tap arms it, second tap (within 3s) confirms.
607
+ function makeCancelBtn() {
608
+ const btn = makeBtn('Cancel task', 'cancel', null);
609
+ let armed = false;
610
+ btn.onclick = function () {
611
+ if (armed) { answer(undefined); return; }
612
+ armed = true;
613
+ btn.classList.add('armed');
614
+ btn.textContent = 'Tap again to cancel';
615
+ if (cancelArmTimer) clearTimeout(cancelArmTimer);
616
+ cancelArmTimer = setTimeout(function () {
617
+ armed = false;
618
+ btn.classList.remove('armed');
619
+ btn.textContent = 'Cancel task';
620
+ cancelArmTimer = null;
621
+ }, 3000);
622
+ };
623
+ return btn;
624
+ }
625
+
626
+ function renderButtons(buttons) {
627
+ promptButtons.innerHTML = '';
628
+ for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
629
+ promptButtons.appendChild(makeCancelBtn());
630
+ }
631
+
632
+ // Manual-entry view: editable textarea + Submit, reachable from the
633
+ // recommendation view via "Answer manually".
634
+ function showManualEntry(prefill) {
635
+ promptRec.style.display = 'none';
636
+ promptInput.style.display = 'block';
637
+ promptInput.value = prefill || '';
638
+ renderButtons([
639
+ makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); }),
640
+ makeBtn('← Back', 'secondary', function () { showRecommendation(); })
641
+ ]);
642
+ promptInput.focus();
643
+ }
644
+
645
+ // Recommendation view (Mode A): show the suggested answer and let the user
646
+ // accept it in one tap or switch to manual entry.
647
+ function showRecommendation() {
648
+ promptInput.style.display = 'none';
649
+ promptRec.style.display = 'block';
650
+ renderButtons([
651
+ makeBtn('✓ Accept recommended', 'primary', function () { answer(''); }),
652
+ makeBtn('✎ Answer manually', 'secondary', function () { showManualEntry(activeRecommended); })
653
+ ]);
654
+ }
655
+
474
656
  function showPrompt(msg) {
475
657
  activePromptId = msg.id;
476
658
  promptQ.textContent = msg.question;
477
- promptInput.value = msg.recommended || '';
478
- promptButtons.innerHTML = '';
479
- promptButtons.appendChild(makeBtn('Submit', 'var(--green)', function () { answer(promptInput.value); }));
659
+ activeRecommended = msg.recommended || '';
480
660
  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(''); }));
661
+ // Mode A: there's a recommendation lead with "Accept recommended".
662
+ promptRecText.textContent = msg.recommended;
663
+ showRecommendation();
664
+ } else {
665
+ // Mode B: no recommendation — the user must type an answer (or skip).
666
+ promptRec.style.display = 'none';
667
+ promptInput.style.display = 'block';
668
+ promptInput.value = '';
669
+ const buttons = [makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); })];
670
+ if (msg.allowSkip) {
671
+ buttons.push(makeBtn('Skip question', 'secondary', function () { answer(''); }));
672
+ }
673
+ renderButtons(buttons);
674
+ promptInput.focus();
485
675
  }
486
- promptButtons.appendChild(makeBtn('Cancel task', 'var(--red)', function () { answer(undefined); }));
487
676
  promptCard.style.display = 'block';
488
677
  setEnabled(false);
489
- promptInput.focus();
490
678
  }
491
679
 
492
680
  function handleMsg(msg) {
@@ -570,6 +758,7 @@ export function html(wsUrl) {
570
758
  streamText = '';
571
759
  }
572
760
  addBubble('error', msg.message || 'Error');
761
+ notify('Agent error', msg.message || 'Error', 'pi-error');
573
762
  setEnabled(true);
574
763
  break;
575
764
  case 'agent_end':
@@ -577,15 +766,19 @@ export function html(wsUrl) {
577
766
  currentBubble = null;
578
767
  streamText = '';
579
768
  setEnabled(true);
580
- if (msg.contextUsage && msg.contextUsage.percent != null) {
581
- contextFill.style.width = msg.contextUsage.percent + '%';
582
- }
769
+ notify('Task finished', '', 'pi-end');
770
+ setContextBar(msg.contextUsage);
771
+ break;
772
+ case 'context':
773
+ // Seeds the bar for a client that joined mid-session.
774
+ setContextBar(msg.contextUsage);
583
775
  break;
584
776
  case 'client_count':
585
- clientStatus.textContent = msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected';
777
+ setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
586
778
  break;
587
779
  case 'prompt':
588
780
  showPrompt(msg);
781
+ notify('pi needs your input', msg.question, 'pi-prompt');
589
782
  break;
590
783
  case 'prompt_resolved':
591
784
  if (activePromptId === msg.id) closePrompt();
@@ -646,7 +839,7 @@ export function html(wsUrl) {
646
839
  ws.addEventListener('open', () => {
647
840
  reconnectOverlay.classList.remove('visible');
648
841
  reconnectDelay = 1000;
649
- clientStatus.textContent = 'connected';
842
+ setConn('up', 'connected');
650
843
  setEnabled(true);
651
844
  });
652
845
  ws.addEventListener('message', (e) => {
@@ -654,6 +847,7 @@ export function html(wsUrl) {
654
847
  });
655
848
  ws.addEventListener('close', () => {
656
849
  setEnabled(false);
850
+ setConn('down', 'disconnected');
657
851
  reconnectOverlay.classList.add('visible');
658
852
  reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
659
853
  setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
@@ -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.6.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",