@lawrence369/loop-cli 0.1.1 → 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/CHANGELOG.md +27 -0
- package/README.md +17 -12
- package/dist/agent/launcher.js +8 -9
- package/dist/agent/pty-session.js +14 -7
- package/dist/core/conversation.js +104 -19
- package/dist/core/loop.js +10 -10
- package/dist/index.js +71 -17
- package/dist/orchestrator/daemon-entry.d.ts +8 -0
- package/dist/orchestrator/daemon-entry.js +15 -0
- package/dist/orchestrator/daemon.js +8 -9
- package/dist/ui/interactive.js +6 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-03-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Shared plan async/sync contract mismatch: all plan functions now properly awaited
|
|
13
|
+
- Shared plan field name drift: `reviewerScore`/`reviewerApproved` → `score`/`approved`
|
|
14
|
+
- Agent launcher IPC socket path: `daemon.sock` → `loop.sock` to match daemon
|
|
15
|
+
- Agent launcher IPC message format: aligned to UPPERCASE types + nested `data` object
|
|
16
|
+
- Daemon start/stop/status: real background daemon with PID file and IPC-based status
|
|
17
|
+
- Placeholder IPC handlers (LAUNCH_AGENT, RESUME_AGENTS, LAUNCH_GROUP, STOP_GROUP) now return explicit "not implemented" errors
|
|
18
|
+
- README: config field names, default values, and command list aligned with actual CLI
|
|
19
|
+
- CLI version string now matches package.json
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Multi-turn manual mode: readline-based follow-up prompting between PTY sessions
|
|
24
|
+
- Daemon entry script for proper background process management
|
|
25
|
+
- 33 new regression tests (315 total across 26 test files)
|
|
26
|
+
|
|
27
|
+
## [0.1.2] - 2026-03-10
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Fix `posix_spawnp failed` crash: `@clack/prompts` placeholder text was leaking as actual CLI arguments
|
|
32
|
+
- Add try-catch around PTY spawn with clear error message when engine CLI is not found
|
|
33
|
+
- Add automatic fallback to non-interactive `engine.run()` when PTY spawn fails
|
|
34
|
+
|
|
8
35
|
## [0.1.0] - 2026-03-10
|
|
9
36
|
|
|
10
37
|
### Added
|
package/README.md
CHANGED
|
@@ -60,15 +60,17 @@ loop [task] # Run iteration loop (interactive if no task)
|
|
|
60
60
|
loop daemon start # Start background daemon
|
|
61
61
|
loop daemon stop # Stop daemon
|
|
62
62
|
loop daemon status # Check daemon status
|
|
63
|
-
loop bus
|
|
64
|
-
loop bus
|
|
63
|
+
loop bus send <message> # Send a message on the event bus
|
|
64
|
+
loop bus check <id> # Check for pending bus messages
|
|
65
|
+
loop bus status # Show event bus status
|
|
65
66
|
loop chat # Open real-time dashboard
|
|
66
67
|
loop plan show # Show current iteration plan
|
|
67
68
|
loop plan clear # Clear plan
|
|
68
|
-
loop ctx add
|
|
69
|
+
loop ctx add <title> # Add architectural decision
|
|
69
70
|
loop ctx list # List decisions
|
|
71
|
+
loop ctx resolve <id> # Resolve a decision
|
|
70
72
|
loop skills list # List available skills
|
|
71
|
-
loop skills
|
|
73
|
+
loop skills add <name> # Add a new skill
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
## Options
|
|
@@ -77,12 +79,12 @@ loop skills show <name> # Show skill content
|
|
|
77
79
|
|------|-------------|
|
|
78
80
|
| `-e, --executor <engine>` | Executor engine: `claude` \| `gemini` \| `codex` |
|
|
79
81
|
| `-r, --reviewer <engine>` | Reviewer engine: `claude` \| `gemini` \| `codex` |
|
|
80
|
-
| `-n, --iterations <num>` | Max iterations (default:
|
|
82
|
+
| `-n, --iterations <num>` | Max iterations (default: 3) |
|
|
81
83
|
| `-d, --dir <path>` | Working directory |
|
|
82
84
|
| `-v, --verbose` | Stream real-time output |
|
|
83
85
|
| `--auto` | Auto mode — skip manual conversation |
|
|
84
86
|
| `--pass <args...>` | Pass native flags to executor CLI |
|
|
85
|
-
| `--threshold <num>` | Approval score threshold, 1-10 (default:
|
|
87
|
+
| `--threshold <num>` | Approval score threshold, 1-10 (default: 9) |
|
|
86
88
|
|
|
87
89
|
## How It Works
|
|
88
90
|
|
|
@@ -104,12 +106,15 @@ Create `.loop/config.json` in your project:
|
|
|
104
106
|
|
|
105
107
|
```json
|
|
106
108
|
{
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"maxIterations":
|
|
110
|
-
"threshold":
|
|
111
|
-
"
|
|
112
|
-
"
|
|
109
|
+
"defaultExecutor": "claude",
|
|
110
|
+
"defaultReviewer": "gemini",
|
|
111
|
+
"maxIterations": 3,
|
|
112
|
+
"threshold": 9,
|
|
113
|
+
"mode": "manual",
|
|
114
|
+
"launchMode": "auto",
|
|
115
|
+
"autoResume": false,
|
|
116
|
+
"skillsDir": ".loop/skills",
|
|
117
|
+
"verbose": false
|
|
113
118
|
}
|
|
114
119
|
```
|
|
115
120
|
|
package/dist/agent/launcher.js
CHANGED
|
@@ -29,7 +29,7 @@ function runDir(projectRoot) {
|
|
|
29
29
|
return path.join(loopDir(projectRoot), "run");
|
|
30
30
|
}
|
|
31
31
|
function daemonSocketPath(projectRoot) {
|
|
32
|
-
return path.join(runDir(projectRoot), "
|
|
32
|
+
return path.join(runDir(projectRoot), "loop.sock");
|
|
33
33
|
}
|
|
34
34
|
function connectSocket(sockPath) {
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
@@ -99,12 +99,13 @@ async function registerWithDaemon(projectRoot, agentType, subscriberId, nickname
|
|
|
99
99
|
catch {
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
const responseData = payload.data;
|
|
103
|
+
if (payload.success === true && responseData?.subscriber_id) {
|
|
103
104
|
if (settled)
|
|
104
105
|
return;
|
|
105
106
|
settled = true;
|
|
106
107
|
cleanup();
|
|
107
|
-
resolve(
|
|
108
|
+
resolve(String(responseData.subscriber_id));
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
111
|
if (payload.type === "error") {
|
|
@@ -118,10 +119,8 @@ async function registerWithDaemon(projectRoot, agentType, subscriberId, nickname
|
|
|
118
119
|
}
|
|
119
120
|
});
|
|
120
121
|
const req = {
|
|
121
|
-
type: "
|
|
122
|
-
agentType,
|
|
123
|
-
nickname,
|
|
124
|
-
parentPid: process.pid,
|
|
122
|
+
type: "REGISTER_AGENT",
|
|
123
|
+
data: { agent_type: agentType, nickname, pid: process.pid },
|
|
125
124
|
};
|
|
126
125
|
client.write(JSON.stringify(req) + "\n");
|
|
127
126
|
});
|
|
@@ -197,8 +196,8 @@ export class AgentLauncher {
|
|
|
197
196
|
if (!client)
|
|
198
197
|
return;
|
|
199
198
|
client.write(JSON.stringify({
|
|
200
|
-
type: "
|
|
201
|
-
subscriberId,
|
|
199
|
+
type: "AGENT_READY",
|
|
200
|
+
data: { subscriber_id: subscriberId },
|
|
202
201
|
}) + "\n");
|
|
203
202
|
client.end();
|
|
204
203
|
}).catch(() => {
|
|
@@ -76,13 +76,20 @@ export class PtySession extends EventEmitter {
|
|
|
76
76
|
const rows = opts?.rows ?? process.stdout.rows ?? 24;
|
|
77
77
|
this._engine = opts?.engine;
|
|
78
78
|
this._promptPattern = DEFAULT_PROMPT_PATTERN;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
try {
|
|
80
|
+
this._pty = pty.spawn(command, args, {
|
|
81
|
+
name: "xterm-256color",
|
|
82
|
+
cols,
|
|
83
|
+
rows,
|
|
84
|
+
cwd,
|
|
85
|
+
env: { ...process.env, ...(opts?.env ?? {}) },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
throw new Error(`Failed to spawn PTY for "${command}": ${msg}\n` +
|
|
91
|
+
`Ensure "${command}" is installed and available in your PATH.`);
|
|
92
|
+
}
|
|
86
93
|
// ── PTY data handler ──────────────────────────────────────────────
|
|
87
94
|
this._pty.onData((data) => {
|
|
88
95
|
if (!this._alive)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Ported from iterloop's conversation.ts. Runs an interactive PTY session
|
|
5
5
|
* with idle detection, mode toggling, and user-controlled continuation.
|
|
6
6
|
*/
|
|
7
|
+
import * as readline from "node:readline";
|
|
7
8
|
import { dim, formatBytes, brandColor } from "../ui/colors.js";
|
|
8
9
|
// ── Idle detection constants ─────────────────────────
|
|
9
10
|
/** Time to wait after detecting idle prompt before auto-proceeding (ms) */
|
|
@@ -109,14 +110,38 @@ async function runPtySession(engine, initialPrompt, opts) {
|
|
|
109
110
|
// Set up renderer before spawning the PTY so the first frame is not missed
|
|
110
111
|
const renderer = new PtyRenderer(engine.name, engine.label);
|
|
111
112
|
renderer.start();
|
|
112
|
-
// Create PTY session via engine.interactive()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
// Create PTY session via engine.interactive(), with fallback to engine.run()
|
|
114
|
+
let session;
|
|
115
|
+
try {
|
|
116
|
+
session = engine.interactive({
|
|
117
|
+
cwd: opts.cwd,
|
|
118
|
+
passthroughArgs: opts.passthroughArgs,
|
|
119
|
+
onData(data) {
|
|
120
|
+
renderer.write(data);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// PTY spawn failed — fall back to non-interactive engine.run()
|
|
126
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
127
|
+
renderer.stop({ elapsed: "0.0s", bytes: "0 B" });
|
|
128
|
+
console.log(dim(` PTY spawn failed: ${msg}`));
|
|
129
|
+
console.log(dim(` Falling back to non-interactive mode...\n`));
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
const output = await engine.run(initialPrompt, {
|
|
132
|
+
cwd: opts.cwd,
|
|
133
|
+
verbose: opts.verbose,
|
|
134
|
+
passthroughArgs: opts.passthroughArgs,
|
|
135
|
+
onData(chunk) {
|
|
136
|
+
process.stdout.write(chunk);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
output,
|
|
141
|
+
bytes: Buffer.byteLength(output),
|
|
142
|
+
durationMs: Date.now() - start,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
120
145
|
return new Promise((resolve, reject) => {
|
|
121
146
|
let done = false;
|
|
122
147
|
let idleTimer = null;
|
|
@@ -252,6 +277,35 @@ async function runPtySession(engine, initialPrompt, opts) {
|
|
|
252
277
|
});
|
|
253
278
|
});
|
|
254
279
|
}
|
|
280
|
+
// ── User prompt helper (readline-based) ──────────────
|
|
281
|
+
/**
|
|
282
|
+
* Prompt the user for a follow-up message using Node's readline module.
|
|
283
|
+
* Returns the trimmed user input, or `null` on Ctrl+D / EOF / empty input / "/done".
|
|
284
|
+
*/
|
|
285
|
+
function promptUser() {
|
|
286
|
+
return new Promise((resolve) => {
|
|
287
|
+
const rl = readline.createInterface({
|
|
288
|
+
input: process.stdin,
|
|
289
|
+
output: process.stdout,
|
|
290
|
+
});
|
|
291
|
+
rl.question(dim(" Follow-up (empty or /done to submit): "), (answer) => {
|
|
292
|
+
rl.close();
|
|
293
|
+
const trimmed = answer.trim();
|
|
294
|
+
if (trimmed === "" || trimmed === "/done") {
|
|
295
|
+
resolve(null);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
resolve(trimmed);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// Handle Ctrl+D (EOF) — readline emits 'close' without calling the callback
|
|
302
|
+
rl.on("close", () => {
|
|
303
|
+
// If the question callback already resolved, this is a no-op.
|
|
304
|
+
// If Ctrl+D was pressed, resolve with null to signal "submit".
|
|
305
|
+
resolve(null);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
255
309
|
// ── Main conversation loop ───────────────────────────
|
|
256
310
|
/**
|
|
257
311
|
* Run a multi-turn conversation with an AI engine.
|
|
@@ -268,22 +322,53 @@ export async function runConversation(opts) {
|
|
|
268
322
|
console.log(dim(" Ctrl+D to submit for review, double Ctrl+C to abort\n"));
|
|
269
323
|
}
|
|
270
324
|
// Run first interactive PTY session
|
|
271
|
-
|
|
272
|
-
|
|
325
|
+
let currentPrompt = initialPrompt;
|
|
326
|
+
let accumulatedOutput = "";
|
|
327
|
+
let totalBytes = 0;
|
|
328
|
+
let totalDurationMs = 0;
|
|
329
|
+
const firstResult = await runPtySession(engine, currentPrompt, {
|
|
330
|
+
cwd,
|
|
331
|
+
verbose,
|
|
332
|
+
mode,
|
|
333
|
+
passthroughArgs,
|
|
334
|
+
});
|
|
335
|
+
accumulatedOutput += firstResult.output;
|
|
336
|
+
totalBytes += firstResult.bytes;
|
|
337
|
+
totalDurationMs += firstResult.durationMs;
|
|
338
|
+
// Auto mode (or switched to auto during first session): done, submit to reviewer
|
|
273
339
|
if (mode.current === "auto") {
|
|
274
340
|
return {
|
|
275
|
-
finalOutput:
|
|
276
|
-
duration_ms:
|
|
277
|
-
bytes_received:
|
|
341
|
+
finalOutput: accumulatedOutput,
|
|
342
|
+
duration_ms: totalDurationMs,
|
|
343
|
+
bytes_received: totalBytes,
|
|
278
344
|
};
|
|
279
345
|
}
|
|
280
|
-
// Manual mode:
|
|
281
|
-
|
|
282
|
-
|
|
346
|
+
// Manual mode: multi-turn loop — prompt user after each PTY session
|
|
347
|
+
while (mode.current === "manual") {
|
|
348
|
+
console.log(dim(` Session complete. Accumulated ${formatBytes(totalBytes)} over ${(totalDurationMs / 1000).toFixed(1)}s.`));
|
|
349
|
+
const followUp = await promptUser();
|
|
350
|
+
// null means user wants to submit (empty input, /done, or Ctrl+D)
|
|
351
|
+
if (followUp === null) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
// Run another PTY session with the follow-up prompt
|
|
355
|
+
currentPrompt = followUp;
|
|
356
|
+
const result = await runPtySession(engine, currentPrompt, {
|
|
357
|
+
cwd,
|
|
358
|
+
verbose,
|
|
359
|
+
mode,
|
|
360
|
+
passthroughArgs,
|
|
361
|
+
});
|
|
362
|
+
accumulatedOutput += "\n" + result.output;
|
|
363
|
+
totalBytes += result.bytes;
|
|
364
|
+
totalDurationMs += result.durationMs;
|
|
365
|
+
// If the mode was switched to auto during the session (via Shift+Tab),
|
|
366
|
+
// the while condition will handle it — no explicit break needed.
|
|
367
|
+
}
|
|
283
368
|
return {
|
|
284
|
-
finalOutput:
|
|
285
|
-
duration_ms:
|
|
286
|
-
bytes_received:
|
|
369
|
+
finalOutput: accumulatedOutput,
|
|
370
|
+
duration_ms: totalDurationMs,
|
|
371
|
+
bytes_received: totalBytes,
|
|
287
372
|
};
|
|
288
373
|
}
|
|
289
374
|
//# sourceMappingURL=conversation.js.map
|
package/dist/core/loop.js
CHANGED
|
@@ -12,10 +12,10 @@ import { createExecutorMessage, parseReviewerOutput, formatForReviewer, } from "
|
|
|
12
12
|
import { evaluateReview } from "./scoring.js";
|
|
13
13
|
import { bold, dim, success, warn, brandColor } from "../ui/colors.js";
|
|
14
14
|
const NO_OP_PLAN = {
|
|
15
|
-
initSharedPlan: () => { },
|
|
16
|
-
updateSharedPlan: () => { },
|
|
17
|
-
getExecutorContext: () => "",
|
|
18
|
-
getReviewerContext: () => "",
|
|
15
|
+
initSharedPlan: async () => { },
|
|
16
|
+
updateSharedPlan: async () => { },
|
|
17
|
+
getExecutorContext: async () => "",
|
|
18
|
+
getReviewerContext: async () => "",
|
|
19
19
|
};
|
|
20
20
|
// Lazy-loaded promise — awaited before first use in runLoop()
|
|
21
21
|
let _planModulePromise = null;
|
|
@@ -67,7 +67,7 @@ export async function runLoop(options) {
|
|
|
67
67
|
};
|
|
68
68
|
// Load shared plan module (awaited — no race condition)
|
|
69
69
|
const plan = await loadPlanModule();
|
|
70
|
-
plan.initSharedPlan(cwd, task);
|
|
70
|
+
await plan.initSharedPlan(cwd, task);
|
|
71
71
|
let executorOutput = "";
|
|
72
72
|
let reviewerFeedback = "";
|
|
73
73
|
for (let i = 1; i <= maxIterations; i++) {
|
|
@@ -78,7 +78,7 @@ export async function runLoop(options) {
|
|
|
78
78
|
initialPrompt = task;
|
|
79
79
|
}
|
|
80
80
|
else {
|
|
81
|
-
const executorContext = plan.getExecutorContext(cwd);
|
|
81
|
+
const executorContext = await plan.getExecutorContext(cwd);
|
|
82
82
|
initialPrompt = [
|
|
83
83
|
"Please revise your previous work based on the following review feedback.",
|
|
84
84
|
"",
|
|
@@ -119,7 +119,7 @@ export async function runLoop(options) {
|
|
|
119
119
|
});
|
|
120
120
|
history.push(executorMsg);
|
|
121
121
|
// ── Reviewer ──
|
|
122
|
-
const reviewerContext = plan.getReviewerContext(cwd);
|
|
122
|
+
const reviewerContext = await plan.getReviewerContext(cwd);
|
|
123
123
|
const reviewPrompt = [
|
|
124
124
|
"You are a code review expert. Please review the following task completion.",
|
|
125
125
|
"",
|
|
@@ -169,14 +169,14 @@ export async function runLoop(options) {
|
|
|
169
169
|
// Evaluate review using scoring module
|
|
170
170
|
const scoringResult = evaluateReview(reviewerMsg.review, scoringConfig);
|
|
171
171
|
// Update shared plan with iteration data
|
|
172
|
-
plan.updateSharedPlan(cwd, {
|
|
172
|
+
await plan.updateSharedPlan(cwd, {
|
|
173
173
|
iteration: i,
|
|
174
174
|
timestamp: new Date().toISOString(),
|
|
175
175
|
executor: executor.name,
|
|
176
176
|
reviewer: reviewer.name,
|
|
177
177
|
executorSummary: executorOutput.slice(0, 500),
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
score: reviewerMsg.review?.score ?? 0,
|
|
179
|
+
approved: scoringResult.approved,
|
|
180
180
|
reviewerFeedback: reviewerFeedback.slice(0, 500),
|
|
181
181
|
}, executorMsg.output.files_changed);
|
|
182
182
|
// ── Check approval ──
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { resolve, join, dirname } from "node:path";
|
|
5
5
|
import { bold, red, yellow, green } from "./ui/colors.js";
|
|
6
6
|
import { ENGINE_NAMES } from "./config/schema.js";
|
|
7
7
|
import { loadConfig } from "./config/index.js";
|
|
@@ -12,12 +12,14 @@ import { SkillRegistry } from "./skills/registry.js";
|
|
|
12
12
|
import { runLoop } from "./core/loop.js";
|
|
13
13
|
import { createEngine } from "./core/engine.js";
|
|
14
14
|
import { EventBus } from "./bus/event-bus.js";
|
|
15
|
-
import {
|
|
15
|
+
import { daemonize, readPidFile, isProcessAlive, removePidFile, } from "./utils/process.js";
|
|
16
|
+
import { createConnection } from "node:net";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
16
18
|
const program = new Command();
|
|
17
19
|
program
|
|
18
20
|
.name("loop")
|
|
19
21
|
.description("Iterative multi-engine AI orchestration CLI — Claude, Gemini, Codex")
|
|
20
|
-
.version("0.
|
|
22
|
+
.version("0.2.0")
|
|
21
23
|
.argument("[task]", "Task description (omit to enter interactive mode)")
|
|
22
24
|
.option("-e, --executor <engine>", "Executor engine: claude | gemini | codex")
|
|
23
25
|
.option("-r, --reviewer <engine>", "Reviewer engine: claude | gemini | codex")
|
|
@@ -124,10 +126,22 @@ daemon
|
|
|
124
126
|
.action(async () => {
|
|
125
127
|
try {
|
|
126
128
|
const cwd = process.cwd();
|
|
127
|
-
const
|
|
129
|
+
const runDirPath = join(cwd, ".loop", "run");
|
|
130
|
+
const pidPath = join(runDirPath, "loop-daemon.pid");
|
|
131
|
+
const logPath = join(runDirPath, "loop-daemon.log");
|
|
132
|
+
// Check if already running
|
|
133
|
+
const existingPid = readPidFile(pidPath);
|
|
134
|
+
if (existingPid !== null && isProcessAlive(existingPid)) {
|
|
135
|
+
console.log(yellow(` Daemon already running (pid=${existingPid})`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Resolve daemon entry script relative to this compiled module
|
|
139
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
140
|
+
const thisDir = dirname(thisFile);
|
|
141
|
+
const entryScript = join(thisDir, "orchestrator", "daemon-entry.js");
|
|
128
142
|
console.log(bold(" Starting daemon..."));
|
|
129
|
-
|
|
130
|
-
console.log(green(
|
|
143
|
+
const pid = daemonize(entryScript, [], logPath);
|
|
144
|
+
console.log(green(` Daemon started (pid=${pid})`));
|
|
131
145
|
}
|
|
132
146
|
catch (err) {
|
|
133
147
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -141,13 +155,17 @@ daemon
|
|
|
141
155
|
.action(async () => {
|
|
142
156
|
try {
|
|
143
157
|
const cwd = process.cwd();
|
|
144
|
-
const
|
|
145
|
-
|
|
158
|
+
const pidPath = join(cwd, ".loop", "run", "loop-daemon.pid");
|
|
159
|
+
const pid = readPidFile(pidPath);
|
|
160
|
+
if (pid === null || !isProcessAlive(pid)) {
|
|
146
161
|
console.log(yellow(" Daemon is not running."));
|
|
162
|
+
removePidFile(pidPath);
|
|
147
163
|
return;
|
|
148
164
|
}
|
|
149
|
-
|
|
150
|
-
console.log(green(
|
|
165
|
+
process.kill(pid, "SIGTERM");
|
|
166
|
+
console.log(green(` Daemon stopped (pid=${pid}).`));
|
|
167
|
+
// Clean up PID file after kill
|
|
168
|
+
removePidFile(pidPath);
|
|
151
169
|
}
|
|
152
170
|
catch (err) {
|
|
153
171
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -161,17 +179,53 @@ daemon
|
|
|
161
179
|
.action(async () => {
|
|
162
180
|
try {
|
|
163
181
|
const cwd = process.cwd();
|
|
164
|
-
const
|
|
165
|
-
|
|
182
|
+
const pidPath = join(cwd, ".loop", "run", "loop-daemon.pid");
|
|
183
|
+
const socketPath = join(cwd, ".loop", "run", "loop.sock");
|
|
184
|
+
const pid = readPidFile(pidPath);
|
|
185
|
+
if (pid === null || !isProcessAlive(pid)) {
|
|
166
186
|
console.log(yellow(" Daemon is not running."));
|
|
167
187
|
return;
|
|
168
188
|
}
|
|
169
|
-
|
|
189
|
+
// Connect to the daemon's IPC socket and request STATUS
|
|
190
|
+
const status = await new Promise((resolvePromise, rejectPromise) => {
|
|
191
|
+
const client = createConnection(socketPath, () => {
|
|
192
|
+
client.write(JSON.stringify({ type: "STATUS", data: {} }) + "\n");
|
|
193
|
+
});
|
|
194
|
+
let buffer = "";
|
|
195
|
+
const timeout = setTimeout(() => {
|
|
196
|
+
client.destroy();
|
|
197
|
+
rejectPromise(new Error("Timed out waiting for daemon status"));
|
|
198
|
+
}, 5_000);
|
|
199
|
+
client.on("data", (data) => {
|
|
200
|
+
buffer += data.toString("utf8");
|
|
201
|
+
const lines = buffer.split("\n");
|
|
202
|
+
buffer = lines.pop() ?? "";
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
if (!line.trim())
|
|
205
|
+
continue;
|
|
206
|
+
try {
|
|
207
|
+
const payload = JSON.parse(line);
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
client.end();
|
|
210
|
+
resolvePromise(payload);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// skip malformed lines
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
client.on("error", (err) => {
|
|
219
|
+
clearTimeout(timeout);
|
|
220
|
+
rejectPromise(err);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
const statusData = (status.data ?? {});
|
|
170
224
|
console.log(bold(" Daemon status:"));
|
|
171
|
-
console.log(` PID: ${
|
|
172
|
-
console.log(` Uptime: ${
|
|
173
|
-
console.log(` Agents: ${
|
|
174
|
-
console.log(` Events: ${
|
|
225
|
+
console.log(` PID: ${statusData.pid ?? pid}`);
|
|
226
|
+
console.log(` Uptime: ${statusData.uptime ?? "?"}s`);
|
|
227
|
+
console.log(` Agents: ${statusData.agents ?? 0}`);
|
|
228
|
+
console.log(` Events: ${statusData.busEvents ?? 0}`);
|
|
175
229
|
}
|
|
176
230
|
catch (err) {
|
|
177
231
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Daemon entry point — spawned as a detached background process by
|
|
4
|
+
* `loop daemon start`. Creates an OrchestratorDaemon and starts it.
|
|
5
|
+
* The IPC server keeps the process alive.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=daemon-entry.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Daemon entry point — spawned as a detached background process by
|
|
4
|
+
* `loop daemon start`. Creates an OrchestratorDaemon and starts it.
|
|
5
|
+
* The IPC server keeps the process alive.
|
|
6
|
+
*/
|
|
7
|
+
import { OrchestratorDaemon } from "./daemon.js";
|
|
8
|
+
const projectRoot = process.cwd();
|
|
9
|
+
const daemon = new OrchestratorDaemon(projectRoot);
|
|
10
|
+
daemon.start().catch((err) => {
|
|
11
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12
|
+
process.stderr.write(`daemon-entry: failed to start: ${message}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=daemon-entry.js.map
|
|
@@ -211,11 +211,10 @@ export class OrchestratorDaemon {
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
case "LAUNCH_AGENT": {
|
|
214
|
-
// Placeholder - actual agent launching is handled by the agent launcher
|
|
215
214
|
return {
|
|
216
|
-
success:
|
|
215
|
+
success: false,
|
|
217
216
|
type: "LAUNCH_AGENT",
|
|
218
|
-
|
|
217
|
+
error: "Not implemented",
|
|
219
218
|
};
|
|
220
219
|
}
|
|
221
220
|
case "CLOSE_AGENT": {
|
|
@@ -229,23 +228,23 @@ export class OrchestratorDaemon {
|
|
|
229
228
|
}
|
|
230
229
|
case "RESUME_AGENTS": {
|
|
231
230
|
return {
|
|
232
|
-
success:
|
|
231
|
+
success: false,
|
|
233
232
|
type: "RESUME_AGENTS",
|
|
234
|
-
|
|
233
|
+
error: "Not implemented",
|
|
235
234
|
};
|
|
236
235
|
}
|
|
237
236
|
case "LAUNCH_GROUP": {
|
|
238
237
|
return {
|
|
239
|
-
success:
|
|
238
|
+
success: false,
|
|
240
239
|
type: "LAUNCH_GROUP",
|
|
241
|
-
|
|
240
|
+
error: "Not implemented",
|
|
242
241
|
};
|
|
243
242
|
}
|
|
244
243
|
case "STOP_GROUP": {
|
|
245
244
|
return {
|
|
246
|
-
success:
|
|
245
|
+
success: false,
|
|
247
246
|
type: "STOP_GROUP",
|
|
248
|
-
|
|
247
|
+
error: "Not implemented",
|
|
249
248
|
};
|
|
250
249
|
}
|
|
251
250
|
default: {
|
package/dist/ui/interactive.js
CHANGED
|
@@ -93,17 +93,20 @@ export async function interactive() {
|
|
|
93
93
|
return null;
|
|
94
94
|
}
|
|
95
95
|
// Native CLI flags (optional)
|
|
96
|
+
const PASS_ARGS_PLACEHOLDER = "e.g., --model claude-sonnet-4-20250514";
|
|
96
97
|
const passArgsInput = await p.text({
|
|
97
98
|
message: "Native CLI flags for executor (optional)",
|
|
98
|
-
placeholder:
|
|
99
|
+
placeholder: PASS_ARGS_PLACEHOLDER,
|
|
99
100
|
defaultValue: "",
|
|
100
101
|
});
|
|
101
102
|
if (p.isCancel(passArgsInput)) {
|
|
102
103
|
p.cancel("Cancelled.");
|
|
103
104
|
return null;
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
// Guard against @clack/prompts returning placeholder text as the value
|
|
107
|
+
const rawPassArgs = typeof passArgsInput === "string" ? passArgsInput : "";
|
|
108
|
+
const passthroughArgs = rawPassArgs.trim() && rawPassArgs.trim() !== PASS_ARGS_PLACEHOLDER
|
|
109
|
+
? rawPassArgs.split(/\s+/).filter(Boolean)
|
|
107
110
|
: [];
|
|
108
111
|
// Task
|
|
109
112
|
const task = await p.text({
|