@mjasnikovs/pi-task 0.17.7 → 0.17.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.
@@ -3,7 +3,14 @@ import type { EventEmitter } from 'node:events';
3
3
  export declare const KILL_GRACE_MS = 5000;
4
4
  /** Base flags shared by all child pi invocations. */
5
5
  export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-extensions", "--no-prompt-templates", "--no-context-files", "--no-session"];
6
+ /** The subset of a child's stdin we use: write the prompt, then close it. */
7
+ export interface WritableLike {
8
+ write(chunk: string): boolean;
9
+ end(): void;
10
+ }
6
11
  export interface ProcLike extends EventEmitter {
12
+ /** Present only when the child was spawned with stdin 'pipe' (prompt delivery). */
13
+ stdin: WritableLike | null;
7
14
  stdout: EventEmitter | null;
8
15
  stderr: EventEmitter | null;
9
16
  killed: boolean;
@@ -12,7 +19,7 @@ export interface ProcLike extends EventEmitter {
12
19
  export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
13
20
  cwd: string;
14
21
  shell: boolean;
15
- stdio: ['ignore', 'pipe', 'pipe'];
22
+ stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
16
23
  }) => ProcLike;
17
24
  export interface ChildResult {
18
25
  stdout: string;
@@ -105,6 +112,7 @@ export declare class JsonEventSink {
105
112
  export declare function runChild(spawn: SpawnFn, invocation: {
106
113
  command: string;
107
114
  args: ReadonlyArray<string>;
115
+ stdin?: string;
108
116
  }, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions): Promise<ChildResult>;
109
117
  export declare function runChildDefault(invocation: {
110
118
  command: string;
@@ -169,11 +169,22 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
169
169
  let stderr = '';
170
170
  let aborted = false;
171
171
  const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
172
+ // Deliver the prompt on stdin, not argv: a large prompt (e.g. an inlined
173
+ // design doc) blows past the OS command-line limit — Windows CreateProcessW
174
+ // caps it at 32767 chars and Node throws `spawn ENAMETOOLONG`. When a
175
+ // prompt is present we open stdin as a pipe; otherwise keep it 'ignore'
176
+ // (git and other arg-only spawns are unaffected). See GitHub issue #1.
177
+ const usesStdin = invocation.stdin !== undefined;
172
178
  const proc = spawn(invocation.command, invocation.args, {
173
179
  cwd,
174
180
  shell: false,
175
- stdio: ['ignore', 'pipe', 'pipe']
181
+ stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
176
182
  });
183
+ if (usesStdin) {
184
+ // pi reads the prompt from stdin and waits for EOF, so write then end.
185
+ proc.stdin?.write(invocation.stdin);
186
+ proc.stdin?.end();
187
+ }
177
188
  // One kill path, shared by user-abort and loop-kill: SIGTERM, then
178
189
  // SIGKILL after a grace period if the child ignored the term.
179
190
  const killProc = () => {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Text-file reads with line endings normalized to LF.
3
+ *
4
+ * Every task-file parser in pi-task assumes `\n` line endings: the front-matter
5
+ * regex (`^---\n`), the body-delimiter strip, `sectionRegex` (`## h[ \t]*\n`),
6
+ * the checkbox/VERIFY-token line splits. On Windows a task file can carry CRLF
7
+ * (an editor save, or a `git` checkout with `core.autocrlf=true`), and classic
8
+ * Mac tooling can emit lone CR. Any of those makes `parseFrontMatter` return
9
+ * null ("malformed front matter") and every `extractSection` miss.
10
+ *
11
+ * Rather than teach each parser about `\r`, we normalize ONCE at the read
12
+ * boundary — the single place file bytes enter the task layer — so everything
13
+ * downstream sees LF. See GitHub issue #1.
14
+ */
15
+ /** Collapse CRLF and lone CR to LF. `\r\n?` matches Windows CRLF and classic-Mac CR. */
16
+ export declare function normalizeNewlines(text: string): string;
17
+ /** Read a UTF-8 text file with line endings normalized to LF. */
18
+ export declare function readTextFile(filePath: string): Promise<string>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Text-file reads with line endings normalized to LF.
3
+ *
4
+ * Every task-file parser in pi-task assumes `\n` line endings: the front-matter
5
+ * regex (`^---\n`), the body-delimiter strip, `sectionRegex` (`## h[ \t]*\n`),
6
+ * the checkbox/VERIFY-token line splits. On Windows a task file can carry CRLF
7
+ * (an editor save, or a `git` checkout with `core.autocrlf=true`), and classic
8
+ * Mac tooling can emit lone CR. Any of those makes `parseFrontMatter` return
9
+ * null ("malformed front matter") and every `extractSection` miss.
10
+ *
11
+ * Rather than teach each parser about `\r`, we normalize ONCE at the read
12
+ * boundary — the single place file bytes enter the task layer — so everything
13
+ * downstream sees LF. See GitHub issue #1.
14
+ */
15
+ import * as fsp from 'node:fs/promises';
16
+ /** Collapse CRLF and lone CR to LF. `\r\n?` matches Windows CRLF and classic-Mac CR. */
17
+ export function normalizeNewlines(text) {
18
+ return text.replace(/\r\n?/g, '\n');
19
+ }
20
+ /** Read a UTF-8 text file with line endings normalized to LF. */
21
+ export async function readTextFile(filePath) {
22
+ return normalizeNewlines(await fsp.readFile(filePath, 'utf8'));
23
+ }
@@ -1,7 +1,13 @@
1
1
  /** Pick the right way to re-invoke pi: prefer the current pi script under the
2
2
  * current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
3
- * pattern from pi-coding-agent's official subagent example. */
4
- export declare function getPiInvocation(args: string[]): {
3
+ * pattern from pi-coding-agent's official subagent example.
4
+ *
5
+ * `stdin`, when given, is the prompt to feed the child over stdin instead of as
6
+ * an argv element — pi reads its prompt from stdin when no positional message
7
+ * is passed, which keeps a large prompt off the length-limited command line
8
+ * (see runChild / GitHub issue #1). It is threaded through unchanged. */
9
+ export declare function getPiInvocation(args: string[], stdin?: string): {
5
10
  command: string;
6
11
  args: string[];
12
+ stdin?: string;
7
13
  };
@@ -2,23 +2,28 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  /** Pick the right way to re-invoke pi: prefer the current pi script under the
4
4
  * current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
5
- * pattern from pi-coding-agent's official subagent example. */
6
- export function getPiInvocation(args) {
5
+ * pattern from pi-coding-agent's official subagent example.
6
+ *
7
+ * `stdin`, when given, is the prompt to feed the child over stdin instead of as
8
+ * an argv element — pi reads its prompt from stdin when no positional message
9
+ * is passed, which keeps a large prompt off the length-limited command line
10
+ * (see runChild / GitHub issue #1). It is threaded through unchanged. */
11
+ export function getPiInvocation(args, stdin) {
7
12
  // Test/dev override: point at a specific pi binary directly. Bypasses the
8
13
  // re-invoke-current-script heuristic, which goes wrong under `bun test`
9
14
  // (currentScript is the .test.ts file, not a pi entrypoint).
10
15
  if (process.env.PI_BIN) {
11
- return { command: process.env.PI_BIN, args };
16
+ return { command: process.env.PI_BIN, args, stdin };
12
17
  }
13
18
  const currentScript = process.argv[1];
14
19
  const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/');
15
20
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
16
- return { command: process.execPath, args: [currentScript, ...args] };
21
+ return { command: process.execPath, args: [currentScript, ...args], stdin };
17
22
  }
18
23
  const execName = path.basename(process.execPath).toLowerCase();
19
24
  const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
20
25
  if (!isGenericRuntime) {
21
- return { command: process.execPath, args };
26
+ return { command: process.execPath, args, stdin };
22
27
  }
23
- return { command: 'pi', args };
28
+ return { command: 'pi', args, stdin };
24
29
  }
@@ -9,6 +9,7 @@ import * as fsp from 'node:fs/promises';
9
9
  import * as path from 'node:path';
10
10
  import { tasksDir, ensureTasksDir, readTaskFile, setTaskSection } from './task-io.js';
11
11
  import { extractSection, parseFrontMatter } from './task-parsers.js';
12
+ import { readTextFile } from '../shared/fs-text.js';
12
13
  import { RESUMABLE_STATES } from './task-types.js';
13
14
  const AUTO_FILE_RE = /^(TASK_AUTO_\d{4,})\.md$/;
14
15
  const MAX_TASKS = 30;
@@ -117,7 +118,7 @@ export async function findResumableAuto(cwd) {
117
118
  if (!m)
118
119
  continue;
119
120
  try {
120
- const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
121
+ const raw = await readTextFile(path.join(tasksDir(cwd), f));
121
122
  const fm = parseFrontMatter(raw);
122
123
  if (!fm)
123
124
  continue;
@@ -15,6 +15,7 @@ import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
15
15
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
16
16
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
17
17
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
18
+ import { readTextFile } from '../shared/fs-text.js';
18
19
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
19
20
  import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
20
21
  import { refineExistingFilesBlock } from './phases.js';
@@ -134,7 +135,9 @@ export async function expandFeatureMentions(cwd, feature) {
134
135
  continue;
135
136
  seen.add(rel);
136
137
  try {
137
- const body = await fsp.readFile(path.resolve(cwd, rel), 'utf8');
138
+ // Normalize CRLF/CR so an @-mentioned design doc saved on Windows
139
+ // inlines with LF endings the downstream phase parsers expect.
140
+ const body = await readTextFile(path.resolve(cwd, rel));
138
141
  if (body.trim().length > 0) {
139
142
  blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
140
143
  }
@@ -23,7 +23,7 @@ export interface PhaseRunResult {
23
23
  /** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
24
24
  modelError?: string;
25
25
  }
26
- export declare function childArgs(tools: string, prompt: string): string[];
26
+ export declare function childArgs(tools: string): string[];
27
27
  export declare const USER_CANCELLED = "__user_cancelled__";
28
28
  /**
29
29
  * Run a child pi process with JSON event-stream output, loop detection, and
@@ -44,7 +44,7 @@ export function connectionRetryBackoffMs(attempt) {
44
44
  }
45
45
  const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
46
46
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
47
- export function childArgs(tools, prompt) {
47
+ export function childArgs(tools) {
48
48
  // `--mode json` puts the child into the structured event stream the
49
49
  // unified runner parses in `mode: 'json-events'`. Without it the child
50
50
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -55,8 +55,12 @@ export function childArgs(tools, prompt) {
55
55
  // instead of `--tools ''` (which pi would reject). Used by pure-judgment
56
56
  // phases like critique-triage that should reason only over the text we
57
57
  // hand them, never spend time reading the repo.
58
+ //
59
+ // The prompt is NOT an argv element: it goes to the child over stdin (see
60
+ // runChild below / getPiInvocation), so a large inlined-design prompt can't
61
+ // overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
58
62
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
59
- return [...CHILD_BASE_ARGS, '--mode', 'json', ...toolFlags, prompt];
63
+ return [...CHILD_BASE_ARGS, '--mode', 'json', ...toolFlags];
60
64
  }
61
65
  // Sentinel error thrown when the user dismisses a grill-me dialog.
62
66
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
@@ -68,7 +72,7 @@ export const USER_CANCELLED = '__user_cancelled__';
68
72
  * phase-level code.
69
73
  */
70
74
  export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn) {
71
- const invocation = getPiInvocation(childArgs(tools, prompt));
75
+ const invocation = getPiInvocation(childArgs(tools), prompt);
72
76
  let loopHit;
73
77
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
74
78
  mode: 'json-events',
@@ -21,6 +21,7 @@ import { PHASES, postCommitPhase } from './phases.js';
21
21
  import { handleFailure } from './failure-classifier.js';
22
22
  import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
23
23
  import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
24
+ import { readTextFile } from '../shared/fs-text.js';
24
25
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
25
26
  import { startWidget } from './widget.js';
26
27
  import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
@@ -705,7 +706,7 @@ async function handleTaskList(_args, ctx) {
705
706
  const rows = [];
706
707
  for (const f of taskFiles) {
707
708
  try {
708
- const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
709
+ const raw = await readTextFile(path.join(tasksDir(cwd), f));
709
710
  const fm = parseFrontMatter(raw);
710
711
  if (!fm)
711
712
  continue;
@@ -754,7 +755,7 @@ async function handleTaskResume(args, ctx) {
754
755
  if (!m)
755
756
  continue;
756
757
  try {
757
- const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
758
+ const raw = await readTextFile(path.join(tasksDir(cwd), f));
758
759
  const fm = parseFrontMatter(raw);
759
760
  if (!fm)
760
761
  continue;
@@ -8,6 +8,7 @@ import * as fsp from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
9
  import { TASKS_DIR_NAME } from './task-types.js';
10
10
  import { emitFrontMatter, parseFrontMatter, sectionRegex } from './task-parsers.js';
11
+ import { readTextFile } from '../shared/fs-text.js';
11
12
  // ─── Directory & path helpers ────────────────────────────────────────────────
12
13
  export function tasksDir(cwd) {
13
14
  return path.join(cwd, TASKS_DIR_NAME);
@@ -58,11 +59,14 @@ export async function allocateTaskId(cwd) {
58
59
  }
59
60
  // ─── File read/write ─────────────────────────────────────────────────────────
60
61
  export async function readTaskFile(cwd, id) {
61
- const raw = await fsp.readFile(taskFilePath(cwd, id), 'utf8');
62
+ // Normalize CRLF/CR LF at the read boundary: every parser below (front
63
+ // matter, body strip, sectionRegex) assumes '\n'. A Windows/autocrlf file
64
+ // would otherwise fail as "malformed front matter". See shared/fs-text.ts.
65
+ const raw = await readTextFile(taskFilePath(cwd, id));
62
66
  const fm = parseFrontMatter(raw);
63
67
  if (!fm)
64
68
  throw new Error(`malformed front matter in ${id}.md`);
65
- const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
69
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
66
70
  return { frontMatter: fm, body };
67
71
  }
68
72
  export async function writeTaskFile(cwd, fm, body) {
@@ -32,11 +32,15 @@ export function emitFrontMatter(fm) {
32
32
  return lines.join('\n');
33
33
  }
34
34
  export function parseFrontMatter(content) {
35
- const m = /^---\n([\s\S]*?)\n---\n?/.exec(content);
35
+ // `\r?\n` throughout so a CRLF/Windows task file parses too. Reads normally
36
+ // pass through readTextFile (which normalizes to LF), but tolerating CRLF at
37
+ // the parser itself is the safety net for any read site that forgets to —
38
+ // exactly the class of miss that shipped in 0.17.8. See shared/fs-text.ts.
39
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
36
40
  if (!m)
37
41
  return null;
38
42
  const obj = {};
39
- for (const line of m[1].split('\n')) {
43
+ for (const line of m[1].split(/\r?\n/)) {
40
44
  const kv = /^([a-z_]+):\s*(.*)$/.exec(line);
41
45
  if (kv)
42
46
  obj[kv[1]] = kv[2];
@@ -69,7 +73,9 @@ export function sectionRegex(heading) {
69
73
  // after a long /task-auto run). Keeping blanks in group 2 lets the `.trim()`
70
74
  // every reader already applies collapse them, which also self-heals files
71
75
  // that already accumulated the gap.
72
- return new RegExp(`(^## ${escapeRegex(heading)}[ \\t]*\\n)([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, 'm');
76
+ // `[ \t]*\r?\n` on the heading line so a CRLF file still matches the section
77
+ // (belt-and-suspenders alongside read-boundary normalization; see fs-text.ts).
78
+ return new RegExp(`(^## ${escapeRegex(heading)}[ \\t]*\\r?\\n)([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, 'm');
73
79
  }
74
80
  export function extractSection(body, heading) {
75
81
  const m = sectionRegex(heading).exec(body);
@@ -423,7 +423,7 @@ export async function docsFocused(input) {
423
423
  const { pkg, chunks, hitCache, indexingMs } = rawResult;
424
424
  const concatenated = chunks.map(c => c.content).join('\n\n');
425
425
  const prompt = buildPrompt(pkg, input.query, concatenated);
426
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
426
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
427
427
  const child = await runChild(spawn, invocation, input.cwd, input.signal);
428
428
  const parsed = parseChildOutput(child.stdout);
429
429
  const excerptVerified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
@@ -24,7 +24,7 @@ export async function fetchFocused(input) {
24
24
  title: cleaned.title,
25
25
  content: truncated
26
26
  });
27
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
27
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
28
28
  const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
29
29
  if (childResult.aborted) {
30
30
  return {
@@ -72,7 +72,7 @@ export async function runWorker(input) {
72
72
  let leakRetries = 0;
73
73
  for (;;) {
74
74
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
75
- const invocation = getPiInvocation([...baseArgs, prompt]);
75
+ const invocation = getPiInvocation([...baseArgs], prompt);
76
76
  const tStart = Date.now();
77
77
  let tFirstByte = null;
78
78
  // loop === false turns the guard off entirely (detector is null and no
@@ -104,7 +104,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
104
104
  };
105
105
  const concatenated = chunks.map(c => c.content).join('\n\n');
106
106
  const prompt = buildProjectPrompt(projectName, params.query, concatenated);
107
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
107
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
108
108
  const child = await runChild(spawn, invocation, ctx.cwd, signal);
109
109
  const failure = formatChildFailure(child, 'Project docs lookup aborted.');
110
110
  if (failure !== null) {
@@ -206,7 +206,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
206
206
  };
207
207
  const concatenated = chunks.map(c => c.content).join('\n\n');
208
208
  const prompt = buildPrompt(pkg, params.query, concatenated);
209
- const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
209
+ const invocation = getPiInvocation([...CHILD_ARGS], prompt);
210
210
  const child = await runChild(spawn, invocation, ctx.cwd, signal);
211
211
  const failure = formatChildFailure(child, 'Docs lookup aborted.');
212
212
  if (failure !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.7",
3
+ "version": "0.17.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",