@mjasnikovs/pi-task 0.20.2 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,14 @@ export interface NotifyMessage {
40
40
  message: string;
41
41
  level: 'info' | 'warning' | 'error';
42
42
  }
43
+ /** Lines the user typed while a task run owned the session, waiting for the next
44
+ * task turn. Shown in the composer so a held message is never a silent one. */
45
+ export interface HeldMessage {
46
+ type: 'held';
47
+ texts: string[];
48
+ /** True while a task run owns the session (drives the composer placeholder). */
49
+ runActive: boolean;
50
+ }
43
51
  export interface ViewerMessage {
44
52
  type: 'viewer';
45
53
  title: string;
@@ -67,7 +75,7 @@ export type { SnapshotMessage } from './session-state.js';
67
75
  /** Server → browser messages. The live text_delta / tool_* / agent_* /
68
76
  * user_message deltas are emitted by the SessionState mutators
69
77
  * and not all enumerated here; the snapshot below carries the full state. */
70
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
78
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | HeldMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
71
79
  /** Browser → server messages. */
72
80
  export interface ClientChatMessage {
73
81
  type: 'message';
@@ -82,5 +90,9 @@ export interface ClientPromptAnswer {
82
90
  export interface ClientInterrupt {
83
91
  type: 'interrupt';
84
92
  }
85
- export type ClientMessage = ClientChatMessage | ClientPromptAnswer | ClientInterrupt;
93
+ /** Drop everything held for the next task turn (the composer's ✕). */
94
+ export interface ClientClearHeld {
95
+ type: 'clear_held';
96
+ }
97
+ export type ClientMessage = ClientChatMessage | ClientPromptAnswer | ClientInterrupt | ClientClearHeld;
86
98
  export declare function isClientMessage(x: unknown): x is ClientMessage;
@@ -8,6 +8,8 @@ export function isClientMessage(x) {
8
8
  return typeof m.text === 'string';
9
9
  if (m.type === 'interrupt')
10
10
  return true;
11
+ if (m.type === 'clear_held')
12
+ return true;
11
13
  if (m.type === 'prompt_answer') {
12
14
  return typeof m.id === 'string' && (m.value === undefined || typeof m.value === 'string');
13
15
  }
@@ -1,2 +1,10 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * Where a plain (non-slash) browser line goes. Exported so the decision is
4
+ * tested as SHIPPED rather than as a copy — the three branches are the whole of
5
+ * issue #8, and the middle one is the regression.
6
+ */
7
+ export declare function routePlainLine(plain: string, send: (text: string, opts?: {
8
+ deliverAs: 'steer' | 'followUp';
9
+ }) => void): void;
2
10
  export declare function registerRemote(pi: ExtensionAPI): void;
@@ -1,16 +1,40 @@
1
1
  import { getConfig } from '../config/config.js';
2
2
  import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify } from './bridge.js';
3
3
  import { setupEvents } from './events.js';
4
- import { reset, addUserTurn } from './session-state.js';
4
+ import { reset, addUserTurn, setHeld } from './session-state.js';
5
5
  import { html } from './ui.js';
6
6
  import { qrLines } from './qr.js';
7
7
  import { startServer, formatAddresses } from './server.js';
8
8
  import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls, hostFromResult } from './tailscale.js';
9
9
  import { isAgentIdle } from './state.js';
10
+ import { holdInput, isRunActive, clearHeldInput, heldInput, setHeldInputListener } from '../task/mid-run-input.js';
10
11
  const _g = globalThis;
11
12
  if (!_g.__piRemote)
12
13
  _g.__piRemote = { server: null, send: null, serveResult: null };
13
14
  const S = _g.__piRemote;
15
+ /**
16
+ * Where a plain (non-slash) browser line goes. Exported so the decision is
17
+ * tested as SHIPPED rather than as a copy — the three branches are the whole of
18
+ * issue #8, and the middle one is the regression.
19
+ */
20
+ export function routePlainLine(plain, send) {
21
+ addUserTurn(plain);
22
+ if (!isAgentIdle()) {
23
+ // A live turn: steer it (inject into the current generation) so the
24
+ // nudge lands immediately.
25
+ send(plain, { deliverAs: 'steer' });
26
+ }
27
+ else if (isRunActive()) {
28
+ // Idle, but a task run owns the session — which is most of a run, since
29
+ // the spec phases and every gate are child processes. Sending here opens
30
+ // a SECOND turn beside the run; that is what killed a live run on pi
31
+ // 0.82.1. Hold it for the next task turn instead.
32
+ holdInput(plain);
33
+ }
34
+ else {
35
+ send(plain);
36
+ }
37
+ }
14
38
  export function registerRemote(pi) {
15
39
  async function ensureServer() {
16
40
  if (S.server)
@@ -27,21 +51,9 @@ export function registerRemote(pi) {
27
51
  return;
28
52
  }
29
53
  dispatchRemoteLine(text, {
30
- onPlain: plain => {
31
- addUserTurn(plain);
32
- if (isAgentIdle()) {
33
- S.send?.(plain);
34
- }
35
- else {
36
- // Mid-run: steer the live turn (inject the message into the
37
- // current generation) rather than queueing it for after, so a
38
- // remote nudge lands immediately — matching the composer's
39
- // "delivered mid-run" affordance.
40
- S.send?.(plain, { deliverAs: 'steer' });
41
- }
42
- }
54
+ onPlain: plain => routePlainLine(plain, (t, opts) => S.send?.(t, opts))
43
55
  });
44
- }, wsUrl => html(wsUrl), interruptAgent);
56
+ }, wsUrl => html(wsUrl), interruptAgent, clearHeldInput);
45
57
  // Hands-off HTTPS: point Tailscale serve at our port so phones get a
46
58
  // secure context. Best-effort — any failure degrades to the http URL.
47
59
  S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
