@lawrence369/loop-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/dist/agent/activity.d.ts +64 -0
  5. package/dist/agent/activity.js +265 -0
  6. package/dist/agent/launcher.d.ts +42 -0
  7. package/dist/agent/launcher.js +243 -0
  8. package/dist/agent/pty-session.d.ts +113 -0
  9. package/dist/agent/pty-session.js +490 -0
  10. package/dist/agent/ready-detector.d.ts +46 -0
  11. package/dist/agent/ready-detector.js +86 -0
  12. package/dist/agent/wrapper.d.ts +18 -0
  13. package/dist/agent/wrapper.js +110 -0
  14. package/dist/bin/lclaude.d.ts +3 -0
  15. package/dist/bin/lclaude.js +7 -0
  16. package/dist/bin/lcodex.d.ts +3 -0
  17. package/dist/bin/lcodex.js +7 -0
  18. package/dist/bin/lgemini.d.ts +3 -0
  19. package/dist/bin/lgemini.js +7 -0
  20. package/dist/bus/daemon.d.ts +56 -0
  21. package/dist/bus/daemon.js +135 -0
  22. package/dist/bus/event-bus.d.ts +105 -0
  23. package/dist/bus/event-bus.js +157 -0
  24. package/dist/bus/message.d.ts +48 -0
  25. package/dist/bus/message.js +129 -0
  26. package/dist/bus/queue.d.ts +50 -0
  27. package/dist/bus/queue.js +100 -0
  28. package/dist/bus/store.d.ts +88 -0
  29. package/dist/bus/store.js +212 -0
  30. package/dist/bus/subscriber.d.ts +76 -0
  31. package/dist/bus/subscriber.js +187 -0
  32. package/dist/config/index.d.ts +8 -0
  33. package/dist/config/index.js +72 -0
  34. package/dist/config/schema.d.ts +18 -0
  35. package/dist/config/schema.js +58 -0
  36. package/dist/core/conversation.d.ts +34 -0
  37. package/dist/core/conversation.js +289 -0
  38. package/dist/core/engine.d.ts +40 -0
  39. package/dist/core/engine.js +288 -0
  40. package/dist/core/loop.d.ts +33 -0
  41. package/dist/core/loop.js +209 -0
  42. package/dist/core/protocol.d.ts +60 -0
  43. package/dist/core/protocol.js +162 -0
  44. package/dist/core/scoring.d.ts +34 -0
  45. package/dist/core/scoring.js +69 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.js +408 -0
  48. package/dist/orchestrator/daemon.d.ts +74 -0
  49. package/dist/orchestrator/daemon.js +294 -0
  50. package/dist/orchestrator/group.d.ts +73 -0
  51. package/dist/orchestrator/group.js +166 -0
  52. package/dist/orchestrator/ipc-server.d.ts +60 -0
  53. package/dist/orchestrator/ipc-server.js +166 -0
  54. package/dist/orchestrator/scheduler.d.ts +32 -0
  55. package/dist/orchestrator/scheduler.js +95 -0
  56. package/dist/plan/context.d.ts +8 -0
  57. package/dist/plan/context.js +42 -0
  58. package/dist/plan/decisions.d.ts +18 -0
  59. package/dist/plan/decisions.js +143 -0
  60. package/dist/plan/shared-plan.d.ts +33 -0
  61. package/dist/plan/shared-plan.js +211 -0
  62. package/dist/skills/executor.d.ts +7 -0
  63. package/dist/skills/executor.js +11 -0
  64. package/dist/skills/loader.d.ts +16 -0
  65. package/dist/skills/loader.js +80 -0
  66. package/dist/skills/registry.d.ts +13 -0
  67. package/dist/skills/registry.js +54 -0
  68. package/dist/terminal/adapter.d.ts +61 -0
  69. package/dist/terminal/adapter.js +42 -0
  70. package/dist/terminal/detect.d.ts +30 -0
  71. package/dist/terminal/detect.js +77 -0
  72. package/dist/terminal/iterm2-adapter.d.ts +19 -0
  73. package/dist/terminal/iterm2-adapter.js +120 -0
  74. package/dist/terminal/pty-adapter.d.ts +18 -0
  75. package/dist/terminal/pty-adapter.js +84 -0
  76. package/dist/terminal/terminal-adapter.d.ts +17 -0
  77. package/dist/terminal/terminal-adapter.js +94 -0
  78. package/dist/terminal/tmux-adapter.d.ts +18 -0
  79. package/dist/terminal/tmux-adapter.js +127 -0
  80. package/dist/ui/banner.d.ts +3 -0
  81. package/dist/ui/banner.js +145 -0
  82. package/dist/ui/colors.d.ts +41 -0
  83. package/dist/ui/colors.js +65 -0
  84. package/dist/ui/dashboard.d.ts +32 -0
  85. package/dist/ui/dashboard.js +138 -0
  86. package/dist/ui/input.d.ts +10 -0
  87. package/dist/ui/input.js +96 -0
  88. package/dist/ui/interactive.d.ts +13 -0
  89. package/dist/ui/interactive.js +230 -0
  90. package/dist/ui/renderer.d.ts +33 -0
  91. package/dist/ui/renderer.js +106 -0
  92. package/dist/utils/ansi.d.ts +11 -0
  93. package/dist/utils/ansi.js +16 -0
  94. package/dist/utils/fs.d.ts +34 -0
  95. package/dist/utils/fs.js +115 -0
  96. package/dist/utils/lock.d.ts +12 -0
  97. package/dist/utils/lock.js +116 -0
  98. package/dist/utils/process.d.ts +31 -0
  99. package/dist/utils/process.js +111 -0
  100. package/dist/utils/pty-filter.d.ts +31 -0
  101. package/dist/utils/pty-filter.js +187 -0
  102. package/package.json +71 -0
  103. package/skills/loop/SKILL.md +19 -0
  104. package/skills/plan/SKILL.md +9 -0
  105. package/skills/review/SKILL.md +14 -0
