nolo-cli 0.1.11 → 0.1.12
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/agentRuntimeCommands.ts +4 -4
- package/ai/agent/cliExecutor.ts +733 -0
- package/ai/agent/cliPrompt.ts +10 -0
- package/ai/agent/machineRunPermissions.ts +95 -0
- package/client/compactDialog.ts +219 -0
- package/connector-experimental/capabilities.ts +73 -0
- package/connector-experimental/codexBinary.ts +41 -0
- package/connector-experimental/heartbeatLoop.ts +22 -0
- package/connector-experimental/machineInfo.ts +46 -0
- package/connector-experimental/protocol.ts +54 -0
- package/machineCommands.ts +5 -5
- package/package.json +7 -11
package/agentRuntimeCommands.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { MachineHeartbeat } from "connector-experimental/protocol";
|
|
2
|
-
import { detectMachineInfo } from "connector-experimental/machineInfo";
|
|
1
|
+
import type { MachineHeartbeat } from "./connector-experimental/protocol";
|
|
2
|
+
import { detectMachineInfo } from "./connector-experimental/machineInfo";
|
|
3
3
|
import {
|
|
4
4
|
assertMachineRunAllowed,
|
|
5
5
|
buildMachinePermissionPromptBlock,
|
|
6
6
|
resolveMachineRunPermissionPolicy,
|
|
7
|
-
} from "
|
|
7
|
+
} from "./ai/agent/machineRunPermissions";
|
|
8
8
|
import { resolveConnectorWebSocketTarget } from "./connectorWebSocketTarget";
|
|
9
9
|
import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
10
10
|
|
|
@@ -56,7 +56,7 @@ function readOption(args: string[], flag: string) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
async function defaultExecuteCli(provider: string, prompt: string, options: { model?: string; yolo?: boolean }) {
|
|
59
|
-
const { executeCli } = await import("ai/agent/cliExecutor");
|
|
59
|
+
const { executeCli } = await import("./ai/agent/cliExecutor");
|
|
60
60
|
return executeCli(provider as any, prompt, options);
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Executor - 通过命令行工具执行 AI 任务
|
|
3
|
+
*
|
|
4
|
+
* 设计为可扩展:当前支持 Copilot CLI、Gemini CLI、Codex CLI 与 Claude CLI。
|
|
5
|
+
*
|
|
6
|
+
* 注意:
|
|
7
|
+
* - CLI provider 与普通 model 路由共享 prompt / model / 最近文本上下文这些能力面
|
|
8
|
+
* - 但 CLI 不暴露本仓库可编排的 tool-calls 协议,因此这里只返回文本结果
|
|
9
|
+
* - 对缺少稳定增量输出协议的 CLI,流式接口允许退化为“完成后一次性回传”
|
|
10
|
+
*
|
|
11
|
+
* 使用方式:
|
|
12
|
+
* import { executeCli } from "ai/agent/cliExecutor";
|
|
13
|
+
* const result = await executeCli("copilot", prompt, { model: "claude-haiku-4.5" });
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { exec, spawn } from "child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { buildCliPrompt } from "./cliPrompt";
|
|
22
|
+
|
|
23
|
+
// ── 类型定义 ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** 已支持的 CLI 工具。新增时在此联合类型追加,并在 EXECUTORS 里注册实现 */
|
|
26
|
+
export type CliProvider = "copilot" | "gemini" | "codex" | "claude";
|
|
27
|
+
|
|
28
|
+
export interface CliExecuteOptions {
|
|
29
|
+
model?: string;
|
|
30
|
+
timeout?: number; // ms,默认 120_000
|
|
31
|
+
cwd?: string;
|
|
32
|
+
yolo?: boolean; // 允许所有工具,默认 true(后台任务常用)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CliExecuteResult {
|
|
36
|
+
text: string; // 解析后的纯文本回复
|
|
37
|
+
raw: string; // 原始 stdout
|
|
38
|
+
elapsed: number; // 耗时 ms
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type CliSessionMessage = {
|
|
42
|
+
role: "user" | "assistant";
|
|
43
|
+
content: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface CliSessionHandle {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
provider: CliProvider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CliSessionState extends CliSessionHandle {
|
|
52
|
+
systemPrompt?: string;
|
|
53
|
+
options: CliExecuteOptions;
|
|
54
|
+
messages: CliSessionMessage[];
|
|
55
|
+
createdAt: number;
|
|
56
|
+
updatedAt: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CliSessionTurnResult extends CliExecuteResult {
|
|
60
|
+
sessionId: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cliSessions = new Map<string, CliSessionState>();
|
|
64
|
+
|
|
65
|
+
// ── 各 provider 实现 ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Copilot CLI 执行器
|
|
69
|
+
* 调用 gh copilot -- -p "..." --silent
|
|
70
|
+
*/
|
|
71
|
+
function executeCopilot(
|
|
72
|
+
prompt: string,
|
|
73
|
+
options: CliExecuteOptions
|
|
74
|
+
): Promise<CliExecuteResult> {
|
|
75
|
+
const {
|
|
76
|
+
model,
|
|
77
|
+
timeout = 120_000,
|
|
78
|
+
cwd = process.cwd(),
|
|
79
|
+
yolo = true,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const args = [
|
|
84
|
+
"NO_COLOR=1",
|
|
85
|
+
"gh copilot --",
|
|
86
|
+
`-p ${JSON.stringify(prompt)}`,
|
|
87
|
+
"--silent",
|
|
88
|
+
"--disable-builtin-mcps",
|
|
89
|
+
"--stream off",
|
|
90
|
+
"--no-color",
|
|
91
|
+
];
|
|
92
|
+
if (model) args.push(`--model ${model}`);
|
|
93
|
+
if (yolo) args.push("--yolo");
|
|
94
|
+
|
|
95
|
+
const cmd = args.join(" ");
|
|
96
|
+
const start = Date.now();
|
|
97
|
+
|
|
98
|
+
exec(
|
|
99
|
+
cmd,
|
|
100
|
+
{ timeout, cwd, env: { ...process.env } },
|
|
101
|
+
(error, stdout, stderr) => {
|
|
102
|
+
if (error) {
|
|
103
|
+
if (error.killed && (error as any).signal === "SIGTERM") {
|
|
104
|
+
return reject(new Error(`Copilot CLI timed out after ${timeout}ms`));
|
|
105
|
+
}
|
|
106
|
+
return reject(
|
|
107
|
+
new Error(`Copilot CLI failed: ${error.message}\nstderr: ${stderr}`)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const text = stdout
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter((l) => !l.includes("A new release of gh is available"))
|
|
114
|
+
.filter((l) => !l.includes("To upgrade, run:"))
|
|
115
|
+
.filter((l) => !l.includes("https://github.com/cli/cli/releases"))
|
|
116
|
+
.join("\n")
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
resolve({ text, raw: stdout, elapsed: Date.now() - start });
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeGeminiContent(content: unknown): string {
|
|
126
|
+
if (typeof content === "string") return content;
|
|
127
|
+
if (Array.isArray(content)) {
|
|
128
|
+
return content
|
|
129
|
+
.map((part) => {
|
|
130
|
+
if (typeof part === "string") return part;
|
|
131
|
+
if (part && typeof part === "object" && typeof (part as any).text === "string") {
|
|
132
|
+
return (part as any).text;
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
})
|
|
136
|
+
.join("");
|
|
137
|
+
}
|
|
138
|
+
if (content && typeof content === "object" && typeof (content as any).text === "string") {
|
|
139
|
+
return (content as any).text;
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extractGeminiEventText(event: any): string {
|
|
145
|
+
if (event?.type === "message" && event.role === "assistant") {
|
|
146
|
+
return normalizeGeminiContent(event.content);
|
|
147
|
+
}
|
|
148
|
+
if (event?.type === "result" && typeof event.result === "string") {
|
|
149
|
+
return event.result;
|
|
150
|
+
}
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseGeminiStreamJson(stdout: string): string {
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
|
|
157
|
+
for (const line of stdout.split("\n")) {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (!trimmed) continue;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const event = JSON.parse(trimmed);
|
|
163
|
+
const text = extractGeminiEventText(event);
|
|
164
|
+
if (text) parts.push(text);
|
|
165
|
+
} catch {
|
|
166
|
+
// ignore malformed lines
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return parts.join("");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function executeGemini(
|
|
174
|
+
prompt: string,
|
|
175
|
+
options: CliExecuteOptions
|
|
176
|
+
): Promise<CliExecuteResult> {
|
|
177
|
+
const {
|
|
178
|
+
model = "gemini-3-flash-preview",
|
|
179
|
+
timeout = 120_000,
|
|
180
|
+
cwd = process.cwd(),
|
|
181
|
+
yolo = true,
|
|
182
|
+
} = options;
|
|
183
|
+
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const args = [
|
|
186
|
+
"NO_COLOR=1",
|
|
187
|
+
"NODE_OPTIONS='--no-deprecation'",
|
|
188
|
+
"gemini",
|
|
189
|
+
`-p ${JSON.stringify(prompt)}`,
|
|
190
|
+
"--output-format stream-json",
|
|
191
|
+
`-m ${JSON.stringify(model)}`,
|
|
192
|
+
];
|
|
193
|
+
if (yolo) args.push("--yolo");
|
|
194
|
+
args.push("-e none");
|
|
195
|
+
|
|
196
|
+
const cmd = args.join(" ");
|
|
197
|
+
const start = Date.now();
|
|
198
|
+
|
|
199
|
+
exec(
|
|
200
|
+
cmd,
|
|
201
|
+
{ timeout, cwd, env: { ...process.env } },
|
|
202
|
+
(error, stdout, stderr) => {
|
|
203
|
+
if (error) {
|
|
204
|
+
if (error.killed && (error as any).signal === "SIGTERM") {
|
|
205
|
+
return reject(new Error(`Gemini CLI timed out after ${timeout}ms`));
|
|
206
|
+
}
|
|
207
|
+
return reject(
|
|
208
|
+
new Error(`Gemini CLI failed: ${error.message}\nstderr: ${stderr}`)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
resolve({
|
|
213
|
+
text: parseGeminiStreamJson(stdout),
|
|
214
|
+
raw: stdout,
|
|
215
|
+
elapsed: Date.now() - start,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function executeCodex(
|
|
223
|
+
prompt: string,
|
|
224
|
+
options: CliExecuteOptions
|
|
225
|
+
): Promise<CliExecuteResult> {
|
|
226
|
+
const {
|
|
227
|
+
model,
|
|
228
|
+
timeout = 120_000,
|
|
229
|
+
cwd = process.cwd(),
|
|
230
|
+
yolo = true,
|
|
231
|
+
} = options;
|
|
232
|
+
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
const tempDir = mkdtempSync(join(tmpdir(), "codex-exec-"));
|
|
235
|
+
const outputFile = join(tempDir, "last-message.txt");
|
|
236
|
+
const args = [
|
|
237
|
+
"exec",
|
|
238
|
+
"--skip-git-repo-check",
|
|
239
|
+
"--ephemeral",
|
|
240
|
+
"--color",
|
|
241
|
+
"never",
|
|
242
|
+
"--output-last-message",
|
|
243
|
+
outputFile,
|
|
244
|
+
"--cd",
|
|
245
|
+
cwd,
|
|
246
|
+
];
|
|
247
|
+
if (model) {
|
|
248
|
+
args.push("--model");
|
|
249
|
+
args.push(model);
|
|
250
|
+
}
|
|
251
|
+
if (yolo) {
|
|
252
|
+
args.push("--sandbox");
|
|
253
|
+
args.push("danger-full-access");
|
|
254
|
+
}
|
|
255
|
+
const start = Date.now();
|
|
256
|
+
const proc = spawn("codex", [...args, prompt], {
|
|
257
|
+
cwd,
|
|
258
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
259
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
260
|
+
});
|
|
261
|
+
let stdout = "";
|
|
262
|
+
let stderr = "";
|
|
263
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
+
|
|
265
|
+
if (timeout > 0) {
|
|
266
|
+
timer = setTimeout(() => {
|
|
267
|
+
proc.kill("SIGTERM");
|
|
268
|
+
reject(new Error(`Codex CLI timed out after ${timeout}ms`));
|
|
269
|
+
}, timeout);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
273
|
+
stdout += data.toString();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
277
|
+
stderr += data.toString();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
proc.on("close", (code) => {
|
|
281
|
+
if (timer) clearTimeout(timer);
|
|
282
|
+
if (code !== 0 && code !== null) {
|
|
283
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
284
|
+
reject(new Error(`Codex CLI exited with code ${code}\nstderr: ${stderr}`));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let text = stdout.trim();
|
|
289
|
+
try {
|
|
290
|
+
const lastMessage = readFileSync(outputFile, "utf8").trim();
|
|
291
|
+
if (lastMessage) text = lastMessage;
|
|
292
|
+
} catch {
|
|
293
|
+
// Fall back to raw stdout when the output file is missing.
|
|
294
|
+
} finally {
|
|
295
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
resolve({
|
|
299
|
+
text,
|
|
300
|
+
raw: stdout,
|
|
301
|
+
elapsed: Date.now() - start,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
proc.on("error", (err) => {
|
|
306
|
+
if (timer) clearTimeout(timer);
|
|
307
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
308
|
+
reject(err);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function executeClaude(
|
|
314
|
+
prompt: string,
|
|
315
|
+
options: CliExecuteOptions
|
|
316
|
+
): Promise<CliExecuteResult> {
|
|
317
|
+
const {
|
|
318
|
+
model,
|
|
319
|
+
timeout = 120_000,
|
|
320
|
+
cwd = process.cwd(),
|
|
321
|
+
yolo = true,
|
|
322
|
+
} = options;
|
|
323
|
+
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
const args = ["-p", prompt];
|
|
326
|
+
if (model) {
|
|
327
|
+
args.push("--model");
|
|
328
|
+
args.push(model);
|
|
329
|
+
}
|
|
330
|
+
if (yolo) {
|
|
331
|
+
args.push("--permission-mode");
|
|
332
|
+
args.push("bypassPermissions");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const start = Date.now();
|
|
336
|
+
const proc = spawn("claude", args, {
|
|
337
|
+
cwd,
|
|
338
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
339
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
340
|
+
});
|
|
341
|
+
let stdout = "";
|
|
342
|
+
let stderr = "";
|
|
343
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
344
|
+
|
|
345
|
+
if (timeout > 0) {
|
|
346
|
+
timer = setTimeout(() => {
|
|
347
|
+
proc.kill("SIGTERM");
|
|
348
|
+
reject(new Error(`Claude CLI timed out after ${timeout}ms`));
|
|
349
|
+
}, timeout);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
353
|
+
stdout += data.toString();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
357
|
+
stderr += data.toString();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
proc.on("close", (code) => {
|
|
361
|
+
if (timer) clearTimeout(timer);
|
|
362
|
+
if (code !== 0 && code !== null) {
|
|
363
|
+
reject(new Error(`Claude CLI exited with code ${code}\nstderr: ${stderr}`));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
resolve({
|
|
368
|
+
text: stdout.trim(),
|
|
369
|
+
raw: stdout,
|
|
370
|
+
elapsed: Date.now() - start,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
proc.on("error", (err) => {
|
|
375
|
+
if (timer) clearTimeout(timer);
|
|
376
|
+
reject(err);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── 注册表(新增 CLI 工具时在这里加) ────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const EXECUTORS: Record<
|
|
384
|
+
CliProvider,
|
|
385
|
+
(prompt: string, options: CliExecuteOptions) => Promise<CliExecuteResult>
|
|
386
|
+
> = {
|
|
387
|
+
copilot: executeCopilot,
|
|
388
|
+
gemini: executeGemini,
|
|
389
|
+
codex: executeCodex,
|
|
390
|
+
claude: executeClaude,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
function formatCliSessionTask(messages: CliSessionMessage[]): string {
|
|
394
|
+
return messages
|
|
395
|
+
.map((message, index) => {
|
|
396
|
+
const speaker = message.role === "user" ? "用户" : "助手";
|
|
397
|
+
return `[${index + 1}] ${speaker}\n${message.content.trim()}`;
|
|
398
|
+
})
|
|
399
|
+
.join("\n\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function buildCliSessionPrompt(session: CliSessionState, userInput: string): string {
|
|
403
|
+
const transcript = formatCliSessionTask([
|
|
404
|
+
...session.messages,
|
|
405
|
+
{ role: "user", content: userInput },
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
return buildCliPrompt(
|
|
409
|
+
session.systemPrompt,
|
|
410
|
+
[
|
|
411
|
+
"以下是当前对话,请基于完整上下文继续回答最后一条用户消息。",
|
|
412
|
+
transcript,
|
|
413
|
+
].join("\n\n"),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function getCliSessionOrThrow(sessionId: string): CliSessionState {
|
|
418
|
+
const session = cliSessions.get(sessionId);
|
|
419
|
+
if (!session) {
|
|
420
|
+
throw new Error(`Unknown CLI session: "${sessionId}"`);
|
|
421
|
+
}
|
|
422
|
+
return session;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── 公开 API ──────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 执行 CLI 任务
|
|
429
|
+
*
|
|
430
|
+
* @param provider CLI 工具类型(如 "copilot" | "gemini" | "codex" | "claude")
|
|
431
|
+
* @param prompt 完整 prompt(system prompt 已由调用方拼好)
|
|
432
|
+
* @param options 执行选项
|
|
433
|
+
*/
|
|
434
|
+
export async function executeCli(
|
|
435
|
+
provider: CliProvider,
|
|
436
|
+
prompt: string,
|
|
437
|
+
options: CliExecuteOptions = {}
|
|
438
|
+
): Promise<CliExecuteResult> {
|
|
439
|
+
const executor = EXECUTORS[provider];
|
|
440
|
+
if (!executor) {
|
|
441
|
+
throw new Error(`Unknown CLI provider: "${provider}". Supported: ${Object.keys(EXECUTORS).join(", ")}`);
|
|
442
|
+
}
|
|
443
|
+
return executor(prompt, options);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export { buildCliPrompt };
|
|
447
|
+
|
|
448
|
+
export function startCliSession(
|
|
449
|
+
provider: CliProvider,
|
|
450
|
+
options: CliExecuteOptions & { systemPrompt?: string } = {},
|
|
451
|
+
): CliSessionHandle {
|
|
452
|
+
if (!EXECUTORS[provider]) {
|
|
453
|
+
throw new Error(`Unknown CLI provider: "${provider}". Supported: ${Object.keys(EXECUTORS).join(", ")}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const sessionId = randomUUID();
|
|
457
|
+
cliSessions.set(sessionId, {
|
|
458
|
+
sessionId,
|
|
459
|
+
provider,
|
|
460
|
+
systemPrompt: options.systemPrompt,
|
|
461
|
+
options: {
|
|
462
|
+
model: options.model,
|
|
463
|
+
timeout: options.timeout,
|
|
464
|
+
cwd: options.cwd,
|
|
465
|
+
yolo: options.yolo,
|
|
466
|
+
},
|
|
467
|
+
messages: [],
|
|
468
|
+
createdAt: Date.now(),
|
|
469
|
+
updatedAt: Date.now(),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return { sessionId, provider };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function getCliSession(sessionId: string): CliSessionState | null {
|
|
476
|
+
return cliSessions.get(sessionId) ?? null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function closeCliSession(sessionId: string): boolean {
|
|
480
|
+
return cliSessions.delete(sessionId);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function executeCliSessionTurn(
|
|
484
|
+
sessionId: string,
|
|
485
|
+
userInput: string,
|
|
486
|
+
options: Partial<CliExecuteOptions> = {},
|
|
487
|
+
): Promise<CliSessionTurnResult> {
|
|
488
|
+
const trimmedInput = userInput.trim();
|
|
489
|
+
if (!trimmedInput) {
|
|
490
|
+
throw new Error("CLI session turn requires non-empty user input.");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const session = getCliSessionOrThrow(sessionId);
|
|
494
|
+
const prompt = buildCliSessionPrompt(session, trimmedInput);
|
|
495
|
+
const result = await executeCli(session.provider, prompt, {
|
|
496
|
+
...session.options,
|
|
497
|
+
...options,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
session.messages.push(
|
|
501
|
+
{ role: "user", content: trimmedInput },
|
|
502
|
+
{ role: "assistant", content: result.text },
|
|
503
|
+
);
|
|
504
|
+
session.updatedAt = Date.now();
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
...result,
|
|
508
|
+
sessionId: session.sessionId,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function executeCliSessionTurnStreaming(
|
|
513
|
+
sessionId: string,
|
|
514
|
+
userInput: string,
|
|
515
|
+
options: Partial<CliExecuteOptions> & { onChunk: (chunk: string) => void },
|
|
516
|
+
): Promise<CliSessionTurnResult> {
|
|
517
|
+
const trimmedInput = userInput.trim();
|
|
518
|
+
if (!trimmedInput) {
|
|
519
|
+
throw new Error("CLI session turn requires non-empty user input.");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const session = getCliSessionOrThrow(sessionId);
|
|
523
|
+
const prompt = buildCliSessionPrompt(session, trimmedInput);
|
|
524
|
+
const result = await executeCliStreaming(session.provider, prompt, {
|
|
525
|
+
...session.options,
|
|
526
|
+
...options,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
session.messages.push(
|
|
530
|
+
{ role: "user", content: trimmedInput },
|
|
531
|
+
{ role: "assistant", content: result.text },
|
|
532
|
+
);
|
|
533
|
+
session.updatedAt = Date.now();
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
...result,
|
|
537
|
+
sessionId: session.sessionId,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 忽略 gh 版本更新提示行 */
|
|
542
|
+
function filterNoiseLine(line: string): boolean {
|
|
543
|
+
return (
|
|
544
|
+
!line.includes("A new release of gh is available") &&
|
|
545
|
+
!line.includes("To upgrade, run:") &&
|
|
546
|
+
!line.includes("https://github.com/cli/cli/releases")
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 流式执行 Copilot CLI,通过 onChunk 回调逐块返回输出
|
|
552
|
+
* 使用 spawn 替代 exec,不缓冲 stdout
|
|
553
|
+
*/
|
|
554
|
+
export function executeCliStreaming(
|
|
555
|
+
provider: CliProvider,
|
|
556
|
+
prompt: string,
|
|
557
|
+
options: CliExecuteOptions & { onChunk: (chunk: string) => void }
|
|
558
|
+
): Promise<CliExecuteResult> {
|
|
559
|
+
if (provider === "gemini") {
|
|
560
|
+
return executeGeminiStreaming(prompt, options);
|
|
561
|
+
}
|
|
562
|
+
if (provider === "codex") {
|
|
563
|
+
return executeCli(provider, prompt, options).then((result) => {
|
|
564
|
+
if (result.text) options.onChunk(result.text);
|
|
565
|
+
return result;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if (provider === "claude") {
|
|
569
|
+
return executeCli(provider, prompt, options).then((result) => {
|
|
570
|
+
if (result.text) options.onChunk(result.text);
|
|
571
|
+
return result;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (provider !== "copilot") {
|
|
575
|
+
return executeCli(provider, prompt, options);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const { model, timeout = 120_000, cwd = process.cwd(), yolo = true, onChunk } = options;
|
|
579
|
+
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const args = [
|
|
582
|
+
"copilot", "--",
|
|
583
|
+
"-p", prompt,
|
|
584
|
+
"--silent",
|
|
585
|
+
"--disable-builtin-mcps",
|
|
586
|
+
];
|
|
587
|
+
if (model) { args.push("--model"); args.push(model); }
|
|
588
|
+
if (yolo) args.push("--yolo");
|
|
589
|
+
|
|
590
|
+
const start = Date.now();
|
|
591
|
+
const proc = spawn("gh", args, {
|
|
592
|
+
cwd,
|
|
593
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
let raw = "";
|
|
597
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
598
|
+
|
|
599
|
+
if (timeout > 0) {
|
|
600
|
+
timer = setTimeout(() => {
|
|
601
|
+
proc.kill("SIGTERM");
|
|
602
|
+
reject(new Error(`Copilot CLI timed out after ${timeout}ms`));
|
|
603
|
+
}, timeout);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
607
|
+
const chunk = data.toString();
|
|
608
|
+
raw += chunk;
|
|
609
|
+
// 过滤噪音行后再推送
|
|
610
|
+
const cleaned = chunk
|
|
611
|
+
.split("\n")
|
|
612
|
+
.filter(filterNoiseLine)
|
|
613
|
+
.join("\n");
|
|
614
|
+
if (cleaned) onChunk(cleaned);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
proc.on("close", (code) => {
|
|
618
|
+
if (timer) clearTimeout(timer);
|
|
619
|
+
if (code !== 0 && code !== null) {
|
|
620
|
+
reject(new Error(`Copilot CLI exited with code ${code}`));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const text = raw
|
|
624
|
+
.split("\n")
|
|
625
|
+
.filter(filterNoiseLine)
|
|
626
|
+
.join("\n")
|
|
627
|
+
.trim();
|
|
628
|
+
resolve({ text, raw, elapsed: Date.now() - start });
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
proc.on("error", (err) => {
|
|
632
|
+
if (timer) clearTimeout(timer);
|
|
633
|
+
reject(err);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function executeGeminiStreaming(
|
|
639
|
+
prompt: string,
|
|
640
|
+
options: CliExecuteOptions & { onChunk: (chunk: string) => void }
|
|
641
|
+
): Promise<CliExecuteResult> {
|
|
642
|
+
const {
|
|
643
|
+
model = "gemini-3-flash-preview",
|
|
644
|
+
timeout = 120_000,
|
|
645
|
+
cwd = process.cwd(),
|
|
646
|
+
yolo = true,
|
|
647
|
+
onChunk,
|
|
648
|
+
} = options;
|
|
649
|
+
|
|
650
|
+
return new Promise((resolve, reject) => {
|
|
651
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "-m", model];
|
|
652
|
+
if (yolo) args.push("--yolo");
|
|
653
|
+
args.push("-e", "none");
|
|
654
|
+
|
|
655
|
+
const start = Date.now();
|
|
656
|
+
const proc = spawn("gemini", args, {
|
|
657
|
+
cwd,
|
|
658
|
+
env: { ...process.env, NO_COLOR: "1", NODE_OPTIONS: "--no-deprecation" },
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
let raw = "";
|
|
662
|
+
let stderr = "";
|
|
663
|
+
let lineBuffer = "";
|
|
664
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
665
|
+
|
|
666
|
+
const flushLineBuffer = () => {
|
|
667
|
+
const trimmed = lineBuffer.trim();
|
|
668
|
+
if (!trimmed) return;
|
|
669
|
+
try {
|
|
670
|
+
const event = JSON.parse(trimmed);
|
|
671
|
+
const text = extractGeminiEventText(event);
|
|
672
|
+
if (text) onChunk(text);
|
|
673
|
+
} catch {
|
|
674
|
+
// ignore partial or malformed lines
|
|
675
|
+
}
|
|
676
|
+
lineBuffer = "";
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (timeout > 0) {
|
|
680
|
+
timer = setTimeout(() => {
|
|
681
|
+
proc.kill("SIGTERM");
|
|
682
|
+
reject(new Error(`Gemini CLI timed out after ${timeout}ms`));
|
|
683
|
+
}, timeout);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
687
|
+
const chunk = data.toString();
|
|
688
|
+
raw += chunk;
|
|
689
|
+
lineBuffer += chunk;
|
|
690
|
+
|
|
691
|
+
let newlineIndex = lineBuffer.indexOf("\n");
|
|
692
|
+
while (newlineIndex !== -1) {
|
|
693
|
+
const line = lineBuffer.slice(0, newlineIndex).trim();
|
|
694
|
+
lineBuffer = lineBuffer.slice(newlineIndex + 1);
|
|
695
|
+
if (line) {
|
|
696
|
+
try {
|
|
697
|
+
const event = JSON.parse(line);
|
|
698
|
+
const text = extractGeminiEventText(event);
|
|
699
|
+
if (text) onChunk(text);
|
|
700
|
+
} catch {
|
|
701
|
+
// ignore malformed lines
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
newlineIndex = lineBuffer.indexOf("\n");
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
709
|
+
stderr += data.toString();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
proc.on("close", (code) => {
|
|
713
|
+
if (timer) clearTimeout(timer);
|
|
714
|
+
flushLineBuffer();
|
|
715
|
+
|
|
716
|
+
if (code !== 0 && code !== null) {
|
|
717
|
+
reject(new Error(`Gemini CLI exited with code ${code}\nstderr: ${stderr}`));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
resolve({
|
|
722
|
+
text: parseGeminiStreamJson(raw),
|
|
723
|
+
raw,
|
|
724
|
+
elapsed: Date.now() - start,
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
proc.on("error", (err) => {
|
|
729
|
+
if (timer) clearTimeout(timer);
|
|
730
|
+
reject(err);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建包含 system prompt 的完整 prompt。
|
|
3
|
+
*
|
|
4
|
+
* CLI agent 与普通 model 共用 prompt / model / 最近文本上下文这些能力面,
|
|
5
|
+
* 但 CLI 不走本地 tool-calls 协议,因此这里只做文本级结构化拼接。
|
|
6
|
+
*/
|
|
7
|
+
export function buildCliPrompt(systemPrompt: string | undefined, taskPrompt: string): string {
|
|
8
|
+
if (!systemPrompt?.trim()) return taskPrompt;
|
|
9
|
+
return `[角色设定]\n${systemPrompt.trim()}\n\n[当前任务]\n${taskPrompt.trim()}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type MachineRunPermissionMode = "read_only" | "full_access";
|
|
2
|
+
|
|
3
|
+
export type MachineRunPermissionPolicy = {
|
|
4
|
+
mode: MachineRunPermissionMode;
|
|
5
|
+
allowFilesystemRead: boolean;
|
|
6
|
+
allowFilesystemWrite: boolean;
|
|
7
|
+
allowShell: boolean;
|
|
8
|
+
writableRoots: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_POLICY: MachineRunPermissionPolicy = {
|
|
12
|
+
mode: "read_only",
|
|
13
|
+
allowFilesystemRead: true,
|
|
14
|
+
allowFilesystemWrite: false,
|
|
15
|
+
allowShell: false,
|
|
16
|
+
writableRoots: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const WRITE_DENIED =
|
|
20
|
+
"Machine permission denied: this bound machine agent is read-only for filesystem writes. Enable machine write permission for this agent before asking it to modify files.";
|
|
21
|
+
|
|
22
|
+
const SHELL_DENIED =
|
|
23
|
+
"Machine permission denied: this bound machine agent cannot run arbitrary shell commands. Enable machine shell permission for this agent before asking it to execute commands.";
|
|
24
|
+
|
|
25
|
+
function asObject(value: unknown): Record<string, any> {
|
|
26
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeWritableRoots(value: unknown): string[] {
|
|
30
|
+
if (!Array.isArray(value)) return [];
|
|
31
|
+
return value
|
|
32
|
+
.filter((item): item is string => typeof item === "string")
|
|
33
|
+
.map((item) => item.trim())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveMachineRunPermissionPolicy(agentConfig: any): MachineRunPermissionPolicy {
|
|
38
|
+
const runtimeBinding = asObject(agentConfig?.runtimeBinding);
|
|
39
|
+
const raw = asObject(
|
|
40
|
+
agentConfig?.machinePermissions ??
|
|
41
|
+
runtimeBinding.permissions ??
|
|
42
|
+
runtimeBinding.machinePermissions ??
|
|
43
|
+
agentConfig?.boundRuntimeMachine?.permissions
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const mode: MachineRunPermissionMode =
|
|
47
|
+
raw.mode === "full_access" || raw.allowFilesystemWrite === true || raw.allowShell === true
|
|
48
|
+
? "full_access"
|
|
49
|
+
: "read_only";
|
|
50
|
+
|
|
51
|
+
if (mode === "full_access") {
|
|
52
|
+
return {
|
|
53
|
+
mode,
|
|
54
|
+
allowFilesystemRead: raw.allowFilesystemRead !== false,
|
|
55
|
+
allowFilesystemWrite: true,
|
|
56
|
+
allowShell: raw.allowShell !== false,
|
|
57
|
+
writableRoots: normalizeWritableRoots(raw.writableRoots),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...DEFAULT_POLICY,
|
|
63
|
+
allowFilesystemRead: raw.allowFilesystemRead !== false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const WRITE_INTENT_RE =
|
|
68
|
+
/\b(write|overwrite|edit|modify|delete|remove|rename|move|create|save|patch|apply patch|mkdir|touch|rm|rmdir|del|erase|copy-item|move-item|remove-item|set-content|add-content|new-item)\b|[\u5199\u5220\u6539\u4FEE\u4FDD\u5B58\u521B\u5EFA\u79FB\u52A8]/i;
|
|
69
|
+
|
|
70
|
+
const SHELL_INTENT_RE =
|
|
71
|
+
/\b(run|execute|exec|shell|terminal|powershell|pwsh|bash|zsh|cmd\.exe|npm|bun|node|python|git|curl|wget|ssh|scp|chmod|chown|sudo)\b|[\u8FD0\u884C\u6267\u884C\u547D\u4EE4\u7EC8\u7AEF]/i;
|
|
72
|
+
|
|
73
|
+
export function assertMachineRunAllowed(userInput: string, policy: MachineRunPermissionPolicy) {
|
|
74
|
+
const task = String(userInput || "");
|
|
75
|
+
if (!policy.allowFilesystemWrite && WRITE_INTENT_RE.test(task)) {
|
|
76
|
+
throw new Error(WRITE_DENIED);
|
|
77
|
+
}
|
|
78
|
+
if (!policy.allowShell && SHELL_INTENT_RE.test(task)) {
|
|
79
|
+
throw new Error(SHELL_DENIED);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildMachinePermissionPromptBlock(policy: MachineRunPermissionPolicy) {
|
|
84
|
+
return [
|
|
85
|
+
"--- Machine permission policy ---",
|
|
86
|
+
`Mode: ${policy.mode}`,
|
|
87
|
+
`Filesystem reads are ${policy.allowFilesystemRead ? "allowed" : "not allowed"}.`,
|
|
88
|
+
`File writes are ${policy.allowFilesystemWrite ? "allowed" : "not allowed"}.`,
|
|
89
|
+
`Arbitrary shell commands are ${policy.allowShell ? "allowed" : "not allowed"}.`,
|
|
90
|
+
policy.writableRoots.length
|
|
91
|
+
? `Writable roots: ${policy.writableRoots.join(", ")}`
|
|
92
|
+
: "Writable roots: none.",
|
|
93
|
+
"If the user asks for an operation outside this policy, refuse instead of attempting it.",
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// packages/cli/client/compactDialog.ts
|
|
2
|
+
// HTTP-only compact helper for CLI TUI (no Redux store available).
|
|
3
|
+
|
|
4
|
+
import { ulid } from "ulid";
|
|
5
|
+
|
|
6
|
+
const DB_PATH = "/api/v1/db";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract userId from a JWT-style auth token without verifying the signature.
|
|
10
|
+
* Mirrors the logic of `parseToken` in `auth/token.ts` without the crypto imports.
|
|
11
|
+
*/
|
|
12
|
+
function parseTokenUserId(token: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const [payloadBase64] = token.split(".");
|
|
15
|
+
const payload = JSON.parse(
|
|
16
|
+
Buffer.from(payloadBase64, "base64").toString("utf8")
|
|
17
|
+
);
|
|
18
|
+
return typeof payload?.userId === "string" ? payload.userId : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the custom ID (ULID) from a dialog key like `dialog-{userId}-{id}`.
|
|
26
|
+
* Mirrors `extractCustomId` from `core/prefix` without importing it.
|
|
27
|
+
*/
|
|
28
|
+
function extractCustomId(key: string): string {
|
|
29
|
+
const parts = key.split("-");
|
|
30
|
+
return parts.slice(2).join("-");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readDialogRecord(
|
|
34
|
+
fetchImpl: typeof fetch,
|
|
35
|
+
serverUrl: string,
|
|
36
|
+
authToken: string,
|
|
37
|
+
dialogKey: string
|
|
38
|
+
): Promise<Record<string, unknown>> {
|
|
39
|
+
const res = await fetchImpl(`${serverUrl}${DB_PATH}/read/${dialogKey}`, {
|
|
40
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new Error(`Failed to read dialog "${dialogKey}": HTTP ${res.status}`);
|
|
44
|
+
}
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
return data as Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fields carried forward from the source dialog into the fork.
|
|
51
|
+
* Conversation summary/compression state is intentionally excluded so the new
|
|
52
|
+
* dialog starts clean: summary, summarizedBeforeId, proactiveSummary,
|
|
53
|
+
* proactiveSummaryBeforeId, compressionCount, summaryPending must NOT be
|
|
54
|
+
* inherited — they describe the old conversation, not the fork.
|
|
55
|
+
*/
|
|
56
|
+
const FORKED_CARRY_FIELDS = [
|
|
57
|
+
"cybots",
|
|
58
|
+
"type",
|
|
59
|
+
"title",
|
|
60
|
+
"spaceId",
|
|
61
|
+
"category",
|
|
62
|
+
"referenceKeys",
|
|
63
|
+
"triggerType",
|
|
64
|
+
"schedule",
|
|
65
|
+
"taskPrompt",
|
|
66
|
+
"executionMode",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
function buildForkedDialogRecord(
|
|
70
|
+
current: Record<string, unknown>,
|
|
71
|
+
userId: string
|
|
72
|
+
): Record<string, unknown> & { dbKey: string; id: string } {
|
|
73
|
+
const newId = ulid();
|
|
74
|
+
const dbKey = `dialog-${userId}-${newId}`;
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
|
|
77
|
+
// Explicitly pick only the allowed fields — never spread `current` wholesale.
|
|
78
|
+
const carried: Record<string, unknown> = {};
|
|
79
|
+
for (const field of FORKED_CARRY_FIELDS) {
|
|
80
|
+
if (current[field] !== undefined) {
|
|
81
|
+
carried[field] = current[field];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...carried,
|
|
87
|
+
id: newId,
|
|
88
|
+
dbKey,
|
|
89
|
+
inheritedFromDialogKey: current.dbKey,
|
|
90
|
+
inheritedFromDialogTitle: current.title,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
// reset per-dialog stats
|
|
94
|
+
inputTokens: 0,
|
|
95
|
+
outputTokens: 0,
|
|
96
|
+
totalCost: 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function writeDialogRecord(
|
|
101
|
+
fetchImpl: typeof fetch,
|
|
102
|
+
serverUrl: string,
|
|
103
|
+
authToken: string,
|
|
104
|
+
record: Record<string, unknown> & { dbKey: string }
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const res = await fetchImpl(`${serverUrl}${DB_PATH}/write/`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
Authorization: `Bearer ${authToken}`,
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({ data: record, customKey: record.dbKey }),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Failed to write forked dialog "${record.dbKey}": HTTP ${res.status}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Best-effort: register the forked dialog in the space sidebar.
|
|
123
|
+
* Failure here is non-fatal since the dialog is already stored.
|
|
124
|
+
*/
|
|
125
|
+
async function addDialogToSpaceIfNeeded(
|
|
126
|
+
fetchImpl: typeof fetch,
|
|
127
|
+
serverUrl: string,
|
|
128
|
+
authToken: string,
|
|
129
|
+
record: Record<string, unknown> & { dbKey: string }
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const rawSpaceId = record.spaceId;
|
|
132
|
+
if (!rawSpaceId || typeof rawSpaceId !== "string") return;
|
|
133
|
+
|
|
134
|
+
const normalizedSpaceId = rawSpaceId.startsWith("space-")
|
|
135
|
+
? rawSpaceId.slice("space-".length)
|
|
136
|
+
: rawSpaceId;
|
|
137
|
+
const spaceKey = `space-${normalizedSpaceId}`;
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
|
|
140
|
+
const contentEntry = {
|
|
141
|
+
title: typeof record.title === "string" ? record.title : record.id,
|
|
142
|
+
type: "dialog",
|
|
143
|
+
contentKey: record.dbKey,
|
|
144
|
+
pinned: false,
|
|
145
|
+
createdAt: now,
|
|
146
|
+
updatedAt: now,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetchImpl(`${serverUrl}${DB_PATH}/patch/${spaceKey}`, {
|
|
151
|
+
method: "PATCH",
|
|
152
|
+
headers: {
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
Authorization: `Bearer ${authToken}`,
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({ contents: { [record.dbKey]: contentEntry } }),
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
console.warn(
|
|
160
|
+
`[nolo] compact: addDialogToSpace failed for ${spaceKey}: HTTP ${res.status}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.warn(`[nolo] compact: addDialogToSpace error: ${error}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type CompactDialogResult = {
|
|
169
|
+
dialogId: string;
|
|
170
|
+
dialogKey: string;
|
|
171
|
+
spaceId?: string;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compact the current dialog by forking it:
|
|
176
|
+
* 1. Read the current dialog config from the server.
|
|
177
|
+
* 2. Build a new dialog record that inherits from the old one.
|
|
178
|
+
* 3. Write the new record to the server.
|
|
179
|
+
* 4. Register the new dialog in the space sidebar (best-effort).
|
|
180
|
+
*
|
|
181
|
+
* Returns the new dialog's ID so the TUI can switch to it.
|
|
182
|
+
*/
|
|
183
|
+
export async function compactDialog(options: {
|
|
184
|
+
serverUrl: string;
|
|
185
|
+
authToken: string;
|
|
186
|
+
dialogId: string;
|
|
187
|
+
fetchImpl?: typeof fetch;
|
|
188
|
+
}): Promise<CompactDialogResult> {
|
|
189
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
190
|
+
|
|
191
|
+
const userId = parseTokenUserId(options.authToken);
|
|
192
|
+
if (!userId) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"[nolo] compact: cannot compact — invalid or missing auth token"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const dialogKey = `dialog-${userId}-${options.dialogId}`;
|
|
199
|
+
const current = await readDialogRecord(
|
|
200
|
+
fetchImpl,
|
|
201
|
+
options.serverUrl,
|
|
202
|
+
options.authToken,
|
|
203
|
+
dialogKey
|
|
204
|
+
);
|
|
205
|
+
const next = buildForkedDialogRecord(current, userId);
|
|
206
|
+
await writeDialogRecord(fetchImpl, options.serverUrl, options.authToken, next);
|
|
207
|
+
await addDialogToSpaceIfNeeded(
|
|
208
|
+
fetchImpl,
|
|
209
|
+
options.serverUrl,
|
|
210
|
+
options.authToken,
|
|
211
|
+
next
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
dialogId: extractCustomId(next.dbKey),
|
|
216
|
+
dialogKey: next.dbKey,
|
|
217
|
+
spaceId: typeof next.spaceId === "string" ? next.spaceId : undefined,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveLaunchableCodexCommand } from "./codexBinary";
|
|
5
|
+
|
|
6
|
+
type EnvLike = Record<string, string | undefined>;
|
|
7
|
+
|
|
8
|
+
type DetectRuntimeCapabilitiesOptions = {
|
|
9
|
+
commandExists?: (command: string) => boolean;
|
|
10
|
+
commandLaunchable?: (command: string, args: string[]) => boolean;
|
|
11
|
+
env?: EnvLike;
|
|
12
|
+
probeLaunchable?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type CommandProbe = {
|
|
16
|
+
command: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const COMMAND_CAPABILITIES: Array<[probes: CommandProbe[], capability: string]> = [
|
|
21
|
+
[[{ command: "codex", args: ["--version"] }], "codex-cli"],
|
|
22
|
+
[[{ command: "claude", args: ["--version"] }], "claude-code"],
|
|
23
|
+
[
|
|
24
|
+
[
|
|
25
|
+
{ command: "gh", args: ["--version"] },
|
|
26
|
+
{ command: "gh", args: ["copilot", "--", "--help"] },
|
|
27
|
+
],
|
|
28
|
+
"copilot-cli",
|
|
29
|
+
],
|
|
30
|
+
[[{ command: "gemini", args: ["--version"] }], "gemini-cli"],
|
|
31
|
+
[[{ command: "kimi", args: ["--version"] }], "kimi-cli"],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function defaultCommandExists(command: string) {
|
|
35
|
+
const pathEntries = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
|
|
36
|
+
const extensions = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
37
|
+
return pathEntries.some((entry) =>
|
|
38
|
+
extensions.some((extension) => existsSync(join(entry, `${command}${extension}`)))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultCommandLaunchable(command: string, args: string[]) {
|
|
43
|
+
const executable = command === "codex" ? resolveLaunchableCodexCommand() : command;
|
|
44
|
+
const result = spawnSync(executable, args, {
|
|
45
|
+
stdio: "pipe",
|
|
46
|
+
timeout: 3_000,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
return !result.error && result.status === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectRuntimeCapabilities(
|
|
53
|
+
options: DetectRuntimeCapabilitiesOptions = {}
|
|
54
|
+
): string[] {
|
|
55
|
+
const commandExists = options.commandExists ?? defaultCommandExists;
|
|
56
|
+
const commandLaunchable = options.commandLaunchable ?? defaultCommandLaunchable;
|
|
57
|
+
const env = options.env ?? process.env;
|
|
58
|
+
const capabilities: string[] = [];
|
|
59
|
+
|
|
60
|
+
for (const [probes, capability] of COMMAND_CAPABILITIES) {
|
|
61
|
+
if (!probes.every((probe) => commandExists(probe.command))) continue;
|
|
62
|
+
if (options.probeLaunchable && !probes.every((probe) => commandLaunchable(probe.command, probe.args))) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
capabilities.push(capability);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (env.NOLO_LOCAL_LLM_ENDPOINT || env.LLAMA_SERVER_URL) {
|
|
69
|
+
capabilities.push("local-llm");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return capabilities;
|
|
73
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
type EnvLike = Record<string, string | undefined>;
|
|
6
|
+
|
|
7
|
+
const WINDOWS_APPS_CODEX_PATTERN = /\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\resources$/i;
|
|
8
|
+
|
|
9
|
+
function pathEntries(env: EnvLike) {
|
|
10
|
+
return (env.PATH ?? process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function findWindowsAppsCodex(env: EnvLike) {
|
|
14
|
+
if (process.platform !== "win32") return "";
|
|
15
|
+
for (const entry of pathEntries(env)) {
|
|
16
|
+
if (!WINDOWS_APPS_CODEX_PATTERN.test(entry)) continue;
|
|
17
|
+
const candidate = join(entry, "codex.exe");
|
|
18
|
+
if (existsSync(candidate)) return candidate;
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function copyCodexToUserBin(source: string, env: EnvLike) {
|
|
24
|
+
const target =
|
|
25
|
+
env.NOLO_CODEX_SHIM_PATH ??
|
|
26
|
+
join(homedir(), ".nolo", "bin", "codex.exe");
|
|
27
|
+
if (existsSync(target)) return target;
|
|
28
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
29
|
+
copyFileSync(source, target);
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveLaunchableCodexCommand(env: EnvLike = process.env) {
|
|
34
|
+
const explicit = env.NOLO_CODEX_BIN?.trim();
|
|
35
|
+
if (explicit) return explicit;
|
|
36
|
+
|
|
37
|
+
const windowsAppsCodex = findWindowsAppsCodex(env);
|
|
38
|
+
if (windowsAppsCodex) return copyCodexToUserBin(windowsAppsCodex, env);
|
|
39
|
+
|
|
40
|
+
return "codex";
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type HeartbeatLoopOptions = {
|
|
2
|
+
intervalMs: number;
|
|
3
|
+
sendHeartbeat: () => Promise<void>;
|
|
4
|
+
sleep?: (ms: number) => Promise<void>;
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
maxBeats?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
export async function runHeartbeatLoop(options: HeartbeatLoopOptions): Promise<void> {
|
|
12
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
13
|
+
let beats = 0;
|
|
14
|
+
|
|
15
|
+
while (!options.signal?.aborted) {
|
|
16
|
+
await options.sendHeartbeat();
|
|
17
|
+
beats += 1;
|
|
18
|
+
if (options.maxBeats && beats >= options.maxBeats) return;
|
|
19
|
+
if (options.signal?.aborted) return;
|
|
20
|
+
await sleep(options.intervalMs);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { arch, hostname, homedir, platform } from "node:os";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import { detectRuntimeCapabilities } from "./capabilities";
|
|
7
|
+
import type { MachineHeartbeat } from "./protocol";
|
|
8
|
+
|
|
9
|
+
const CONNECTOR_VERSION = "0.1.0-experimental";
|
|
10
|
+
|
|
11
|
+
function defaultMachineIdPath() {
|
|
12
|
+
return join(homedir(), ".nolo", "machine-id");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveMachineId(path = defaultMachineIdPath()) {
|
|
16
|
+
try {
|
|
17
|
+
if (existsSync(path)) {
|
|
18
|
+
const existing = readFileSync(path, "utf8").trim();
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
}
|
|
21
|
+
const next = `machine-${randomUUID()}`;
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileSync(path, `${next}\n`, "utf8");
|
|
24
|
+
return next;
|
|
25
|
+
} catch {
|
|
26
|
+
return `machine-${hostname().toLowerCase()}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectMachineInfo(overrides?: {
|
|
31
|
+
machineId?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
capabilities?: string[];
|
|
34
|
+
probeLaunchable?: boolean;
|
|
35
|
+
}): MachineHeartbeat {
|
|
36
|
+
return {
|
|
37
|
+
machineId: overrides?.machineId ?? resolveMachineId(),
|
|
38
|
+
name: overrides?.name ?? hostname(),
|
|
39
|
+
platform: platform(),
|
|
40
|
+
arch: arch(),
|
|
41
|
+
connectorVersion: CONNECTOR_VERSION,
|
|
42
|
+
capabilities: overrides?.capabilities ?? detectRuntimeCapabilities({
|
|
43
|
+
probeLaunchable: overrides?.probeLaunchable,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type MachineHeartbeatInput = {
|
|
2
|
+
machineId?: unknown;
|
|
3
|
+
name?: unknown;
|
|
4
|
+
platform?: unknown;
|
|
5
|
+
arch?: unknown;
|
|
6
|
+
connectorVersion?: unknown;
|
|
7
|
+
capabilities?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type MachineHeartbeat = {
|
|
11
|
+
machineId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
platform: string;
|
|
14
|
+
arch: string;
|
|
15
|
+
connectorVersion?: string;
|
|
16
|
+
capabilities: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const normalizeRequiredString = (value: unknown, field: string) => {
|
|
20
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
21
|
+
throw new Error(`${field} is required`);
|
|
22
|
+
}
|
|
23
|
+
return value.trim();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const normalizeOptionalString = (value: unknown) =>
|
|
27
|
+
typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
28
|
+
|
|
29
|
+
export function normalizeCapabilityList(value: unknown): string[] {
|
|
30
|
+
if (!Array.isArray(value)) return [];
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
const result: string[] = [];
|
|
33
|
+
for (const item of value) {
|
|
34
|
+
if (typeof item !== "string") continue;
|
|
35
|
+
const normalized = item.trim();
|
|
36
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
37
|
+
seen.add(normalized);
|
|
38
|
+
result.push(normalized);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeMachineHeartbeat(input: MachineHeartbeatInput): MachineHeartbeat {
|
|
44
|
+
const heartbeat: MachineHeartbeat = {
|
|
45
|
+
machineId: normalizeRequiredString(input.machineId, "machineId"),
|
|
46
|
+
name: normalizeRequiredString(input.name, "name"),
|
|
47
|
+
platform: normalizeRequiredString(input.platform, "platform"),
|
|
48
|
+
arch: normalizeRequiredString(input.arch, "arch"),
|
|
49
|
+
capabilities: normalizeCapabilityList(input.capabilities),
|
|
50
|
+
};
|
|
51
|
+
const connectorVersion = normalizeOptionalString(input.connectorVersion);
|
|
52
|
+
if (connectorVersion) heartbeat.connectorVersion = connectorVersion;
|
|
53
|
+
return heartbeat;
|
|
54
|
+
}
|
package/machineCommands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { MachineHeartbeat } from "connector-experimental/protocol";
|
|
2
|
-
import { detectMachineInfo } from "connector-experimental/machineInfo";
|
|
1
|
+
import type { MachineHeartbeat } from "./connector-experimental/protocol";
|
|
2
|
+
import { detectMachineInfo } from "./connector-experimental/machineInfo";
|
|
3
3
|
import { mkdirSync, openSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
@@ -7,12 +7,12 @@ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
|
7
7
|
import {
|
|
8
8
|
type HeartbeatLoopOptions,
|
|
9
9
|
runHeartbeatLoop as defaultRunHeartbeatLoop,
|
|
10
|
-
} from "connector-experimental/heartbeatLoop";
|
|
10
|
+
} from "./connector-experimental/heartbeatLoop";
|
|
11
11
|
import {
|
|
12
12
|
assertMachineRunAllowed,
|
|
13
13
|
buildMachinePermissionPromptBlock,
|
|
14
14
|
resolveMachineRunPermissionPolicy,
|
|
15
|
-
} from "
|
|
15
|
+
} from "./ai/agent/machineRunPermissions";
|
|
16
16
|
import { resolveConnectorWebSocketTarget } from "./connectorWebSocketTarget";
|
|
17
17
|
|
|
18
18
|
type EnvLike = Record<string, string | undefined>;
|
|
@@ -161,7 +161,7 @@ async function defaultConnectWebSocket(url: string, options: ConnectorWebSocketO
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
async function defaultExecuteCli(provider: string, prompt: string, options: { model?: string; yolo?: boolean }) {
|
|
164
|
-
const { executeCli } = await import("ai/agent/cliExecutor");
|
|
164
|
+
const { executeCli } = await import("./ai/agent/cliExecutor");
|
|
165
165
|
return executeCli(provider as any, prompt, options);
|
|
166
166
|
}
|
|
167
167
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nolo-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agent-first terminal workspace for Nolo",
|
|
6
6
|
"bin": {
|
|
@@ -16,21 +16,17 @@
|
|
|
16
16
|
"defaultServer.ts",
|
|
17
17
|
"machineCommands.ts",
|
|
18
18
|
"updateCommands.ts",
|
|
19
|
-
"client
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
19
|
+
"client/**/*.ts",
|
|
20
|
+
"tui/**/*.ts",
|
|
21
|
+
"README.md",
|
|
22
|
+
"ai/**/*.ts",
|
|
23
|
+
"connector-experimental/**/*.ts"
|
|
24
24
|
],
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
|
-
"devDependencies": {
|
|
29
|
-
"bun-types": "latest"
|
|
30
|
-
},
|
|
31
28
|
"dependencies": {
|
|
32
|
-
"
|
|
33
|
-
"connector-experimental": "workspace:*"
|
|
29
|
+
"ulid": "^2.3.0"
|
|
34
30
|
},
|
|
35
31
|
"peerDependencies": {
|
|
36
32
|
"typescript": "^5.0.0"
|