@@ -55,6 +67,8 @@ export function registerRemote(pi) {
55
67
  const bridge = getBridge();
56
68
  reset();
57
69
  setupEvents(pi);
70
+ // Mirror held mid-run input into the browser composer.
71
+ setHeldInputListener(() => setHeld(heldInput(), isRunActive()));
58
72
  // Seed a shimmed ctx so commands that don't need newSession (/task-list,
59
73
  // /task-cancel, /task-auto-cancel) work immediately from the remote without
60
74
  // any terminal interaction. Only overwrite if null or already shimmed —
@@ -42,5 +42,5 @@ export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: st
42
42
  * real server) escapes as an uncaughtException that crashes pi (issue #7).
43
43
  * Retrying the real bind has no probe and no window. */
44
44
  export declare function listenWithRetry(server: import('node:http').Server, start: number, max: number): Promise<number>;
45
- export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void): Promise<ServerHandle>;
45
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void, onClearHeld?: () => void): Promise<ServerHandle>;
46
46
  export {};
@@ -94,7 +94,7 @@ export function listenWithRetry(server, start, max) {
94
94
  server.listen(port, '0.0.0.0');
95
95
  });
96
96
  }
97
- export async function startServer(onMessage, getHtml, onInterrupt) {
97
+ export async function startServer(onMessage, getHtml, onInterrupt, onClearHeld) {
98
98
  const ips = getLocalIPs();
99
99
  const ip = ips.primary;
100
100
  // The bound port isn't known until listenWithRetry succeeds, and wsUrl
@@ -211,6 +211,10 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
211
211
  answerPrompt(msg.id, msg.value);
212
212
  return;
213
213
  }
214
+ if (msg.type === 'clear_held') {
215
+ onClearHeld?.();
216
+ return;
217
+ }
214
218
  // type === 'message': ignore while a prompt is pending (composer is
215
219
  // disabled in the browser; this is the server-side guard).
216
220
  if (getState().prompt)
@@ -18,6 +18,8 @@ export interface SnapshotMessage {
18
18
  prompt: PromptMessage | null;
19
19
  context: ContextUsage | null;
20
20
  model: string | null;
21
+ held: string[];
22
+ heldRunActive: boolean;
21
23
  }
22
24
  interface SessionState {
23
25
  history: HistoryBuffer;
@@ -29,6 +31,13 @@ interface SessionState {
29
31
  context: ContextUsage | null;
30
32
  /** Human-readable active model name (e.g. "Qwen3.6 27B"), for the header chip. */
31
33
  model: string | null;
34
+ /** Lines typed while a task run owned the session, waiting for the next task
35
+ * turn to steer. Mirrors src/task/mid-run-input.ts so the browser can show
36
+ * what is pending instead of leaving the user guessing. */
37
+ held: string[];
38
+ /** True while a task run owns the session, so the composer can say what a
39
+ * typed line will do BEFORE the user types it. */
40
+ heldRunActive: boolean;
32
41
  /** tool start timestamps (ms), keyed by toolCallId — kept off the serialized
33
42
  * parts so it never reaches the client; used only to compute elapsedMs. */
34
43
  toolStarts: Record<string, number>;
@@ -51,6 +60,8 @@ export declare function updateTool(toolCallId: string, partialResult: unknown):
51
60
  export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
52
61
  export declare function agentEnd(context: ContextUsage, model?: string): void;
53
62
  export declare function addUserTurn(text: string): void;
63
+ /** Mirror the held-input list to every browser. */
64
+ export declare function setHeld(texts: string[], runActive: boolean): void;
54
65
  export declare function addError(message: string): void;
55
66
  /** A persistent inline note (committed to the transcript so it survives reconnect)
56
67
  * plus a live delta so connected clients render it immediately. */
@@ -22,6 +22,8 @@ function fresh() {
22
22
  prompt: null,
23
23
  context: null,
24
24
  model: null,
25
+ held: [],
26
+ heldRunActive: false,
25
27
  toolStarts: {},
26
28
  sink: wsBroadcast
27
29
  };
@@ -157,6 +159,13 @@ export function addUserTurn(text) {
157
159
  s.history.addUserMessage(text);
158
160
  s.sink({ type: 'user_message', text });
159
161
  }
162
+ /** Mirror the held-input list to every browser. */
163
+ export function setHeld(texts, runActive) {
164
+ const s = getState();
165
+ s.held = [...texts];
166
+ s.heldRunActive = runActive;
167
+ s.sink({ type: 'held', texts: s.held, runActive });
168
+ }
160
169
  export function addError(message) {
161
170
  const s = getState();
162
171
  s.history.addError(message);
@@ -197,6 +206,8 @@ export function setContext(context) {
197
206
  /** Wipe everything (new session) and tell connected clients to clear. */
198
207
  export function reset() {
199
208
  const s = getState();
209
+ s.held = [];
210
+ s.heldRunActive = false;
200
211
  s.history = new HistoryBuffer(20);
201
212
  s.live = null;
202
213
  s.agentRunning = false;
@@ -220,6 +231,8 @@ export function snapshot() {
220
231
  taskWidgetData: s.taskWidgetData,
221
232
  prompt: s.prompt,
222
233
  context: s.context,
223
- model: s.model
234
+ model: s.model,
235
+ held: [...s.held],
236
+ heldRunActive: s.heldRunActive
224
237
  };
225
238
  }
@@ -13,6 +13,10 @@ export function clientScript(wsUrl) {
13
13
  const inputEl = document.getElementById('input');
14
14
  const sendBtn = document.getElementById('send');
15
15
  const contextFill = document.getElementById('context-bar-fill');
16
+ const heldBar = document.getElementById('held-bar');
17
+ const heldLabel = document.getElementById('held-label');
18
+ const heldText = document.getElementById('held-text');
19
+ const heldClear = document.getElementById('held-clear');
16
20
  function setContextBar(usage) {
17
21
  if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
18
22
  setStatusChip(usage);
@@ -308,6 +312,24 @@ export function clientScript(wsUrl) {
308
312
  s.appendChild(e);
309
313
  }
310
314
 
315
+ // Lines typed while a task run owns the session. They are NOT sent yet: the
316
+ // run's phases are child processes, so sending would open a second turn
317
+ // beside the run. Held here and steered into the next task turn.
318
+ let held = [];
319
+ let runHolding = false;
320
+ function renderHeld() {
321
+ if (!held.length) { heldBar.style.display = 'none'; return; }
322
+ heldBar.style.display = 'flex';
323
+ heldLabel.textContent = held.length === 1
324
+ ? 'waiting for the task turn:'
325
+ : held.length + ' waiting for the task turn:';
326
+ heldText.textContent = held.join(' · ');
327
+ }
328
+ heldClear.addEventListener('click', () => {
329
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
330
+ ws.send(JSON.stringify({ type: 'clear_held' }));
331
+ });
332
+
311
333
  // Reconcile the composer (input + Send/Stop button) with the current state.
312
334
  // The input is disabled ONLY while disconnected or a prompt card is open — a
313
335
  // running agent no longer locks it, so messages can steer the live turn.
@@ -316,8 +338,10 @@ export function clientScript(wsUrl) {
316
338
  const promptOpen = activePromptId !== null;
317
339
  inputEl.disabled = !connected || promptOpen;
318
340
  inputEl.placeholder = agentRunning
319
- ? 'message the agent delivered mid-run'
320
- : 'type a message\\u2026 (/ for commands)';
341
+ ? 'message the agent \\u2014 steers the live turn'
342
+ : (runHolding
343
+ ? 'task running \\u2014 held for the next task turn'
344
+ : 'type a message\\u2026 (/ for commands)');
321
345
  if (agentRunning && !promptOpen) {
322
346
  // Send morphs into a red Stop that interrupts the running turn.
323
347
  sendBtn.classList.add('stop');
@@ -858,6 +882,12 @@ export function clientScript(wsUrl) {
858
882
 
859
883
  function handleMsg(msg) {
860
884
  switch (msg.type) {
885
+ case 'held':
886
+ held = msg.texts || [];
887
+ runHolding = !!msg.runActive;
888
+ renderHeld();
889
+ refreshComposer();
890
+ break;
861
891
  case 'snapshot': {
862
892
  // Authoritative full state on every (re)connect: replace the WHOLE view.
863
893
  // This is what kills duplicated transcript / stale-orphaned widgets —
@@ -878,6 +908,9 @@ export function clientScript(wsUrl) {
878
908
  setModelName(msg.model);
879
909
  if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
880
910
  agentRunning = !!msg.agentRunning;
911
+ held = msg.held || [];
912
+ runHolding = !!msg.heldRunActive;
913
+ renderHeld();
881
914
  turnHadContent = !!(msg.live && msg.live.parts && msg.live.parts.length);
882
915
  if (msg.prompt) showPrompt(msg.prompt);
883
916
  refreshComposer();
@@ -1066,8 +1099,10 @@ export function clientScript(wsUrl) {
1066
1099
  // user_message back to every client (us included), which renders the
1067
1100
  // bubble. Don't render it here too, or the sender sees it twice.
1068
1101
  // Mid-run the message steers the live turn (no state change here); when
1069
- // idle, optimistically show the spinner until agent_start lands.
1070
- if (!agentRunning) showThinking();
1102
+ // idle, optimistically show the spinner until agent_start lands. While a
1103
+ // task run holds the session nothing starts now — the line is held for the
1104
+ // next task turn, so a spinner would be a lie.
1105
+ if (!agentRunning && !runHolding) showThinking();
1071
1106
  }
1072
1107
 
1073
1108
  sendBtn.addEventListener('click', onSendClick);
@@ -1 +1 @@
1
- export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right (the surface bar that used to live on .code-lang). */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps (clamped to 2) \u2014 never truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n /* Fill carries a soft highlight band that sweeps left\u2192right through the mauve,\n so the bar reads as \"working\" even while the step count holds still. The\n gradient tile is 2x the fill width and shifts one full period per cycle,\n so the loop is seamless. Base color stays mauve for reduced-motion. */\n .widget-bar-fill { height: 100%; border-radius: 2px;\n background: linear-gradient(90deg,\n var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,\n var(--mauve) 62%, var(--mauve) 100%);\n background-size: 200% 100%;\n box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);\n animation: bar-flow 2.4s linear infinite;\n transition: width 0.3s ease; }\n @keyframes bar-flow {\n from { background-position: 200% 0; }\n to { background-position: 0% 0; }\n }\n @media (prefers-reduced-motion: reduce) {\n .widget-bar-fill { animation: none; background: var(--mauve); }\n }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n }";
1
+ export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right (the surface bar that used to live on .code-lang). */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n /* Held mid-run input: what you typed is waiting for the next task turn.\n In normal flow, NOT absolutely positioned over the composer \u2014 as an\n overlay it covered the task widget's progress row (caught in a real\n browser against a live /task-auto run). */\n #held-bar {\n display: flex; align-items: center; gap: 8px; flex-shrink: 0;\n background: var(--mantle); border-top: 1px solid var(--surface1);\n padding: 6px 16px; font-size: 12px;\n }\n #held-label { color: var(--yellow); white-space: nowrap; }\n #held-text {\n color: var(--subtext0); overflow: hidden; text-overflow: ellipsis;\n white-space: nowrap; flex: 1;\n }\n #held-clear {\n background: none; border: none; color: var(--subtext0);\n cursor: pointer; font-size: 13px; padding: 0 2px;\n }\n #held-clear:hover { color: var(--red); }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps (clamped to 2) \u2014 never truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n /* Fill carries a soft highlight band that sweeps left\u2192right through the mauve,\n so the bar reads as \"working\" even while the step count holds still. The\n gradient tile is 2x the fill width and shifts one full period per cycle,\n so the loop is seamless. Base color stays mauve for reduced-motion. */\n .widget-bar-fill { height: 100%; border-radius: 2px;\n background: linear-gradient(90deg,\n var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,\n var(--mauve) 62%, var(--mauve) 100%);\n background-size: 200% 100%;\n box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);\n animation: bar-flow 2.4s linear infinite;\n transition: width 0.3s ease; }\n @keyframes bar-flow {\n from { background-position: 200% 0; }\n to { background-position: 0% 0; }\n }\n @media (prefers-reduced-motion: reduce) {\n .widget-bar-fill { animation: none; background: var(--mauve); }\n }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n #held-bar { padding-left: calc((100% - 920px) / 2 + 16px); padding-right: calc((100% - 920px) / 2 + 16px); }\n }";
@@ -260,6 +260,25 @@ export const STYLES = ` :root {
260
260
  border-top: 1px solid var(--surface0);
261
261
  position: relative;
262
262
  }
263
+ /* Held mid-run input: what you typed is waiting for the next task turn.
264
+ In normal flow, NOT absolutely positioned over the composer — as an
265
+ overlay it covered the task widget's progress row (caught in a real
266
+ browser against a live /task-auto run). */
267
+ #held-bar {
268
+ display: flex; align-items: center; gap: 8px; flex-shrink: 0;
269
+ background: var(--mantle); border-top: 1px solid var(--surface1);
270
+ padding: 6px 16px; font-size: 12px;
271
+ }
272
+ #held-label { color: var(--yellow); white-space: nowrap; }
273
+ #held-text {
274
+ color: var(--subtext0); overflow: hidden; text-overflow: ellipsis;
275
+ white-space: nowrap; flex: 1;
276
+ }
277
+ #held-clear {
278
+ background: none; border: none; color: var(--subtext0);
279
+ cursor: pointer; font-size: 13px; padding: 0 2px;
280
+ }
281
+ #held-clear:hover { color: var(--red); }
263
282
  #cmd-suggestions {
264
283
  display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;
265
284
  background: var(--mantle); border: 1px solid var(--surface1);
@@ -410,4 +429,5 @@ export const STYLES = ` :root {
410
429
  #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }
411
430
  #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }
412
431
  #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }
432
+ #held-bar { padding-left: calc((100% - 920px) / 2 + 16px); padding-right: calc((100% - 920px) / 2 + 16px); }
413
433
  }`;
package/dist/remote/ui.js CHANGED
@@ -58,6 +58,11 @@ ${STYLES}
58
58
  <button id="scroll-bottom" aria-label="Scroll to latest" title="Scroll to latest">&#x2193;</button>
59
59
  </div>
60
60
  <div id="status-panel"></div>
61
+ <div id="held-bar" style="display:none">
62
+ <span id="held-label"></span>
63
+ <span id="held-text"></span>
64
+ <button id="held-clear" type="button" title="Discard">&#x2715;</button>
65
+ </div>
61
66
  <div id="input-bar">
62
67
  <div id="cmd-suggestions"></div>
63
68
  <textarea id="input" placeholder="type a message… (/ for commands)" rows="1" disabled></textarea>
@@ -23,6 +23,8 @@ import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom
23
23
  import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
24
24
  import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
25
25
  import { armCancelListener, disarmCancelListener } from './cancel-input.js';
26
+ import { beginRun, endRun } from './mid-run-input.js';
27
+ import { reportDroppedInput } from './dropped-input.js';
26
28
  import { refineExistingFilesBlock } from './phases.js';
27
29
  import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
28
30
  import { pushNotify } from '../remote/push.js';
@@ -1565,6 +1567,7 @@ async function handleTaskAuto(args, ctx) {
1565
1567
  return;
1566
1568
  }
1567
1569
  autoRunning = true;
1570
+ beginRun(); // the whole loop owns the session, not just the task inside it
1568
1571
  // Take delivery of a typed /task-auto-cancel for the WHOLE run, planning
1569
1572
  // included — planning is children too, so the host is not streaming and the
1570
1573
  // ordinary command path cannot reach us.
@@ -1602,6 +1605,7 @@ async function handleTaskAuto(args, ctx) {
1602
1605
  }
1603
1606
  finally {
1604
1607
  autoRunning = false;
1608
+ reportDroppedInput(endRun(), ctx);
1605
1609
  disarmCancelListener();
1606
1610
  }
1607
1611
  }
@@ -1631,6 +1635,7 @@ async function handleTaskAutoResume(args, ctx) {
1631
1635
  const id = candidate.id;
1632
1636
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1633
1637
  autoRunning = true;
1638
+ beginRun(); // the whole loop owns the session, not just the task inside it
1634
1639
  armTerminalCancel(ctx);
1635
1640
  try {
1636
1641
  // Reuse the interrupted run's research-cache id, dropping only the entries whose
@@ -1650,6 +1655,7 @@ async function handleTaskAutoResume(args, ctx) {
1650
1655
  }
1651
1656
  finally {
1652
1657
  autoRunning = false;
1658
+ reportDroppedInput(endRun(), ctx);
1653
1659
  disarmCancelListener();
1654
1660
  }
1655
1661
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Raw-terminal delivery for /task-auto-cancel.
2
+ * Raw-terminal delivery for input typed while a run owns the main loop.
3
3
  *
4
4
  * THE PROBLEM. While a /task-auto run is in flight, the host's interactive main
5
5
  * loop is parked at `await session.prompt("/task-auto …")` and never loops back
@@ -23,10 +23,25 @@
23
23
  * ourselves and `consume` the keystroke so the line is never queued for a
24
24
  * post-run replay of the confusing "no loop is running" message.
25
25
  *
26
- * Nothing else is intercepted: every other keystroke is passed straight through,
27
- * so typing, history, and the ESC/steer path are untouched. Deliberately no
28
- * bare-key shortcut — ESC already means "interrupt the turn" during the
29
- * implementation turn, and hijacking it would break steerUntilDone.
26
+ * WHAT ELSE IT NOW CARRIES. The same interception makes the terminal behave like
27
+ * the browser for everything else typed mid-run, instead of feeding pi's queue:
28
+ *
29
+ * - a bridge slash command (/task-list, /task-auto-cancel, …) runs immediately,
30
+ * exactly as dispatchRemoteLine runs it for a browser;
31
+ * - a plain line is HELD (src/task/mid-run-input.ts) and steered into the next
32
+ * task turn, rather than starting a competing turn or being replayed after
33
+ * the run.
34
+ *
35
+ * Three things are deliberately NOT intercepted, and each would be a regression:
36
+ * - a keystroke that is not a submit, so typing and history are untouched;
37
+ * - anything typed while a prompt/dialog is open (the raw listener sees keys
38
+ * BEFORE the focused component, so swallowing here would eat the user's
39
+ * answer to a clarify question or the ESC-steer input);
40
+ * - a submit while the agent is streaming — pi's own submit path already
41
+ * steers the live turn, which is the behaviour we want.
42
+ *
43
+ * Deliberately no bare-key shortcut — ESC already means "interrupt the turn"
44
+ * during the implementation turn, and hijacking it would break steerUntilDone.
30
45
  */
31
46
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
32
47
  /**
@@ -45,17 +60,26 @@ export declare function isCancelSubmission(data: string, editorText: string): bo
45
60
  * Prefer the arm/rearm/disarm trio below over calling this directly: the
46
61
  * listener does not survive a session replacement.
47
62
  */
48
- export declare function installCancelListener(ctx: ExtensionCommandContext, onCancel: (live: ExtensionCommandContext) => void): () => void;
49
- /** Begin listening for a typed /task-auto-cancel. Replaces any previous arm. */
50
- export declare function armCancelListener(ctx: ExtensionCommandContext, onCancel: (live: ExtensionCommandContext) => void): void;
63
+ export declare function installCancelListener(ctx: ExtensionCommandContext, onCancel?: (live: ExtensionCommandContext) => void): () => void;
64
+ /**
65
+ * Begin intercepting mid-run terminal input. Refcounted, because runs nest:
66
+ * /task-auto arms for its loop and every task inside it arms again, so a plain
67
+ * "replace" would let the first inner run's end silently un-arm the rest of the
68
+ * loop. The first `onCancel` wins — /task-auto passes one so a typed
69
+ * /task-auto-cancel can post its acknowledgement through the live ctx; a plain
70
+ * /task passes none and lets the generic bridge dispatch handle that command
71
+ * like any other.
72
+ */
73
+ export declare function armCancelListener(ctx: ExtensionCommandContext, onCancel?: (live: ExtensionCommandContext) => void): void;
51
74
  /**
52
75
  * Re-point the armed listener at a replacement ctx. A no-op when nothing is
53
- * armed, so the runner can call it unconditionally on every session swap
54
- * (including for a plain /task, which has no cancel listener of its own).
76
+ * armed, so the runner can call it unconditionally on every session swap.
55
77
  */
56
78
  export declare function rearmCancelListener(ctx: ExtensionCommandContext): void;
57
- /** Stop listening, so a cancel typed after the run goes back through the
58
- * ordinary command path (which then reports there is no loop running). */
79
+ /** Stop listening once the outermost run ends, so input typed after the run goes
80
+ * back through pi's ordinary path. */
59
81
  export declare function disarmCancelListener(): void;
82
+ /** Force-disarm regardless of depth (tests, and hard teardown). */
83
+ export declare function forceDisarmCancelListener(): void;
60
84
  /** Whether a listener is currently armed (tests). */
61
85
  export declare function isCancelListenerArmed(): boolean;
@@ -1,3 +1,6 @@
1
+ import { holdInput, isRunActive } from './mid-run-input.js';
2
+ import { getBridge } from '../remote/bridge.js';
3
+ import { getState } from '../remote/session-state.js';
1
4
  /** The command this listener delivers. Accepts a leading slash only — matching
2
5
  * bare "task-auto-cancel" would fire on prose about the command. */
3
6
  const CANCEL_RE = /^\/task-auto-cancel\s*$/;
@@ -13,6 +16,15 @@ function isSubmitKey(data) {
13
16
  export function isCancelSubmission(data, editorText) {
14
17
  return isSubmitKey(data) && CANCEL_RE.test(editorText.trim());
15
18
  }
19
+ /** Best-effort toast; a failed notification must never break interception. */
20
+ function notify(ctx, message) {
21
+ try {
22
+ ctx.ui.notify(message, 'info');
23
+ }
24
+ catch {
25
+ /* cosmetic only */
26
+ }
27
+ }
16
28
  /**
17
29
  * Watch raw terminal input for a submitted /task-auto-cancel and call `onCancel`
18
30
  * the moment it is typed, however deep in a run we are.
@@ -34,6 +46,8 @@ export function installCancelListener(ctx, onCancel) {
34
46
  let unsubscribe = () => { };
35
47
  try {
36
48
  unsubscribe = ui.onTerminalInput(data => {
49
+ if (!isSubmitKey(data))
50
+ return undefined;
37
51
  let text;
38
52
  try {
39
53
  text = getText();
@@ -43,20 +57,65 @@ export function installCancelListener(ctx, onCancel) {
43
57
  // take the run down with it — fall through to normal handling.
44
58
  return undefined;
45
59
  }
46
- if (!isCancelSubmission(data, text))
60
+ const trimmed = text.trim();
61
+ if (trimmed.length === 0)
47
62
  return undefined;
48
- // Clear what we swallowed, so the editor does not sit there holding a
49
- // command the user believes they submitted.
63
+ const swallow = () => {
64
+ // Clear what we swallowed, so the editor does not sit there
65
+ // holding a line the user believes they submitted.
66
+ try {
67
+ setText?.('');
68
+ }
69
+ catch {
70
+ /* cosmetic only */
71
+ }
72
+ return { consume: true };
73
+ };
74
+ if (CANCEL_RE.test(trimmed) && onCancel) {
75
+ const r = swallow();
76
+ // Hand back the ctx this listener is installed on: after a session
77
+ // replacement the original is stale and using it throws.
78
+ onCancel(ctx);
79
+ return r;
80
+ }
81
+ // A dialog owns the keyboard — this Enter is the user's ANSWER.
82
+ if (getState().prompt !== null)
83
+ return undefined;
84
+ // Outside a run, pi's own handling is correct and unblocked.
85
+ if (!isRunActive())
86
+ return undefined;
87
+ // Streaming: pi's submit path already steers the live turn.
50
88
  try {
51
- setText?.('');
89
+ if (!ctx.isIdle())
90
+ return undefined;
52
91
  }
53
92
  catch {
54
- /* cosmetic only */
93
+ return undefined;
94
+ }
95
+ if (trimmed.startsWith('/')) {
96
+ const space = trimmed.indexOf(' ');
97
+ const name = (space === -1 ? trimmed.slice(1) : trimmed.slice(1, space)).trim();
98
+ const args = space === -1 ? '' : trimmed.slice(space + 1).trim();
99
+ const handler = getBridge().commands.get(name);
100
+ // Unknown to the bridge (a pi builtin like /model): leave it to pi.
101
+ if (!handler)
102
+ return undefined;
103
+ const r = swallow();
104
+ try {
105
+ const result = handler(args, ctx);
106
+ if (result instanceof Promise) {
107
+ result.catch((err) => notify(ctx, `/${name} failed: ${String(err)}`));
108
+ }
109
+ }
110
+ catch (err) {
111
+ notify(ctx, `/${name} failed: ${String(err)}`);
112
+ }
113
+ return r;
55
114
  }
56
- // Hand back the ctx this listener is installed on: after a session
57
- // replacement the original is stale and using it throws.
58
- onCancel(ctx);
59
- return { consume: true };
115
+ const r = swallow();
116
+ holdInput(trimmed);
117
+ notify(ctx, 'Held for the running task — it lands on the next task turn.');
118
+ return r;
60
119
  });
61
120
  }
62
121
  catch {
@@ -85,25 +144,51 @@ export function installCancelListener(ctx, onCancel) {
85
144
  * inside the runner, which must not learn about /task-auto's cancel plumbing.
86
145
  */
87
146
  let armed = null;
88
- /** Begin listening for a typed /task-auto-cancel. Replaces any previous arm. */
147
+ /**
148
+ * Begin intercepting mid-run terminal input. Refcounted, because runs nest:
149
+ * /task-auto arms for its loop and every task inside it arms again, so a plain
150
+ * "replace" would let the first inner run's end silently un-arm the rest of the
151
+ * loop. The first `onCancel` wins — /task-auto passes one so a typed
152
+ * /task-auto-cancel can post its acknowledgement through the live ctx; a plain
153
+ * /task passes none and lets the generic bridge dispatch handle that command
154
+ * like any other.
155
+ */
89
156
  export function armCancelListener(ctx, onCancel) {
90
- disarmCancelListener();
91
- armed = { dispose: installCancelListener(ctx, onCancel), onCancel };
157
+ if (armed) {
158
+ armed.depth++;
159
+ if (!armed.onCancel && onCancel) {
160
+ // Upgrade in place: keep the same install, gain the ack callback.
161
+ armed.dispose();
162
+ armed.dispose = installCancelListener(ctx, onCancel);
163
+ armed.onCancel = onCancel;
164
+ }
165
+ return;
166
+ }
167
+ armed = { dispose: installCancelListener(ctx, onCancel), onCancel, depth: 1 };
92
168
  }
93
169
  /**
94
170
  * Re-point the armed listener at a replacement ctx. A no-op when nothing is
95
- * armed, so the runner can call it unconditionally on every session swap
96
- * (including for a plain /task, which has no cancel listener of its own).
171
+ * armed, so the runner can call it unconditionally on every session swap.
97
172
  */
98
173
  export function rearmCancelListener(ctx) {
99
174
  if (!armed)
100
175
  return;
101
176
  armed.dispose();
102
- armed = { dispose: installCancelListener(ctx, armed.onCancel), onCancel: armed.onCancel };
177
+ armed.dispose = installCancelListener(ctx, armed.onCancel);
103
178
  }
104
- /** Stop listening, so a cancel typed after the run goes back through the
105
- * ordinary command path (which then reports there is no loop running). */
179
+ /** Stop listening once the outermost run ends, so input typed after the run goes
180
+ * back through pi's ordinary path. */
106
181
  export function disarmCancelListener() {
182
+ if (!armed)
183
+ return;
184
+ armed.depth--;
185
+ if (armed.depth > 0)
186
+ return;
187
+ armed.dispose();
188
+ armed = null;
189
+ }
190
+ /** Force-disarm regardless of depth (tests, and hard teardown). */
191
+ export function forceDisarmCancelListener() {
107
192
  armed?.dispose();
108
193
  armed = null;
109
194
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tell the user when mid-run input never reached the agent.
3
+ *
4
+ * A line held for the next task turn (src/task/mid-run-input.ts) normally lands
5
+ * as a steer. When the run ends before any turn starts — a cancel, a failure, a
6
+ * final task with nothing left to implement — the held text has nowhere to go.
7
+ * Delivering it afterwards would recreate the surprise of pi's queue replaying
8
+ * lines into a finished run (issue #8), so it is dropped; dropping it QUIETLY
9
+ * would be the same trap. Both surfaces get told, with the text quoted back so
10
+ * it can be re-sent by hand.
11
+ */
12
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
13
+ export declare function formatDroppedInput(dropped: readonly string[]): string | null;
14
+ export declare function reportDroppedInput(dropped: readonly string[], ctx?: ExtensionCommandContext): void;
@@ -0,0 +1,25 @@
1
+ import { publishNotify } from '../remote/bridge.js';
2
+ /** Trim a held line to something that fits in a toast. */
3
+ function preview(text) {
4
+ const oneLine = text.replace(/\s+/g, ' ').trim();
5
+ return oneLine.length > 80 ? `${oneLine.slice(0, 77)}…` : oneLine;
6
+ }
7
+ export function formatDroppedInput(dropped) {
8
+ if (dropped.length === 0)
9
+ return null;
10
+ const head = preview(dropped[0]);
11
+ const rest = dropped.length > 1 ? ` (+${dropped.length - 1} more)` : '';
12
+ return `The run ended before your message could be delivered: "${head}"${rest}`;
13
+ }
14
+ export function reportDroppedInput(dropped, ctx) {
15
+ const message = formatDroppedInput(dropped);
16
+ if (message === null)
17
+ return;
18
+ try {
19
+ ctx?.ui.notify(message, 'warning');
20
+ }
21
+ catch {
22
+ // A stale ctx after session replacement must not swallow the remote copy.
23
+ }
24
+ publishNotify(message, 'warning');
25
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a typed message DOES while a task run owns the session.
3
+ *
4
+ * Before this module the two surfaces disagreed, and both were wrong whenever the
5
+ * host session was idle — which is most of a run, since the spec phases and every
6
+ * gate are child `pi` processes, not host turns:
7
+ *
8
+ * - browser → `sendUserMessage` with no delivery mode, which starts a SECOND
9
+ * turn alongside the run. Reproduced live on pi 0.82.1 (issue #8):
10
+ * that turn ran the write tool into the project, and when it was
11
+ * still streaming as the pipeline delivered its spec the run died
12
+ * with "TASK_0001 failed: Agent is already processing."
13
+ * - terminal → pi's `pendingUserInputs` queue, drained only by the main loop
14
+ * that our own command handler is parked inside — so the line sat
15
+ * there silently and then fired minutes later against a finished
16
+ * run.
17
+ *
18
+ * The shared rule both surfaces now follow:
19
+ *
20
+ * agent streaming → steer the live turn (unchanged; this half always worked)
21
+ * agent idle, run active → HOLD it here, show it as pending, and deliver it as
22
+ * a steer when the next task turn starts
23
+ * no run active → ordinary message, unchanged
24
+ *
25
+ * Holding is what makes the two surfaces agree AND keeps the run safe: a held
26
+ * line never becomes a competing turn, and it is never silently replayed after
27
+ * the run has finished.
28
+ */
29
+ export declare function setHeldInputListener(fn: (() => void) | null): void;
30
+ /** Mark a task run as owning the session (refcounted; call from a finally). */
31
+ export declare function beginRun(): void;
32
+ /**
33
+ * End a run. Returns anything still held, which the caller MUST report: that
34
+ * text missed its delivery window, and replaying it into a finished run is the
35
+ * exact surprise this module exists to remove — but dropping it silently would
36
+ * be the same trap the terminal queue was.
37
+ */
38
+ export declare function endRun(): string[];
39
+ export declare function isRunActive(): boolean;
40
+ /** For tests: forget all state. */
41
+ export declare function resetMidRunInput(): void;
42
+ /** Hold a line for the next task turn. Blank lines are ignored. */
43
+ export declare function holdInput(text: string): void;
44
+ /** The lines currently waiting (a copy — mutating it must not affect state). */
45
+ export declare function heldInput(): string[];
46
+ export declare function clearHeldInput(): void;
47
+ /**
48
+ * Take everything held, as one steer payload, and clear it. Returns null when
49
+ * nothing is waiting. Clearing BEFORE delivery is deliberate: a delivery that
50
+ * throws must not leave the same text queued to be delivered again on the next
51
+ * turn.
52
+ */
53
+ export declare function takeHeldInput(): string | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What a typed message DOES while a task run owns the session.
3
+ *
4
+ * Before this module the two surfaces disagreed, and both were wrong whenever the
5
+ * host session was idle — which is most of a run, since the spec phases and every
6
+ * gate are child `pi` processes, not host turns:
7
+ *
8
+ * - browser → `sendUserMessage` with no delivery mode, which starts a SECOND
9
+ * turn alongside the run. Reproduced live on pi 0.82.1 (issue #8):
10
+ * that turn ran the write tool into the project, and when it was
11
+ * still streaming as the pipeline delivered its spec the run died
12
+ * with "TASK_0001 failed: Agent is already processing."
13
+ * - terminal → pi's `pendingUserInputs` queue, drained only by the main loop
14
+ * that our own command handler is parked inside — so the line sat
15
+ * there silently and then fired minutes later against a finished
16
+ * run.
17
+ *
18
+ * The shared rule both surfaces now follow:
19
+ *
20
+ * agent streaming → steer the live turn (unchanged; this half always worked)
21
+ * agent idle, run active → HOLD it here, show it as pending, and deliver it as
22
+ * a steer when the next task turn starts
23
+ * no run active → ordinary message, unchanged
24
+ *
25
+ * Holding is what makes the two surfaces agree AND keeps the run safe: a held
26
+ * line never becomes a competing turn, and it is never silently replayed after
27
+ * the run has finished.
28
+ */
29
+ /** Held lines, oldest first. Delivered as one steer at the next turn start. */
30
+ let held = [];
31
+ /** Depth, not a boolean: /task-auto brackets its whole loop and each task inside
32
+ * it brackets its own run, so a plain flag would clear on the first inner end
33
+ * and leave the rest of the loop looking idle. */
34
+ let runDepth = 0;
35
+ /** Fired whenever the held list changes, so the surfaces can re-render. */
36
+ let onChange = null;
37
+ export function setHeldInputListener(fn) {
38
+ onChange = fn;
39
+ }
40
+ /** Mark a task run as owning the session (refcounted; call from a finally). */
41
+ export function beginRun() {
42
+ runDepth++;
43
+ if (runDepth === 1)
44
+ onChange?.(); // surfaces switch to "held" mode
45
+ }
46
+ /**
47
+ * End a run. Returns anything still held, which the caller MUST report: that
48
+ * text missed its delivery window, and replaying it into a finished run is the
49
+ * exact surprise this module exists to remove — but dropping it silently would
50
+ * be the same trap the terminal queue was.
51
+ */
52
+ export function endRun() {
53
+ runDepth = Math.max(0, runDepth - 1);
54
+ if (runDepth > 0)
55
+ return [];
56
+ const dropped = held;
57
+ held = [];
58
+ onChange?.();
59
+ return dropped;
60
+ }
61
+ export function isRunActive() {
62
+ return runDepth > 0;
63
+ }
64
+ /** For tests: forget all state. */
65
+ export function resetMidRunInput() {
66
+ held = [];
67
+ runDepth = 0;
68
+ onChange = null;
69
+ }
70
+ /** Hold a line for the next task turn. Blank lines are ignored. */
71
+ export function holdInput(text) {
72
+ const trimmed = text.trim();
73
+ if (trimmed.length === 0)
74
+ return;
75
+ held.push(trimmed);
76
+ onChange?.();
77
+ }
78
+ /** The lines currently waiting (a copy — mutating it must not affect state). */
79
+ export function heldInput() {
80
+ return [...held];
81
+ }
82
+ export function clearHeldInput() {
83
+ if (held.length === 0)
84
+ return;
85
+ held = [];
86
+ onChange?.();
87
+ }
88
+ /**
89
+ * Take everything held, as one steer payload, and clear it. Returns null when
90
+ * nothing is waiting. Clearing BEFORE delivery is deliberate: a delivery that
91
+ * throws must not leave the same text queued to be delivered again on the next
92
+ * turn.
93
+ */
94
+ export function takeHeldInput() {
95
+ if (held.length === 0)
96
+ return null;
97
+ const payload = held.join('\n\n');
98
+ held = [];
99
+ onChange?.();
100
+ return payload;
101
+ }
@@ -36,7 +36,9 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
36
36
  import { titleForDisplay } from './parsers.js';
37
37
  import { USER_CANCELLED } from './child-runner.js';
38
38
  import { cancelCheckpoint } from './cancel-points.js';
39
- import { rearmCancelListener } from './cancel-input.js';
39
+ import { armCancelListener, disarmCancelListener, rearmCancelListener } from './cancel-input.js';
40
+ import { beginRun, endRun, takeHeldInput } from './mid-run-input.js';
41
+ import { reportDroppedInput } from './dropped-input.js';
40
42
  import { formatTimings } from './timings.js';
41
43
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
42
44
  // ─── Module-level state ──────────────────────────────────────────────────────
@@ -151,6 +153,10 @@ export class TaskRunner {
151
153
  async run() {
152
154
  const cwd = this._cwd;
153
155
  const ctx = this._ctx;
156
+ // Mid-run input holds instead of starting a competing turn from here on,
157
+ // and the terminal interception is armed for the same window.
158
+ beginRun();
159
+ armCancelListener(ctx);
154
160
  // Initialise or resume the TASK file.
155
161
  let id;
156
162
  let title;
@@ -280,6 +286,8 @@ export class TaskRunner {
280
286
  finally {
281
287
  this._disposeWidget();
282
288
  clearActiveTask(this);
289
+ disarmCancelListener();
290
+ reportDroppedInput(endRun(), ctx);
283
291
  }
284
292
  }
285
293
  /** Stop the phase widget — clearing both the terminal and remote surfaces —
@@ -290,7 +298,7 @@ export class TaskRunner {
290
298
  this._stopWidget?.();
291
299
  this._stopWidget = null;
292
300
  }
293
- async _deliverSpec(ctx) {
301
+ async _deliverSpec(_ctx) {
294
302
  const spec = this._specForDelivery();
295
303
  // Keep the rich status block alive across the implementation turn (the phase
296
304
  // widget was disposed at handoff). Awaited (/task-auto) stays armed across all
@@ -316,12 +324,11 @@ export class TaskRunner {
316
324
  throw new Error('extension not initialised (no ExtensionAPI captured)');
317
325
  }
318
326
  armImplWidget(meta, { oneShot: true });
319
- if (ctx.isIdle()) {
320
- piApi.sendUserMessage(spec);
321
- }
322
- else {
323
- piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
324
- }
327
+ // Always name a delivery mode. pi ignores it when the session is idle and
328
+ // uses it when something else is streaming — so this one call is correct
329
+ // in both cases, where an isIdle() check is a check-then-act race that
330
+ // loses to any turn starting in between (issue #8).
331
+ piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
325
332
  }
326
333
  /**
327
334
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
@@ -472,7 +479,7 @@ export async function resumeAcrossCompactions(ctx) {
472
479
  while (resumes < MAX_COMPACTION_RESUMES
473
480
  && !wasInterrupted(ctx)
474
481
  && endedAtCompactionBoundary(ctx)) {
475
- await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION);
482
+ await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION, { deliverAs: 'followUp' });
476
483
  await ctx.waitForIdle();
477
484
  resumes++;
478
485
  }
@@ -582,7 +589,7 @@ export async function steerUntilDone(ctx, promptSteer, watchdog) {
582
589
  const steer = await ask(ctx);
583
590
  if (steer === undefined || steer.trim().length === 0)
584
591
  return true; // pause
585
- await ctx.sendUserMessage(steer);
592
+ await ctx.sendUserMessage(steer, { deliverAs: 'followUp' });
586
593
  await ctx.waitForIdle();
587
594
  }
588
595
  return false;
@@ -615,7 +622,8 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
615
622
  // /task-auto-cancel has to survive. No-op unless a run armed one.
616
623
  rearmCancelListener(newCtx);
617
624
  const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
618
- await newCtx.sendUserMessage(spec);
625
+ // Queue-or-run: never throws, whatever else is on the session (issue #8).
626
+ await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
619
627
  if (opts.waitForImplementation) {
620
628
  await newCtx.waitForIdle();
621
629
  // A threshold auto-compaction parks the turn at idle WITHOUT
@@ -709,6 +717,26 @@ export async function markResumable(cwd, taskId) {
709
717
  * paused/failed) leaves the task resumable and tells the user to /task-resume.
710
718
  */
711
719
  export async function runGatedTask(ctx, cwd, raw, opts = {}) {
720
+ // The GATES are part of the run, and they are child processes with the host
721
+ // session idle — the same hold window as the spec phases. Bracketing only
722
+ // TaskRunner would leave verify/enforce looking like "no run", which a live
723
+ // run on pi 0.82.1 showed as runActive=false while the widget still read
724
+ // "verifying work" (issue #8). The body has many early returns, so the
725
+ // bracket lives in this wrapper rather than in a dozen places.
726
+ beginRun();
727
+ // Arm the raw-stdin interception for the WHOLE run. Without this a plain
728
+ // /task had no terminal path at all — only /task-auto armed one — so a line
729
+ // typed during it went into pi's queue and fired after the run (seen live).
730
+ armCancelListener(ctx);
731
+ try {
732
+ await runGatedTaskInner(ctx, cwd, raw, opts);
733
+ }
734
+ finally {
735
+ disarmCancelListener();
736
+ reportDroppedInput(endRun(), ctx);
737
+ }
738
+ }
739
+ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
712
740
  const abort = new AbortController();
713
741
  const deps = opts.deps
714
742
  ?? buildGateDeps({
@@ -912,6 +940,22 @@ async function handleTaskCancel(_args, ctx) {
912
940
  export function registerTask(pi) {
913
941
  piApi = pi;
914
942
  setupImplWidget(pi);
943
+ // Deliver whatever the user typed while the run held the session, at the
944
+ // first moment there is a live turn to steer. agent_start fires as streaming
945
+ // begins, so `steer` is accepted here; when nothing is held this is a no-op,
946
+ // which is every turn outside a run.
947
+ pi.on('agent_start', () => {
948
+ const held = takeHeldInput();
949
+ if (held === null)
950
+ return;
951
+ try {
952
+ pi.sendUserMessage(held, { deliverAs: 'steer' });
953
+ publishNotify('Delivered your message to the running task.', 'info');
954
+ }
955
+ catch (err) {
956
+ publishNotify(`Could not deliver your message: ${err.message}`, 'warning');
957
+ }
958
+ });
915
959
  registerBridgeCommand(pi, 'task', {
916
960
  description: 'Start a new task. Usage: /task <prompt>',
917
961
  handler: handleTask
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",