@@ -0,0 +1,187 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { isProcessAlive } from "../utils/process.js";
3
+ /**
4
+ * Manages subscriber registration, lifecycle, and metadata.
5
+ *
6
+ * Subscriber IDs follow the format `{agentType}:{randomId}`,
7
+ * e.g. "claude:abc12345".
8
+ */
9
+ export class SubscriberManager {
10
+ store;
11
+ agents = new Map();
12
+ constructor(store) {
13
+ this.store = store;
14
+ }
15
+ /**
16
+ * Load agent data from disk into memory.
17
+ */
18
+ async load() {
19
+ this.agents = await this.store.loadAgents();
20
+ }
21
+ /**
22
+ * Persist agent data to disk.
23
+ */
24
+ async save() {
25
+ await this.store.saveAgents(this.agents);
26
+ }
27
+ /**
28
+ * Register a new agent on the bus.
29
+ * Returns the generated subscriber ID.
30
+ */
31
+ async register(agentType, metadata = {}) {
32
+ await this.load();
33
+ const sessionId = randomBytes(4).toString("hex");
34
+ const subscriberId = `${agentType}:${sessionId}`;
35
+ const now = new Date().toISOString();
36
+ // Generate a unique nickname
37
+ const nickname = metadata.nickname || this.generateNickname(agentType);
38
+ const agentMeta = {
39
+ agent_type: agentType,
40
+ nickname,
41
+ status: "active",
42
+ joined_at: now,
43
+ last_seen: now,
44
+ pid: metadata.pid ?? process.pid,
45
+ tty: metadata.tty,
46
+ tmux_pane: metadata.tmux_pane,
47
+ launch_mode: metadata.launch_mode ?? "",
48
+ activity_state: metadata.activity_state ?? "starting",
49
+ last_activity: metadata.last_activity,
50
+ };
51
+ this.agents.set(subscriberId, agentMeta);
52
+ // Ensure queue directory exists
53
+ await this.store.ensureQueue(subscriberId);
54
+ await this.save();
55
+ return subscriberId;
56
+ }
57
+ /**
58
+ * Unregister an agent, marking it as inactive.
59
+ */
60
+ async unregister(subscriberId) {
61
+ await this.load();
62
+ const meta = this.agents.get(subscriberId);
63
+ if (!meta)
64
+ return;
65
+ meta.status = "inactive";
66
+ meta.last_seen = new Date().toISOString();
67
+ await this.save();
68
+ }
69
+ /**
70
+ * Rename a subscriber's nickname.
71
+ */
72
+ async rename(subscriberId, nickname) {
73
+ await this.load();
74
+ const meta = this.agents.get(subscriberId);
75
+ if (!meta) {
76
+ throw new Error(`Subscriber "${subscriberId}" not found`);
77
+ }
78
+ // Check for nickname conflicts
79
+ for (const [id, other] of this.agents) {
80
+ if (id !== subscriberId && other.nickname === nickname && other.status === "active") {
81
+ throw new Error(`Nickname "${nickname}" is already in use by ${id}`);
82
+ }
83
+ }
84
+ meta.nickname = nickname;
85
+ await this.save();
86
+ }
87
+ /**
88
+ * Update specific metadata fields for a subscriber.
89
+ */
90
+ async updateMetadata(subscriberId, updates) {
91
+ await this.load();
92
+ const meta = this.agents.get(subscriberId);
93
+ if (!meta) {
94
+ throw new Error(`Subscriber "${subscriberId}" not found`);
95
+ }
96
+ if (updates.status !== undefined)
97
+ meta.status = updates.status;
98
+ if (updates.last_seen !== undefined)
99
+ meta.last_seen = updates.last_seen;
100
+ if (updates.pid !== undefined)
101
+ meta.pid = updates.pid;
102
+ if (updates.tty !== undefined)
103
+ meta.tty = updates.tty;
104
+ if (updates.tmux_pane !== undefined)
105
+ meta.tmux_pane = updates.tmux_pane;
106
+ if (updates.launch_mode !== undefined)
107
+ meta.launch_mode = updates.launch_mode;
108
+ if (updates.activity_state !== undefined)
109
+ meta.activity_state = updates.activity_state;
110
+ if (updates.last_activity !== undefined)
111
+ meta.last_activity = updates.last_activity;
112
+ if (updates.nickname !== undefined)
113
+ meta.nickname = updates.nickname;
114
+ await this.save();
115
+ }
116
+ /**
117
+ * Clean up subscribers whose processes are no longer alive.
118
+ * Returns the list of subscriber IDs that were marked inactive.
119
+ */
120
+ async cleanupInactive() {
121
+ await this.load();
122
+ const cleaned = [];
123
+ for (const [id, meta] of this.agents) {
124
+ if (meta.status !== "active")
125
+ continue;
126
+ // If the agent has a PID, check if it's still alive
127
+ if (meta.pid > 0 && !isProcessAlive(meta.pid)) {
128
+ meta.status = "inactive";
129
+ meta.last_seen = new Date().toISOString();
130
+ cleaned.push(id);
131
+ }
132
+ }
133
+ if (cleaned.length > 0) {
134
+ await this.save();
135
+ }
136
+ return cleaned;
137
+ }
138
+ /**
139
+ * List all agents (both active and inactive).
140
+ */
141
+ async list() {
142
+ await this.load();
143
+ return new Map(this.agents);
144
+ }
145
+ /**
146
+ * Get metadata for a specific subscriber.
147
+ */
148
+ async get(subscriberId) {
149
+ await this.load();
150
+ return this.agents.get(subscriberId);
151
+ }
152
+ /**
153
+ * Generate a unique auto-nickname for the given agent type.
154
+ * Format: {prefix}-{N} where N is the lowest unused integer.
155
+ */
156
+ generateNickname(agentType) {
157
+ const prefix = this.nicknamePrefix(agentType);
158
+ const usedNicknames = new Set();
159
+ for (const meta of this.agents.values()) {
160
+ if (meta.status === "active" && meta.nickname) {
161
+ usedNicknames.add(meta.nickname);
162
+ }
163
+ }
164
+ let idx = 1;
165
+ while (usedNicknames.has(`${prefix}-${idx}`)) {
166
+ idx++;
167
+ }
168
+ return `${prefix}-${idx}`;
169
+ }
170
+ /**
171
+ * Get the nickname prefix for a given agent type.
172
+ */
173
+ nicknamePrefix(agentType) {
174
+ switch (agentType) {
175
+ case "claude":
176
+ case "claude-code":
177
+ return "claude";
178
+ case "gemini":
179
+ return "gemini";
180
+ case "codex":
181
+ return "codex";
182
+ default:
183
+ return agentType || "agent";
184
+ }
185
+ }
186
+ }
187
+ //# sourceMappingURL=subscriber.js.map
@@ -0,0 +1,8 @@
1
+ import { type LoopConfig } from "./schema.js";
2
+ export { type LoopConfig, type EngineName, type ExecutionMode, DEFAULT_CONFIG, validateConfig } from "./schema.js";
3
+ /**
4
+ * Load configuration with cascade:
5
+ * DEFAULT_CONFIG -> ~/.loop/config.json -> <cwd>/.loop/config.json -> env vars
6
+ */
7
+ export declare function loadConfig(cwd?: string): Promise<LoopConfig>;
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { DEFAULT_CONFIG, validateConfig, } from "./schema.js";
5
+ export { DEFAULT_CONFIG, validateConfig } from "./schema.js";
6
+ function loadJsonSafe(filePath) {
7
+ try {
8
+ const content = readFileSync(filePath, "utf-8");
9
+ const parsed = JSON.parse(content);
10
+ if (typeof parsed === "object" && parsed !== null) {
11
+ return parsed;
12
+ }
13
+ return {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ function applyEnvOverrides(config) {
20
+ const result = { ...config };
21
+ const executor = process.env.LOOP_EXECUTOR;
22
+ if (executor === "claude" || executor === "gemini" || executor === "codex") {
23
+ result.defaultExecutor = executor;
24
+ }
25
+ const reviewer = process.env.LOOP_REVIEWER;
26
+ if (reviewer === "claude" || reviewer === "gemini" || reviewer === "codex") {
27
+ result.defaultReviewer = reviewer;
28
+ }
29
+ const iterations = process.env.LOOP_ITERATIONS;
30
+ if (iterations) {
31
+ const n = parseInt(iterations, 10);
32
+ if (!isNaN(n) && n >= 1 && n <= 20) {
33
+ result.maxIterations = n;
34
+ }
35
+ }
36
+ const threshold = process.env.LOOP_THRESHOLD;
37
+ if (threshold) {
38
+ const n = parseInt(threshold, 10);
39
+ if (!isNaN(n) && n >= 1 && n <= 10) {
40
+ result.threshold = n;
41
+ }
42
+ }
43
+ const mode = process.env.LOOP_MODE;
44
+ if (mode === "auto" || mode === "manual") {
45
+ result.mode = mode;
46
+ }
47
+ return result;
48
+ }
49
+ /**
50
+ * Load configuration with cascade:
51
+ * DEFAULT_CONFIG -> ~/.loop/config.json -> <cwd>/.loop/config.json -> env vars
52
+ */
53
+ export async function loadConfig(cwd) {
54
+ // Start with defaults
55
+ let merged = { ...DEFAULT_CONFIG };
56
+ // Layer 1: Global config (~/.loop/config.json)
57
+ const globalPath = join(homedir(), ".loop", "config.json");
58
+ const globalConfig = loadJsonSafe(globalPath);
59
+ merged = { ...merged, ...globalConfig };
60
+ // Layer 2: Project config (<cwd>/.loop/config.json)
61
+ if (cwd) {
62
+ const projectPath = join(cwd, ".loop", "config.json");
63
+ const projectConfig = loadJsonSafe(projectPath);
64
+ merged = { ...merged, ...projectConfig };
65
+ }
66
+ // Validate and normalize
67
+ let config = validateConfig(merged);
68
+ // Layer 3: Environment variable overrides
69
+ config = applyEnvOverrides(config);
70
+ return config;
71
+ }
72
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,18 @@
1
+ export type EngineName = "claude" | "gemini" | "codex";
2
+ export type ExecutionMode = "auto" | "manual";
3
+ export type LaunchMode = "terminal" | "tmux" | "iterm2" | "pty" | "auto";
4
+ export interface LoopConfig {
5
+ defaultExecutor: EngineName;
6
+ defaultReviewer: EngineName;
7
+ maxIterations: number;
8
+ threshold: number;
9
+ mode: ExecutionMode;
10
+ launchMode: LaunchMode;
11
+ autoResume: boolean;
12
+ skillsDir?: string;
13
+ verbose: boolean;
14
+ }
15
+ export declare const ENGINE_NAMES: readonly EngineName[];
16
+ export declare const DEFAULT_CONFIG: LoopConfig;
17
+ export declare function validateConfig(raw: unknown): LoopConfig;
18
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1,58 @@
1
+ export const ENGINE_NAMES = ["claude", "gemini", "codex"];
2
+ export const DEFAULT_CONFIG = {
3
+ defaultExecutor: "claude",
4
+ defaultReviewer: "gemini",
5
+ maxIterations: 3,
6
+ threshold: 9,
7
+ mode: "manual",
8
+ launchMode: "auto",
9
+ autoResume: false,
10
+ verbose: false,
11
+ };
12
+ function isEngineName(value) {
13
+ return typeof value === "string" && ENGINE_NAMES.includes(value);
14
+ }
15
+ function isExecutionMode(value) {
16
+ return value === "auto" || value === "manual";
17
+ }
18
+ function isLaunchMode(value) {
19
+ return (value === "terminal" ||
20
+ value === "tmux" ||
21
+ value === "iterm2" ||
22
+ value === "pty" ||
23
+ value === "auto");
24
+ }
25
+ export function validateConfig(raw) {
26
+ if (typeof raw !== "object" || raw === null) {
27
+ return { ...DEFAULT_CONFIG };
28
+ }
29
+ const obj = raw;
30
+ return {
31
+ defaultExecutor: isEngineName(obj.defaultExecutor)
32
+ ? obj.defaultExecutor
33
+ : DEFAULT_CONFIG.defaultExecutor,
34
+ defaultReviewer: isEngineName(obj.defaultReviewer)
35
+ ? obj.defaultReviewer
36
+ : DEFAULT_CONFIG.defaultReviewer,
37
+ maxIterations: typeof obj.maxIterations === "number" &&
38
+ obj.maxIterations >= 1 &&
39
+ obj.maxIterations <= 20
40
+ ? obj.maxIterations
41
+ : DEFAULT_CONFIG.maxIterations,
42
+ threshold: typeof obj.threshold === "number" &&
43
+ obj.threshold >= 1 &&
44
+ obj.threshold <= 10
45
+ ? obj.threshold
46
+ : DEFAULT_CONFIG.threshold,
47
+ mode: isExecutionMode(obj.mode) ? obj.mode : DEFAULT_CONFIG.mode,
48
+ launchMode: isLaunchMode(obj.launchMode)
49
+ ? obj.launchMode
50
+ : DEFAULT_CONFIG.launchMode,
51
+ autoResume: typeof obj.autoResume === "boolean"
52
+ ? obj.autoResume
53
+ : DEFAULT_CONFIG.autoResume,
54
+ skillsDir: typeof obj.skillsDir === "string" ? obj.skillsDir : undefined,
55
+ verbose: typeof obj.verbose === "boolean" ? obj.verbose : DEFAULT_CONFIG.verbose,
56
+ };
57
+ }
58
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Multi-turn conversation session with an AI engine.
3
+ *
4
+ * Ported from iterloop's conversation.ts. Runs an interactive PTY session
5
+ * with idle detection, mode toggling, and user-controlled continuation.
6
+ */
7
+ import type { Engine } from "./engine.js";
8
+ export type ExecutionMode = "auto" | "manual";
9
+ export interface ConversationOptions {
10
+ engine: Engine;
11
+ initialPrompt: string;
12
+ cwd: string;
13
+ verbose: boolean;
14
+ mode: {
15
+ current: ExecutionMode;
16
+ };
17
+ passthroughArgs?: string[];
18
+ }
19
+ export interface ConversationResult {
20
+ finalOutput: string;
21
+ duration_ms: number;
22
+ bytes_received: number;
23
+ }
24
+ /**
25
+ * Run a multi-turn conversation with an AI engine.
26
+ *
27
+ * In **auto** mode the PTY session runs to completion (idle detection / silence
28
+ * timeout) and returns immediately.
29
+ *
30
+ * In **manual** mode the user is prompted after each turn to continue, switch
31
+ * modes, or submit for review.
32
+ */
33
+ export declare function runConversation(opts: ConversationOptions): Promise<ConversationResult>;
34
+ //# sourceMappingURL=conversation.d.ts.map
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Multi-turn conversation session with an AI engine.
3
+ *
4
+ * Ported from iterloop's conversation.ts. Runs an interactive PTY session
5
+ * with idle detection, mode toggling, and user-controlled continuation.
6
+ */
7
+ import { dim, formatBytes, brandColor } from "../ui/colors.js";
8
+ // ── Idle detection constants ─────────────────────────
9
+ /** Time to wait after detecting idle prompt before auto-proceeding (ms) */
10
+ const IDLE_DEBOUNCE_MS = 2000;
11
+ /** Silence timeout: no output for this long = likely idle (ms) */
12
+ const SILENCE_TIMEOUT_AUTO_MS = 30_000; // auto mode: 30s (executor may run long ops)
13
+ const SILENCE_TIMEOUT_MANUAL_MS = 5_000; // manual mode: 5s
14
+ /** Global PTY session timeout (ms) — prevents hanging forever */
15
+ const PTY_GLOBAL_TIMEOUT_MS = 3_600_000; // 1 hour
16
+ const TERMINAL_RESET = "\x1b[0m\x1b[?25h\x1b[?2026l\x1b[?1049l\x1b[?1047l\x1b[?47l";
17
+ // ── Renderer (inline, minimal) ───────────────────────
18
+ class PtyRenderer {
19
+ color;
20
+ engineLabel;
21
+ receivedBytes = 0;
22
+ started = false;
23
+ endedWithLineBreak = true;
24
+ constructor(engineName, engineLabel) {
25
+ this.color = brandColor(engineName);
26
+ this.engineLabel = engineLabel;
27
+ }
28
+ start() {
29
+ if (this.started)
30
+ return;
31
+ this.started = true;
32
+ const header = this.color(` \u250C\u2500 \u25A0 ${this.engineLabel} (executor) ${"\u2500".repeat(Math.max(0, 44 - this.engineLabel.length))}\u2510`);
33
+ console.log(header);
34
+ console.log(this.color(" \u2502"));
35
+ this.endedWithLineBreak = true;
36
+ }
37
+ write(data) {
38
+ if (!this.started)
39
+ return;
40
+ this.receivedBytes += Buffer.byteLength(data);
41
+ process.stdout.write(data);
42
+ this.endedWithLineBreak = /(?:\r\n|\r|\n)$/.test(data);
43
+ }
44
+ stop(stats) {
45
+ if (!this.started)
46
+ return;
47
+ this.started = false;
48
+ // Restore common terminal modes in case the CLI was killed mid-TUI
49
+ process.stdout.write(TERMINAL_RESET);
50
+ if (!this.endedWithLineBreak) {
51
+ process.stdout.write("\r\n");
52
+ }
53
+ console.log(this.color(" \u2502"));
54
+ const statsText = `\u2713 done ${dim(`(${stats.elapsed}, ${stats.bytes})`)}`;
55
+ const footer = this.color(` \u2514${"\u2500".repeat(42)}`) +
56
+ ` ${statsText} ` +
57
+ this.color("\u2500\u2518");
58
+ console.log(footer);
59
+ }
60
+ get totalBytes() {
61
+ return this.receivedBytes;
62
+ }
63
+ }
64
+ function startKeystrokeHandler(opts) {
65
+ const { session, mode, onDone, onCancel } = opts;
66
+ const isTTY = !!process.stdin.isTTY;
67
+ if (isTTY)
68
+ process.stdin.setRawMode(true);
69
+ process.stdin.resume();
70
+ let lastCtrlC = 0;
71
+ function onData(data) {
72
+ if (!session.isAlive)
73
+ return;
74
+ const str = typeof data === "string" ? data : data.toString("utf8");
75
+ // Shift+Tab → toggle mode
76
+ if (str === "\x1b[Z") {
77
+ mode.current = mode.current === "auto" ? "manual" : "auto";
78
+ return;
79
+ }
80
+ // Ctrl+D → done
81
+ if (str === "\x04") {
82
+ onDone();
83
+ return;
84
+ }
85
+ // Ctrl+C → double-press = cancel, single = forward
86
+ if (str === "\x03") {
87
+ const now = Date.now();
88
+ if (now - lastCtrlC < 500) {
89
+ onCancel();
90
+ return;
91
+ }
92
+ lastCtrlC = now;
93
+ session.write(str);
94
+ return;
95
+ }
96
+ session.write(str);
97
+ }
98
+ process.stdin.on("data", onData);
99
+ return () => {
100
+ process.stdin.removeListener("data", onData);
101
+ if (isTTY)
102
+ process.stdin.setRawMode(false);
103
+ process.stdin.pause();
104
+ };
105
+ }
106
+ // ── PTY-based interactive session ────────────────────
107
+ async function runPtySession(engine, initialPrompt, opts) {
108
+ const globalStart = Date.now();
109
+ // Set up renderer before spawning the PTY so the first frame is not missed
110
+ const renderer = new PtyRenderer(engine.name, engine.label);
111
+ renderer.start();
112
+ // Create PTY session via engine.interactive()
113
+ const session = engine.interactive({
114
+ cwd: opts.cwd,
115
+ passthroughArgs: opts.passthroughArgs,
116
+ onData(data) {
117
+ renderer.write(data);
118
+ },
119
+ });
120
+ return new Promise((resolve, reject) => {
121
+ let done = false;
122
+ let idleTimer = null;
123
+ let silenceTimer = null;
124
+ let globalTimer = null;
125
+ let promptSent = false;
126
+ function stopRenderer() {
127
+ const elapsed = `${((Date.now() - globalStart) / 1000).toFixed(1)}s`;
128
+ renderer.stop({ elapsed, bytes: formatBytes(renderer.totalBytes) });
129
+ }
130
+ function finish() {
131
+ if (done)
132
+ return;
133
+ done = true;
134
+ // Clean up timers
135
+ if (idleTimer)
136
+ clearTimeout(idleTimer);
137
+ if (silenceTimer)
138
+ clearTimeout(silenceTimer);
139
+ if (globalTimer)
140
+ clearTimeout(globalTimer);
141
+ // Stop keystroke forwarding
142
+ cleanupKeystrokes();
143
+ // Kill session if still alive
144
+ session.kill();
145
+ // Stop renderer (prints footer)
146
+ stopRenderer();
147
+ // Capture clean output for reviewer
148
+ const output = session.getCleanOutput();
149
+ const durationMs = Date.now() - globalStart;
150
+ resolve({ output, bytes: renderer.totalBytes, durationMs });
151
+ }
152
+ function cancel(message = "Cancelled by user") {
153
+ if (done)
154
+ return;
155
+ done = true;
156
+ if (idleTimer)
157
+ clearTimeout(idleTimer);
158
+ if (silenceTimer)
159
+ clearTimeout(silenceTimer);
160
+ if (globalTimer)
161
+ clearTimeout(globalTimer);
162
+ cleanupKeystrokes();
163
+ session.kill();
164
+ stopRenderer();
165
+ reject(new Error(message));
166
+ }
167
+ // Global timeout — prevent PTY session from hanging forever
168
+ globalTimer = setTimeout(() => {
169
+ if (!done) {
170
+ cancel("PTY session timed out (1 hour limit)");
171
+ }
172
+ }, PTY_GLOBAL_TIMEOUT_MS);
173
+ // Start keystroke forwarding (raw mode -> PTY)
174
+ const cleanupKeystrokes = startKeystrokeHandler({
175
+ session,
176
+ mode: opts.mode,
177
+ onDone: finish,
178
+ onCancel: cancel,
179
+ });
180
+ // ── Idle detection ──
181
+ function resetSilenceTimer() {
182
+ if (silenceTimer)
183
+ clearTimeout(silenceTimer);
184
+ if (done)
185
+ return;
186
+ const silenceTimeoutMs = opts.mode.current === "auto"
187
+ ? SILENCE_TIMEOUT_AUTO_MS
188
+ : SILENCE_TIMEOUT_MANUAL_MS;
189
+ silenceTimer = setTimeout(() => {
190
+ if (done || !promptSent)
191
+ return;
192
+ // Silence = CLI likely idle, proceed for both modes
193
+ finish();
194
+ }, silenceTimeoutMs);
195
+ }
196
+ // On PTY idle event (prompt detected)
197
+ session.on("idle", () => {
198
+ if (done)
199
+ return;
200
+ if (!promptSent) {
201
+ // First idle = CLI is ready for input, send the task
202
+ promptSent = true;
203
+ session.sendLine(initialPrompt);
204
+ resetSilenceTimer();
205
+ return;
206
+ }
207
+ // Subsequent idle = CLI finished responding
208
+ // Both modes: finish PTY session with debounce
209
+ if (idleTimer)
210
+ clearTimeout(idleTimer);
211
+ idleTimer = setTimeout(() => {
212
+ if (!done)
213
+ finish();
214
+ }, IDLE_DEBOUNCE_MS);
215
+ });
216
+ // Reset silence timer on each output chunk
217
+ session.on("pty-data", () => {
218
+ resetSilenceTimer();
219
+ // Cancel idle debounce if more output arrives
220
+ if (idleTimer) {
221
+ clearTimeout(idleTimer);
222
+ idleTimer = null;
223
+ }
224
+ });
225
+ // Handle PTY process exit
226
+ session.on("exit", () => {
227
+ if (!done) {
228
+ // Small delay to collect any final output
229
+ setTimeout(() => finish(), 200);
230
+ }
231
+ });
232
+ // Fallback: if no idle event fires within 10s, send prompt anyway
233
+ setTimeout(() => {
234
+ if (!promptSent && !done) {
235
+ promptSent = true;
236
+ session.sendLine(initialPrompt);
237
+ resetSilenceTimer();
238
+ }
239
+ }, 10_000);
240
+ // Handle terminal resize
241
+ const onResize = () => {
242
+ if (!done && session.isAlive) {
243
+ const cols = process.stdout.columns || 80;
244
+ const rows = process.stdout.rows || 24;
245
+ session.resize(cols, rows);
246
+ }
247
+ };
248
+ process.stdout.on("resize", onResize);
249
+ // Clean up resize handler on session exit
250
+ session.on("exit", () => {
251
+ process.stdout.removeListener("resize", onResize);
252
+ });
253
+ });
254
+ }
255
+ // ── Main conversation loop ───────────────────────────
256
+ /**
257
+ * Run a multi-turn conversation with an AI engine.
258
+ *
259
+ * In **auto** mode the PTY session runs to completion (idle detection / silence
260
+ * timeout) and returns immediately.
261
+ *
262
+ * In **manual** mode the user is prompted after each turn to continue, switch
263
+ * modes, or submit for review.
264
+ */
265
+ export async function runConversation(opts) {
266
+ const { engine, initialPrompt, cwd, verbose, mode, passthroughArgs } = opts;
267
+ if (mode.current === "manual") {
268
+ console.log(dim(" Ctrl+D to submit for review, double Ctrl+C to abort\n"));
269
+ }
270
+ // Run first interactive PTY session
271
+ const { output, bytes, durationMs } = await runPtySession(engine, initialPrompt, { cwd, verbose, mode, passthroughArgs });
272
+ // Auto mode: done, submit to reviewer
273
+ if (mode.current === "auto") {
274
+ return {
275
+ finalOutput: output,
276
+ duration_ms: durationMs,
277
+ bytes_received: bytes,
278
+ };
279
+ }
280
+ // Manual mode: the user can continue interacting or submit
281
+ // For now, return the first session output. Full multi-turn manual flow
282
+ // (promptUser loop) is handled at a higher layer.
283
+ return {
284
+ finalOutput: output,
285
+ duration_ms: durationMs,
286
+ bytes_received: bytes,
287
+ };
288
+ }
289
+ //# sourceMappingURL=conversation.js.map