@lawrence369/loop-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent/activity.d.ts +64 -0
- package/dist/agent/activity.js +265 -0
- package/dist/agent/launcher.d.ts +42 -0
- package/dist/agent/launcher.js +243 -0
- package/dist/agent/pty-session.d.ts +113 -0
- package/dist/agent/pty-session.js +490 -0
- package/dist/agent/ready-detector.d.ts +46 -0
- package/dist/agent/ready-detector.js +86 -0
- package/dist/agent/wrapper.d.ts +18 -0
- package/dist/agent/wrapper.js +110 -0
- package/dist/bin/lclaude.d.ts +3 -0
- package/dist/bin/lclaude.js +7 -0
- package/dist/bin/lcodex.d.ts +3 -0
- package/dist/bin/lcodex.js +7 -0
- package/dist/bin/lgemini.d.ts +3 -0
- package/dist/bin/lgemini.js +7 -0
- package/dist/bus/daemon.d.ts +56 -0
- package/dist/bus/daemon.js +135 -0
- package/dist/bus/event-bus.d.ts +105 -0
- package/dist/bus/event-bus.js +157 -0
- package/dist/bus/message.d.ts +48 -0
- package/dist/bus/message.js +129 -0
- package/dist/bus/queue.d.ts +50 -0
- package/dist/bus/queue.js +100 -0
- package/dist/bus/store.d.ts +88 -0
- package/dist/bus/store.js +212 -0
- package/dist/bus/subscriber.d.ts +76 -0
- package/dist/bus/subscriber.js +187 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +72 -0
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.js +58 -0
- package/dist/core/conversation.d.ts +34 -0
- package/dist/core/conversation.js +289 -0
- package/dist/core/engine.d.ts +40 -0
- package/dist/core/engine.js +288 -0
- package/dist/core/loop.d.ts +33 -0
- package/dist/core/loop.js +209 -0
- package/dist/core/protocol.d.ts +60 -0
- package/dist/core/protocol.js +162 -0
- package/dist/core/scoring.d.ts +34 -0
- package/dist/core/scoring.js +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +408 -0
- package/dist/orchestrator/daemon.d.ts +74 -0
- package/dist/orchestrator/daemon.js +294 -0
- package/dist/orchestrator/group.d.ts +73 -0
- package/dist/orchestrator/group.js +166 -0
- package/dist/orchestrator/ipc-server.d.ts +60 -0
- package/dist/orchestrator/ipc-server.js +166 -0
- package/dist/orchestrator/scheduler.d.ts +32 -0
- package/dist/orchestrator/scheduler.js +95 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +42 -0
- package/dist/plan/decisions.d.ts +18 -0
- package/dist/plan/decisions.js +143 -0
- package/dist/plan/shared-plan.d.ts +33 -0
- package/dist/plan/shared-plan.js +211 -0
- package/dist/skills/executor.d.ts +7 -0
- package/dist/skills/executor.js +11 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +54 -0
- package/dist/terminal/adapter.d.ts +61 -0
- package/dist/terminal/adapter.js +42 -0
- package/dist/terminal/detect.d.ts +30 -0
- package/dist/terminal/detect.js +77 -0
- package/dist/terminal/iterm2-adapter.d.ts +19 -0
- package/dist/terminal/iterm2-adapter.js +120 -0
- package/dist/terminal/pty-adapter.d.ts +18 -0
- package/dist/terminal/pty-adapter.js +84 -0
- package/dist/terminal/terminal-adapter.d.ts +17 -0
- package/dist/terminal/terminal-adapter.js +94 -0
- package/dist/terminal/tmux-adapter.d.ts +18 -0
- package/dist/terminal/tmux-adapter.js +127 -0
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.js +145 -0
- package/dist/ui/colors.d.ts +41 -0
- package/dist/ui/colors.js +65 -0
- package/dist/ui/dashboard.d.ts +32 -0
- package/dist/ui/dashboard.js +138 -0
- package/dist/ui/input.d.ts +10 -0
- package/dist/ui/input.js +96 -0
- package/dist/ui/interactive.d.ts +13 -0
- package/dist/ui/interactive.js +230 -0
- package/dist/ui/renderer.d.ts +33 -0
- package/dist/ui/renderer.js +106 -0
- package/dist/utils/ansi.d.ts +11 -0
- package/dist/utils/ansi.js +16 -0
- package/dist/utils/fs.d.ts +34 -0
- package/dist/utils/fs.js +115 -0
- package/dist/utils/lock.d.ts +12 -0
- package/dist/utils/lock.js +116 -0
- package/dist/utils/process.d.ts +31 -0
- package/dist/utils/process.js +111 -0
- package/dist/utils/pty-filter.d.ts +31 -0
- package/dist/utils/pty-filter.js +187 -0
- package/package.json +71 -0
- package/skills/loop/SKILL.md +19 -0
- package/skills/plan/SKILL.md +9 -0
- package/skills/review/SKILL.md +14 -0
|
@@ -0,0 +1,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
|