pi-long-task 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dejan Jacimovic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # Pi Long Task
2
+
3
+ Pi Long Task is a Pi extension that turns a larger coding request into a clear TODO plan, works through the tasks one by one, and reports the result.
4
+
5
+ It is useful when you want Pi to handle a multi-step change without losing track of what has been done, what is still left, and whether changes should be committed.
6
+
7
+ ## What you get
8
+
9
+ When you ask Pi to use `pi_long_task`, it will:
10
+
11
+ 1. Create or clean up a TODO plan from your request.
12
+ 2. Work through each unfinished TODO task in order.
13
+ 3. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
14
+ 4. Return a summary with completed, failed, blocked, and remaining task counts.
15
+ 5. Optionally commit completed work after each task.
16
+
17
+ A finished run gives you:
18
+
19
+ - a concise status summary in Pi
20
+ - a generated `TODO.md`
21
+ - a generated `TASK_RESULT.md`
22
+ - commit hashes when commits were enabled and created
23
+ - any remaining or blocked tasks clearly listed
24
+
25
+ ## Install
26
+
27
+ After this package is published to npm, install it with:
28
+
29
+ ```bash
30
+ pi install npm:pi-long-task
31
+ ```
32
+
33
+ For local development, load this checkout for one Pi session:
34
+
35
+ ```bash
36
+ pi -e /path/to/pi-long-task
37
+ ```
38
+
39
+ Or install the local checkout so Pi can load it normally:
40
+
41
+ ```bash
42
+ pi install /path/to/pi-long-task
43
+ ```
44
+
45
+ After installing, start `pi` in your target project and ask it to use the `pi_long_task` tool.
46
+
47
+ ## Usage
48
+
49
+ Run without commits:
50
+
51
+ ```text
52
+ Use pi_long_task with inputText "add tests for the parser and fix any failures" and commit false.
53
+ ```
54
+
55
+ Run and allow commits:
56
+
57
+ ```text
58
+ Use pi_long_task with inputText "implement the TODOs in @TODO.md" and commit true.
59
+ ```
60
+
61
+ Use a pasted TODO plan:
62
+
63
+ ```text
64
+ Use pi_long_task with inputText "<paste TODO markdown here>" and commit false.
65
+ ```
66
+
67
+ ## Options
68
+
69
+ The tool has two inputs:
70
+
71
+ ```ts
72
+ {
73
+ inputText: string;
74
+ commit: boolean;
75
+ }
76
+ ```
77
+
78
+ - `inputText` is the request or TODO markdown to work on.
79
+ - `commit` controls whether Pi Long Task may create git commits.
80
+
81
+ No other public options are required.
82
+
83
+ ## Commits and files
84
+
85
+ When `commit` is `false`, Pi Long Task never creates commits.
86
+
87
+ When `commit` is `true`, it may commit eligible task changes after a task reports useful progress. It avoids committing:
88
+
89
+ - generated run files under `tmp/pi-long-task/`
90
+ - generated `TASK_RESULT.md` files
91
+ - files that were already dirty before the task started
92
+
93
+ This lets you keep existing local work separate from Pi Long Task changes.
94
+
95
+ ## Validate the install
96
+
97
+ Run the local checks:
98
+
99
+ ```bash
100
+ cd /path/to/pi-long-task
101
+ npm run check
102
+ ```
103
+
104
+ Check that Pi can load the extension:
105
+
106
+ ```bash
107
+ PI_OFFLINE=1 pi --mode json --no-extensions -e /path/to/pi-long-task --no-session
108
+ ```
109
+
110
+ Run the full native smoke test if Pi has usable model credentials:
111
+
112
+ ```bash
113
+ npm run smoke:native
114
+ ```
115
+
116
+ That smoke test creates disposable git repos and verifies both `commit: false` and `commit: true` runs.
117
+
118
+ ## Notes
119
+
120
+ - Tasks run one at a time.
121
+ - Real runs require a working Pi model/login or API key.
122
+ - Run artifacts are written under `tmp/pi-long-task/<run-id>/`.
123
+
124
+ ## License
125
+
126
+ MIT. See [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "pi-long-task",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension for breaking down and running long coding tasks safely.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "long-task",
9
+ "task-runner",
10
+ "coding-agent",
11
+ "pi-extension"
12
+ ],
13
+ "files": [
14
+ "LICENSE",
15
+ "README.md",
16
+ "scripts",
17
+ "src"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --experimental-strip-types --test",
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "eslint .",
23
+ "format": "prettier --write .",
24
+ "format:check": "prettier --check .",
25
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
26
+ "smoke:native": "node scripts/native_smoke.mjs"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./src/index.ts"
31
+ ]
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*",
37
+ "typebox": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-ai": "^0.74.2",
41
+ "@earendil-works/pi-coding-agent": "^0.74.2",
42
+ "@earendil-works/pi-tui": "^0.74.2",
43
+ "@eslint/js": "^10.0.1",
44
+ "@types/node": "^25.9.3",
45
+ "eslint": "^10.5.0",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "globals": "^17.6.0",
48
+ "prettier": "^3.8.4",
49
+ "typebox": "^1.2.13",
50
+ "typescript": "^6.0.3",
51
+ "typescript-eslint": "^8.61.1"
52
+ },
53
+ "license": "MIT",
54
+ "author": "Dejan Jacimovic <dejan@stuntcoders.com>",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/thestuntcoder/pi-long-task.git"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/thestuntcoder/pi-long-task/issues"
61
+ },
62
+ "homepage": "https://github.com/thestuntcoder/pi-long-task#readme"
63
+ }
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { mkdtemp, rm, mkdir } from "node:fs/promises";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const repoRoot = path.resolve(scriptDir, "..");
11
+
12
+ const PI_BINARY = process.env.PI_BINARY || "pi";
13
+ const EXTENSION_PATH = path.resolve(process.env.PI_COORDINATOR_EXTENSION || repoRoot);
14
+ const MODEL = process.env.PI_SMOKE_MODEL || "openai-codex/gpt-5.5:minimal";
15
+ const TIMEOUT_MS = positiveInteger(process.env.PI_SMOKE_TIMEOUT_MS, 20 * 60 * 1000);
16
+ const KEEP = truthy(process.env.PI_SMOKE_KEEP);
17
+
18
+ const cases = [
19
+ {
20
+ name: "commit-false",
21
+ commit: false,
22
+ markerFile: "SMOKE_FALSE.txt",
23
+ markerText: "commit false smoke ok",
24
+ title: "Create non-commit smoke marker",
25
+ goal: "Verify Pi Long Task can modify the disposable repo while commits are disabled.",
26
+ },
27
+ {
28
+ name: "commit-true",
29
+ commit: true,
30
+ markerFile: "SMOKE_TRUE.txt",
31
+ markerText: "commit true smoke ok",
32
+ title: "Create commit smoke marker",
33
+ goal: "Verify Pi Long Task can modify the disposable repo and commit eligible changes.",
34
+ },
35
+ ];
36
+
37
+ const smokeRoot = await mkdtemp(path.join(os.tmpdir(), "pi-long-task-native-smoke-"));
38
+ let success = false;
39
+
40
+ try {
41
+ console.log(`Native Pi Long Task smoke root: ${smokeRoot}`);
42
+ console.log(`Extension: ${EXTENSION_PATH}`);
43
+ console.log(`Outer model: ${MODEL}`);
44
+
45
+ const summaries = [];
46
+ for (const testCase of cases) {
47
+ summaries.push(await runSmokeCase(testCase));
48
+ }
49
+
50
+ success = true;
51
+ console.log("\nNative Pi Long Task smoke passed:");
52
+ for (const summary of summaries) {
53
+ const commitText = summary.commitHash ? `, Pi Long Task commit ${summary.commitHash}` : "";
54
+ console.log(`- ${summary.name}: ${summary.status}, marker verified${commitText}`);
55
+ }
56
+ } finally {
57
+ if (success && !KEEP) {
58
+ await rm(smokeRoot, { recursive: true, force: true });
59
+ } else {
60
+ console.log(`Smoke artifacts kept at: ${smokeRoot}`);
61
+ }
62
+ }
63
+
64
+ async function runSmokeCase(testCase) {
65
+ const caseDir = path.join(smokeRoot, testCase.name);
66
+ const repoDir = path.join(caseDir, "repo");
67
+ await mkdir(repoDir, { recursive: true });
68
+ initializeGitRepo(repoDir, testCase.name);
69
+
70
+ const prompt = buildPrompt(testCase);
71
+ const stdoutPath = path.join(caseDir, "pi.stdout.jsonl");
72
+ const stderrPath = path.join(caseDir, "pi.stderr.log");
73
+ const args = [
74
+ "-p",
75
+ "--mode",
76
+ "json",
77
+ "--no-session",
78
+ "--no-extensions",
79
+ "-e",
80
+ EXTENSION_PATH,
81
+ "--no-context-files",
82
+ "--no-skills",
83
+ "--no-prompt-templates",
84
+ "--tools",
85
+ "pi_long_task",
86
+ "--model",
87
+ MODEL,
88
+ prompt,
89
+ ];
90
+
91
+ console.log(`\n[${testCase.name}] running Pi in ${repoDir}`);
92
+ const result = await runProcess(PI_BINARY, args, {
93
+ cwd: repoDir,
94
+ env: {
95
+ ...process.env,
96
+ PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK || "1",
97
+ },
98
+ timeoutMs: TIMEOUT_MS,
99
+ stdoutPath,
100
+ stderrPath,
101
+ });
102
+
103
+ if (result.code !== 0) {
104
+ throw new Error(`[${testCase.name}] pi exited with ${result.code}. stdout: ${stdoutPath}; stderr: ${stderrPath}`);
105
+ }
106
+
107
+ const events = parseJsonEvents(result.stdout);
108
+ const longTaskEnd = events.find((event) => event.type === "tool_execution_end" && event.toolName === "pi_long_task");
109
+ if (!longTaskEnd) {
110
+ throw new Error(`[${testCase.name}] did not observe pi_long_task tool_execution_end in ${stdoutPath}`);
111
+ }
112
+ if (longTaskEnd.isError) {
113
+ throw new Error(`[${testCase.name}] pi_long_task ended with isError=true. See ${stdoutPath}`);
114
+ }
115
+
116
+ const details = longTaskEnd.result?.details;
117
+ assertLongTaskDetails(testCase, details, stdoutPath);
118
+ assertMarker(repoDir, testCase.markerFile, testCase.markerText);
119
+ assertGitState(repoDir, testCase, details);
120
+
121
+ const summary = {
122
+ name: testCase.name,
123
+ status: details.status,
124
+ commitHash: details.commits?.[0]?.hash,
125
+ };
126
+ console.log(`[${testCase.name}] ok; stdout: ${stdoutPath}`);
127
+ return summary;
128
+ }
129
+
130
+ function initializeGitRepo(repoDir, caseName) {
131
+ runGit(["init", "-q"], repoDir);
132
+ runGit(["config", "user.email", "smoke@example.invalid"], repoDir);
133
+ runGit(["config", "user.name", "Pi Long Task Smoke"], repoDir);
134
+ writeFileSync(path.join(repoDir, "README.md"), `# ${caseName}\n`, "utf8");
135
+ runGit(["add", "README.md"], repoDir);
136
+ runGit(["commit", "-q", "-m", "init"], repoDir);
137
+ }
138
+
139
+ function buildPrompt(testCase) {
140
+ return `Call the \`pi_long_task\` tool exactly once. Do not use any other tool and do not answer from your own knowledge.
141
+ Use these exact parameters:
142
+ - commit: ${String(testCase.commit)}
143
+ - inputText:
144
+ \`\`\`markdown
145
+ # TODO
146
+
147
+ ## TODO 1 — ${testCase.title}
148
+
149
+ **Goal:** ${testCase.goal}
150
+
151
+ **Status:**
152
+ - [ ] Create a file named \`${testCase.markerFile}\` containing exactly \`${testCase.markerText}\` followed by a newline.
153
+ - [ ] Verify it by running \`cat ${testCase.markerFile}\`.
154
+ \`\`\``;
155
+ }
156
+
157
+ function assertLongTaskDetails(testCase, details, stdoutPath) {
158
+ if (!details || typeof details !== "object") {
159
+ throw new Error(`[${testCase.name}] missing Pi Long Task result details. See ${stdoutPath}`);
160
+ }
161
+ const expected = {
162
+ status: "done",
163
+ totalTasks: 1,
164
+ completedTasks: 1,
165
+ failedTasks: 0,
166
+ blockedTasks: 0,
167
+ };
168
+ for (const [key, value] of Object.entries(expected)) {
169
+ if (details[key] !== value) {
170
+ throw new Error(`[${testCase.name}] expected details.${key}=${value}, got ${details[key]}. See ${stdoutPath}`);
171
+ }
172
+ }
173
+ const commitCount = Array.isArray(details.commits) ? details.commits.length : 0;
174
+ if (testCase.commit && commitCount !== 1) {
175
+ throw new Error(`[${testCase.name}] expected one Pi Long Task commit, got ${commitCount}. See ${stdoutPath}`);
176
+ }
177
+ if (!testCase.commit && commitCount !== 0) {
178
+ throw new Error(`[${testCase.name}] expected zero Pi Long Task commits, got ${commitCount}. See ${stdoutPath}`);
179
+ }
180
+ }
181
+
182
+ function assertMarker(repoDir, markerFile, markerText) {
183
+ const markerPath = path.join(repoDir, markerFile);
184
+ if (!existsSync(markerPath)) {
185
+ throw new Error(`missing smoke marker ${markerPath}`);
186
+ }
187
+ const actual = readFileSync(markerPath, "utf8");
188
+ const expected = `${markerText}\n`;
189
+ if (actual !== expected) {
190
+ throw new Error(`unexpected marker contents in ${markerPath}: ${JSON.stringify(actual)}`);
191
+ }
192
+ }
193
+
194
+ function assertGitState(repoDir, testCase, details) {
195
+ const commitCount = Number(runGit(["rev-list", "--count", "HEAD"], repoDir).trim());
196
+ const status = runGit(["status", "--short", "--untracked-files=all"], repoDir);
197
+
198
+ if (testCase.commit) {
199
+ if (commitCount !== 2) {
200
+ throw new Error(`[${testCase.name}] expected exactly 2 commits after commit=true run, got ${commitCount}`);
201
+ }
202
+ const changedInHead = runGit(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], repoDir)
203
+ .trim()
204
+ .split("\n")
205
+ .filter(Boolean)
206
+ .sort();
207
+ if (JSON.stringify(changedInHead) !== JSON.stringify([testCase.markerFile])) {
208
+ throw new Error(
209
+ `[${testCase.name}] expected HEAD to contain only ${testCase.markerFile}, got ${changedInHead.join(", ")}`,
210
+ );
211
+ }
212
+ if (!details.commits?.[0]?.hash) {
213
+ throw new Error(`[${testCase.name}] missing commit hash in Pi Long Task details`);
214
+ }
215
+ if (status.includes(testCase.markerFile)) {
216
+ throw new Error(`[${testCase.name}] committed marker still appears dirty in git status:\n${status}`);
217
+ }
218
+ } else {
219
+ if (commitCount !== 1) {
220
+ throw new Error(`[${testCase.name}] expected no new commits after commit=false run, got ${commitCount}`);
221
+ }
222
+ if (!status.includes(`?? ${testCase.markerFile}`)) {
223
+ throw new Error(`[${testCase.name}] expected uncommitted marker in git status, got:\n${status}`);
224
+ }
225
+ }
226
+
227
+ const trackedArtifacts = runGit(["ls-files", "tmp/pi-long-task"], repoDir).trim();
228
+ if (trackedArtifacts) {
229
+ throw new Error(`[${testCase.name}] Pi Long Task artifacts were tracked unexpectedly:\n${trackedArtifacts}`);
230
+ }
231
+ }
232
+
233
+ function runGit(args, cwd) {
234
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
235
+ if (result.status !== 0) {
236
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${result.stderr || result.stdout}`);
237
+ }
238
+ return result.stdout;
239
+ }
240
+
241
+ function runProcess(command, args, options) {
242
+ return new Promise((resolve, reject) => {
243
+ const child = spawn(command, args, {
244
+ cwd: options.cwd,
245
+ env: options.env,
246
+ stdio: ["ignore", "pipe", "pipe"],
247
+ });
248
+ let stdout = "";
249
+ let stderr = "";
250
+ let settled = false;
251
+
252
+ const timeout = setTimeout(() => {
253
+ if (settled) {
254
+ return;
255
+ }
256
+ settled = true;
257
+ child.kill("SIGTERM");
258
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
259
+ reject(new Error(`${command} timed out after ${options.timeoutMs}ms`));
260
+ }, options.timeoutMs);
261
+
262
+ child.stdout.setEncoding("utf8");
263
+ child.stderr.setEncoding("utf8");
264
+ child.stdout.on("data", (chunk) => {
265
+ stdout += chunk;
266
+ appendFileSync(options.stdoutPath, chunk);
267
+ });
268
+ child.stderr.on("data", (chunk) => {
269
+ stderr += chunk;
270
+ appendFileSync(options.stderrPath, chunk);
271
+ });
272
+ child.on("error", (error) => {
273
+ if (settled) {
274
+ return;
275
+ }
276
+ settled = true;
277
+ clearTimeout(timeout);
278
+ reject(error);
279
+ });
280
+ child.on("close", (code, signal) => {
281
+ if (settled) {
282
+ return;
283
+ }
284
+ settled = true;
285
+ clearTimeout(timeout);
286
+ resolve({ code, signal, stdout, stderr });
287
+ });
288
+ });
289
+ }
290
+
291
+ function appendFileSync(filePath, chunk) {
292
+ writeFileSync(filePath, chunk, { encoding: "utf8", flag: "a" });
293
+ }
294
+
295
+ function parseJsonEvents(stdout) {
296
+ const events = [];
297
+ for (const rawLine of stdout.split(/\n/)) {
298
+ const jsonStart = rawLine.indexOf("{");
299
+ if (jsonStart === -1) {
300
+ continue;
301
+ }
302
+ const line = rawLine.slice(jsonStart).trim();
303
+ if (!line) {
304
+ continue;
305
+ }
306
+ try {
307
+ events.push(JSON.parse(line));
308
+ } catch {
309
+ // Pi may emit terminal control sequences next to JSON in some terminals.
310
+ // Keep parsing best-effort; missing required events are asserted later.
311
+ }
312
+ }
313
+ return events;
314
+ }
315
+
316
+ function truthy(value) {
317
+ return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase());
318
+ }
319
+
320
+ function positiveInteger(value, fallback) {
321
+ const parsed = Number(value);
322
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
323
+ }