@mjasnikovs/pi-task 0.13.7 → 0.13.9

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.
@@ -11,7 +11,7 @@ const ITEMS = [
11
11
  {
12
12
  id: 'autoCommit',
13
13
  label: 'auto-commit',
14
- description: 'git commit after each /task-auto sub-task'
14
+ description: 'git commit around each /task-auto sub-task (checkpoint before, snapshot after)'
15
15
  }
16
16
  ];
17
17
  function makeTheme(theme) {
@@ -28,6 +28,19 @@ export interface AskSpec {
28
28
  recommended2?: string;
29
29
  /** Whether the browser card shows a Skip button (answers with empty string). */
30
30
  allowSkip: boolean;
31
+ /**
32
+ * When set, the local TUI shows a select() picker of these entries instead of
33
+ * a bare text input — one option per line, arrow-key navigable. Each entry's
34
+ * `label` is what the picker displays; its `value` is what ask() resolves to
35
+ * when chosen. A built-in "type a different answer" entry is appended that
36
+ * falls back to a text input, preserving the free-text override. Used for the
37
+ * binary A/B grill/clarify fork. Remote browsers ignore this and keep
38
+ * rendering recommended/recommended2 as buttons.
39
+ */
40
+ options?: {
41
+ label: string;
42
+ value: string;
43
+ }[];
31
44
  }
32
45
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
33
46
  export declare class SessionUI {
@@ -38,6 +51,15 @@ export declare class SessionUI {
38
51
  get hasUI(): boolean;
39
52
  /** Race the local input against a remote answer; first to settle wins. */
40
53
  ask(spec: AskSpec): Promise<string | undefined>;
54
+ /**
55
+ * The local-TUI half of ask(). With `spec.options` it renders a select()
56
+ * picker (each option on its own line) plus a trailing "type a different
57
+ * answer" entry that drops to a text input; the chosen entry's `value` is
58
+ * returned. Without options it falls back to a single text input. Cancelling
59
+ * either dialog (or an abort when the remote wins the race) resolves to
60
+ * undefined.
61
+ */
62
+ private askLocal;
41
63
  }
42
64
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
43
65
  export declare function publishViewer(title: string, text: string): void;
@@ -25,6 +25,9 @@ export function answerPrompt(id, value) {
25
25
  b.pending.delete(id);
26
26
  settle(value);
27
27
  }
28
+ /** Trailing picker entry that drops to a free-text input — the local mirror of
29
+ * the remote card's "✎ Manual answer" button. */
30
+ const TYPE_OWN_LABEL = 'Type a different answer…';
28
31
  /** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
29
32
  export class SessionUI {
30
33
  ctx;
@@ -63,9 +66,7 @@ export class SessionUI {
63
66
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
64
67
  // the rejection some implementations throw on abort so it never leaks.
65
68
  const local = this.ctx.hasUI ?
66
- this.ctx.ui
67
- .input(spec.localTitle, spec.recommended, { signal: ac.signal })
68
- .catch(() => undefined)
69
+ this.askLocal(spec, ac.signal).catch(() => undefined)
69
70
  : new Promise(() => { });
70
71
  try {
71
72
  const winner = await Promise.race([
@@ -81,6 +82,31 @@ export class SessionUI {
81
82
  clearPrompt(id);
82
83
  }
83
84
  }
85
+ /**
86
+ * The local-TUI half of ask(). With `spec.options` it renders a select()
87
+ * picker (each option on its own line) plus a trailing "type a different
88
+ * answer" entry that drops to a text input; the chosen entry's `value` is
89
+ * returned. Without options it falls back to a single text input. Cancelling
90
+ * either dialog (or an abort when the remote wins the race) resolves to
91
+ * undefined.
92
+ */
93
+ async askLocal(spec, signal) {
94
+ const opts = spec.options;
95
+ if (opts && opts.length > 0) {
96
+ const labels = opts.map(o => o.label);
97
+ const choice = await this.ctx.ui.select(spec.localTitle, [...labels, TYPE_OWN_LABEL], {
98
+ signal
99
+ });
100
+ if (choice === undefined)
101
+ return undefined; // cancelled / aborted
102
+ if (choice === TYPE_OWN_LABEL) {
103
+ return this.ctx.ui.input(spec.localTitle, undefined, { signal });
104
+ }
105
+ const hit = opts.find(o => o.label === choice);
106
+ return hit ? hit.value : choice;
107
+ }
108
+ return this.ctx.ui.input(spec.localTitle, spec.recommended, { signal });
109
+ }
84
110
  }
85
111
  export function publishNotify(message, level) {
86
112
  getBridge().broadcast({ type: 'notify', message, level });
@@ -5,7 +5,7 @@ import { reset, addUserTurn } 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
- import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls } from './tailscale.js';
8
+ import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls, hostFromResult } from './tailscale.js';
9
9
  import { isAgentIdle } from './state.js';
