@lawrence369/loop-cli 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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent/activity.d.ts +64 -0
- package/dist/agent/activity.js +265 -0
- package/dist/agent/launcher.d.ts +42 -0
- package/dist/agent/launcher.js +243 -0
- package/dist/agent/pty-session.d.ts +113 -0
- package/dist/agent/pty-session.js +490 -0
- package/dist/agent/ready-detector.d.ts +46 -0
- package/dist/agent/ready-detector.js +86 -0
- package/dist/agent/wrapper.d.ts +18 -0
- package/dist/agent/wrapper.js +110 -0
- package/dist/bin/lclaude.d.ts +3 -0
- package/dist/bin/lclaude.js +7 -0
- package/dist/bin/lcodex.d.ts +3 -0
- package/dist/bin/lcodex.js +7 -0
- package/dist/bin/lgemini.d.ts +3 -0
- package/dist/bin/lgemini.js +7 -0
- package/dist/bus/daemon.d.ts +56 -0
- package/dist/bus/daemon.js +135 -0
- package/dist/bus/event-bus.d.ts +105 -0
- package/dist/bus/event-bus.js +157 -0
- package/dist/bus/message.d.ts +48 -0
- package/dist/bus/message.js +129 -0
- package/dist/bus/queue.d.ts +50 -0
- package/dist/bus/queue.js +100 -0
- package/dist/bus/store.d.ts +88 -0
- package/dist/bus/store.js +212 -0
- package/dist/bus/subscriber.d.ts +76 -0
- package/dist/bus/subscriber.js +187 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +72 -0
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.js +58 -0
- package/dist/core/conversation.d.ts +34 -0
- package/dist/core/conversation.js +289 -0
- package/dist/core/engine.d.ts +40 -0
- package/dist/core/engine.js +288 -0
- package/dist/core/loop.d.ts +33 -0
- package/dist/core/loop.js +209 -0
- package/dist/core/protocol.d.ts +60 -0
- package/dist/core/protocol.js +162 -0
- package/dist/core/scoring.d.ts +34 -0
- package/dist/core/scoring.js +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +408 -0
- package/dist/orchestrator/daemon.d.ts +74 -0
- package/dist/orchestrator/daemon.js +294 -0
- package/dist/orchestrator/group.d.ts +73 -0
- package/dist/orchestrator/group.js +166 -0
- package/dist/orchestrator/ipc-server.d.ts +60 -0
- package/dist/orchestrator/ipc-server.js +166 -0
- package/dist/orchestrator/scheduler.d.ts +32 -0
- package/dist/orchestrator/scheduler.js +95 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +42 -0
- package/dist/plan/decisions.d.ts +18 -0
- package/dist/plan/decisions.js +143 -0
- package/dist/plan/shared-plan.d.ts +33 -0
- package/dist/plan/shared-plan.js +211 -0
- package/dist/skills/executor.d.ts +7 -0
- package/dist/skills/executor.js +11 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +54 -0
- package/dist/terminal/adapter.d.ts +61 -0
- package/dist/terminal/adapter.js +42 -0
- package/dist/terminal/detect.d.ts +30 -0
- package/dist/terminal/detect.js +77 -0
- package/dist/terminal/iterm2-adapter.d.ts +19 -0
- package/dist/terminal/iterm2-adapter.js +120 -0
- package/dist/terminal/pty-adapter.d.ts +18 -0
- package/dist/terminal/pty-adapter.js +84 -0
- package/dist/terminal/terminal-adapter.d.ts +17 -0
- package/dist/terminal/terminal-adapter.js +94 -0
- package/dist/terminal/tmux-adapter.d.ts +18 -0
- package/dist/terminal/tmux-adapter.js +127 -0
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.js +145 -0
- package/dist/ui/colors.d.ts +41 -0
- package/dist/ui/colors.js +65 -0
- package/dist/ui/dashboard.d.ts +32 -0
- package/dist/ui/dashboard.js +138 -0
- package/dist/ui/input.d.ts +10 -0
- package/dist/ui/input.js +96 -0
- package/dist/ui/interactive.d.ts +13 -0
- package/dist/ui/interactive.js +230 -0
- package/dist/ui/renderer.d.ts +33 -0
- package/dist/ui/renderer.js +106 -0
- package/dist/utils/ansi.d.ts +11 -0
- package/dist/utils/ansi.js +16 -0
- package/dist/utils/fs.d.ts +34 -0
- package/dist/utils/fs.js +115 -0
- package/dist/utils/lock.d.ts +12 -0
- package/dist/utils/lock.js +116 -0
- package/dist/utils/process.d.ts +31 -0
- package/dist/utils/process.js +111 -0
- package/dist/utils/pty-filter.d.ts +31 -0
- package/dist/utils/pty-filter.js +187 -0
- package/package.json +71 -0
- package/skills/loop/SKILL.md +19 -0
- package/skills/plan/SKILL.md +9 -0
- package/skills/review/SKILL.md +14 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { dim, formatBytes } from "./colors.js";
|
|
2
|
+
const TERMINAL_RESET = "\x1b[0m\x1b[?25h\x1b[?2026l\x1b[?1049l\x1b[?1047l\x1b[?47l";
|
|
3
|
+
export class PtyRenderer {
|
|
4
|
+
color = (s) => s;
|
|
5
|
+
engineLabel = "";
|
|
6
|
+
role = "";
|
|
7
|
+
receivedBytes = 0;
|
|
8
|
+
started = false;
|
|
9
|
+
endedWithLineBreak = true;
|
|
10
|
+
start(engineLabel, role, color) {
|
|
11
|
+
if (this.started)
|
|
12
|
+
return;
|
|
13
|
+
this.started = true;
|
|
14
|
+
this.color = color;
|
|
15
|
+
this.engineLabel = engineLabel;
|
|
16
|
+
this.role = role;
|
|
17
|
+
this.receivedBytes = 0;
|
|
18
|
+
const header = this.color(` \u250C\u2500 \u25A0 ${this.engineLabel} (${this.role}) ${"\u2500".repeat(Math.max(0, 44 - this.engineLabel.length - this.role.length - 3))}\u2510`);
|
|
19
|
+
console.log(header);
|
|
20
|
+
console.log(this.color(" \u2502"));
|
|
21
|
+
this.endedWithLineBreak = true;
|
|
22
|
+
}
|
|
23
|
+
write(data) {
|
|
24
|
+
if (!this.started)
|
|
25
|
+
return;
|
|
26
|
+
this.receivedBytes += Buffer.byteLength(data);
|
|
27
|
+
process.stdout.write(data);
|
|
28
|
+
this.endedWithLineBreak = /(?:\r\n|\r|\n)$/.test(data);
|
|
29
|
+
}
|
|
30
|
+
stop(stats) {
|
|
31
|
+
if (!this.started)
|
|
32
|
+
return;
|
|
33
|
+
this.started = false;
|
|
34
|
+
// Restore common terminal modes in case the executor CLI was killed mid-TUI
|
|
35
|
+
process.stdout.write(TERMINAL_RESET);
|
|
36
|
+
if (!this.endedWithLineBreak) {
|
|
37
|
+
process.stdout.write("\r\n");
|
|
38
|
+
}
|
|
39
|
+
const elapsed = formatElapsed(stats.elapsed_ms);
|
|
40
|
+
const bytes = formatBytes(stats.bytes);
|
|
41
|
+
console.log(this.color(" \u2502"));
|
|
42
|
+
const statsText = `\u2713 done ${dim(`(${elapsed}, ${bytes})`)}`;
|
|
43
|
+
const footer = this.color(` \u2514${"\u2500".repeat(42)}`) +
|
|
44
|
+
` ${statsText} ` +
|
|
45
|
+
this.color("\u2500\u2518");
|
|
46
|
+
console.log(footer);
|
|
47
|
+
}
|
|
48
|
+
get totalBytes() {
|
|
49
|
+
return this.receivedBytes;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function formatElapsed(ms) {
|
|
53
|
+
if (ms < 1000)
|
|
54
|
+
return `${ms}ms`;
|
|
55
|
+
const sec = ms / 1000;
|
|
56
|
+
if (sec < 60)
|
|
57
|
+
return `${sec.toFixed(1)}s`;
|
|
58
|
+
const min = Math.floor(sec / 60);
|
|
59
|
+
const remainSec = Math.floor(sec % 60);
|
|
60
|
+
return `${min}m${remainSec}s`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Forward user input to the PTY while reserving a small set of
|
|
64
|
+
* loop control shortcuts.
|
|
65
|
+
* Returns a cleanup function.
|
|
66
|
+
*/
|
|
67
|
+
export function startKeystrokeHandler(writeToPty, opts) {
|
|
68
|
+
const isTTY = !!process.stdin.isTTY;
|
|
69
|
+
if (isTTY)
|
|
70
|
+
process.stdin.setRawMode(true);
|
|
71
|
+
process.stdin.resume();
|
|
72
|
+
let lastCtrlC = 0;
|
|
73
|
+
function onData(data) {
|
|
74
|
+
const str = typeof data === "string" ? data : data.toString("utf8");
|
|
75
|
+
// Shift+Tab: toggle mode
|
|
76
|
+
if (str === "\x1b[Z") {
|
|
77
|
+
opts.onModeToggle();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Ctrl+D: done
|
|
81
|
+
if (str === "\x04") {
|
|
82
|
+
opts.onDone();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Ctrl+C: double-tap to cancel, single forwards to PTY
|
|
86
|
+
if (str === "\x03") {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
if (now - lastCtrlC < 500) {
|
|
89
|
+
opts.onCancel();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
lastCtrlC = now;
|
|
93
|
+
writeToPty(str);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
writeToPty(str);
|
|
97
|
+
}
|
|
98
|
+
process.stdin.on("data", onData);
|
|
99
|
+
return () => {
|
|
100
|
+
process.stdin.removeListener("data", onData);
|
|
101
|
+
if (isTTY)
|
|
102
|
+
process.stdin.setRawMode(false);
|
|
103
|
+
process.stdin.pause();
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=renderer.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip all ANSI escape sequences from a string.
|
|
3
|
+
* Delegates to the battle-tested `strip-ansi` package for robust PTY handling.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stripAnsi(s: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if the string consists entirely of ANSI escape codes
|
|
8
|
+
* (i.e., stripping them yields an empty string).
|
|
9
|
+
*/
|
|
10
|
+
export declare function isAnsiOnly(s: string): boolean;
|
|
11
|
+
//# sourceMappingURL=ansi.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import stripAnsiModule from "strip-ansi";
|
|
2
|
+
/**
|
|
3
|
+
* Strip all ANSI escape sequences from a string.
|
|
4
|
+
* Delegates to the battle-tested `strip-ansi` package for robust PTY handling.
|
|
5
|
+
*/
|
|
6
|
+
export function stripAnsi(s) {
|
|
7
|
+
return stripAnsiModule(s);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Returns true if the string consists entirely of ANSI escape codes
|
|
11
|
+
* (i.e., stripping them yields an empty string).
|
|
12
|
+
*/
|
|
13
|
+
export function isAnsiOnly(s) {
|
|
14
|
+
return s.length > 0 && stripAnsiModule(s).length === 0;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=ansi.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure a directory exists, creating it recursively if needed.
|
|
3
|
+
*/
|
|
4
|
+
export declare function ensureDir(dir: string): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Append a JSON object as a single line to a JSONL file.
|
|
7
|
+
*/
|
|
8
|
+
export declare function appendJsonl(filePath: string, data: unknown): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Read all lines from a JSONL file, parsing each as JSON.
|
|
11
|
+
* Returns an empty array if the file does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readJsonl<T>(filePath: string): Promise<T[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Atomically write a file by writing to a temp file then renaming.
|
|
16
|
+
*/
|
|
17
|
+
export declare function safeWriteFile(filePath: string, content: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Read a file's contents, returning null if it does not exist.
|
|
20
|
+
*/
|
|
21
|
+
export declare function safeReadFile(filePath: string): Promise<string | null>;
|
|
22
|
+
/**
|
|
23
|
+
* Check if a file exists.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Truncate a file to empty (or create it if it doesn't exist).
|
|
28
|
+
*/
|
|
29
|
+
export declare function truncateFile(filePath: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Read the last non-empty line from a file.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readLastLine(filePath: string): Promise<string | null>;
|
|
34
|
+
//# sourceMappingURL=fs.d.ts.map
|
package/dist/utils/fs.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdir, appendFile, readFile, writeFile, access, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Ensure a directory exists, creating it recursively if needed.
|
|
5
|
+
*/
|
|
6
|
+
export async function ensureDir(dir) {
|
|
7
|
+
await mkdir(dir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Append a JSON object as a single line to a JSONL file.
|
|
11
|
+
*/
|
|
12
|
+
export async function appendJsonl(filePath, data) {
|
|
13
|
+
await ensureDir(dirname(filePath));
|
|
14
|
+
const line = JSON.stringify(data) + "\n";
|
|
15
|
+
await appendFile(filePath, line, "utf8");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read all lines from a JSONL file, parsing each as JSON.
|
|
19
|
+
* Returns an empty array if the file does not exist.
|
|
20
|
+
*/
|
|
21
|
+
export async function readJsonl(filePath) {
|
|
22
|
+
let content;
|
|
23
|
+
try {
|
|
24
|
+
content = await readFile(filePath, "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
try {
|
|
36
|
+
results.push(JSON.parse(line));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Skip malformed lines
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Atomically write a file by writing to a temp file then renaming.
|
|
46
|
+
*/
|
|
47
|
+
export async function safeWriteFile(filePath, content) {
|
|
48
|
+
await ensureDir(dirname(filePath));
|
|
49
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(tmpPath, content, "utf8");
|
|
52
|
+
await rename(tmpPath, filePath);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
// Clean up temp file on failure
|
|
56
|
+
try {
|
|
57
|
+
await unlink(tmpPath);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Ignore cleanup errors
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read a file's contents, returning null if it does not exist.
|
|
67
|
+
*/
|
|
68
|
+
export async function safeReadFile(filePath) {
|
|
69
|
+
try {
|
|
70
|
+
return await readFile(filePath, "utf8");
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Check if a file exists.
|
|
81
|
+
*/
|
|
82
|
+
export async function fileExists(filePath) {
|
|
83
|
+
try {
|
|
84
|
+
await access(filePath);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Truncate a file to empty (or create it if it doesn't exist).
|
|
93
|
+
*/
|
|
94
|
+
export async function truncateFile(filePath) {
|
|
95
|
+
await ensureDir(dirname(filePath));
|
|
96
|
+
await writeFile(filePath, "", "utf8");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read the last non-empty line from a file.
|
|
100
|
+
*/
|
|
101
|
+
export async function readLastLine(filePath) {
|
|
102
|
+
let content;
|
|
103
|
+
try {
|
|
104
|
+
content = await readFile(filePath, "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
110
|
+
return lines.length > 0 ? lines[lines.length - 1] : null;
|
|
111
|
+
}
|
|
112
|
+
function isNodeError(err) {
|
|
113
|
+
return err instanceof Error && "code" in err;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=fs.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acquire a file-based lock, execute a function, then release.
|
|
3
|
+
* The lock file contains the PID of the holder.
|
|
4
|
+
* Stale locks (dead PID or older than 30s) are automatically cleaned.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => Promise<T>, timeout?: number): Promise<T>;
|
|
7
|
+
/**
|
|
8
|
+
* Atomically increment a counter file under a file lock.
|
|
9
|
+
* If the counter file is missing or corrupt, attempts to recover from 0.
|
|
10
|
+
*/
|
|
11
|
+
export declare function nextSeq(counterPath: string, lockPath: string): Promise<number>;
|
|
12
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { openSync, writeSync, closeSync, readFileSync, writeFileSync, unlinkSync, statSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { isProcessAlive } from "./process.js";
|
|
4
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
5
|
+
const LOCK_POLL_MS = 25;
|
|
6
|
+
const LOCK_STALE_MS = 30000;
|
|
7
|
+
function isNodeError(err) {
|
|
8
|
+
return err instanceof Error && "code" in err;
|
|
9
|
+
}
|
|
10
|
+
function cleanupStaleLock(lockPath) {
|
|
11
|
+
let shouldRemove = false;
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(lockPath, "utf8").trim();
|
|
14
|
+
const pid = parseInt(raw, 10);
|
|
15
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
16
|
+
shouldRemove = true;
|
|
17
|
+
}
|
|
18
|
+
else if (!isProcessAlive(pid)) {
|
|
19
|
+
shouldRemove = true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
shouldRemove = true;
|
|
24
|
+
}
|
|
25
|
+
if (!shouldRemove) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(lockPath);
|
|
28
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
29
|
+
shouldRemove = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
shouldRemove = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (shouldRemove) {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(lockPath);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Ignore cleanup errors
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Acquire a file-based lock, execute a function, then release.
|
|
50
|
+
* The lock file contains the PID of the holder.
|
|
51
|
+
* Stale locks (dead PID or older than 30s) are automatically cleaned.
|
|
52
|
+
*/
|
|
53
|
+
export async function withFileLock(lockPath, fn, timeout = LOCK_TIMEOUT_MS) {
|
|
54
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
55
|
+
const deadline = Date.now() + timeout;
|
|
56
|
+
let lockFd = null;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
try {
|
|
59
|
+
lockFd = openSync(lockPath, "wx");
|
|
60
|
+
writeSync(lockFd, `${process.pid}\n`);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (isNodeError(err) && err.code === "EEXIST") {
|
|
65
|
+
cleanupStaleLock(lockPath);
|
|
66
|
+
await sleep(LOCK_POLL_MS);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (lockFd === null) {
|
|
73
|
+
throw new Error(`Failed to acquire file lock: ${lockPath} (timeout ${timeout}ms)`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return await fn();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
try {
|
|
80
|
+
closeSync(lockFd);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(lockPath);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Atomically increment a counter file under a file lock.
|
|
95
|
+
* If the counter file is missing or corrupt, attempts to recover from 0.
|
|
96
|
+
*/
|
|
97
|
+
export async function nextSeq(counterPath, lockPath) {
|
|
98
|
+
return withFileLock(lockPath, async () => {
|
|
99
|
+
let current = 0;
|
|
100
|
+
try {
|
|
101
|
+
const raw = readFileSync(counterPath, "utf8").trim();
|
|
102
|
+
const parsed = parseInt(raw, 10);
|
|
103
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
104
|
+
current = parsed;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Counter file missing or unreadable - start from 0
|
|
109
|
+
}
|
|
110
|
+
const next = current + 1;
|
|
111
|
+
mkdirSync(dirname(counterPath), { recursive: true });
|
|
112
|
+
writeFileSync(counterPath, `${next}\n`, "utf8");
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a process with the given PID is alive.
|
|
3
|
+
*/
|
|
4
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Set up signal handlers for graceful shutdown.
|
|
7
|
+
* The cleanup function is called once on the first signal received.
|
|
8
|
+
*/
|
|
9
|
+
export declare function setupSignalHandlers(cleanup: () => Promise<void>): void;
|
|
10
|
+
/**
|
|
11
|
+
* Daemonize a script by spawning it detached with stdio redirected to a log file.
|
|
12
|
+
* Returns the PID of the child process.
|
|
13
|
+
*/
|
|
14
|
+
export declare function daemonize(script: string, args: string[], logPath: string): number;
|
|
15
|
+
/**
|
|
16
|
+
* Write the current process PID to a file.
|
|
17
|
+
*/
|
|
18
|
+
export declare function writePidFile(pidPath: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Read a PID from a file, returning null if missing or invalid.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readPidFile(pidPath: string): number | null;
|
|
23
|
+
/**
|
|
24
|
+
* Remove a PID file.
|
|
25
|
+
*/
|
|
26
|
+
export declare function removePidFile(pidPath: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Get the absolute path to a script within this package.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveScript(relativePath: string): string;
|
|
31
|
+
//# sourceMappingURL=process.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
function isNodeError(err) {
|
|
6
|
+
return err instanceof Error && "code" in err;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Check if a process with the given PID is alive.
|
|
10
|
+
*/
|
|
11
|
+
export function isProcessAlive(pid) {
|
|
12
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (isNodeError(err) && err.code === "EPERM") {
|
|
21
|
+
// Process exists but we lack permission to signal it
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Set up signal handlers for graceful shutdown.
|
|
29
|
+
* The cleanup function is called once on the first signal received.
|
|
30
|
+
*/
|
|
31
|
+
export function setupSignalHandlers(cleanup) {
|
|
32
|
+
let cleaning = false;
|
|
33
|
+
const handler = () => {
|
|
34
|
+
if (cleaning)
|
|
35
|
+
return;
|
|
36
|
+
cleaning = true;
|
|
37
|
+
cleanup().finally(() => {
|
|
38
|
+
process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
process.on("SIGTERM", handler);
|
|
42
|
+
process.on("SIGINT", handler);
|
|
43
|
+
process.on("exit", () => {
|
|
44
|
+
// Synchronous cleanup on exit - best effort
|
|
45
|
+
if (!cleaning) {
|
|
46
|
+
cleaning = true;
|
|
47
|
+
// Can't await here, but we try to be helpful
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Daemonize a script by spawning it detached with stdio redirected to a log file.
|
|
53
|
+
* Returns the PID of the child process.
|
|
54
|
+
*/
|
|
55
|
+
export function daemonize(script, args, logPath) {
|
|
56
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
57
|
+
const logFd = openSync(logPath, "a");
|
|
58
|
+
const child = spawn(process.execPath, [script, ...args], {
|
|
59
|
+
detached: true,
|
|
60
|
+
stdio: ["ignore", logFd, logFd],
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
});
|
|
63
|
+
child.unref();
|
|
64
|
+
closeSync(logFd);
|
|
65
|
+
const pid = child.pid;
|
|
66
|
+
if (pid === undefined) {
|
|
67
|
+
throw new Error("Failed to spawn daemon process");
|
|
68
|
+
}
|
|
69
|
+
return pid;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Write the current process PID to a file.
|
|
73
|
+
*/
|
|
74
|
+
export function writePidFile(pidPath) {
|
|
75
|
+
mkdirSync(dirname(pidPath), { recursive: true });
|
|
76
|
+
writeFileSync(pidPath, `${process.pid}\n`, "utf8");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read a PID from a file, returning null if missing or invalid.
|
|
80
|
+
*/
|
|
81
|
+
export function readPidFile(pidPath) {
|
|
82
|
+
try {
|
|
83
|
+
const raw = readFileSync(pidPath, "utf8").trim();
|
|
84
|
+
const pid = parseInt(raw, 10);
|
|
85
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
86
|
+
return pid;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove a PID file.
|
|
96
|
+
*/
|
|
97
|
+
export function removePidFile(pidPath) {
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(pidPath);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Ignore - file may not exist
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get the absolute path to a script within this package.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveScript(relativePath) {
|
|
109
|
+
return resolve(relativePath);
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=process.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY output classification and filtering.
|
|
3
|
+
*
|
|
4
|
+
* Ported from iterloop's pty-session.ts — classifies raw terminal output lines
|
|
5
|
+
* into content, status updates, or ignorable TUI noise so that only meaningful
|
|
6
|
+
* text reaches the reviewer and transcript.
|
|
7
|
+
*/
|
|
8
|
+
export type LineClass = "content" | "status" | "ignore";
|
|
9
|
+
/**
|
|
10
|
+
* Classify a single (ANSI-stripped) line of PTY output.
|
|
11
|
+
*
|
|
12
|
+
* - `"content"` — meaningful AI output to keep
|
|
13
|
+
* - `"status"` — transient status / spinner text
|
|
14
|
+
* - `"ignore"` — TUI chrome, empty lines, noise
|
|
15
|
+
*/
|
|
16
|
+
export declare function classifyLine(line: string, engine?: string): LineClass;
|
|
17
|
+
/**
|
|
18
|
+
* Filter raw PTY output to extract only meaningful content.
|
|
19
|
+
*
|
|
20
|
+
* Strips TUI chrome, spinners, box-drawing, update notices, and other noise.
|
|
21
|
+
* For Claude output, strips the ⏺ content marker prefix.
|
|
22
|
+
*
|
|
23
|
+
* Result is capped at ~50 KB (last 50 KB on overflow).
|
|
24
|
+
*/
|
|
25
|
+
export declare function filterOutput(raw: string, _engine?: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Remove duplicate consecutive lines (TUI re-renders can emit the same
|
|
28
|
+
* content multiple times). Comparison ignores whitespace differences.
|
|
29
|
+
*/
|
|
30
|
+
export declare function deduplicateLines(lines: string[]): string[];
|
|
31
|
+
//# sourceMappingURL=pty-filter.d.ts.map
|