ralph-lisa-loop 0.3.9 → 0.3.11
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/README.md +40 -214
- package/dist/cli.js +41 -5
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +1223 -56
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +47 -0
- package/dist/state.d.ts +19 -1
- package/dist/state.js +62 -3
- package/package.json +1 -1
- package/templates/roles/lisa.md +10 -3
- package/templates/roles/ralph.md +21 -4
package/dist/commands.js
CHANGED
|
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
38
38
|
})();
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
40
|
exports.generateSessionName = generateSessionName;
|
|
41
|
+
exports.runGate = runGate;
|
|
41
42
|
exports.cmdInit = cmdInit;
|
|
42
43
|
exports.cmdWhoseTurn = cmdWhoseTurn;
|
|
43
44
|
exports.cmdSubmitRalph = cmdSubmitRalph;
|
|
@@ -46,16 +47,24 @@ exports.cmdStatus = cmdStatus;
|
|
|
46
47
|
exports.cmdRead = cmdRead;
|
|
47
48
|
exports.cmdRecap = cmdRecap;
|
|
48
49
|
exports.cmdStep = cmdStep;
|
|
50
|
+
exports.parseSubtasks = parseSubtasks;
|
|
51
|
+
exports.cmdSubtask = cmdSubtask;
|
|
49
52
|
exports.cmdHistory = cmdHistory;
|
|
50
53
|
exports.cmdArchive = cmdArchive;
|
|
51
54
|
exports.cmdClean = cmdClean;
|
|
55
|
+
exports.cmdStop = cmdStop;
|
|
52
56
|
exports.cmdUpdateTask = cmdUpdateTask;
|
|
57
|
+
exports.cmdForceTurn = cmdForceTurn;
|
|
58
|
+
exports.executeForceTurn = executeForceTurn;
|
|
53
59
|
exports.cmdUninit = cmdUninit;
|
|
54
60
|
exports.cmdInitProject = cmdInitProject;
|
|
55
61
|
exports.cmdStart = cmdStart;
|
|
56
62
|
exports.cmdAuto = cmdAuto;
|
|
57
63
|
exports.cmdPolicy = cmdPolicy;
|
|
58
64
|
exports.cmdLogs = cmdLogs;
|
|
65
|
+
exports.cmdRemote = cmdRemote;
|
|
66
|
+
exports.cmdStateDir = cmdStateDir;
|
|
67
|
+
exports.cmdAddContext = cmdAddContext;
|
|
59
68
|
exports.cmdDoctor = cmdDoctor;
|
|
60
69
|
const fs = __importStar(require("node:fs"));
|
|
61
70
|
const path = __importStar(require("node:path"));
|
|
@@ -128,6 +137,60 @@ function getFilesChanged() {
|
|
|
128
137
|
return [];
|
|
129
138
|
}
|
|
130
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* V4-01: Ralph Auto Gate — run test/lint commands before CODE/FIX submission.
|
|
142
|
+
* Config via env vars only (config file deferred to future version):
|
|
143
|
+
* RL_RALPH_GATE=true — enable gate (default: false)
|
|
144
|
+
* RL_GATE_COMMANDS=cmd1|cmd2 — pipe-separated commands
|
|
145
|
+
* RL_GATE_MODE=warn|block — warn (default) or block on failure
|
|
146
|
+
* Gate results are NOT written to work.md (prevents Lisa anchoring).
|
|
147
|
+
*/
|
|
148
|
+
function runGate(tag) {
|
|
149
|
+
if (tag !== "CODE" && tag !== "FIX")
|
|
150
|
+
return;
|
|
151
|
+
if (process.env.RL_RALPH_GATE !== "true")
|
|
152
|
+
return;
|
|
153
|
+
const commands = (process.env.RL_GATE_COMMANDS || "").split("|").filter(Boolean);
|
|
154
|
+
if (commands.length === 0)
|
|
155
|
+
return;
|
|
156
|
+
const mode = process.env.RL_GATE_MODE === "block" ? "block" : "warn";
|
|
157
|
+
const failures = [];
|
|
158
|
+
console.log("Running gate checks...");
|
|
159
|
+
for (const cmd of commands) {
|
|
160
|
+
try {
|
|
161
|
+
(0, node_child_process_1.execSync)(cmd, { stdio: "pipe", timeout: 120000 });
|
|
162
|
+
console.log(` PASS ${cmd}`);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
const stderr = e.stderr ? e.stderr.toString().trim().split("\n").slice(-3).join("\n") : "exit code " + e.status;
|
|
166
|
+
failures.push({ cmd, error: stderr });
|
|
167
|
+
console.log(` FAIL ${cmd}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (failures.length === 0) {
|
|
171
|
+
console.log("Gate: all checks passed.");
|
|
172
|
+
console.log("");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
console.log("");
|
|
176
|
+
console.log(line());
|
|
177
|
+
if (mode === "block") {
|
|
178
|
+
console.error("Gate BLOCKED submission:");
|
|
179
|
+
for (const f of failures) {
|
|
180
|
+
console.error(` - ${f.cmd}: ${f.error}`);
|
|
181
|
+
}
|
|
182
|
+
console.error(line());
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log("Gate warnings (submission proceeds):");
|
|
187
|
+
for (const f of failures) {
|
|
188
|
+
console.log(` - ${f.cmd}: ${f.error}`);
|
|
189
|
+
}
|
|
190
|
+
console.log(line());
|
|
191
|
+
console.log("");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
131
194
|
// ─── init ────────────────────────────────────────
|
|
132
195
|
function cmdInit(args) {
|
|
133
196
|
const task = args.join(" ");
|
|
@@ -203,11 +266,42 @@ function cmdSubmitRalph(args) {
|
|
|
203
266
|
console.error(line());
|
|
204
267
|
process.exit(1);
|
|
205
268
|
}
|
|
269
|
+
// NEEDS_WORK response enforcement (Proposal §3.2)
|
|
270
|
+
const dir = (0, state_js_1.stateDir)();
|
|
271
|
+
const reviewContent = (0, state_js_1.readFile)(path.join(dir, "review.md"));
|
|
272
|
+
const lastLisaTag = extractLastTag(reviewContent);
|
|
273
|
+
const { getPolicyMode } = require("./policy.js");
|
|
274
|
+
const nwMode = getPolicyMode();
|
|
275
|
+
if (nwMode !== "off") {
|
|
276
|
+
const nwViolations = (0, policy_js_1.checkNeedsWorkResponse)(tag, lastLisaTag);
|
|
277
|
+
if (nwViolations.length > 0) {
|
|
278
|
+
if (nwMode === "block") {
|
|
279
|
+
console.error(line());
|
|
280
|
+
console.error("Submission BLOCKED — must respond to NEEDS_WORK:");
|
|
281
|
+
for (const v of nwViolations) {
|
|
282
|
+
console.error(` - ${v.message}`);
|
|
283
|
+
}
|
|
284
|
+
console.error(line());
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
console.log(line());
|
|
289
|
+
console.log("Warning — submitting after NEEDS_WORK without addressing it:");
|
|
290
|
+
for (const v of nwViolations) {
|
|
291
|
+
console.log(` - ${v.message}`);
|
|
292
|
+
}
|
|
293
|
+
console.log(line());
|
|
294
|
+
console.log("");
|
|
295
|
+
}
|
|
296
|
+
violations.push(...nwViolations);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// V4-01: Auto gate — run test/lint before CODE/FIX submission
|
|
300
|
+
runGate(tag);
|
|
206
301
|
const round = (0, state_js_1.getRound)();
|
|
207
302
|
const step = (0, state_js_1.getStep)();
|
|
208
303
|
const ts = (0, state_js_1.timestamp)();
|
|
209
304
|
const summary = (0, state_js_1.extractSummary)(content);
|
|
210
|
-
const dir = (0, state_js_1.stateDir)();
|
|
211
305
|
// Auto-inject task context so Lisa always sees the task goal
|
|
212
306
|
// Use last meaningful line (update-task appends new directions at the end)
|
|
213
307
|
const taskFile = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
@@ -221,8 +315,21 @@ function cmdSubmitRalph(args) {
|
|
|
221
315
|
filesChangedSection = `**Files Changed**:\n${files.map((f) => `- ${f}`).join("\n")}\n\n`;
|
|
222
316
|
}
|
|
223
317
|
}
|
|
318
|
+
// Auto-inject context.md so Lisa sees runtime directives (Proposal §3.5)
|
|
319
|
+
let contextSection = "";
|
|
320
|
+
const contextPath = path.join(dir, "context.md");
|
|
321
|
+
if (fs.existsSync(contextPath)) {
|
|
322
|
+
const ctxContent = (0, state_js_1.readFile)(contextPath);
|
|
323
|
+
if (ctxContent && !ctxContent.includes("(visible to Lisa).\n\n\n")) {
|
|
324
|
+
// Only inject if context has actual entries (not just the header)
|
|
325
|
+
const ctxLines = ctxContent.split("\n").filter((l) => l.startsWith("- ["));
|
|
326
|
+
if (ctxLines.length > 0) {
|
|
327
|
+
contextSection = `**Context**: ${ctxLines.join("; ")}\n`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
224
331
|
const taskLine = taskContext ? `**Task**: ${taskContext}\n` : "";
|
|
225
|
-
(0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n${taskLine}**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
|
|
332
|
+
(0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n${taskLine}${contextSection}**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
|
|
226
333
|
// External sources (--file/--stdin) get compact history to reduce context bloat
|
|
227
334
|
const historyContent = external
|
|
228
335
|
? `[${tag}] ${summary}\n\n(Full content in work.md)`
|
|
@@ -230,6 +337,26 @@ function cmdSubmitRalph(args) {
|
|
|
230
337
|
(0, state_js_1.appendHistory)("Ralph", historyContent);
|
|
231
338
|
(0, state_js_1.updateLastAction)("Ralph", content);
|
|
232
339
|
(0, state_js_1.setTurn)("lisa");
|
|
340
|
+
// CONSENSUS actions (Proposal §3.7 + §3.6)
|
|
341
|
+
if (tag === "CONSENSUS") {
|
|
342
|
+
// Clean .dual-agent/tests/ on Ralph CONSENSUS (covers single-round closure:
|
|
343
|
+
// Lisa [PASS] + Ralph [CONSENSUS] — Lisa never submits [CONSENSUS] in this path)
|
|
344
|
+
const testsDir = path.join(dir, "tests");
|
|
345
|
+
if (fs.existsSync(testsDir)) {
|
|
346
|
+
fs.rmSync(testsDir, { recursive: true, force: true });
|
|
347
|
+
console.log("Cleaned .dual-agent/tests/ (topic closed)");
|
|
348
|
+
}
|
|
349
|
+
// Subtask reminder (explicit, not auto-mark)
|
|
350
|
+
const taskContent = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
351
|
+
const incomplete = parseSubtasks(taskContent).filter((s) => !s.done);
|
|
352
|
+
if (incomplete.length > 0) {
|
|
353
|
+
console.log("");
|
|
354
|
+
console.log("Hint: If a subtask was completed, run: ralph-lisa subtask done <N>");
|
|
355
|
+
for (const s of incomplete) {
|
|
356
|
+
console.log(` - [ ] #${s.index} ${s.text}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
233
360
|
console.log(line());
|
|
234
361
|
if (violations.length > 0) {
|
|
235
362
|
console.log(`Submitted OK (with warnings): [${tag}] ${summary}`);
|
|
@@ -315,6 +442,41 @@ function cmdSubmitLisa(args) {
|
|
|
315
442
|
(0, state_js_1.appendHistory)("Lisa", historyContent);
|
|
316
443
|
(0, state_js_1.updateLastAction)("Lisa", content);
|
|
317
444
|
(0, state_js_1.setTurn)("ralph");
|
|
445
|
+
// Deadlock counter: track consecutive NEEDS_WORK rounds (Proposal §3.2)
|
|
446
|
+
const nwCountPath = path.join(dir, "needs_work_count.txt");
|
|
447
|
+
if (tag === "NEEDS_WORK") {
|
|
448
|
+
const currentCount = parseInt((0, state_js_1.readFile)(nwCountPath) || "0", 10);
|
|
449
|
+
const newCount = currentCount + 1;
|
|
450
|
+
(0, state_js_1.writeFile)(nwCountPath, String(newCount));
|
|
451
|
+
if (newCount >= 5) {
|
|
452
|
+
// Trigger deadlock — write flag for watcher to detect
|
|
453
|
+
const deadlockPath = path.join(dir, "deadlock.txt");
|
|
454
|
+
(0, state_js_1.writeFile)(deadlockPath, `DEADLOCK at round ${round}: ${newCount} consecutive NEEDS_WORK rounds\nTimestamp: ${ts}\nAction: Watcher will pause. User intervention required.`);
|
|
455
|
+
console.log("");
|
|
456
|
+
console.log(line("!", 40));
|
|
457
|
+
console.log(`DEADLOCK: ${newCount} consecutive NEEDS_WORK rounds.`);
|
|
458
|
+
console.log("Watcher will pause for user intervention.");
|
|
459
|
+
console.log("To resolve: ralph-lisa scope-update or ralph-lisa force-turn");
|
|
460
|
+
console.log(line("!", 40));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
// Reset counter on non-NEEDS_WORK review
|
|
465
|
+
(0, state_js_1.writeFile)(nwCountPath, "0");
|
|
466
|
+
const deadlockPath = path.join(dir, "deadlock.txt");
|
|
467
|
+
try {
|
|
468
|
+
fs.unlinkSync(deadlockPath);
|
|
469
|
+
}
|
|
470
|
+
catch { }
|
|
471
|
+
}
|
|
472
|
+
// Clean .dual-agent/tests/ on CONSENSUS (Proposal §3.6 — evidence preserved until closure)
|
|
473
|
+
if (tag === "CONSENSUS") {
|
|
474
|
+
const testsDir = path.join(dir, "tests");
|
|
475
|
+
if (fs.existsSync(testsDir)) {
|
|
476
|
+
fs.rmSync(testsDir, { recursive: true, force: true });
|
|
477
|
+
console.log("Cleaned .dual-agent/tests/ (topic closed)");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
318
480
|
// Increment round
|
|
319
481
|
const nextRound = (parseInt(round, 10) || 0) + 1;
|
|
320
482
|
(0, state_js_1.setRound)(nextRound);
|
|
@@ -508,21 +670,32 @@ function extractLastTag(fileContent) {
|
|
|
508
670
|
// ─── step ────────────────────────────────────────
|
|
509
671
|
function cmdStep(args) {
|
|
510
672
|
(0, state_js_1.checkSession)();
|
|
511
|
-
// Parse
|
|
673
|
+
// Parse flags
|
|
512
674
|
const forceIdx = args.indexOf("--force");
|
|
513
675
|
const force = forceIdx !== -1;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
676
|
+
const taskIdx = args.indexOf("--task");
|
|
677
|
+
let taskDesc = "";
|
|
678
|
+
const skipIndices = new Set();
|
|
679
|
+
if (forceIdx !== -1)
|
|
680
|
+
skipIndices.add(forceIdx);
|
|
681
|
+
if (taskIdx !== -1) {
|
|
682
|
+
skipIndices.add(taskIdx);
|
|
683
|
+
if (taskIdx + 1 < args.length) {
|
|
684
|
+
taskDesc = args[taskIdx + 1];
|
|
685
|
+
skipIndices.add(taskIdx + 1);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const filteredArgs = args.filter((_, i) => !skipIndices.has(i));
|
|
517
689
|
const stepName = filteredArgs.join(" ");
|
|
518
690
|
if (!stepName) {
|
|
519
691
|
console.error('Usage: ralph-lisa step "step name"');
|
|
692
|
+
console.error(' ralph-lisa step "step name" --task "first task"');
|
|
520
693
|
console.error(" ralph-lisa step --force \"step name\" (skip consensus check)");
|
|
521
694
|
process.exit(1);
|
|
522
695
|
}
|
|
696
|
+
const dir = (0, state_js_1.stateDir)();
|
|
523
697
|
// Check consensus before allowing step transition
|
|
524
698
|
if (!force) {
|
|
525
|
-
const dir = (0, state_js_1.stateDir)();
|
|
526
699
|
const workContent = (0, state_js_1.readFile)(path.join(dir, "work.md"));
|
|
527
700
|
const reviewContent = (0, state_js_1.readFile)(path.join(dir, "review.md"));
|
|
528
701
|
const workTag = extractLastTag(workContent);
|
|
@@ -539,14 +712,135 @@ function cmdStep(args) {
|
|
|
539
712
|
console.error('Use --force to skip this check: ralph-lisa step --force "step name"');
|
|
540
713
|
process.exit(1);
|
|
541
714
|
}
|
|
715
|
+
// Check subtask completion before step transition (Proposal §3.4)
|
|
716
|
+
const taskContent = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
717
|
+
const incomplete = parseSubtasks(taskContent).filter((s) => !s.done);
|
|
718
|
+
if (incomplete.length > 0) {
|
|
719
|
+
console.error("Error: Incomplete subtasks remain. Cannot proceed to next step.");
|
|
720
|
+
for (const s of incomplete) {
|
|
721
|
+
console.error(` - [ ] #${s.index} ${s.text}`);
|
|
722
|
+
}
|
|
723
|
+
console.error("");
|
|
724
|
+
console.error("Complete all subtasks first, or use --force to skip this check.");
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
542
727
|
}
|
|
543
728
|
(0, state_js_1.setStep)(stepName);
|
|
544
729
|
(0, state_js_1.setRound)(1);
|
|
545
|
-
|
|
730
|
+
(0, state_js_1.setTurn)("ralph");
|
|
731
|
+
// Reset work/review to prevent stale consensus tags from carrying over
|
|
732
|
+
(0, state_js_1.writeFile)(path.join(dir, "work.md"), "# Ralph Work\n\n(Waiting for Ralph to submit)\n");
|
|
733
|
+
(0, state_js_1.writeFile)(path.join(dir, "review.md"), "# Lisa Review\n\n(Waiting for Lisa to respond)\n");
|
|
546
734
|
const ts = (0, state_js_1.timestamp)();
|
|
735
|
+
// Initialize task.md for new step (Proposal §3.3 + §3.4)
|
|
736
|
+
let taskContent = `# ${stepName}\n\n## Subtasks\n`;
|
|
737
|
+
if (taskDesc) {
|
|
738
|
+
taskContent += `- [ ] #1 ${taskDesc}\n`;
|
|
739
|
+
}
|
|
740
|
+
taskContent += `\n---\nCreated: ${ts}\n`;
|
|
741
|
+
(0, state_js_1.writeFile)(path.join(dir, "task.md"), taskContent);
|
|
547
742
|
const entry = `\n---\n\n# Step: ${stepName}\n\nStarted: ${ts}\n\n`;
|
|
548
743
|
fs.appendFileSync(path.join(dir, "history.md"), entry, "utf-8");
|
|
549
744
|
console.log(`Entered step: ${stepName} (round reset to 1)`);
|
|
745
|
+
if (taskDesc) {
|
|
746
|
+
console.log(` Subtask #1: ${taskDesc}`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function parseSubtasks(taskContent) {
|
|
750
|
+
const subtasks = [];
|
|
751
|
+
const lines = taskContent.split("\n");
|
|
752
|
+
for (const line of lines) {
|
|
753
|
+
const doneMatch = line.match(/^- \[x\] #(\d+)\s+(.*)/i);
|
|
754
|
+
if (doneMatch) {
|
|
755
|
+
subtasks.push({ index: parseInt(doneMatch[1], 10), text: doneMatch[2], done: true, line });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const todoMatch = line.match(/^- \[ \] #(\d+)\s+(.*)/);
|
|
759
|
+
if (todoMatch) {
|
|
760
|
+
subtasks.push({ index: parseInt(todoMatch[1], 10), text: todoMatch[2], done: false, line });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return subtasks;
|
|
764
|
+
}
|
|
765
|
+
// ─── subtask ─────────────────────────────────────
|
|
766
|
+
function cmdSubtask(args) {
|
|
767
|
+
(0, state_js_1.checkSession)();
|
|
768
|
+
const subcmd = args[0] || "";
|
|
769
|
+
const rest = args.slice(1);
|
|
770
|
+
switch (subcmd) {
|
|
771
|
+
case "add": {
|
|
772
|
+
const desc = rest.join(" ");
|
|
773
|
+
if (!desc) {
|
|
774
|
+
console.error('Usage: ralph-lisa subtask add "task description"');
|
|
775
|
+
process.exit(1);
|
|
776
|
+
}
|
|
777
|
+
const dir = (0, state_js_1.stateDir)();
|
|
778
|
+
const taskPath = path.join(dir, "task.md");
|
|
779
|
+
const content = (0, state_js_1.readFile)(taskPath);
|
|
780
|
+
const existing = parseSubtasks(content);
|
|
781
|
+
const nextIndex = existing.length > 0 ? Math.max(...existing.map((s) => s.index)) + 1 : 1;
|
|
782
|
+
const newLine = `- [ ] #${nextIndex} ${desc}`;
|
|
783
|
+
// Insert before the --- separator if present, otherwise append
|
|
784
|
+
const lines = content.split("\n");
|
|
785
|
+
const sepIdx = lines.findIndex((l) => l.startsWith("---"));
|
|
786
|
+
if (sepIdx !== -1) {
|
|
787
|
+
lines.splice(sepIdx, 0, newLine);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
lines.push(newLine);
|
|
791
|
+
}
|
|
792
|
+
(0, state_js_1.writeFile)(taskPath, lines.join("\n"));
|
|
793
|
+
console.log(`Added subtask #${nextIndex}: ${desc}`);
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
case "done": {
|
|
797
|
+
const num = parseInt(rest[0], 10);
|
|
798
|
+
if (isNaN(num)) {
|
|
799
|
+
console.error("Usage: ralph-lisa subtask done <number>");
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
const dir = (0, state_js_1.stateDir)();
|
|
803
|
+
const taskPath = path.join(dir, "task.md");
|
|
804
|
+
const content = (0, state_js_1.readFile)(taskPath);
|
|
805
|
+
const subtasks = parseSubtasks(content);
|
|
806
|
+
const target = subtasks.find((s) => s.index === num);
|
|
807
|
+
if (!target) {
|
|
808
|
+
console.error(`Error: Subtask #${num} not found.`);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
if (target.done) {
|
|
812
|
+
console.log(`Subtask #${num} already completed.`);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
const updated = content.replace(target.line, target.line.replace("- [ ]", "- [x]"));
|
|
816
|
+
(0, state_js_1.writeFile)(taskPath, updated);
|
|
817
|
+
(0, state_js_1.appendHistory)("System", `[SUBTASK] Completed #${num}: ${target.text}`);
|
|
818
|
+
console.log(`Completed subtask #${num}: ${target.text}`);
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
case "list": {
|
|
822
|
+
const dir = (0, state_js_1.stateDir)();
|
|
823
|
+
const content = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
824
|
+
const subtasks = parseSubtasks(content);
|
|
825
|
+
if (subtasks.length === 0) {
|
|
826
|
+
console.log("No subtasks defined.");
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
for (const s of subtasks) {
|
|
830
|
+
const mark = s.done ? "x" : " ";
|
|
831
|
+
console.log(` [${mark}] #${s.index} ${s.text}`);
|
|
832
|
+
}
|
|
833
|
+
const done = subtasks.filter((s) => s.done).length;
|
|
834
|
+
console.log(`\n${done}/${subtasks.length} completed`);
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
default:
|
|
838
|
+
console.error("Usage: ralph-lisa subtask <add|done|list> [args]");
|
|
839
|
+
console.error(' subtask add "description" Add a new subtask');
|
|
840
|
+
console.error(" subtask done <number> Mark subtask as complete");
|
|
841
|
+
console.error(" subtask list List all subtasks");
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
550
844
|
}
|
|
551
845
|
// ─── history ─────────────────────────────────────
|
|
552
846
|
function cmdHistory() {
|
|
@@ -576,12 +870,214 @@ function cmdClean() {
|
|
|
576
870
|
console.log("Session cleaned");
|
|
577
871
|
}
|
|
578
872
|
}
|
|
579
|
-
// ───
|
|
873
|
+
// ─── stop ─────────────────────────────────────────
|
|
874
|
+
/**
|
|
875
|
+
* Check if a PID belongs to our watcher/wrapper process by matching
|
|
876
|
+
* the exact watcher script path in the process args.
|
|
877
|
+
*/
|
|
878
|
+
function isOurProcess(pid, dir) {
|
|
879
|
+
const watcherScriptPath = path.join(dir, "watcher.sh");
|
|
880
|
+
try {
|
|
881
|
+
const args = (0, node_child_process_1.execSync)(`ps -p ${pid} -o args= 2>/dev/null || true`)
|
|
882
|
+
.toString()
|
|
883
|
+
.trim();
|
|
884
|
+
return args !== "" && args.includes(watcherScriptPath);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Send a signal to a PID after verifying it belongs to our project.
|
|
892
|
+
* Returns true if signal was sent, false if skipped.
|
|
893
|
+
*/
|
|
894
|
+
function signalOurProcess(pid, dir, signal, label) {
|
|
895
|
+
if (!isOurProcess(pid, dir)) {
|
|
896
|
+
console.log(` ${label}: PID ${pid} is not ours (stale PID file), skipping signal`);
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
process.kill(Number(pid), signal);
|
|
901
|
+
console.log(` ${label}: sent ${signal} to PID ${pid}`);
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
console.log(` ${label}: PID ${pid} already dead`);
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Clean stale PID files and orphaned state files.
|
|
911
|
+
*/
|
|
912
|
+
function cleanStaleFiles(dir) {
|
|
913
|
+
const staleFiles = [
|
|
914
|
+
"watcher.pid",
|
|
915
|
+
"watcher_wrapper.pid",
|
|
916
|
+
"deadlock.txt",
|
|
917
|
+
".checkpoint_ack",
|
|
918
|
+
"control.txt",
|
|
919
|
+
".turn_changed",
|
|
920
|
+
];
|
|
921
|
+
for (const f of staleFiles) {
|
|
922
|
+
const fp = path.join(dir, f);
|
|
923
|
+
try {
|
|
924
|
+
fs.unlinkSync(fp);
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
// file didn't exist
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function cmdStop(args) {
|
|
932
|
+
const force = args.includes("--force");
|
|
933
|
+
const noArchive = args.includes("--no-archive");
|
|
934
|
+
const projectDir = process.cwd();
|
|
935
|
+
const dir = (0, state_js_1.stateDir)(projectDir);
|
|
936
|
+
const sessionName = generateSessionName(projectDir);
|
|
937
|
+
console.log(line());
|
|
938
|
+
console.log(force ? "Stopping (force)..." : "Stopping gracefully...");
|
|
939
|
+
console.log(line());
|
|
940
|
+
// Read PIDs before any action
|
|
941
|
+
const wrapperPidFile = path.join(dir, "watcher_wrapper.pid");
|
|
942
|
+
const watcherPidFile = path.join(dir, "watcher.pid");
|
|
943
|
+
const wrapperPid = fs.existsSync(wrapperPidFile)
|
|
944
|
+
? (0, state_js_1.readFile)(wrapperPidFile).trim()
|
|
945
|
+
: "";
|
|
946
|
+
const watcherPid = fs.existsSync(watcherPidFile)
|
|
947
|
+
? (0, state_js_1.readFile)(watcherPidFile).trim()
|
|
948
|
+
: "";
|
|
949
|
+
if (force) {
|
|
950
|
+
// -- Force stop: SIGKILL everything --
|
|
951
|
+
if (wrapperPid) {
|
|
952
|
+
signalOurProcess(wrapperPid, dir, "SIGKILL", "Wrapper");
|
|
953
|
+
}
|
|
954
|
+
if (watcherPid && isOurProcess(watcherPid, dir)) {
|
|
955
|
+
// Only collect children after confirming ownership to avoid
|
|
956
|
+
// killing children of an unrelated process (PID reuse safety)
|
|
957
|
+
let childPids = [];
|
|
958
|
+
try {
|
|
959
|
+
childPids = (0, node_child_process_1.execSync)(`pgrep -P ${watcherPid} 2>/dev/null || true`)
|
|
960
|
+
.toString()
|
|
961
|
+
.trim()
|
|
962
|
+
.split("\n")
|
|
963
|
+
.filter(Boolean);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
// no children
|
|
967
|
+
}
|
|
968
|
+
signalOurProcess(watcherPid, dir, "SIGKILL", "Watcher");
|
|
969
|
+
for (const cpid of childPids) {
|
|
970
|
+
try {
|
|
971
|
+
process.kill(Number(cpid), "SIGKILL");
|
|
972
|
+
console.log(` Accelerator child: killed PID ${cpid}`);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
// already dead
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
else if (watcherPid) {
|
|
980
|
+
console.log(` Watcher: PID ${watcherPid} is not ours (stale PID file), skipping signal`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
// -- Graceful stop: SIGTERM + wait --
|
|
985
|
+
// 1. Stop wrapper first (prevent watcher restart)
|
|
986
|
+
if (wrapperPid) {
|
|
987
|
+
signalOurProcess(wrapperPid, dir, "SIGTERM", "Wrapper");
|
|
988
|
+
}
|
|
989
|
+
// 2. Stop watcher (triggers cleanup trap)
|
|
990
|
+
// Signal graceful stop so watcher clears .watcher_state (step39)
|
|
991
|
+
(0, state_js_1.writeFile)(path.join(dir, ".graceful_stop"), "1");
|
|
992
|
+
if (watcherPid) {
|
|
993
|
+
signalOurProcess(watcherPid, dir, "SIGTERM", "Watcher");
|
|
994
|
+
// Wait for watcher to clean up (up to 5s)
|
|
995
|
+
const deadline = Date.now() + 5000;
|
|
996
|
+
while (Date.now() < deadline && fs.existsSync(watcherPidFile)) {
|
|
997
|
+
(0, node_child_process_1.execSync)("sleep 0.5");
|
|
998
|
+
}
|
|
999
|
+
if (fs.existsSync(watcherPidFile)) {
|
|
1000
|
+
console.log(" Watcher did not exit cleanly, forcing...");
|
|
1001
|
+
signalOurProcess(watcherPid, dir, "SIGKILL", "Watcher (force)");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// 3. Send /exit to agent panes
|
|
1005
|
+
try {
|
|
1006
|
+
(0, node_child_process_1.execSync)(`tmux has-session -t "${sessionName}" 2>/dev/null`);
|
|
1007
|
+
for (const pane of ["0.0", "0.1"]) {
|
|
1008
|
+
try {
|
|
1009
|
+
(0, node_child_process_1.execSync)(`tmux send-keys -t "${sessionName}:${pane}" "/exit" Enter 2>/dev/null`);
|
|
1010
|
+
console.log(` Sent /exit to pane ${pane}`);
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// pane doesn't exist
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// Wait for agents to exit (up to 10s)
|
|
1017
|
+
// Check pane process count rather than session existence,
|
|
1018
|
+
// since session stays alive until we explicitly kill it.
|
|
1019
|
+
const agentDeadline = Date.now() + 10000;
|
|
1020
|
+
while (Date.now() < agentDeadline) {
|
|
1021
|
+
try {
|
|
1022
|
+
// List pane PIDs — if only shells remain (no claude/codex), agents exited
|
|
1023
|
+
const paneProcs = (0, node_child_process_1.execSync)(`tmux list-panes -t "${sessionName}" -F "#{pane_pid}" 2>/dev/null || true`).toString().trim();
|
|
1024
|
+
if (!paneProcs)
|
|
1025
|
+
break;
|
|
1026
|
+
// Check if any pane still has agent child processes
|
|
1027
|
+
const pids = paneProcs.split("\n").filter(Boolean);
|
|
1028
|
+
let agentRunning = false;
|
|
1029
|
+
for (const pp of pids) {
|
|
1030
|
+
const children = (0, node_child_process_1.execSync)(`pgrep -P ${pp} 2>/dev/null || true`).toString().trim();
|
|
1031
|
+
if (children) {
|
|
1032
|
+
agentRunning = true;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (!agentRunning)
|
|
1037
|
+
break;
|
|
1038
|
+
(0, node_child_process_1.execSync)("sleep 1");
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
// session doesn't exist
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// Kill tmux session if still alive
|
|
1050
|
+
try {
|
|
1051
|
+
(0, node_child_process_1.execSync)(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
|
|
1052
|
+
console.log(` Killed tmux session: ${sessionName}`);
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
console.log(` No tmux session: ${sessionName}`);
|
|
1056
|
+
}
|
|
1057
|
+
// Clean state files
|
|
1058
|
+
cleanStaleFiles(dir);
|
|
1059
|
+
console.log(" Cleaned state files");
|
|
1060
|
+
// Archive
|
|
1061
|
+
if (!noArchive) {
|
|
1062
|
+
try {
|
|
1063
|
+
const archiveName = `stop-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
|
|
1064
|
+
cmdArchive([archiveName]);
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
console.log(" Archive skipped (no session data)");
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
console.log(line());
|
|
1071
|
+
console.log("Stopped.");
|
|
1072
|
+
console.log(line());
|
|
1073
|
+
}
|
|
1074
|
+
// ─── scope-update / update-task ──────────────────
|
|
580
1075
|
function cmdUpdateTask(args) {
|
|
581
1076
|
(0, state_js_1.checkSession)();
|
|
582
1077
|
const newTask = args.join(" ");
|
|
583
1078
|
if (!newTask) {
|
|
584
|
-
console.error('Usage: ralph-lisa update
|
|
1079
|
+
console.error('Usage: ralph-lisa scope-update "new scope description"');
|
|
1080
|
+
console.error(' ralph-lisa update-task "new scope description" (alias)');
|
|
585
1081
|
process.exit(1);
|
|
586
1082
|
}
|
|
587
1083
|
const dir = (0, state_js_1.stateDir)();
|
|
@@ -589,8 +1085,66 @@ function cmdUpdateTask(args) {
|
|
|
589
1085
|
const ts = (0, state_js_1.timestamp)();
|
|
590
1086
|
// Append new direction with timestamp (preserves history)
|
|
591
1087
|
fs.appendFileSync(taskPath, `\n---\nUpdated: ${ts}\n\n${newTask}\n`, "utf-8");
|
|
592
|
-
|
|
593
|
-
|
|
1088
|
+
// Log scope change to history (Proposal §3.1)
|
|
1089
|
+
(0, state_js_1.appendHistory)("System", `[SCOPE] Task scope updated: ${newTask}`);
|
|
1090
|
+
(0, state_js_1.updateLastAction)("System", `[SCOPE] ${newTask}`);
|
|
1091
|
+
// Clear deadlock flag — scope change resolves deadlocks (Proposal §3.2)
|
|
1092
|
+
const deadlockPath = path.join(dir, "deadlock.txt");
|
|
1093
|
+
try {
|
|
1094
|
+
fs.unlinkSync(deadlockPath);
|
|
1095
|
+
}
|
|
1096
|
+
catch { }
|
|
1097
|
+
const nwCountPath = path.join(dir, "needs_work_count.txt");
|
|
1098
|
+
(0, state_js_1.writeFile)(nwCountPath, "0");
|
|
1099
|
+
console.log(`Scope updated: ${newTask}`);
|
|
1100
|
+
console.log(`(Appended to ${taskPath}, logged to history.md)`);
|
|
1101
|
+
}
|
|
1102
|
+
// ─── force-turn ─────────────────────────────────
|
|
1103
|
+
function cmdForceTurn(args, confirmed = false) {
|
|
1104
|
+
(0, state_js_1.checkSession)();
|
|
1105
|
+
const agent = args[0];
|
|
1106
|
+
if (agent !== "ralph" && agent !== "lisa") {
|
|
1107
|
+
console.error("Usage: ralph-lisa force-turn <ralph|lisa>");
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
const dir = (0, state_js_1.stateDir)();
|
|
1111
|
+
// Block in full-auto mode (watcher running = auto mode)
|
|
1112
|
+
const watcherPid = path.join(dir, "watcher.pid");
|
|
1113
|
+
if (fs.existsSync(watcherPid)) {
|
|
1114
|
+
const pid = (0, state_js_1.readFile)(watcherPid).trim();
|
|
1115
|
+
try {
|
|
1116
|
+
process.kill(Number(pid), 0); // check if alive
|
|
1117
|
+
console.error("Error: force-turn is disabled in auto mode (watcher is running).");
|
|
1118
|
+
console.error("Stop auto mode first, or manually edit turn.txt.");
|
|
1119
|
+
process.exit(1);
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
// watcher not running, PID stale — allow
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!confirmed) {
|
|
1126
|
+
const readline = require("node:readline");
|
|
1127
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1128
|
+
rl.question(`Force turn to ${agent}? This skips normal review flow. (y/N) `, (answer) => {
|
|
1129
|
+
rl.close();
|
|
1130
|
+
if (answer.trim().toLowerCase() === "y") {
|
|
1131
|
+
executeForceTurn(agent, dir);
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
console.log("Aborted.");
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
executeForceTurn(agent, dir);
|
|
1140
|
+
}
|
|
1141
|
+
function executeForceTurn(agent, dir) {
|
|
1142
|
+
(0, state_js_1.setTurn)(agent);
|
|
1143
|
+
const ts = (0, state_js_1.timestamp)();
|
|
1144
|
+
(0, state_js_1.appendHistory)("System", `[FORCE] Turn manually assigned to ${agent} by user`);
|
|
1145
|
+
(0, state_js_1.updateLastAction)("System", `[FORCE] Turn assigned to ${agent}`);
|
|
1146
|
+
console.log(`Turn forced to: ${agent}`);
|
|
1147
|
+
console.log("(Logged to history.md)");
|
|
594
1148
|
}
|
|
595
1149
|
// ─── uninit ──────────────────────────────────────
|
|
596
1150
|
const MARKER = "RALPH-LISA-LOOP";
|
|
@@ -760,14 +1314,21 @@ function cmdInitProject(args) {
|
|
|
760
1314
|
}
|
|
761
1315
|
// Find templates directory (shipped inside npm package)
|
|
762
1316
|
const templatesDir = findTemplatesDir();
|
|
763
|
-
// 1. Append Ralph role
|
|
1317
|
+
// 1. Append/update Ralph role in CLAUDE.md
|
|
764
1318
|
const claudeMd = path.join(resolvedDir, "CLAUDE.md");
|
|
1319
|
+
const ralphRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "ralph.md"));
|
|
765
1320
|
if (fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER)) {
|
|
766
|
-
|
|
1321
|
+
// Marker present — we own this content, update to latest template
|
|
1322
|
+
console.log("[Claude] Updating Ralph role in CLAUDE.md...");
|
|
1323
|
+
const existing = fs.readFileSync(claudeMd, "utf-8");
|
|
1324
|
+
const markerTag = `<!-- ${MARKER} -->`;
|
|
1325
|
+
const markerIdx = existing.indexOf(markerTag);
|
|
1326
|
+
const before = markerIdx > 0 ? existing.slice(0, markerIdx) : "";
|
|
1327
|
+
fs.writeFileSync(claudeMd, before + ralphRole, "utf-8");
|
|
1328
|
+
console.log("[Claude] Updated.");
|
|
767
1329
|
}
|
|
768
1330
|
else {
|
|
769
1331
|
console.log("[Claude] Appending Ralph role to CLAUDE.md...");
|
|
770
|
-
const ralphRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "ralph.md"));
|
|
771
1332
|
if (fs.existsSync(claudeMd)) {
|
|
772
1333
|
fs.appendFileSync(claudeMd, "\n\n", "utf-8");
|
|
773
1334
|
}
|
|
@@ -776,12 +1337,19 @@ function cmdInitProject(args) {
|
|
|
776
1337
|
}
|
|
777
1338
|
// 2. Create/update CODEX.md with Lisa role
|
|
778
1339
|
const codexMd = path.join(resolvedDir, "CODEX.md");
|
|
1340
|
+
const lisaRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "lisa.md"));
|
|
779
1341
|
if (fs.existsSync(codexMd) && (0, state_js_1.readFile)(codexMd).includes(MARKER)) {
|
|
780
|
-
|
|
1342
|
+
// Marker present — we own this content, update to latest template
|
|
1343
|
+
console.log("[Codex] Updating Lisa role in CODEX.md...");
|
|
1344
|
+
const existing = fs.readFileSync(codexMd, "utf-8");
|
|
1345
|
+
const markerTag = `<!-- ${MARKER} -->`;
|
|
1346
|
+
const markerIdx = existing.indexOf(markerTag);
|
|
1347
|
+
const before = markerIdx > 0 ? existing.slice(0, markerIdx) : "";
|
|
1348
|
+
fs.writeFileSync(codexMd, before + lisaRole, "utf-8");
|
|
1349
|
+
console.log("[Codex] Updated.");
|
|
781
1350
|
}
|
|
782
1351
|
else {
|
|
783
1352
|
console.log("[Codex] Creating CODEX.md with Lisa role...");
|
|
784
|
-
const lisaRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "lisa.md"));
|
|
785
1353
|
if (fs.existsSync(codexMd)) {
|
|
786
1354
|
fs.appendFileSync(codexMd, "\n\n", "utf-8");
|
|
787
1355
|
}
|
|
@@ -1037,6 +1605,52 @@ function cmdAuto(args) {
|
|
|
1037
1605
|
console.log("Mode: FULL AUTO (no permission prompts)");
|
|
1038
1606
|
console.log("");
|
|
1039
1607
|
const { execSync } = require("node:child_process");
|
|
1608
|
+
// Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
|
|
1609
|
+
const claudeMd = path.join(projectDir, "CLAUDE.md");
|
|
1610
|
+
const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
|
|
1611
|
+
const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
|
|
1612
|
+
if (!hasFullInit && !hasSession) {
|
|
1613
|
+
console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
|
|
1614
|
+
process.exit(1);
|
|
1615
|
+
}
|
|
1616
|
+
const sessionName = generateSessionName(projectDir);
|
|
1617
|
+
const dir = (0, state_js_1.stateDir)(projectDir);
|
|
1618
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1619
|
+
// Stale detection: check for leftover PID files from a previous run.
|
|
1620
|
+
// Runs before prerequisite checks so stale state is always cleaned,
|
|
1621
|
+
// even in environments without tmux/claude/codex.
|
|
1622
|
+
for (const pidFileName of ["watcher.pid", "watcher_wrapper.pid"]) {
|
|
1623
|
+
const pidFile = path.join(dir, pidFileName);
|
|
1624
|
+
if (fs.existsSync(pidFile)) {
|
|
1625
|
+
const pid = (0, state_js_1.readFile)(pidFile).trim();
|
|
1626
|
+
if (pid) {
|
|
1627
|
+
if (isOurProcess(pid, dir)) {
|
|
1628
|
+
console.error(`Error: ${pidFileName.replace(".pid", "")} is still running (PID ${pid}).`);
|
|
1629
|
+
console.error("Run 'ralph-lisa stop' first.");
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
else {
|
|
1633
|
+
// PID is dead or belongs to another process — stale file
|
|
1634
|
+
console.log(`Warning: Stale ${pidFileName} found (PID ${pid} is not ours). Cleaning up.`);
|
|
1635
|
+
try {
|
|
1636
|
+
fs.unlinkSync(pidFile);
|
|
1637
|
+
}
|
|
1638
|
+
catch { }
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// Clean orphaned state files from stale runs
|
|
1644
|
+
for (const staleFile of ["deadlock.txt", ".checkpoint_ack", "control.txt"]) {
|
|
1645
|
+
const fp = path.join(dir, staleFile);
|
|
1646
|
+
if (fs.existsSync(fp)) {
|
|
1647
|
+
console.log(`Warning: Cleaning stale ${staleFile}`);
|
|
1648
|
+
try {
|
|
1649
|
+
fs.unlinkSync(fp);
|
|
1650
|
+
}
|
|
1651
|
+
catch { }
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1040
1654
|
// Check prerequisites
|
|
1041
1655
|
try {
|
|
1042
1656
|
execSync("which tmux", { stdio: "pipe" });
|
|
@@ -1081,23 +1695,22 @@ function cmdAuto(args) {
|
|
|
1081
1695
|
console.log("");
|
|
1082
1696
|
}
|
|
1083
1697
|
}
|
|
1084
|
-
// Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
|
|
1085
|
-
const claudeMd = path.join(projectDir, "CLAUDE.md");
|
|
1086
|
-
const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
|
|
1087
|
-
const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
|
|
1088
|
-
if (!hasFullInit && !hasSession) {
|
|
1089
|
-
console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
|
|
1090
|
-
process.exit(1);
|
|
1091
|
-
}
|
|
1092
1698
|
// Initialize task
|
|
1093
1699
|
if (task) {
|
|
1094
1700
|
console.log(`Task: ${task}`);
|
|
1095
1701
|
cmdInit(task.split(" "));
|
|
1096
1702
|
console.log("");
|
|
1097
1703
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1704
|
+
// Check if tmux session already exists
|
|
1705
|
+
try {
|
|
1706
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`);
|
|
1707
|
+
console.error(`Error: tmux session "${sessionName}" already exists.`);
|
|
1708
|
+
console.error("Run 'ralph-lisa stop' first.");
|
|
1709
|
+
process.exit(1);
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
// session doesn't exist — good
|
|
1713
|
+
}
|
|
1101
1714
|
// Archive pane logs from previous runs (for transcript preservation)
|
|
1102
1715
|
const logsDir = path.join(dir, "logs");
|
|
1103
1716
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
@@ -1122,28 +1735,44 @@ function cmdAuto(args) {
|
|
|
1122
1735
|
// Create watcher script
|
|
1123
1736
|
const watcherScript = path.join(dir, "watcher.sh");
|
|
1124
1737
|
let watcherContent = `#!/bin/bash
|
|
1125
|
-
# Turn watcher
|
|
1738
|
+
# Turn watcher v4 - round-based change detection + persistent state
|
|
1126
1739
|
# Architecture: polling main loop + optional event acceleration
|
|
1127
|
-
#
|
|
1740
|
+
# v4: Round-based detection fixes double-flip deadlock (step39)
|
|
1128
1741
|
|
|
1129
1742
|
STATE_DIR=".dual-agent"
|
|
1130
1743
|
SESSION="${sessionName}"
|
|
1131
1744
|
SCRIPT_PATH="\$(cd "\$(dirname "\$0")" && pwd)/watcher.sh"
|
|
1132
1745
|
SEEN_TURN=""
|
|
1133
1746
|
ACKED_TURN=""
|
|
1747
|
+
SEEN_ROUND=""
|
|
1748
|
+
ACKED_ROUND=""
|
|
1134
1749
|
FAIL_COUNT=0
|
|
1135
1750
|
ACCEL_PID=""
|
|
1136
1751
|
LAST_ACK_TIME=0
|
|
1752
|
+
DELIVERY_PENDING=0
|
|
1753
|
+
PENDING_TARGET=""
|
|
1754
|
+
CONSENSUS_AT_ROUND=""
|
|
1755
|
+
WATCHER_STATE_FILE="\${STATE_DIR}/.watcher_state"
|
|
1137
1756
|
CHECKPOINT_ROUNDS=\${RL_CHECKPOINT_ROUNDS:-0}
|
|
1138
1757
|
CHECKPOINT_REMIND_TIME=0
|
|
1758
|
+
DEADLOCK_REMIND_TIME=0
|
|
1139
1759
|
CLEANUP_DONE=0
|
|
1140
1760
|
|
|
1761
|
+
# Per-turn escalation state (step38: anti-flooding + stuck-agent detection)
|
|
1762
|
+
NOTIFY_SENT_AT=0 # epoch when first notification was sent this turn
|
|
1763
|
+
REMINDER_LEVEL=0 # 0=initial, 1=REMINDER sent, 2=slash sent, 3=user notified
|
|
1764
|
+
CURRENT_TURN_HASH="" # hash of turn.txt content for change detection
|
|
1765
|
+
|
|
1141
1766
|
PANE0_LOG="\${STATE_DIR}/pane0.log"
|
|
1142
1767
|
PANE1_LOG="\${STATE_DIR}/pane1.log"
|
|
1143
1768
|
PID_FILE="\${STATE_DIR}/watcher.pid"
|
|
1144
1769
|
|
|
1145
1770
|
# Interactive prompt patterns (do NOT send "go" if matched)
|
|
1146
|
-
|
|
1771
|
+
# Covers: passwords, confirmations, Claude Code permission prompts, Codex approval prompts
|
|
1772
|
+
# NOTE: patterns must be specific enough to avoid false positives in normal agent output
|
|
1773
|
+
# BAD: [Aa]pprove — matches "I approve this approach" in agent output
|
|
1774
|
+
# GOOD: Allow once — only matches literal permission button text
|
|
1775
|
+
INTERACTIVE_RE='[Pp]assword[: ]|[Pp]assphrase|[Uu]sername[: ]|[Tt]oken[: ]|[Ll]ogin[: ]|\\(y/[Nn]\\)|\\(Y/[Nn]\\)|\\[y/[Nn]\\]|\\[Y/[Nn]\\]|Are you sure|Continue\\?|[Pp]ress [Ee]nter|MFA|2FA|one-time|OTP|Do you want to proceed|[Gg]rant .* access|[Aa]llow .* to run|[Aa]llow .* to edit|Allow once|Allow always|Deny once|Deny always|allow_once|allow_always|reject_once|reject_always|[Yy]es.*[Nn]o.*[Aa]lways'
|
|
1147
1776
|
|
|
1148
1777
|
# Pause state per pane: 0=active, consecutive hit count
|
|
1149
1778
|
PANE0_PROMPT_HITS=0
|
|
@@ -1169,6 +1798,42 @@ fi
|
|
|
1169
1798
|
|
|
1170
1799
|
echo \$\$ > "\$PID_FILE"
|
|
1171
1800
|
|
|
1801
|
+
# ─── State persistence (step39) ─────────────────
|
|
1802
|
+
|
|
1803
|
+
save_watcher_state() {
|
|
1804
|
+
cat > "\$WATCHER_STATE_FILE" <<WSTATE
|
|
1805
|
+
SEEN_TURN=\$SEEN_TURN
|
|
1806
|
+
ACKED_TURN=\$ACKED_TURN
|
|
1807
|
+
SEEN_ROUND=\$SEEN_ROUND
|
|
1808
|
+
ACKED_ROUND=\$ACKED_ROUND
|
|
1809
|
+
DELIVERY_PENDING=\$DELIVERY_PENDING
|
|
1810
|
+
PENDING_TARGET=\$PENDING_TARGET
|
|
1811
|
+
WSTATE
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
restore_watcher_state() {
|
|
1815
|
+
if [[ -f "\$WATCHER_STATE_FILE" ]]; then
|
|
1816
|
+
echo "[Watcher] Restoring state from \$WATCHER_STATE_FILE"
|
|
1817
|
+
source "\$WATCHER_STATE_FILE"
|
|
1818
|
+
echo "[Watcher] Restored: SEEN_ROUND=\$SEEN_ROUND ACKED_ROUND=\$ACKED_ROUND DELIVERY_PENDING=\$DELIVERY_PENDING"
|
|
1819
|
+
if (( DELIVERY_PENDING )) && [[ -n "\$PENDING_TARGET" ]]; then
|
|
1820
|
+
echo "[Watcher] Replaying pending delivery for \$PENDING_TARGET"
|
|
1821
|
+
if trigger_agent "\$PENDING_TARGET"; then
|
|
1822
|
+
DELIVERY_PENDING=0
|
|
1823
|
+
PENDING_TARGET=""
|
|
1824
|
+
LAST_ACK_TIME=\$(date +%s)
|
|
1825
|
+
save_watcher_state
|
|
1826
|
+
echo "[Watcher] Pending delivery replayed successfully"
|
|
1827
|
+
else
|
|
1828
|
+
echo "[Watcher] Pending delivery replay failed, will retry"
|
|
1829
|
+
fi
|
|
1830
|
+
fi
|
|
1831
|
+
fi
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
# Clean only transient files on startup (NOT .watcher_state — that's for crash recovery)
|
|
1835
|
+
rm -f "\${STATE_DIR}/.checkpoint_ack" "\${STATE_DIR}/deadlock.txt" "\${STATE_DIR}/control.txt" "\${STATE_DIR}/.graceful_stop"
|
|
1836
|
+
|
|
1172
1837
|
# ─── Cleanup trap ────────────────────────────────
|
|
1173
1838
|
|
|
1174
1839
|
cleanup() {
|
|
@@ -1184,6 +1849,15 @@ cleanup() {
|
|
|
1184
1849
|
fi
|
|
1185
1850
|
# Clean up PID and flag files
|
|
1186
1851
|
rm -f "\$PID_FILE" "\${STATE_DIR}/.turn_changed"
|
|
1852
|
+
# Only remove .watcher_state on graceful stop (step39)
|
|
1853
|
+
# .graceful_stop is written by 'ralph-lisa stop' before SIGTERM
|
|
1854
|
+
# Crash/unexpected exits preserve .watcher_state so wrapper restart can replay pending deliveries
|
|
1855
|
+
if [[ -f "\${STATE_DIR}/.graceful_stop" ]]; then
|
|
1856
|
+
rm -f "\$WATCHER_STATE_FILE" "\${STATE_DIR}/.graceful_stop"
|
|
1857
|
+
echo "[Watcher] Graceful stop — watcher state cleared"
|
|
1858
|
+
else
|
|
1859
|
+
echo "[Watcher] Unexpected exit — watcher state preserved for crash recovery"
|
|
1860
|
+
fi
|
|
1187
1861
|
# Archive pane logs (not delete) so transcripts are preserved
|
|
1188
1862
|
local logs_dir="\${STATE_DIR}/logs"
|
|
1189
1863
|
mkdir -p "\$logs_dir"
|
|
@@ -1315,9 +1989,28 @@ send_go_to_pane() {
|
|
|
1315
1989
|
return 1
|
|
1316
1990
|
fi
|
|
1317
1991
|
|
|
1318
|
-
# 3.
|
|
1319
|
-
#
|
|
1320
|
-
|
|
1992
|
+
# 3. Wait for agent to be idle (output stable for 5s)
|
|
1993
|
+
# Prevents injecting text while agent is mid-response
|
|
1994
|
+
local stable_wait=0
|
|
1995
|
+
while (( stable_wait < 30 )); do
|
|
1996
|
+
if check_output_stable "\$log_file" 5; then
|
|
1997
|
+
break
|
|
1998
|
+
fi
|
|
1999
|
+
echo "[Watcher] Waiting for \$agent_name to finish output..."
|
|
2000
|
+
sleep 3
|
|
2001
|
+
stable_wait=\$((stable_wait + 3))
|
|
2002
|
+
done
|
|
2003
|
+
if (( stable_wait >= 30 )); then
|
|
2004
|
+
echo "[Watcher] \$agent_name still producing output after 30s, sending anyway"
|
|
2005
|
+
fi
|
|
2006
|
+
|
|
2007
|
+
# 4. Re-check interactive prompt (may have appeared while waiting)
|
|
2008
|
+
if check_for_interactive_prompt "\$pane"; then
|
|
2009
|
+
echo "[Watcher] Skipping \$agent_name - interactive prompt appeared during wait"
|
|
2010
|
+
return 1
|
|
2011
|
+
fi
|
|
2012
|
+
|
|
2013
|
+
# 5. Send trigger message + Enter with retry
|
|
1321
2014
|
local detect_marker="\${go_msg:0:20}"
|
|
1322
2015
|
while (( attempt < max_retries )); do
|
|
1323
2016
|
tmux send-keys -t "\${SESSION}:\${pane}" -l "\$go_msg" 2>/dev/null || true
|
|
@@ -1344,8 +2037,29 @@ send_go_to_pane() {
|
|
|
1344
2037
|
return 1
|
|
1345
2038
|
fi
|
|
1346
2039
|
|
|
1347
|
-
|
|
1348
|
-
|
|
2040
|
+
# 6. Post-send verification: wait up to 20s for agent to start responding
|
|
2041
|
+
# Record size AFTER send+retry completes (not before), so we only measure
|
|
2042
|
+
# the agent's actual response, not the injected text appearing in the pane.
|
|
2043
|
+
local post_send_baseline=0
|
|
2044
|
+
if [[ -f "\$log_file" ]]; then
|
|
2045
|
+
post_send_baseline=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
|
|
2046
|
+
fi
|
|
2047
|
+
local verify_wait=0
|
|
2048
|
+
while (( verify_wait < 20 )); do
|
|
2049
|
+
sleep 4
|
|
2050
|
+
verify_wait=\$((verify_wait + 4))
|
|
2051
|
+
if [[ -f "\$log_file" ]]; then
|
|
2052
|
+
local cur_size
|
|
2053
|
+
cur_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
|
|
2054
|
+
if (( cur_size > post_send_baseline + 100 )); then
|
|
2055
|
+
echo "[Watcher] OK: \$agent_name responded (output grew +\$((cur_size - post_send_baseline)) bytes)"
|
|
2056
|
+
return 0
|
|
2057
|
+
fi
|
|
2058
|
+
fi
|
|
2059
|
+
done
|
|
2060
|
+
|
|
2061
|
+
echo "[Watcher] WARN: \$agent_name did not produce output after send — may not have received message"
|
|
2062
|
+
return 1
|
|
1349
2063
|
}
|
|
1350
2064
|
|
|
1351
2065
|
# ─── trigger_agent ───────────────────────────────
|
|
@@ -1433,6 +2147,32 @@ trigger_agent() {
|
|
|
1433
2147
|
return 1
|
|
1434
2148
|
}
|
|
1435
2149
|
|
|
2150
|
+
# ─── consensus detection (step38: suppress notifications after consensus) ────
|
|
2151
|
+
|
|
2152
|
+
# Returns 0 if consensus reached (suppress notifications), 1 otherwise
|
|
2153
|
+
check_consensus_reached() {
|
|
2154
|
+
local work_file="\${STATE_DIR}/work.md"
|
|
2155
|
+
local review_file="\${STATE_DIR}/review.md"
|
|
2156
|
+
|
|
2157
|
+
if [[ ! -f "\$work_file" ]] || [[ ! -f "\$review_file" ]]; then
|
|
2158
|
+
return 1
|
|
2159
|
+
fi
|
|
2160
|
+
|
|
2161
|
+
# Extract last tag from canonical header: ## [TAG] Round N | Step: ...
|
|
2162
|
+
# Use sed (POSIX-compatible) instead of grep -oP (Perl, not available on macOS)
|
|
2163
|
+
local work_tag review_tag
|
|
2164
|
+
work_tag=\$(grep '^## \\[' "\$work_file" | grep '] Round [0-9]' | sed 's/^## \\[\\([A-Z_]*\\)\\].*/\\1/' | tail -1)
|
|
2165
|
+
review_tag=\$(grep '^## \\[' "\$review_file" | grep '] Round [0-9]' | sed 's/^## \\[\\([A-Z_]*\\)\\].*/\\1/' | tail -1)
|
|
2166
|
+
|
|
2167
|
+
# Consensus combos: CONSENSUS+CONSENSUS, CONSENSUS+PASS, PASS+CONSENSUS
|
|
2168
|
+
if [[ "\$work_tag" == "CONSENSUS" && "\$review_tag" == "CONSENSUS" ]] ||
|
|
2169
|
+
[[ "\$work_tag" == "CONSENSUS" && "\$review_tag" == "PASS" ]] ||
|
|
2170
|
+
[[ "\$work_tag" == "PASS" && "\$review_tag" == "CONSENSUS" ]]; then
|
|
2171
|
+
return 0
|
|
2172
|
+
fi
|
|
2173
|
+
return 1
|
|
2174
|
+
}
|
|
2175
|
+
|
|
1436
2176
|
# ─── check_and_trigger (state machine) ───────────
|
|
1437
2177
|
|
|
1438
2178
|
check_and_trigger() {
|
|
@@ -1445,27 +2185,118 @@ check_and_trigger() {
|
|
|
1445
2185
|
truncate_log_if_needed "0.0" "\$PANE0_LOG"
|
|
1446
2186
|
truncate_log_if_needed "0.1" "\$PANE1_LOG"
|
|
1447
2187
|
|
|
2188
|
+
# Control file polling (Proposal §3.11): process commands from control.txt
|
|
2189
|
+
if [[ -f "\$STATE_DIR/control.txt" ]]; then
|
|
2190
|
+
local ctrl_cmd
|
|
2191
|
+
ctrl_cmd=\$(cat "\$STATE_DIR/control.txt" 2>/dev/null)
|
|
2192
|
+
rm -f "\$STATE_DIR/control.txt"
|
|
2193
|
+
if [[ -n "\$ctrl_cmd" ]]; then
|
|
2194
|
+
echo "[Watcher] Control command: \$ctrl_cmd"
|
|
2195
|
+
case "\$ctrl_cmd" in
|
|
2196
|
+
pause)
|
|
2197
|
+
echo "[Watcher] PAUSED by control file. Write 'resume' to control.txt to continue."
|
|
2198
|
+
while true; do
|
|
2199
|
+
sleep 2
|
|
2200
|
+
if [[ -f "\$STATE_DIR/control.txt" ]]; then
|
|
2201
|
+
local resume_cmd
|
|
2202
|
+
resume_cmd=\$(cat "\$STATE_DIR/control.txt" 2>/dev/null)
|
|
2203
|
+
rm -f "\$STATE_DIR/control.txt"
|
|
2204
|
+
if [[ "\$resume_cmd" == "resume" ]]; then
|
|
2205
|
+
echo "[Watcher] RESUMED by control file."
|
|
2206
|
+
# Reset escalation state so we restart from L0 (step38)
|
|
2207
|
+
NOTIFY_SENT_AT=0
|
|
2208
|
+
REMINDER_LEVEL=0
|
|
2209
|
+
break
|
|
2210
|
+
else
|
|
2211
|
+
echo "[Watcher] Ignoring '\$resume_cmd' while paused (only 'resume' accepted)"
|
|
2212
|
+
fi
|
|
2213
|
+
fi
|
|
2214
|
+
done
|
|
2215
|
+
;;
|
|
2216
|
+
resume)
|
|
2217
|
+
echo "[Watcher] Already running — resume ignored."
|
|
2218
|
+
;;
|
|
2219
|
+
"msg ralph "*)
|
|
2220
|
+
local ralph_msg="\${ctrl_cmd#msg ralph }"
|
|
2221
|
+
echo "[Watcher] Sending message to Ralph: \$ralph_msg"
|
|
2222
|
+
send_go_to_pane "0.0" "Ralph" "\$PANE0_LOG" "\$ralph_msg"
|
|
2223
|
+
;;
|
|
2224
|
+
"msg lisa "*)
|
|
2225
|
+
local lisa_msg="\${ctrl_cmd#msg lisa }"
|
|
2226
|
+
echo "[Watcher] Sending message to Lisa: \$lisa_msg"
|
|
2227
|
+
send_go_to_pane "0.1" "Lisa" "\$PANE1_LOG" "\$lisa_msg"
|
|
2228
|
+
;;
|
|
2229
|
+
"scope-update "*)
|
|
2230
|
+
local new_scope="\${ctrl_cmd#scope-update }"
|
|
2231
|
+
echo "[Watcher] Updating scope: \$new_scope"
|
|
2232
|
+
ralph-lisa scope-update "\$new_scope" 2>&1 || true
|
|
2233
|
+
;;
|
|
2234
|
+
*)
|
|
2235
|
+
echo "[Watcher] Unknown control command: \$ctrl_cmd"
|
|
2236
|
+
;;
|
|
2237
|
+
esac
|
|
2238
|
+
fi
|
|
2239
|
+
fi
|
|
2240
|
+
|
|
2241
|
+
# Deadlock detection (Proposal §3.2): pause if deadlock.txt exists
|
|
2242
|
+
if [[ -f "\$STATE_DIR/deadlock.txt" ]]; then
|
|
2243
|
+
local now_epoch
|
|
2244
|
+
now_epoch=\$(date +%s)
|
|
2245
|
+
if (( DEADLOCK_REMIND_TIME == 0 )) || (( now_epoch - DEADLOCK_REMIND_TIME >= 30 )); then
|
|
2246
|
+
echo "[Watcher] DEADLOCK detected — pausing. See: \$STATE_DIR/deadlock.txt"
|
|
2247
|
+
echo "[Watcher] To resolve: ralph-lisa scope-update or ralph-lisa force-turn"
|
|
2248
|
+
echo "[Watcher] Or remove: rm \$STATE_DIR/deadlock.txt"
|
|
2249
|
+
DEADLOCK_REMIND_TIME=\$now_epoch
|
|
2250
|
+
fi
|
|
2251
|
+
return
|
|
2252
|
+
fi
|
|
2253
|
+
|
|
1448
2254
|
if [[ -f "\$STATE_DIR/turn.txt" ]]; then
|
|
1449
2255
|
CURRENT_TURN=\$(cat "\$STATE_DIR/turn.txt" 2>/dev/null || echo "")
|
|
2256
|
+
CURRENT_ROUND=\$(cat "\$STATE_DIR/round.txt" 2>/dev/null || echo "0")
|
|
1450
2257
|
|
|
1451
|
-
# Detect new
|
|
1452
|
-
|
|
1453
|
-
|
|
2258
|
+
# Detect new round (step39: round-based detection fixes double-flip deadlock)
|
|
2259
|
+
# Round is monotonically increasing, so A→B→A during delivery is always detected.
|
|
2260
|
+
local round_changed=0
|
|
2261
|
+
if [[ -n "\$CURRENT_ROUND" && "\$CURRENT_ROUND" != "\$SEEN_ROUND" ]]; then
|
|
2262
|
+
round_changed=1
|
|
2263
|
+
echo "[Watcher] Round changed: \$SEEN_ROUND -> \$CURRENT_ROUND (turn: \$SEEN_TURN -> \$CURRENT_TURN)"
|
|
1454
2264
|
SEEN_TURN="\$CURRENT_TURN"
|
|
2265
|
+
SEEN_ROUND="\$CURRENT_ROUND"
|
|
1455
2266
|
FAIL_COUNT=0
|
|
1456
2267
|
LAST_ACK_TIME=0
|
|
2268
|
+
# Reset per-turn escalation state (step38)
|
|
2269
|
+
NOTIFY_SENT_AT=0
|
|
2270
|
+
REMINDER_LEVEL=0
|
|
2271
|
+
|
|
2272
|
+
# Mark delivery pending (step39: decouple ack from delivery)
|
|
2273
|
+
DELIVERY_PENDING=1
|
|
2274
|
+
PENDING_TARGET="\$CURRENT_TURN"
|
|
2275
|
+
save_watcher_state
|
|
1457
2276
|
|
|
1458
2277
|
# Write round separator to pane logs for transcript tracking
|
|
1459
2278
|
local round_ts
|
|
1460
2279
|
round_ts=\$(date "+%Y-%m-%d %H:%M:%S")
|
|
1461
|
-
local round_marker="\\n\\n===== [
|
|
2280
|
+
local round_marker="\\n\\n===== [Round \$CURRENT_ROUND -> \$CURRENT_TURN] \$round_ts =====\\n\\n"
|
|
1462
2281
|
echo -e "\$round_marker" >> "\$PANE0_LOG" 2>/dev/null || true
|
|
1463
2282
|
echo -e "\$round_marker" >> "\$PANE1_LOG" 2>/dev/null || true
|
|
2283
|
+
elif [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
|
|
2284
|
+
# Fallback: turn changed without round change (e.g., force-turn)
|
|
2285
|
+
echo "[Watcher] Turn changed (no round change): \$SEEN_TURN -> \$CURRENT_TURN"
|
|
2286
|
+
SEEN_TURN="\$CURRENT_TURN"
|
|
2287
|
+
FAIL_COUNT=0
|
|
2288
|
+
LAST_ACK_TIME=0
|
|
2289
|
+
NOTIFY_SENT_AT=0
|
|
2290
|
+
REMINDER_LEVEL=0
|
|
2291
|
+
DELIVERY_PENDING=1
|
|
2292
|
+
PENDING_TARGET="\$CURRENT_TURN"
|
|
2293
|
+
save_watcher_state
|
|
2294
|
+
round_changed=1
|
|
1464
2295
|
fi
|
|
1465
2296
|
|
|
1466
2297
|
# Cooldown: skip delivery if last ack was < 30s ago (prevents re-triggering during normal work)
|
|
1467
|
-
#
|
|
1468
|
-
if (( LAST_ACK_TIME > 0 )); then
|
|
2298
|
+
# ONLY applies when round has NOT changed — new rounds always deliver immediately (step39)
|
|
2299
|
+
if (( !round_changed && LAST_ACK_TIME > 0 )); then
|
|
1469
2300
|
local now_epoch
|
|
1470
2301
|
now_epoch=\$(date +%s)
|
|
1471
2302
|
local elapsed=\$(( now_epoch - LAST_ACK_TIME ))
|
|
@@ -1474,6 +2305,21 @@ check_and_trigger() {
|
|
|
1474
2305
|
fi
|
|
1475
2306
|
fi
|
|
1476
2307
|
|
|
2308
|
+
# Consensus suppression (step38): suppress notifications when consensus reached
|
|
2309
|
+
# step39: only suppress if round hasn't changed since consensus was detected
|
|
2310
|
+
if check_consensus_reached; then
|
|
2311
|
+
if [[ "\$CONSENSUS_AT_ROUND" == "" ]]; then
|
|
2312
|
+
CONSENSUS_AT_ROUND="\$CURRENT_ROUND"
|
|
2313
|
+
fi
|
|
2314
|
+
if [[ "\$CURRENT_ROUND" == "\$CONSENSUS_AT_ROUND" ]]; then
|
|
2315
|
+
return
|
|
2316
|
+
fi
|
|
2317
|
+
# Round changed since consensus — clear suppression, deliver normally
|
|
2318
|
+
CONSENSUS_AT_ROUND=""
|
|
2319
|
+
else
|
|
2320
|
+
CONSENSUS_AT_ROUND=""
|
|
2321
|
+
fi
|
|
2322
|
+
|
|
1477
2323
|
# Checkpoint: pause for user review at configured round intervals
|
|
1478
2324
|
if (( CHECKPOINT_ROUNDS > 0 )); then
|
|
1479
2325
|
local round
|
|
@@ -1500,8 +2346,8 @@ check_and_trigger() {
|
|
|
1500
2346
|
fi
|
|
1501
2347
|
fi
|
|
1502
2348
|
|
|
1503
|
-
# Need to deliver? (
|
|
1504
|
-
if [[ -n "\$
|
|
2349
|
+
# Need to deliver? (step39: round-based + DELIVERY_PENDING)
|
|
2350
|
+
if (( DELIVERY_PENDING )) || [[ -n "\$SEEN_ROUND" && "\$SEEN_ROUND" != "\$ACKED_ROUND" ]]; then
|
|
1505
2351
|
# Backoff on repeated failures
|
|
1506
2352
|
if (( FAIL_COUNT >= 30 )); then
|
|
1507
2353
|
echo "[Watcher] ALERT: \$FAIL_COUNT consecutive failures. Manual intervention needed."
|
|
@@ -1511,29 +2357,90 @@ check_and_trigger() {
|
|
|
1511
2357
|
sleep 30
|
|
1512
2358
|
fi
|
|
1513
2359
|
|
|
1514
|
-
|
|
2360
|
+
local deliver_target="\${PENDING_TARGET:-\$SEEN_TURN}"
|
|
2361
|
+
if trigger_agent "\$deliver_target"; then
|
|
1515
2362
|
ACKED_TURN="\$SEEN_TURN"
|
|
2363
|
+
ACKED_ROUND="\$SEEN_ROUND"
|
|
2364
|
+
DELIVERY_PENDING=0
|
|
2365
|
+
PENDING_TARGET=""
|
|
1516
2366
|
FAIL_COUNT=0
|
|
1517
2367
|
LAST_ACK_TIME=\$(date +%s)
|
|
1518
|
-
|
|
2368
|
+
NOTIFY_SENT_AT=\$(date +%s)
|
|
2369
|
+
REMINDER_LEVEL=0
|
|
2370
|
+
save_watcher_state
|
|
2371
|
+
echo "[Watcher] Round \$SEEN_ROUND acknowledged: \$SEEN_TURN (cooldown 30s)"
|
|
1519
2372
|
else
|
|
1520
2373
|
FAIL_COUNT=\$((FAIL_COUNT + 1))
|
|
1521
2374
|
echo "[Watcher] Trigger failed (fail_count=\$FAIL_COUNT), will retry next cycle"
|
|
1522
2375
|
fi
|
|
2376
|
+
|
|
2377
|
+
# Escalation (step38): agent was notified but turn hasn't changed
|
|
2378
|
+
elif [[ -n "\$SEEN_TURN" && "\$SEEN_ROUND" == "\$ACKED_ROUND" && "\$NOTIFY_SENT_AT" -gt 0 ]]; then
|
|
2379
|
+
local now_epoch elapsed
|
|
2380
|
+
now_epoch=\$(date +%s)
|
|
2381
|
+
elapsed=\$(( now_epoch - NOTIFY_SENT_AT ))
|
|
2382
|
+
|
|
2383
|
+
# Determine target pane for escalation
|
|
2384
|
+
local target_pane target_name target_log
|
|
2385
|
+
if [[ "\$SEEN_TURN" == "ralph" ]]; then
|
|
2386
|
+
target_pane="0.0"; target_name="Ralph"; target_log="\$PANE0_LOG"
|
|
2387
|
+
else
|
|
2388
|
+
target_pane="0.1"; target_name="Lisa"; target_log="\$PANE1_LOG"
|
|
2389
|
+
fi
|
|
2390
|
+
|
|
2391
|
+
# Check for context limit in pane output (unrecoverable — notify user immediately)
|
|
2392
|
+
local pane_tail
|
|
2393
|
+
pane_tail=\$(tmux capture-pane -t "\${SESSION}:\${target_pane}" -p 2>/dev/null | tail -10)
|
|
2394
|
+
if echo "\$pane_tail" | grep -qiE "context limit|conversation too long|token limit|context window"; then
|
|
2395
|
+
if (( REMINDER_LEVEL < 3 )); then
|
|
2396
|
+
echo "[Watcher] CONTEXT LIMIT detected for \$target_name. Manual intervention required."
|
|
2397
|
+
echo "[Watcher] Restart the agent session to continue."
|
|
2398
|
+
REMINDER_LEVEL=3
|
|
2399
|
+
fi
|
|
2400
|
+
|
|
2401
|
+
# Time-based escalation: each level checked independently by elapsed time.
|
|
2402
|
+
# If L1/L2 delivery fails, time still advances, so L3 is always reachable.
|
|
2403
|
+
|
|
2404
|
+
# Level 3: notify user after 10 minutes — always reachable regardless of L1/L2 success
|
|
2405
|
+
elif (( elapsed >= 600 && REMINDER_LEVEL < 3 )); then
|
|
2406
|
+
echo "[Watcher] STUCK: \$target_name has not responded for \${elapsed}s. Manual intervention needed."
|
|
2407
|
+
REMINDER_LEVEL=3
|
|
2408
|
+
|
|
2409
|
+
# Level 2: slash command after 5 minutes, with prompt guard
|
|
2410
|
+
elif (( elapsed >= 300 && REMINDER_LEVEL < 2 )); then
|
|
2411
|
+
if ! check_for_interactive_prompt "\$target_pane"; then
|
|
2412
|
+
echo "[Watcher] Escalation L2: Sending /check-turn to \$target_name (no response for \${elapsed}s)"
|
|
2413
|
+
if send_go_to_pane "\$target_pane" "\$target_name" "\$target_log" "/check-turn"; then
|
|
2414
|
+
REMINDER_LEVEL=2
|
|
2415
|
+
fi
|
|
2416
|
+
else
|
|
2417
|
+
echo "[Watcher] Escalation L2: Skipped — interactive prompt detected for \$target_name"
|
|
2418
|
+
fi
|
|
2419
|
+
|
|
2420
|
+
# Level 1: REMINDER after 2 minutes
|
|
2421
|
+
elif (( elapsed >= 120 && REMINDER_LEVEL < 1 )); then
|
|
2422
|
+
echo "[Watcher] Escalation L1: Sending REMINDER to \$target_name (no response for \${elapsed}s)"
|
|
2423
|
+
if send_go_to_pane "\$target_pane" "\$target_name" "\$target_log" "REMINDER: It is your turn. Please check turn and continue working."; then
|
|
2424
|
+
REMINDER_LEVEL=1
|
|
2425
|
+
fi
|
|
2426
|
+
fi
|
|
1523
2427
|
fi
|
|
1524
2428
|
fi
|
|
1525
2429
|
}
|
|
1526
2430
|
|
|
1527
2431
|
# ─── Main ────────────────────────────────────────
|
|
1528
2432
|
|
|
1529
|
-
echo "[Watcher] Starting
|
|
1530
|
-
echo "[Watcher] Monitoring \$STATE_DIR/turn.txt"
|
|
2433
|
+
echo "[Watcher] Starting v4... (Ctrl+C to stop)"
|
|
2434
|
+
echo "[Watcher] Monitoring \$STATE_DIR/turn.txt + round.txt"
|
|
1531
2435
|
echo "[Watcher] Pane logs: \$PANE0_LOG, \$PANE1_LOG"
|
|
1532
2436
|
if (( CHECKPOINT_ROUNDS > 0 )); then
|
|
1533
2437
|
echo "[Watcher] Checkpoint every \$CHECKPOINT_ROUNDS rounds (RL_CHECKPOINT_ROUNDS)"
|
|
1534
2438
|
fi
|
|
1535
2439
|
echo "[Watcher] PID: \$\$"
|
|
1536
2440
|
|
|
2441
|
+
# Restore state from crash (step39)
|
|
2442
|
+
restore_watcher_state
|
|
2443
|
+
|
|
1537
2444
|
sleep 5
|
|
1538
2445
|
check_and_trigger
|
|
1539
2446
|
|
|
@@ -1578,6 +2485,9 @@ done
|
|
|
1578
2485
|
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`);
|
|
1579
2486
|
// Pane 0: Ralph (left), Pane 1: Lisa (right)
|
|
1580
2487
|
execSync(`tmux new-session -d -s "${sessionName}" -n "main" -c "${projectDir}"`);
|
|
2488
|
+
// Set authoritative state directory in tmux env (Proposal §3.10)
|
|
2489
|
+
// Both agents resolve to this regardless of their working directory
|
|
2490
|
+
execSync(`tmux set-environment -t "${sessionName}" RL_STATE_DIR "${dir}"`);
|
|
1581
2491
|
execSync(`tmux split-window -h -t "${sessionName}" -c "${projectDir}"`);
|
|
1582
2492
|
// Pane 0 = Ralph (left), Pane 1 = Lisa (right)
|
|
1583
2493
|
execSync(`tmux send-keys -t "${sessionName}:0.0" "echo '=== Ralph (Claude Code) ===' && ${claudeCmd}" Enter`);
|
|
@@ -1713,7 +2623,7 @@ function cmdPolicyCheckConsensus() {
|
|
|
1713
2623
|
}
|
|
1714
2624
|
/**
|
|
1715
2625
|
* Comprehensive check for proceeding to the next step:
|
|
1716
|
-
* 1.
|
|
2626
|
+
* 1. Consensus reached: [CONSENSUS]+[CONSENSUS], [CONSENSUS]+[PASS], or [PASS]+[CONSENSUS]
|
|
1717
2627
|
* 2. Ralph's submission passes policy checks
|
|
1718
2628
|
* 3. Lisa's submission passes policy checks
|
|
1719
2629
|
*/
|
|
@@ -1727,12 +2637,13 @@ function cmdPolicyCheckNextStep() {
|
|
|
1727
2637
|
const workTag = workContent ? (0, state_js_1.extractTag)(workContent) : "";
|
|
1728
2638
|
const reviewTag = reviewContent ? (0, state_js_1.extractTag)(reviewContent) : "";
|
|
1729
2639
|
const allIssues = [];
|
|
1730
|
-
// 1. Consensus check
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
2640
|
+
// 1. Consensus check — mirrors cmdStep() logic: accept CONSENSUS+CONSENSUS,
|
|
2641
|
+
// CONSENSUS+PASS, or PASS+CONSENSUS combinations
|
|
2642
|
+
const consensusReached = (workTag === "CONSENSUS" && reviewTag === "CONSENSUS") ||
|
|
2643
|
+
(workTag === "CONSENSUS" && reviewTag === "PASS") ||
|
|
2644
|
+
(workTag === "PASS" && reviewTag === "CONSENSUS");
|
|
2645
|
+
if (!consensusReached) {
|
|
2646
|
+
allIssues.push(`Consensus not reached: Ralph=[${workTag || "none"}], Lisa=[${reviewTag || "none"}]. Need [CONSENSUS]+[CONSENSUS] or [PASS]+[CONSENSUS] combination.`);
|
|
1736
2647
|
}
|
|
1737
2648
|
// 2. Policy checks on latest submissions (if content exists)
|
|
1738
2649
|
if (workContent && workTag) {
|
|
@@ -1746,7 +2657,7 @@ function cmdPolicyCheckNextStep() {
|
|
|
1746
2657
|
allIssues.push(`Lisa: ${v.message}`);
|
|
1747
2658
|
}
|
|
1748
2659
|
if (allIssues.length === 0) {
|
|
1749
|
-
console.log("Ready to proceed: consensus reached and all checks pass.");
|
|
2660
|
+
console.log("Ready to proceed: consensus reached ([CONSENSUS]/[PASS] combination) and all checks pass.");
|
|
1750
2661
|
return;
|
|
1751
2662
|
}
|
|
1752
2663
|
console.error("Not ready to proceed:");
|
|
@@ -1848,6 +2759,255 @@ function cmdLogs(args) {
|
|
|
1848
2759
|
console.log(" ralph-lisa logs cat View live pane logs");
|
|
1849
2760
|
console.log(" ralph-lisa logs cat <file> View specific log file");
|
|
1850
2761
|
}
|
|
2762
|
+
// ─── remote (ttyd) ──────────────────────────────
|
|
2763
|
+
/**
|
|
2764
|
+
* Check if a PID belongs to a ttyd process to avoid killing unrelated processes.
|
|
2765
|
+
*/
|
|
2766
|
+
function isTtydProcess(pid) {
|
|
2767
|
+
try {
|
|
2768
|
+
// Check if process is alive first
|
|
2769
|
+
process.kill(pid, 0);
|
|
2770
|
+
// Verify it's actually ttyd by checking command line
|
|
2771
|
+
const cmdline = (0, node_child_process_1.execSync)(`ps -p ${pid} -o comm=`, { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
2772
|
+
return cmdline.includes("ttyd");
|
|
2773
|
+
}
|
|
2774
|
+
catch {
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
function cmdRemote(args) {
|
|
2779
|
+
(0, state_js_1.checkSession)();
|
|
2780
|
+
const dir = (0, state_js_1.stateDir)();
|
|
2781
|
+
const pidFile = path.join(dir, "ttyd.pid");
|
|
2782
|
+
// --stop / stop: kill existing ttyd
|
|
2783
|
+
if (args.includes("--stop") || args.includes("stop")) {
|
|
2784
|
+
if (!fs.existsSync(pidFile)) {
|
|
2785
|
+
console.log("No ttyd server running.");
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
const pid = Number((0, state_js_1.readFile)(pidFile));
|
|
2789
|
+
if (isTtydProcess(pid)) {
|
|
2790
|
+
process.kill(pid, "SIGTERM");
|
|
2791
|
+
console.log(`Stopped ttyd (PID ${pid}).`);
|
|
2792
|
+
}
|
|
2793
|
+
else {
|
|
2794
|
+
console.log(`Stale PID ${pid} (not a ttyd process). Cleaning up.`);
|
|
2795
|
+
}
|
|
2796
|
+
try {
|
|
2797
|
+
fs.unlinkSync(pidFile);
|
|
2798
|
+
}
|
|
2799
|
+
catch { }
|
|
2800
|
+
// Clean up the dedicated remote tmux session
|
|
2801
|
+
try {
|
|
2802
|
+
(0, node_child_process_1.execSync)("tmux list-sessions -F '#{session_name}'", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
|
|
2803
|
+
.trim().split("\n")
|
|
2804
|
+
.filter((s) => s.endsWith("-remote"))
|
|
2805
|
+
.forEach((s) => { try {
|
|
2806
|
+
(0, node_child_process_1.execSync)(`tmux kill-session -t ${s}`, { stdio: "pipe" });
|
|
2807
|
+
}
|
|
2808
|
+
catch { } });
|
|
2809
|
+
}
|
|
2810
|
+
catch { }
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
// Check ttyd installed
|
|
2814
|
+
try {
|
|
2815
|
+
(0, node_child_process_1.execSync)("which ttyd", { stdio: "pipe" });
|
|
2816
|
+
}
|
|
2817
|
+
catch {
|
|
2818
|
+
console.error("Error: ttyd not found.");
|
|
2819
|
+
console.error("Install: brew install ttyd (macOS) / apt install ttyd (Linux)");
|
|
2820
|
+
console.error("Or visit: https://github.com/tsl0922/ttyd");
|
|
2821
|
+
process.exit(1);
|
|
2822
|
+
}
|
|
2823
|
+
// Find tmux session: explicit arg > auto-detect > list available
|
|
2824
|
+
const knownFlags = ["--port", "--auth", "--stop"];
|
|
2825
|
+
const reservedWords = ["stop"];
|
|
2826
|
+
const explicitSession = args.find((a) => !a.startsWith("--") && !reservedWords.includes(a) && !knownFlags.includes(args[args.indexOf(a) - 1]));
|
|
2827
|
+
// Resolve session: explicit arg > auto-detect from cwd > single running rll-* session
|
|
2828
|
+
let rllSessions = [];
|
|
2829
|
+
try {
|
|
2830
|
+
rllSessions = (0, node_child_process_1.execSync)("tmux list-sessions -F '#{session_name}'", {
|
|
2831
|
+
encoding: "utf-8",
|
|
2832
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2833
|
+
})
|
|
2834
|
+
.trim()
|
|
2835
|
+
.split("\n")
|
|
2836
|
+
.filter((s) => s.startsWith("rll-") && !/-\d+$/.test(s));
|
|
2837
|
+
}
|
|
2838
|
+
catch { }
|
|
2839
|
+
let sessionName;
|
|
2840
|
+
if (explicitSession) {
|
|
2841
|
+
sessionName = explicitSession;
|
|
2842
|
+
}
|
|
2843
|
+
else {
|
|
2844
|
+
// Try auto-detect from project dir
|
|
2845
|
+
const projectRoot = (0, state_js_1.findProjectRoot)() || process.cwd();
|
|
2846
|
+
const detected = generateSessionName(projectRoot);
|
|
2847
|
+
if (rllSessions.includes(detected)) {
|
|
2848
|
+
sessionName = detected;
|
|
2849
|
+
}
|
|
2850
|
+
else if (rllSessions.length === 1) {
|
|
2851
|
+
// Only one rll-* session running — use it
|
|
2852
|
+
sessionName = rllSessions[0];
|
|
2853
|
+
}
|
|
2854
|
+
else {
|
|
2855
|
+
sessionName = detected; // will fail below with helpful hint
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
try {
|
|
2859
|
+
(0, node_child_process_1.execSync)(`tmux has-session -t ${sessionName}`, { stdio: "pipe" });
|
|
2860
|
+
}
|
|
2861
|
+
catch {
|
|
2862
|
+
let hint = "";
|
|
2863
|
+
if (rllSessions.length > 0) {
|
|
2864
|
+
hint = `\nAvailable sessions:\n${rllSessions.map((s) => ` ${s}`).join("\n")}\n\nUsage: ralph-lisa remote <session-name>`;
|
|
2865
|
+
}
|
|
2866
|
+
console.error(`Error: tmux session '${sessionName}' not found.${hint}`);
|
|
2867
|
+
if (!hint)
|
|
2868
|
+
console.error("Start a session first: ralph-lisa auto \"task\"");
|
|
2869
|
+
process.exit(1);
|
|
2870
|
+
}
|
|
2871
|
+
// Parse options
|
|
2872
|
+
let port = 7681;
|
|
2873
|
+
let authFlag = "";
|
|
2874
|
+
const portIdx = args.indexOf("--port");
|
|
2875
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
2876
|
+
port = Number(args[portIdx + 1]);
|
|
2877
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2878
|
+
console.error("Error: --port must be a valid port number (1-65535).");
|
|
2879
|
+
process.exit(1);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
const authIdx = args.indexOf("--auth");
|
|
2883
|
+
if (authIdx !== -1 && args[authIdx + 1]) {
|
|
2884
|
+
authFlag = `-c ${args[authIdx + 1]}`;
|
|
2885
|
+
}
|
|
2886
|
+
// Kill existing ttyd if running
|
|
2887
|
+
if (fs.existsSync(pidFile)) {
|
|
2888
|
+
const oldPid = Number((0, state_js_1.readFile)(pidFile));
|
|
2889
|
+
if (isTtydProcess(oldPid)) {
|
|
2890
|
+
try {
|
|
2891
|
+
process.kill(oldPid, "SIGTERM");
|
|
2892
|
+
}
|
|
2893
|
+
catch { }
|
|
2894
|
+
}
|
|
2895
|
+
try {
|
|
2896
|
+
fs.unlinkSync(pidFile);
|
|
2897
|
+
}
|
|
2898
|
+
catch { }
|
|
2899
|
+
}
|
|
2900
|
+
// Create a dedicated grouped session for ttyd so browser clients
|
|
2901
|
+
// get their own tmux client (independent input/scroll from Mac).
|
|
2902
|
+
// Re-use the same grouped session across browser refreshes.
|
|
2903
|
+
const remoteSession = `${sessionName}-remote`;
|
|
2904
|
+
try {
|
|
2905
|
+
(0, node_child_process_1.execSync)(`tmux has-session -t ${remoteSession}`, { stdio: "pipe" });
|
|
2906
|
+
}
|
|
2907
|
+
catch {
|
|
2908
|
+
(0, node_child_process_1.execSync)(`tmux new-session -d -s ${remoteSession} -t ${sessionName}`, { stdio: "pipe" });
|
|
2909
|
+
}
|
|
2910
|
+
// Launch ttyd — attach to the dedicated remote session
|
|
2911
|
+
const { spawn } = require("node:child_process");
|
|
2912
|
+
const ttydArgs = ["-p", String(port)];
|
|
2913
|
+
if (authFlag) {
|
|
2914
|
+
const [user, pass] = args[authIdx + 1].split(":");
|
|
2915
|
+
ttydArgs.push("-c", `${user}:${pass}`);
|
|
2916
|
+
}
|
|
2917
|
+
ttydArgs.push("tmux", "attach", "-t", remoteSession);
|
|
2918
|
+
const child = spawn("ttyd", ttydArgs, {
|
|
2919
|
+
detached: true,
|
|
2920
|
+
stdio: "ignore",
|
|
2921
|
+
});
|
|
2922
|
+
child.unref();
|
|
2923
|
+
(0, state_js_1.writeFile)(pidFile, String(child.pid));
|
|
2924
|
+
// Detect LAN IP
|
|
2925
|
+
let lanIp = "localhost";
|
|
2926
|
+
try {
|
|
2927
|
+
const os = require("node:os");
|
|
2928
|
+
const nets = os.networkInterfaces();
|
|
2929
|
+
for (const name of Object.keys(nets)) {
|
|
2930
|
+
for (const net of nets[name]) {
|
|
2931
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
2932
|
+
lanIp = net.address;
|
|
2933
|
+
break;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
if (lanIp !== "localhost")
|
|
2937
|
+
break;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
catch { }
|
|
2941
|
+
console.log(line());
|
|
2942
|
+
console.log("ttyd server started");
|
|
2943
|
+
console.log(line());
|
|
2944
|
+
console.log("");
|
|
2945
|
+
console.log(` Local: http://localhost:${port}`);
|
|
2946
|
+
console.log(` Network: http://${lanIp}:${port}`);
|
|
2947
|
+
console.log("");
|
|
2948
|
+
console.log(` Session: ${sessionName}`);
|
|
2949
|
+
console.log(` PID: ${child.pid}`);
|
|
2950
|
+
if (authFlag) {
|
|
2951
|
+
console.log(" Auth: enabled");
|
|
2952
|
+
}
|
|
2953
|
+
else {
|
|
2954
|
+
console.log(" Auth: none (use --auth user:pass for LAN access)");
|
|
2955
|
+
}
|
|
2956
|
+
console.log("");
|
|
2957
|
+
console.log("Stop with: ralph-lisa remote --stop");
|
|
2958
|
+
console.log(line());
|
|
2959
|
+
}
|
|
2960
|
+
// ─── state-dir ───────────────────────────────────
|
|
2961
|
+
function cmdStateDir(args) {
|
|
2962
|
+
const setPath = args[0];
|
|
2963
|
+
if (setPath) {
|
|
2964
|
+
// Set mode: write to tmux env if in tmux, otherwise show instructions
|
|
2965
|
+
const resolvedPath = path.resolve(setPath);
|
|
2966
|
+
if (process.env.TMUX) {
|
|
2967
|
+
try {
|
|
2968
|
+
(0, node_child_process_1.execSync)(`tmux set-environment RL_STATE_DIR "${resolvedPath}"`, { stdio: "pipe" });
|
|
2969
|
+
console.log(`State dir set (tmux env): ${resolvedPath}`);
|
|
2970
|
+
}
|
|
2971
|
+
catch {
|
|
2972
|
+
console.error("Error: Failed to set tmux environment variable.");
|
|
2973
|
+
process.exit(1);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
else {
|
|
2977
|
+
console.log(`Not in tmux session. Set manually:`);
|
|
2978
|
+
console.log(` export RL_STATE_DIR="${resolvedPath}"`);
|
|
2979
|
+
}
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
// Show mode: display all sources + active
|
|
2983
|
+
const tmuxDir = (0, state_js_1.getTmuxStateDir)();
|
|
2984
|
+
const envDir = process.env.RL_STATE_DIR || null;
|
|
2985
|
+
const root = (0, state_js_1.findProjectRoot)();
|
|
2986
|
+
const autoDir = root ? path.join(root, state_js_1.STATE_DIR) : path.join(process.cwd(), state_js_1.STATE_DIR);
|
|
2987
|
+
const resolved = (0, state_js_1.resolveStateDir)();
|
|
2988
|
+
console.log(`tmux env: ${tmuxDir || "(not set)"}`);
|
|
2989
|
+
console.log(`shell env: ${envDir || "(not set)"}`);
|
|
2990
|
+
console.log(`auto-detect: ${autoDir}`);
|
|
2991
|
+
console.log(`→ using: ${resolved.dir} [${resolved.source}]`);
|
|
2992
|
+
}
|
|
2993
|
+
// ─── add-context ─────────────────────────────────
|
|
2994
|
+
function cmdAddContext(args) {
|
|
2995
|
+
(0, state_js_1.checkSession)();
|
|
2996
|
+
const text = args.join(" ");
|
|
2997
|
+
if (!text) {
|
|
2998
|
+
console.error('Usage: ralph-lisa add-context "user directive or context note"');
|
|
2999
|
+
process.exit(1);
|
|
3000
|
+
}
|
|
3001
|
+
const dir = (0, state_js_1.stateDir)();
|
|
3002
|
+
const contextPath = path.join(dir, "context.md");
|
|
3003
|
+
const ts = (0, state_js_1.timestamp)();
|
|
3004
|
+
if (!fs.existsSync(contextPath)) {
|
|
3005
|
+
(0, state_js_1.writeFile)(contextPath, `# Context Notes\n\nRuntime directives from Ralph (visible to Lisa).\n\n`);
|
|
3006
|
+
}
|
|
3007
|
+
fs.appendFileSync(contextPath, `- [${ts}] ${text}\n`, "utf-8");
|
|
3008
|
+
console.log(`Context added: ${text}`);
|
|
3009
|
+
console.log(`(Written to ${contextPath})`);
|
|
3010
|
+
}
|
|
1851
3011
|
// ─── doctor ──────────────────────────────────────
|
|
1852
3012
|
function cmdDoctor(args) {
|
|
1853
3013
|
const strict = args.includes("--strict");
|
|
@@ -1892,6 +3052,13 @@ function cmdDoctor(args) {
|
|
|
1892
3052
|
required: false,
|
|
1893
3053
|
installHint: "apt install inotify-tools (Linux)",
|
|
1894
3054
|
},
|
|
3055
|
+
{
|
|
3056
|
+
name: "ttyd (remote access)",
|
|
3057
|
+
cmd: "which ttyd",
|
|
3058
|
+
versionCmd: "ttyd --version",
|
|
3059
|
+
required: false,
|
|
3060
|
+
installHint: "brew install ttyd (macOS) / apt install ttyd (Linux)",
|
|
3061
|
+
},
|
|
1895
3062
|
];
|
|
1896
3063
|
for (const check of checks) {
|
|
1897
3064
|
try {
|