10
10
  const _g = globalThis;
11
11
  if (!_g.__piRemote)
@@ -105,10 +105,14 @@ export function registerRemote(pi) {
105
105
  const server = await ensureServer();
106
106
  const httpPrimary = `http://${server.ip}:${server.port}`;
107
107
  const result = S.serveResult ?? { state: 'unavailable' };
108
- const plan = planRemoteUrls(httpPrimary, result);
108
+ const plan = planRemoteUrls(httpPrimary, result, server.port);
109
109
  const primaryUrl = plan.primaryUrl;
110
110
  const qr = await qrLines(primaryUrl);
111
- const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
111
+ const tsHost = hostFromResult(result);
112
+ const addrs = [
113
+ ...plan.urlLines,
114
+ ...formatAddresses(server.ips, server.port, tsHost)
115
+ ];
112
116
  const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
113
117
  const addrLines = [
114
118
  ...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
@@ -24,7 +24,9 @@ export interface ServerHandle {
24
24
  type MessageCallback = (text: string) => void;
25
25
  export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>): LocalIPs;
26
26
  /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
27
- * when present; a single unlabeled primary URL when neither resolves. */
28
- export declare function formatAddresses(ips: LocalIPs, port: number): AddressLine[];
27
+ * when present; a single unlabeled primary URL when neither resolves. The
28
+ * Tailscale line uses the MagicDNS host when known (resolves to the same node,
29
+ * but is what SSH and webpush certs need), falling back to the raw IP. */
30
+ export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: string): AddressLine[];
29
31
  export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
30
32
  export {};
@@ -33,10 +33,14 @@ export function getLocalIPs(nets = networkInterfaces()) {
33
33
  return { tailscale, lan, primary: tailscale ?? lan ?? '127.0.0.1' };
34
34
  }
35
35
  /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
36
- * when present; a single unlabeled primary URL when neither resolves. */
37
- export function formatAddresses(ips, port) {
36
+ * when present; a single unlabeled primary URL when neither resolves. The
37
+ * Tailscale line uses the MagicDNS host when known (resolves to the same node,
38
+ * but is what SSH and webpush certs need), falling back to the raw IP. */
39
+ export function formatAddresses(ips, port, tsHost) {
38
40
  const out = [];
39
- if (ips.tailscale)
41
+ if (tsHost)
42
+ out.push({ label: 'Tailscale', url: `http://${tsHost}:${port}` });
43
+ else if (ips.tailscale)
40
44
  out.push({ label: 'Tailscale', url: `http://${ips.tailscale}:${port}` });
41
45
  if (ips.lan)
42
46
  out.push({ label: 'LAN', url: `http://${ips.lan}:${port}` });
@@ -9,6 +9,7 @@ export interface Run {
9
9
  export type ServeResult = {
10
10
  state: 'served';
11
11
  url: string;
12
+ host: string;
12
13
  } | {
13
14
  state: 'foreign-conflict';
14
15
  host: string;
@@ -18,6 +19,8 @@ export type ServeResult = {
18
19
  } | {
19
20
  state: 'unavailable';
20
21
  };
22
+ /** The MagicDNS host of a serve result, or undefined when the daemon had no name. */
23
+ export declare function hostFromResult(result: ServeResult): string | undefined;
21
24
  export interface RemoteUrlPlan {
22
25
  /** URL to encode in the QR and announce; the https one when serve is live. */
23
26
  primaryUrl: string;
@@ -41,5 +44,8 @@ export declare function ensureTailscaleServe(port: number, run?: Run): Promise<S
41
44
  * Best-effort; callers should additionally `.catch()` since it may run during
42
45
  * shutdown. */
43
46
  export declare function teardownTailscaleServe(port: number, run?: Run): Promise<void>;
44
- /** Pure: pick the primary URL and any hint lines from a serve result. */
45
- export declare function planRemoteUrls(httpPrimary: string, result: ServeResult): RemoteUrlPlan;
47
+ /** Pure: pick the primary URL (what the QR encodes) and any hint lines from a
48
+ * serve result. Prefers the MagicDNS host over the raw IP whenever it's known —
49
+ * the https URL when serve is live, else http://<host>:<port> — so the QR and
50
+ * the announced URL carry the tailnet name (needed for SSH and webpush certs). */
51
+ export declare function planRemoteUrls(httpPrimary: string, result: ServeResult, port: number): RemoteUrlPlan;
@@ -1,4 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
+ /** The MagicDNS host of a serve result, or undefined when the daemon had no name. */
3
+ export function hostFromResult(result) {
4
+ return result.state === 'unavailable' ? undefined : result.host;
5
+ }
2
6
  const defaultRun = (cmd, args) => new Promise(resolve => {
3
7
  execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
4
8
  const exitCode = err && typeof err.code === 'number' ?
@@ -54,7 +58,7 @@ export async function ensureTailscaleServe(port, run = defaultRun) {
54
58
  const serve = await run('tailscale', ['serve', 'status', '--json']);
55
59
  const target = serve443Target(serve.stdout);
56
60
  if (target === ours)
57
- return { state: 'served', url: `https://${host}` };
61
+ return { state: 'served', url: `https://${host}`, host };
58
62
  if (target !== undefined)
59
63
  return { state: 'foreign-conflict', host };
60
64
  // No :443 handler yet — check cert capability before trying to create one.
@@ -64,7 +68,7 @@ export async function ensureTailscaleServe(port, run = defaultRun) {
64
68
  }
65
69
  const set = await run('tailscale', ['serve', '--bg', '--https=443', ours]);
66
70
  if (set.exitCode === 0)
67
- return { state: 'served', url: `https://${host}` };
71
+ return { state: 'served', url: `https://${host}`, host };
68
72
  return { state: 'certs-disabled', host };
69
73
  }
70
74
  /** Tear down the :443 serve handler ONLY if it currently points at our port.
@@ -76,8 +80,11 @@ export async function teardownTailscaleServe(port, run = defaultRun) {
76
80
  await run('tailscale', ['serve', '--https=443', 'off']);
77
81
  }
78
82
  }
79
- /** Pure: pick the primary URL and any hint lines from a serve result. */
80
- export function planRemoteUrls(httpPrimary, result) {
83
+ /** Pure: pick the primary URL (what the QR encodes) and any hint lines from a
84
+ * serve result. Prefers the MagicDNS host over the raw IP whenever it's known —
85
+ * the https URL when serve is live, else http://<host>:<port> — so the QR and
86
+ * the announced URL carry the tailnet name (needed for SSH and webpush certs). */
87
+ export function planRemoteUrls(httpPrimary, result, port) {
81
88
  switch (result.state) {
82
89
  case 'served':
83
90
  return {
@@ -87,7 +94,7 @@ export function planRemoteUrls(httpPrimary, result) {
87
94
  };
88
95
  case 'foreign-conflict':
89
96
  return {
90
- primaryUrl: httpPrimary,
97
+ primaryUrl: `http://${result.host}:${port}`,
91
98
  urlLines: [],
92
99
  hintLines: [
93
100
  'HTTPS: port 443 is already used by another tailscale serve config; not touching it.',
@@ -96,7 +103,7 @@ export function planRemoteUrls(httpPrimary, result) {
96
103
  };
97
104
  case 'certs-disabled':
98
105
  return {
99
- primaryUrl: httpPrimary,
106
+ primaryUrl: `http://${result.host}:${port}`,
100
107
  urlLines: [],
101
108
  hintLines: [
102
109
  'HTTPS (for phone notifications): enable HTTPS in the Tailscale admin console, then restart the remote.'
@@ -55,8 +55,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
55
55
  // clarify — sequential & adaptive: ask one question at a time, feeding every
56
56
  // answer back into the next call so later questions react to earlier ones
57
57
  // (e.g. a framework choice reshapes what gets asked). Each question is shown
58
- // with the model's recommended default pre-filled (Enter to accept, type to
59
- // override); we never auto-answer. The model emits NONE when nothing remains.
58
+ // exactly like /task's grill dialog: a binary fork offers two options (A/B),
59
+ // otherwise the model's recommendation is shown as the input placeholder and
60
+ // in the title. Nothing is pre-filled into the editor — submitting an empty
61
+ // field is what accepts the recommendation (see the typed.length === 0 branch
62
+ // below); typing overrides it. We never auto-answer; the model emits NONE when
63
+ // nothing remains.
60
64
  const theme = ctx.ui.theme;
61
65
  const ui = new SessionUI(ctx);
62
66
  // Inline any @file spec the user referenced so clarify/decompose reason over
@@ -69,26 +73,46 @@ export async function planAuto(ctx, cwd, feature, deps) {
69
73
  const parsed = parseClarifyList(qRaw);
70
74
  if (parsed.length === 0)
71
75
  break; // NONE / nothing left to ask
72
- const { question, suggested } = parsed[0];
76
+ const { question, suggested, alt } = parsed[0];
73
77
  // Render markdown (bold/code) for the displayed prompt; keep plain text
74
78
  // for the editable default and the persisted file.
75
79
  const shownQ = renderInlineMarkdown(question, theme);
76
80
  const plainQ = stripInlineMarkdown(question);
77
81
  const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
78
- const title = suggested ?
79
- `${shownQ}\n${theme.fg('muted', 'Recommended:')}\n\n${renderInlineMarkdown(suggested, theme)}\n\n${theme.fg('muted', 'press Enter to accept')}`
80
- : `${shownQ}\n${theme.fg('muted', '(no recommendation please answer)')}`;
82
+ const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
83
+ // Identical to /task's grill dialog: a binary fork becomes a select()
84
+ // picker locally — each option on its own line, labelled A/B; a single
85
+ // recommendation rides under the question as the input default; an open
86
+ // question shows the bare prompt. No verbose "Recommended:" /
87
+ // "press Enter to accept" scaffolding.
88
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
89
+ const title = !twoOption && plainSuggested ?
90
+ `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
91
+ : shownQ;
81
92
  const a = await ui.ask({
82
93
  localTitle: title,
83
94
  question: plainQ,
84
95
  recommended: plainSuggested,
85
- allowSkip: plainSuggested === undefined
96
+ ...(plainAlt !== undefined && { recommended2: plainAlt }),
97
+ allowSkip: plainSuggested === undefined && plainAlt === undefined,
98
+ ...(twoOption && {
99
+ options: [
100
+ {
101
+ label: `A: ${renderInlineMarkdown(suggested, theme)}`,
102
+ value: plainSuggested
103
+ },
104
+ { label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
105
+ ]
106
+ })
86
107
  });
87
108
  if (a === undefined) {
88
109
  ctx.ui.notify('/task-auto cancelled.', 'warning');
89
110
  return null;
90
111
  }
91
112
  const typed = a.trim();
113
+ // The local picker resolves to the chosen option's full value, but a
114
+ // remote user (or the picker's free-text fallback) may still type a bare
115
+ // "A"/"B" — map those back to the option's full text. Mirrors phaseGrill.
92
116
  let answer;
93
117
  if (typed.length === 0 && plainSuggested) {
94
118
  answer = `${plainSuggested} (accepted recommendation)`;
@@ -96,6 +120,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
96
120
  else if (typed.length === 0) {
97
121
  answer = '(skipped)';
98
122
  }
123
+ else if (twoOption && /^a[.)]?$/i.test(typed)) {
124
+ answer = plainSuggested;
125
+ }
126
+ else if (twoOption && /^b[.)]?$/i.test(typed)) {
127
+ answer = plainAlt;
128
+ }
99
129
  else {
100
130
  answer = typed;
101
131
  }
@@ -230,6 +260,17 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
230
260
  resumeId = undefined;
231
261
  }
232
262
  }
263
+ // Before starting, fold any uncommitted work into its own checkpoint
264
+ // commit so a dirty tree at the start of the run — or edits left behind
265
+ // by an interrupted/failed task — land separately instead of being swept
266
+ // into this task's snapshot. Best-effort and a no-op on a clean tree
267
+ // (gitCommitAll commits nothing), so it only ever produces a commit when
268
+ // there is stray work; the matching post-task commit below is the "after"
269
+ // half. Only the success path is announced to keep the common no-op quiet.
270
+ const checkpoint = await deps.commit(cwd, `chore: checkpoint before "${next.title}"`);
271
+ if (checkpoint.committed) {
272
+ active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
273
+ }
233
274
  const res = await deps.runTask(active, cwd, next.title, {
234
275
  resumeId,
235
276
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
@@ -4,9 +4,10 @@
4
4
  */
5
5
  /**
6
6
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
- * single numbered question followed by a "SUGGESTED: <default>" line, or the
8
- * literal token NONE when no clarification remains. priorQA carries the
9
- * questions already answered so each next question adapts to them.
7
+ * single numbered question followed by a "SUGGESTED: <default>" line, an optional
8
+ * "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
9
+ * when no clarification remains. priorQA carries the questions already answered so
10
+ * each next question adapts to them.
10
11
  */
11
12
  export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) => string;
12
13
  /**
@@ -4,9 +4,10 @@
4
4
  */
5
5
  /**
6
6
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
- * single numbered question followed by a "SUGGESTED: <default>" line, or the
8
- * literal token NONE when no clarification remains. priorQA carries the
9
- * questions already answered so each next question adapts to them.
7
+ * single numbered question followed by a "SUGGESTED: <default>" line, an optional
8
+ * "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
9
+ * when no clarification remains. priorQA carries the questions already answered so
10
+ * each next question adapts to them.
10
11
  */
11
12
  export const AUTO_CLARIFY_PROMPT = (feature, priorQA) => `You are planning how to split a feature into separate implementation tasks, one clarifying question at a time.
12
13
 
@@ -33,14 +34,16 @@ fork the breakdown). Account for the answers so far:
33
34
  real-time vs polling transport, search, deployment).
34
35
  - Skip anything /task will naturally resolve per-task during its own research.
35
36
 
36
- Also propose the single most sensible default answer for this question, inferred
37
- from the repo, the referenced docs, and any stated philosophy or constraints —
37
+ Also propose the most sensible default answer for this question, inferred from
38
+ the repo, the referenced docs, and any stated philosophy or constraints —
38
39
  concrete and decisive, shown to the user as a recommendation they can accept or
39
- override.
40
+ override. When the question is a genuine binary "A or B?" fork, also give the
41
+ single best alternative as a second option; otherwise offer only the one default.
40
42
 
41
43
  OUTPUT FORMAT (exact):
42
44
  - One clarifying question as a single numbered line: "1. ...".
43
45
  - On the NEXT line (never inline), a line that begins with "SUGGESTED: <your recommended default>".
46
+ - ONLY for a binary "A or B?" fork, on the line after that, a line beginning with "ALT: <the alternative option>". Omit the ALT line entirely for open-ended questions.
44
47
  - Put the core question in **bold**, followed by a short one-line rationale in plain prose. Backticks around code/identifiers are fine. Avoid other markdown (headings, bullet lists, links).
45
48
  - Only when the spec already pins down every choice that would change the task breakdown — nothing decision-changing is left to ask — output exactly:
46
49
  NONE`;
@@ -17,9 +17,12 @@ export type AutoAnswer = {
17
17
  export interface ClarifyQuestion {
18
18
  question: string;
19
19
  suggested?: string;
20
+ /** Secondary option for a binary "A or B?" fork; mirrors grill's ALT line. */
21
+ alt?: string;
20
22
  }
21
23
  export declare const GRILL_LINE_RE: RegExp;
22
24
  export declare const SUGGESTED_LINE_RE: RegExp;
25
+ export declare const ALT_LINE_RE: RegExp;
23
26
  export declare function parseGrillQuestions(raw: string): string[];
24
27
  export declare function parseClarifyList(raw: string): ClarifyQuestion[];
25
28
  export declare function autoAnswerHasTag(raw: string): boolean;
@@ -7,6 +7,7 @@ import { MAX_GRILL_QUESTIONS } from './phases.js';
7
7
  // ─── Constants ───────────────────────────────────────────────────────────────
8
8
  export const GRILL_LINE_RE = /^\s*\d+[.)]\s+(.+)$/;
9
9
  export const SUGGESTED_LINE_RE = /^\s*SUGGESTED:\s*(.*)$/i;
10
+ export const ALT_LINE_RE = /^\s*ALT:\s*(.*)$/i;
10
11
  // ─── Grill questions parser ──────────────────────────────────────────────────
11
12
  // The grill-gen prompt instructs the worker to emit the literal token `NONE`
12
13
  // when it has zero questions, so the runner's empty-output guard can still
@@ -30,14 +31,32 @@ export function parseGrillQuestions(raw) {
30
31
  // (e.g. "1. ...so this must be resolved. SUGGESTED: use polling.") rather than
31
32
  // on its own line.
32
33
  const INLINE_SUGGESTED_RE = /\bSUGGESTED:\s*/i;
33
- /** Split a question line's text into the question and any inline SUGGESTED default. */
34
+ const INLINE_ALT_RE = /\bALT:\s*/i;
35
+ /**
36
+ * Split a question line's text into the question, any inline SUGGESTED default,
37
+ * and any inline ALT secondary option (the model may write both on one line:
38
+ * "1. A or B? SUGGESTED: A ALT: B").
39
+ */
34
40
  function splitInlineSuggested(text) {
35
41
  const m = INLINE_SUGGESTED_RE.exec(text);
36
42
  if (!m)
37
43
  return { question: text.trim() };
38
44
  const question = text.slice(0, m.index).trim();
39
- const suggested = text.slice(m.index + m[0].length).trim();
40
- return suggested.length > 0 ? { question, suggested } : { question };
45
+ let rest = text.slice(m.index + m[0].length);
46
+ let alt;
47
+ const altM = INLINE_ALT_RE.exec(rest);
48
+ if (altM) {
49
+ const altText = rest.slice(altM.index + altM[0].length).trim();
50
+ if (altText.length > 0)
51
+ alt = altText;
52
+ rest = rest.slice(0, altM.index);
53
+ }
54
+ const suggested = rest.trim();
55
+ return {
56
+ question,
57
+ ...(suggested.length > 0 && { suggested }),
58
+ ...(alt !== undefined && { alt })
59
+ };
41
60
  }
42
61
  // Parses the /task-auto clarify output: a numbered question list where each
43
62
  // question carries a "SUGGESTED: <default>" recommendation — either on its own
@@ -67,6 +86,15 @@ export function parseClarifyList(raw) {
67
86
  if (suggested.length > 0 && last.suggested === undefined) {
68
87
  last.suggested = suggested;
69
88
  }
89
+ continue;
90
+ }
91
+ const altLine = ALT_LINE_RE.exec(line);
92
+ if (altLine && out.length > 0) {
93
+ const alt = altLine[1].trim();
94
+ const last = out[out.length - 1];
95
+ if (alt.length > 0 && last.alt === undefined) {
96
+ last.alt = alt;
97
+ }
70
98
  }
71
99
  }
72
100
  return out;
@@ -326,10 +326,13 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
326
326
  else {
327
327
  const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
328
328
  const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
329
- const localTitle = plainSuggested ?
330
- plainAlt ?
331
- `${shownQ}\nA: ${renderInlineMarkdown(auto.suggested, theme)}\nB: ${renderInlineMarkdown(auto.alt, theme)}`
332
- : `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
329
+ // A binary fork (suggested + alt) becomes a select() picker locally —
330
+ // each option on its own line, labelled A/B. A single recommendation
331
+ // rides along under the question as the input default; an open
332
+ // question shows the bare prompt.
333
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
334
+ const localTitle = !twoOption && plainSuggested ?
335
+ `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
333
336
  : shownQ;
334
337
  widgetState.lastLine = `awaiting Q${n + 1}`;
335
338
  const a = await ui.ask({
@@ -337,16 +340,25 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
337
340
  question: plainQ,
338
341
  recommended: plainSuggested,
339
342
  recommended2: plainAlt,
340
- allowSkip: plainSuggested === undefined && plainAlt === undefined
343
+ allowSkip: plainSuggested === undefined && plainAlt === undefined,
344
+ ...(twoOption && {
345
+ options: [
346
+ {
347
+ label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
348
+ value: plainSuggested
349
+ },
350
+ { label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
351
+ ]
352
+ })
341
353
  });
342
354
  if (a === undefined)
343
355
  throw new Error(USER_CANCELLED);
344
356
  const typed = a.trim();
345
- // Two-option mode labels the choices "A:"/"B:", so a user (local TUI
346
- // or remote "Manual answer") naturally types the bare letter to pick.
347
- // Map it back to the option's full text — storing the literal "A"
348
- // leaves the next grill-gen call a dangling reference it can't decode.
349
- const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
357
+ // The local picker resolves to the chosen option's full value, but a
358
+ // remote user (or the picker's free-text fallback) may still type a
359
+ // bare "A"/"B" — map those back to the option's full text, since
360
+ // storing the literal letter leaves the next grill-gen call a
361
+ // dangling reference it can't decode.
350
362
  if (typed.length === 0 && plainSuggested) {
351
363
  answer = plainSuggested;
352
364
  }
@@ -11,21 +11,22 @@ const turndown = new TurndownService({
11
11
  });
12
12
  export function cleanHtml(html, baseUrl) {
13
13
  const { document } = parseHTML(html);
14
+ const doc = document;
14
15
  const reader = new Readability(document);
15
16
  const parsed = reader.parse();
16
17
  if (parsed && parsed.content) {
17
18
  return {
18
- title: parsed.title || document.title || new URL(baseUrl).hostname,
19
+ title: parsed.title || doc.title || new URL(baseUrl).hostname,
19
20
  markdown: turndown.turndown(parsed.content).trim(),
20
21
  finalUrl: baseUrl
21
22
  };
22
23
  }
23
24
  // Fallback: turndown the body
24
- const body = document.body;
25
+ const body = doc.body;
25
26
  const bodyHtml = body ? body.innerHTML : '';
26
27
  const markdown = turndown.turndown(bodyHtml).trim();
27
28
  return {
28
- title: document.title || new URL(baseUrl).hostname,
29
+ title: doc.title || new URL(baseUrl).hostname,
29
30
  markdown,
30
31
  finalUrl: baseUrl
31
32
  };
@@ -147,7 +148,9 @@ export async function fetchAndClean(url, opts = {}) {
147
148
  let bytesRead = 0;
148
149
  try {
149
150
  while (true) {
150
- const { value, done } = await reader.read();
151
+ // response.body's stream type doesn't resolve here, so the chunk
152
+ // surfaces as `any`; pin it to the Uint8Array the reader yields.
153
+ const { value, done } = (await reader.read());
151
154
  if (done)
152
155
  break;
153
156
  if (value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.7",
3
+ "version": "0.13.9",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",