pi-background-tasks 0.1.0 → 0.2.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/PUBLISHING.md +10 -8
- package/README.md +64 -9
- package/TESTING.md +103 -0
- package/TEST_PLAN.md +91 -0
- package/extensions/background-tasks.ts +1 -1305
- package/package.json +25 -2
- package/src/core/common.ts +24 -0
- package/src/extension.ts +1244 -0
- package/src/testing/normalize.ts +3 -0
- package/src/ui/background-tasks-manager.ts +565 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-background-tasks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude-Code-like background shell task manager for Pi: bg_run tools, /bg commands,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Claude-Code-like named background shell task manager for Pi: bg_run tools, /bg commands, Shift+Down footer dock, bounded logs, kill/timeout safety, and completion wakeups.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
7
7
|
"author": "Ismail <ismailsalikhodjaev@gmail.com>",
|
|
@@ -27,11 +27,23 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"extensions/",
|
|
30
|
+
"src/",
|
|
30
31
|
"README.md",
|
|
32
|
+
"TESTING.md",
|
|
33
|
+
"TEST_PLAN.md",
|
|
31
34
|
"PUBLISHING.md",
|
|
32
35
|
"LICENSE"
|
|
33
36
|
],
|
|
34
37
|
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test:unit": "tsx --test tests/unit/**/*.test.ts",
|
|
40
|
+
"test:sdk": "tsx --test tests/sdk/**/*.test.ts",
|
|
41
|
+
"test:rpc": "tsx --test tests/rpc/**/*.test.ts",
|
|
42
|
+
"test:component": "tsx --test tests/component/**/*.test.ts",
|
|
43
|
+
"test:package": "tsx --test tests/package/**/*.test.ts",
|
|
44
|
+
"test": "npm run typecheck && npm run test:unit && npm run test:sdk && npm run test:rpc && npm run test:component && npm run test:package",
|
|
45
|
+
"test:pty": "tsx --test tests/pty/**/*.test.ts",
|
|
46
|
+
"test:full": "npm run test && npm run test:pty",
|
|
35
47
|
"smoke": "pi --no-extensions -e ./extensions/background-tasks.ts --offline --no-tools --no-session -p \"/jobs\"",
|
|
36
48
|
"pack:dry-run": "npm pack --dry-run"
|
|
37
49
|
},
|
|
@@ -44,5 +56,16 @@
|
|
|
44
56
|
"@earendil-works/pi-coding-agent": "*",
|
|
45
57
|
"@earendil-works/pi-tui": "*",
|
|
46
58
|
"typebox": "*"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
62
|
+
"@earendil-works/pi-tui": "^0.75.5",
|
|
63
|
+
"@types/node": "^24.0.0",
|
|
64
|
+
"tsx": "^4.19.0",
|
|
65
|
+
"typebox": "^1.1.38",
|
|
66
|
+
"typescript": "^5.9.0"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=22.19.0"
|
|
47
70
|
}
|
|
48
71
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = "running" | "completed" | "failed" | "killed";
|
|
6
|
+
export type TaskContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
|
|
7
|
+
export type BgTaskSnapshot = { id: string; name?: string; command: string; description?: string; status: TaskStatus; outputPath: string; cwd: string; startTime: number; endTime?: number; exitCode?: number | null; signal?: string | null; pid?: number; bytesWritten: number; error?: string; notified: boolean; notifyOnCompletion: boolean; triggerOnCompletion: boolean; timeoutSeconds?: number; contextUsage?: TaskContextUsage };
|
|
8
|
+
export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
9
|
+
export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
10
|
+
export const COMMAND_PREVIEW_CHARS = 90;
|
|
11
|
+
export function sanitizePathSegment(value: string): string { return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "session"; }
|
|
12
|
+
export function stripMatchingQuotes(value: string): string { const t=value.trim(); if(t.length>=2&&((t[0]==='"'&&t.at(-1)==='"')||(t[0]==="'"&&t.at(-1)==="'"))) return t.slice(1,-1); return t; }
|
|
13
|
+
export function compactWhitespace(value: string): string { return value.replace(/\s+/g," ").trim(); }
|
|
14
|
+
export function truncateChars(value: string, maxChars: number): string { return value.length<=maxChars?value:`${value.slice(0,Math.max(0,maxChars-1))}…`; }
|
|
15
|
+
export function normalizeTaskName(value: unknown): string|undefined { if(typeof value!=="string") return undefined; const n=compactWhitespace(stripMatchingQuotes(value)); return n?truncateChars(n,80):undefined; }
|
|
16
|
+
export function deriveTaskNameFromCommand(command: string): string { const n=compactWhitespace(stripMatchingQuotes(command)); if(!n) return "Background task"; const m=n.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/); if(m) return truncateChars(`${m[1]}${m[2]?" run":""} ${m[3]}`,48); return truncateChars(n.split(/\s+/).slice(0,5).join(" ")||n,48); }
|
|
17
|
+
export function taskDisplayName(task: { name?: string; description?: string; command?: string; id?: string }): string { return normalizeTaskName(task.name) ?? normalizeTaskName(task.description) ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined) ?? task.id ?? "Background task"; }
|
|
18
|
+
function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string }|undefined { const input=valueAndRest.trimStart(); if(!input) return undefined; const q=input[0]; if(q==='"'||q==="'"){ let escaped=false, value=""; for(let i=1;i<input.length;i++){ const c=input[i]!; if(escaped){value+=c; escaped=false; continue;} if(c==="\\"){escaped=true; continue;} if(c===q) return {value, rest: input.slice(i+1).trimStart()}; value+=c;} return undefined;} const m=input.match(/^(\S+)(?:\s+([\s\S]*))?$/); return m?{value:m[1]!, rest:m[2]?.trimStart()??""}:undefined; }
|
|
19
|
+
export function parseBgCommandArgs(args: string): { name?: string; command: string } { const input=args.trim(); for(const prefix of ["--name=","-n="]){ if(input.startsWith(prefix)){ const p=parseNameValueAndRest(input.slice(prefix.length)); if(!p) throw new Error(`${prefix.slice(0,-1)} requires a task name`); return {name:normalizeTaskName(p.value), command:p.rest}; }} for(const prefix of ["--name","-n"]){ if(input===prefix||input.startsWith(`${prefix} `)||input.startsWith(`${prefix}\t`)){ const p=parseNameValueAndRest(input.slice(prefix.length)); if(!p) throw new Error(`${prefix} requires a task name`); return {name:normalizeTaskName(p.value), command:p.rest}; }} return {command: input}; }
|
|
20
|
+
export function formatDuration(ms: number): string { if(ms<1000) return `${ms}ms`; const s=Math.floor(ms/1000); if(s<60) return `${s}s`; const m=Math.floor(s/60), rs=s%60; if(m<60) return `${m}m${rs?`${rs}s`:""}`; const h=Math.floor(m/60), rm=m%60; return `${h}h${rm?`${rm}m`:""}`; }
|
|
21
|
+
export function shellInvocation(command: string): { shell: string; args: string[] } { return process.platform === "win32" ? { shell: process.env.ComSpec || "cmd.exe", args: ["/d","/s","/c",command] } : { shell: process.env.SHELL || "/bin/sh", args: ["-c", command] }; }
|
|
22
|
+
export function normalizeMaxBytes(value: unknown, fallback=DEFAULT_LOG_BYTES): number { const raw=typeof value==="number"&&Number.isFinite(value)?Math.floor(value):fallback; return Math.max(1, Math.min(MAX_LOG_BYTES, raw)); }
|
|
23
|
+
export function formatSnapshotList(tasks: BgTaskSnapshot[], now=Date.now()): string { if(tasks.length===0) return "No background tasks in this Pi extension runtime."; return tasks.map(t=>{ const icon=t.status==="running"?"▶":t.status==="completed"?"✓":t.status==="killed"?"■":"✗"; const age=formatDuration((t.endTime??now)-t.startTime); const code=t.exitCode!==undefined?` exit=${t.exitCode}`:""; const pid=t.pid?` pid=${t.pid}`:""; const error=t.error?` error=${truncateChars(t.error,80)}`:""; return `${icon} ${t.id} ${t.status} ${age}${code}${pid} — ${truncateChars(taskDisplayName(t),COMMAND_PREVIEW_CHARS)}${error}\n output: ${t.outputPath}`; }).join("\n"); }
|
|
24
|
+
export async function boundedRead(filePath: string, maxBytes: number, tail: boolean): Promise<{content:string; truncated:boolean; bytesRead:number; totalBytes:number}> { const st=statSync(filePath); const totalBytes=st.size; const bytesToRead=Math.min(totalBytes,maxBytes); if(bytesToRead===0) return {content:"", truncated:false, bytesRead:0, totalBytes}; const f=await open(filePath,"r"); try{ const b=Buffer.alloc(bytesToRead); const pos=tail?Math.max(0,totalBytes-bytesToRead):0; const {bytesRead}=await f.read(b,0,bytesToRead,pos); return {content:b.subarray(0,bytesRead).toString("utf8"), truncated:totalBytes>bytesRead, bytesRead, totalBytes}; } finally { await f.close(); } }
|