@mjasnikovs/pi-task 0.13.7 → 0.13.8

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) {
@@ -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,38 @@ 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
+ // Compact A/B presentation, identical to /task's grill dialog: a binary
84
+ // fork shows both options labelled A/B; a single recommendation shows just
85
+ // the default; an open question shows the bare prompt. No verbose
86
+ // "Recommended:" / "press Enter to accept" scaffolding.
87
+ const title = plainSuggested ?
88
+ plainAlt ?
89
+ `${shownQ}\nA: ${renderInlineMarkdown(suggested, theme)}\nB: ${renderInlineMarkdown(alt, theme)}`
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
86
98
  });
87
99
  if (a === undefined) {
88
100
  ctx.ui.notify('/task-auto cancelled.', 'warning');
89
101
  return null;
90
102
  }
91
103
  const typed = a.trim();
104
+ // Two-option mode labels the choices "A:"/"B:", so a user (local TUI or
105
+ // remote "Manual answer") naturally types the bare letter to pick. Map it
106
+ // back to the option's full text. Mirrors phaseGrill's answer mapping.
107
+ const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
92
108
  let answer;
93
109
  if (typed.length === 0 && plainSuggested) {
94
110
  answer = `${plainSuggested} (accepted recommendation)`;
@@ -96,6 +112,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
96
112
  else if (typed.length === 0) {
97
113
  answer = '(skipped)';
98
114
  }
115
+ else if (twoOption && /^a[.)]?$/i.test(typed)) {
116
+ answer = plainSuggested;
117
+ }
118
+ else if (twoOption && /^b[.)]?$/i.test(typed)) {
119
+ answer = plainAlt;
120
+ }
99
121
  else {
100
122
  answer = typed;
101
123
  }
@@ -230,6 +252,17 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
230
252
  resumeId = undefined;
231
253
  }
232
254
  }
255
+ // Before starting, fold any uncommitted work into its own checkpoint
256
+ // commit so a dirty tree at the start of the run — or edits left behind
257
+ // by an interrupted/failed task — land separately instead of being swept
258
+ // into this task's snapshot. Best-effort and a no-op on a clean tree
259
+ // (gitCommitAll commits nothing), so it only ever produces a commit when
260
+ // there is stray work; the matching post-task commit below is the "after"
261
+ // half. Only the success path is announced to keep the common no-op quiet.
262
+ const checkpoint = await deps.commit(cwd, `chore: checkpoint before "${next.title}"`);
263
+ if (checkpoint.committed) {
264
+ active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
265
+ }
233
266
  const res = await deps.runTask(active, cwd, next.title, {
234
267
  resumeId,
235
268
  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;
@@ -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.8",
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",