@aiti-ai/aiti-cursor-agent 0.1.9
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/AGENTS.md +32 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/client/src/controlled-login-client.mjs +22 -0
- package/client/src/controlled-login-dialog.mjs +270 -0
- package/cursor-agent.test.mjs +154 -0
- package/cursor-quota.test.mjs +85 -0
- package/cursor-quota.ts +138 -0
- package/dev-pack.mjs +246 -0
- package/dist/controlled-login-client.mjs +22 -0
- package/dist/controlled-login-dialog.mjs +270 -0
- package/index.ts +843 -0
- package/package.json +30 -0
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +45 -0
package/index.ts
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cursor-agent — 会话后端扩展:驱动 Cursor Agent CLI(`cursor agent`)会话。
|
|
3
|
+
*
|
|
4
|
+
* 打通 all-sessions 的跨 agent 链路:agentKey "cursor"(fork takeover 映射
|
|
5
|
+
* cursor-agent / cursor-agent-writer → cursor)。pi-web 会话宇宙里的 Cursor
|
|
6
|
+
* 子代理:落盘、Fleet 面板、前端打开 attach 全套复用。
|
|
7
|
+
*
|
|
8
|
+
* CLI 形态(cursor 3.17 实测 --help):
|
|
9
|
+
* cursor agent -p <prompt> --output-format stream-json [--stream-partial-output]
|
|
10
|
+
* [--resume <chatId>] [--model <m>] [--mode plan|ask]
|
|
11
|
+
* - 一次进程 = 一轮(print 模式);多轮会话靠 --resume <chatId> 重新 spawn。
|
|
12
|
+
* - stream-json 行格式官方文档缺位:解析器宽松自适应(实测 3.17:init/user/
|
|
13
|
+
* thinking/assistant 增量词帧/完整帧/result;文本帧一律当增量累积,result
|
|
14
|
+
* 帧唯一收尾;未识别行忽略)。
|
|
15
|
+
* - resume 历史播种:首次 getMessages 从落盘(store.db/jsonl,经共享
|
|
16
|
+
* cursor-cli-store.ts)读既往轮次,接管历史会话不再空白。
|
|
17
|
+
*
|
|
18
|
+
* 能力面(fail-loud):prompt/abort/getMessages ✅;steer(CLI 无交互通道)/
|
|
19
|
+
* fork/rename/compact/respondPermission ❌。权限:print 模式无审批流,工具
|
|
20
|
+
* 访问由用户 cursor 配置决定(需要收紧时调用方经 extraArgs 传 --mode plan)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
24
|
+
import { fetchCursorQuota, readCursorToken } from "./cursor-quota.ts";
|
|
25
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
26
|
+
import { whichBin } from "@aiti-ai/ext-shared/exec-async";
|
|
27
|
+
import { agentSpawnEnv, spawnEnvFeature, spawnCommandFeature, spawnArgsPrefixFeature, spawnEnvList, spawnCommand, spawnArgsPrefix, parseArgsLines, resolveCliLaunch, captureSpawnEnvFeatures } from "@aiti-ai/ext-shared/agent-spawn-env";
|
|
28
|
+
import type { LaunchOverride } from "@aiti-ai/ext-shared/agent-spawn-env";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { readCursorCliChat, type CursorCliMessage } from "@aiti-ai/ext-shared/cursor-cli-store";
|
|
32
|
+
import {
|
|
33
|
+
readExtModelState, readAgentLastUsedModel, readPiModels, renameExtModelState, writeExtModelState,
|
|
34
|
+
mergeModelCatalogs, withCurrentFirst, withLastUsedFirst, type ExtModelEntry,
|
|
35
|
+
} from "@aiti-ai/ext-shared/ext-model-state";
|
|
36
|
+
// 受控登录共享层(权威源 pi-web-ext/_shared/controlled-login.mjs,副本随扩展部署——改动须双向同步)
|
|
37
|
+
import { cleanEnv, createControlledLogin, mountControlledLoginApi, tailLine } from "@aiti-ai/ext-shared/controlled-login";
|
|
38
|
+
// 队列共享实现层(权威源 pi-web-ext/_shared/backend-queue.ts,副本随扩展部署——改动须双向同步)
|
|
39
|
+
import { createBackendQueueSupport, createQueueFilePersistence, migrateQueueSidecar } from "@aiti-ai/ext-shared/backend-queue";
|
|
40
|
+
|
|
41
|
+
type Listener = (event: Record<string, unknown>) => void;
|
|
42
|
+
interface SessionCapabilitiesLike extends Record<string, boolean> {}
|
|
43
|
+
|
|
44
|
+
function lineReader(
|
|
45
|
+
stream: NodeJS.ReadableStream | null,
|
|
46
|
+
onLine: (line: string) => void,
|
|
47
|
+
opts?: { maxLineBytes?: number; onOverflow?: (size: number) => void },
|
|
48
|
+
): () => void {
|
|
49
|
+
if (!stream) return () => {};
|
|
50
|
+
// 字节累积、按 \n 切 Buffer 后整行 utf8 解码(加固 A1)——逐 chunk toString 会把
|
|
51
|
+
// 跨 chunk 的多字节字符(中文)解码成 U+FFFD。
|
|
52
|
+
const maxLineBytes = opts?.maxLineBytes ?? 10 * 1024 * 1024;
|
|
53
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
54
|
+
let overflowed = false;
|
|
55
|
+
const onData = (chunk: Buffer | string) => {
|
|
56
|
+
if (overflowed) return;
|
|
57
|
+
const c = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
58
|
+
buffer = buffer.length === 0 ? c : Buffer.concat([buffer, c]);
|
|
59
|
+
for (;;) {
|
|
60
|
+
const idx = buffer.indexOf(0x0a);
|
|
61
|
+
if (idx === -1) return;
|
|
62
|
+
const line = buffer.subarray(0, idx).toString("utf8");
|
|
63
|
+
buffer = buffer.subarray(idx + 1);
|
|
64
|
+
if (line.trim()) onLine(line);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
stream.on("data", onData);
|
|
68
|
+
return () => {
|
|
69
|
+
stream.off("data", onData);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 模型目录核心(handle 方法与注册级静态 listModels 共用):
|
|
75
|
+
* cursor 内置(auto)+ pi 目录(实时扁平化)。当前模型置顶。
|
|
76
|
+
*/
|
|
77
|
+
async function cursorCatalog(current?: { id: string; provider: string }): Promise<ExtModelEntry[]> {
|
|
78
|
+
const builtin: ExtModelEntry[] = [
|
|
79
|
+
{ id: "auto", label: "Auto", provider: "cursor", source: "ext" },
|
|
80
|
+
];
|
|
81
|
+
const piModels = await readPiModels();
|
|
82
|
+
return withCurrentFirst(
|
|
83
|
+
mergeModelCatalogs(builtin, piModels),
|
|
84
|
+
current,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* cursor 模型族上下文窗口(getContextUsage 换算用):模型目录含 "auto" 等无法
|
|
90
|
+
* 判族的值——未命中返回 undefined(空轨态,不编数字)。
|
|
91
|
+
*/
|
|
92
|
+
export function cursorContextWindow(modelId?: string): number | undefined {
|
|
93
|
+
if (typeof modelId !== "string" || !modelId) return undefined;
|
|
94
|
+
const m = modelId.toLowerCase();
|
|
95
|
+
if (m === "auto") return undefined;
|
|
96
|
+
if (m.includes("gpt-5")) return 272_000;
|
|
97
|
+
if (m.includes("gpt-4")) return 128_000;
|
|
98
|
+
if (/\bo[134]\b|o[134]-/.test(m)) return 200_000;
|
|
99
|
+
if (m.includes("claude")) return m.includes("[1m]") ? 1_000_000 : 200_000;
|
|
100
|
+
if (m.includes("gemini")) return 1_000_000;
|
|
101
|
+
if (m.includes("grok")) return 256_000;
|
|
102
|
+
return undefined; // composer 等自研模型窗口未公开 → 不猜
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const CURSOR_CAPABILITIES: SessionCapabilitiesLike = {
|
|
106
|
+
prompt: true,
|
|
107
|
+
followUp: true, // 排队:共享层 backend-queue(运行中入队,settle 后自动续轮,queue_update 同步前端)
|
|
108
|
+
queueManage: true, // 队列 keeper 全 CRUD(共享层 backend-queue;docs/backend-queue-manager-plan.md M3)
|
|
109
|
+
abort: true,
|
|
110
|
+
getMessages: true,
|
|
111
|
+
// 目录 = 内置(auto 等)+ pi 目录;切换 = 下一轮 spawn 的 --model(每轮单发进程,天然生效)
|
|
112
|
+
listModels: true,
|
|
113
|
+
respondPermission: false,
|
|
114
|
+
getUsage: false,
|
|
115
|
+
fileAttachments: false,
|
|
116
|
+
contextUsage: false,
|
|
117
|
+
setModel: true,
|
|
118
|
+
setThinkingLevel: false,
|
|
119
|
+
fork: false,
|
|
120
|
+
renameSession: false,
|
|
121
|
+
compact: false,
|
|
122
|
+
executeBash: false,
|
|
123
|
+
listTools: false,
|
|
124
|
+
setActiveTools: false,
|
|
125
|
+
navigateTree: false,
|
|
126
|
+
deleteSession: false,
|
|
127
|
+
getCommands: false,
|
|
128
|
+
// 2026-09-06 取证 cursor-agent --help:--mode plan|ask + --yolo;spawn 参数下轮生效
|
|
129
|
+
setPermissionMode: true,
|
|
130
|
+
steer: false,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// CursorHandle
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
class CursorHandle {
|
|
138
|
+
readonly source = "aiti-cursor-agent/cursor";
|
|
139
|
+
readonly capabilities: SessionCapabilitiesLike = CURSOR_CAPABILITIES;
|
|
140
|
+
/** 复合 id("cursor:<chatId>";首轮未定前用占位,chatId 到手后换绑)。 */
|
|
141
|
+
id: string;
|
|
142
|
+
readonly cwd: string;
|
|
143
|
+
|
|
144
|
+
private child: ChildProcess | null = null;
|
|
145
|
+
private detachReader: (() => void) | null = null;
|
|
146
|
+
private listeners = new Set<Listener>();
|
|
147
|
+
private aliveFlag = true;
|
|
148
|
+
private running = false;
|
|
149
|
+
private chatId: string | undefined;
|
|
150
|
+
/** 恢复模式:创建时给定 chatId(All Sessions 历史/URL 恢复)——首轮 spawn 即 --resume。 */
|
|
151
|
+
private resumeChatId: string | undefined;
|
|
152
|
+
private model: { id: string; provider: string } | undefined;
|
|
153
|
+
/** 最近一轮 usage(result 帧机会式提取;wire 未定稿,可能恒缺) */
|
|
154
|
+
private lastUsage: {
|
|
155
|
+
model?: string; inputTokens?: number; outputTokens?: number;
|
|
156
|
+
cacheReadTokens?: number; cacheWriteTokens?: number;
|
|
157
|
+
} | undefined;
|
|
158
|
+
/** 会话累计用量(有 usage 帧才累加;getUsageTotals 数据源) */
|
|
159
|
+
private usageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 0 };
|
|
160
|
+
/** 本 handle 生命周期内的消息(user prompt + assistant 文本/工具块)。 */
|
|
161
|
+
private history: unknown[] = [];
|
|
162
|
+
/** resume/恢复场景:首次 getMessages 时从 CLI 落盘(store.db/jsonl)读既往轮次
|
|
163
|
+
* (只读一次;null=未读,[]=已读但无)。对齐 claude-code-agent 的 prelude 模式。 */
|
|
164
|
+
private prelude: unknown[] | null = null;
|
|
165
|
+
private lastText = "";
|
|
166
|
+
/** 本轮是否已收尾(result 帧 / 进程退出二选一,防重复 message_end/入史)。 */
|
|
167
|
+
private turnEnded = false;
|
|
168
|
+
/**
|
|
169
|
+
* 队列支持(共享层 backend-queue,docs/backend-queue-manager-plan.md §3):print 模式无
|
|
170
|
+
* 实时输入通道(无 steer)——运行中发送一律本地 followUp 队列(可撤回/排序),settle 后自动续轮。
|
|
171
|
+
*/
|
|
172
|
+
private q = createBackendQueueSupport({
|
|
173
|
+
isRunning: () => this.running,
|
|
174
|
+
alive: () => this.aliveFlag,
|
|
175
|
+
dispatch: (text) => this.prompt(text),
|
|
176
|
+
onEvent: (ev) => this.emit(ev),
|
|
177
|
+
log: (m) => console.warn(m),
|
|
178
|
+
// 队列崩溃恢复 sidecar(docs/aiti-remote-queue-and-sidecar-plan.md §3)
|
|
179
|
+
persistence: createQueueFilePersistence(() => (queueStateDir ? join(queueStateDir, "queues", `${this.id}.json`) : undefined), (m) => console.warn(m)),
|
|
180
|
+
});
|
|
181
|
+
/** setPermissionMode 的期望模式(每轮 spawn 参数;下轮生效,同 setModel 语义)。 */
|
|
182
|
+
private desiredMode: string | undefined;
|
|
183
|
+
/** P2 协议委托:成员级启动包装(custom-agent 路径),优先于 extman spawn.* feature。 */
|
|
184
|
+
launchOverride?: LaunchOverride;
|
|
185
|
+
|
|
186
|
+
constructor(id: string, cwd: string) {
|
|
187
|
+
this.id = id;
|
|
188
|
+
this.cwd = cwd;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private emit(event: Record<string, unknown>): void {
|
|
192
|
+
// 逐监听器隔离(加固 A3):一个订阅者抛错不得漏帧、不得沿流回调裸抛
|
|
193
|
+
for (const l of this.listeners) {
|
|
194
|
+
try {
|
|
195
|
+
l(event);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`[aiti-cursor-agent] listener error: ${e instanceof Error ? e.message : String(e)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 宽松提取 chatId(cursor stream-json 字段名未定稿,多候选探测)。 */
|
|
203
|
+
private extractChatId(msg: Record<string, unknown>): void {
|
|
204
|
+
if (this.chatId) return;
|
|
205
|
+
for (const key of ["chatId", "chat_id", "sessionId", "session_id", "conversationId"]) {
|
|
206
|
+
const v = msg[key];
|
|
207
|
+
if (typeof v === "string" && v) {
|
|
208
|
+
this.chatId = v;
|
|
209
|
+
const oldId = this.id;
|
|
210
|
+
this.id = `cursor:${v}`;
|
|
211
|
+
this.emit({ type: "session_backend_ready", oldId, id: this.id });
|
|
212
|
+
// 状态文件跟随换绑(模型选择持久化到真实 chat id 名下)
|
|
213
|
+
renameExtModelState("cursor", oldId, this.id).then(() => this.persistModelState());
|
|
214
|
+
// 队列 sidecar 随 id 换绑(占位 id 随机,不迁移则换绑前排队的条目崩溃后不可恢复)
|
|
215
|
+
migrateQueueSidecar(queueStateDir, oldId, this.id, (m) => console.warn(m));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 宽松提取 assistant 文本增量/完整文本(兼容 claude 形态与扁平形态)。 */
|
|
222
|
+
private consumeLine(line: string): void {
|
|
223
|
+
let msg: Record<string, unknown>;
|
|
224
|
+
try {
|
|
225
|
+
msg = JSON.parse(line) as Record<string, unknown>;
|
|
226
|
+
} catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.extractChatId(msg);
|
|
230
|
+
// result 帧已收尾后忽略晚到的文本帧(防重复帧产生永不结束的悬空流);
|
|
231
|
+
// extractChatId 仍执行,保留 id 绑定机会。
|
|
232
|
+
if (this.turnEnded) return;
|
|
233
|
+
const type = String(msg.type ?? "");
|
|
234
|
+
|
|
235
|
+
if (type === "thinking") return; // thinking 不进正文流(cursor 独立事件类型)
|
|
236
|
+
|
|
237
|
+
// init 帧:model 感知("Auto" 等;变化时广播 model_select)
|
|
238
|
+
if (type === "system" && msg.subtype === "init") {
|
|
239
|
+
const m = typeof msg.model === "string" ? msg.model : undefined;
|
|
240
|
+
if (m) this.trackModel(m, "restore");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// claude stream-json 兼容形态(增量)
|
|
245
|
+
if (type === "stream_event") {
|
|
246
|
+
const ev = (msg.event ?? {}) as Record<string, unknown>;
|
|
247
|
+
const delta = (ev.delta ?? {}) as Record<string, unknown>;
|
|
248
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
249
|
+
this.appendDelta(delta.text);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// cursor stream-json(3.17 实测):--stream-partial-output 下 assistant 帧
|
|
254
|
+
// 是增量词帧("one"/" two"/…),结尾另有完整帧 + result 帧(同全文)。
|
|
255
|
+
// 无稳定的帧型判别字段 —— 一律当增量累积,message_end 只在 result
|
|
256
|
+
// (兑底:进程退出)时发一次,否则每个词/每种帧都会变成独立消息。
|
|
257
|
+
if (type === "assistant") {
|
|
258
|
+
const message = (msg.message ?? {}) as Record<string, unknown>;
|
|
259
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
260
|
+
// 后台模型感知:assistant 帧自带实际服务的 model
|
|
261
|
+
if (typeof message.model === "string" && message.model) this.trackModel(message.model, "set");
|
|
262
|
+
const text = content
|
|
263
|
+
.map((b) => (b as Record<string, unknown>).text)
|
|
264
|
+
.filter((t): t is string => typeof t === "string")
|
|
265
|
+
.join("");
|
|
266
|
+
if (text) this.appendDelta(text);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (type === "result") {
|
|
270
|
+
const result = typeof msg.result === "string" ? msg.result : "";
|
|
271
|
+
this.extractUsage(msg);
|
|
272
|
+
this.finishTurn(result || this.lastText, msg.is_error === true ? "cursor agent reported is_error" : undefined);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 扁平/未知形态兜底:任何带非空 text/content 文本的行都当作增量
|
|
277
|
+
const flatText = typeof msg.text === "string" ? msg.text
|
|
278
|
+
: typeof msg.content === "string" ? msg.content : "";
|
|
279
|
+
if (flatText) this.appendDelta(flatText);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 流式增量:累积全文 + 发 message_update(适配层补 assistantMessageEvent)。 */
|
|
283
|
+
private appendDelta(text: string): void {
|
|
284
|
+
this.lastText += text;
|
|
285
|
+
this.emit({ type: "message_update", delta: text });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 轮次收尾(幂等):入史一条完整 assistant + 一次 message_end + agent_settled。 */
|
|
289
|
+
private finishTurn(text: string, error?: string): void {
|
|
290
|
+
if (this.turnEnded) return;
|
|
291
|
+
this.turnEnded = true;
|
|
292
|
+
// result 帧即本轮语义结束 —— 立即放行下一轮。print 模式一轮一进程,
|
|
293
|
+
// CLI 进程本身可能因 stdin 管道未关而残活,不能等 exit 才清 running
|
|
294
|
+
// (否则后续 prompt 全部被「上一轮仍在运行」拒绝);残活进程由下轮
|
|
295
|
+
// runTurn 收割。
|
|
296
|
+
this.running = false;
|
|
297
|
+
if (text) {
|
|
298
|
+
this.lastText = text;
|
|
299
|
+
this.pushAssistant(text, {});
|
|
300
|
+
this.emit({ type: "message_end", role: "assistant", text });
|
|
301
|
+
}
|
|
302
|
+
this.emit({ type: "agent_settled", ...(error ? { error } : {}), result: text || undefined });
|
|
303
|
+
// 排队续轮:微任务让 agent_settled 先到达前端(流式状态复位)再开新一轮(共享层幂等)
|
|
304
|
+
this.q.scheduleDrain();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private pushAssistant(text: string, message: Record<string, unknown>): void {
|
|
308
|
+
if (!text) return;
|
|
309
|
+
this.history.push({
|
|
310
|
+
role: "assistant",
|
|
311
|
+
content: [{ type: "text", text }],
|
|
312
|
+
model: typeof message.model === "string" ? message.model : this.model?.id ?? "",
|
|
313
|
+
provider: "aiti-cursor-agent",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** spawn 一轮(print 模式;多轮经 --resume)。 */
|
|
318
|
+
private runTurn(text: string): void {
|
|
319
|
+
// 上一轮子进程若仍残活(CLI 打完 result 后可能因 stdin 未关而挂着)——
|
|
320
|
+
// 结果已入库(finishTurn 已收尾),直接收割避免进程堆积。
|
|
321
|
+
if (this.child && this.child.exitCode === null && !this.child.killed) {
|
|
322
|
+
try { this.child.kill("SIGTERM"); } catch { /* already gone */ }
|
|
323
|
+
}
|
|
324
|
+
const args = [
|
|
325
|
+
"agent",
|
|
326
|
+
"-p", text,
|
|
327
|
+
"--output-format", "stream-json",
|
|
328
|
+
"--stream-partial-output",
|
|
329
|
+
"--trust", // workspace trust:非交互模式不信任目录会直接失败(cwd 均为用户项目目录)
|
|
330
|
+
// 权限模式(desiredMode 下轮生效):plan→--mode plan;default/未切→--yolo
|
|
331
|
+
// (2026-09-06 默认改最大权限:headless 下权限询问无法应答,不给 --yolo 会因权限失败);
|
|
332
|
+
// bypassPermissions→--yolo 同款。acceptEdits/dontAsk 在 setPermissionMode 已拒(cursor 无对应档)。
|
|
333
|
+
...(this.desiredMode === "plan" ? ["--mode", "plan"] : []),
|
|
334
|
+
...(!this.desiredMode || this.desiredMode === "default" || this.desiredMode === "bypassPermissions" ? ["--yolo"] : []),
|
|
335
|
+
...(this.chatId ? ["--resume", this.chatId] : this.resumeChatId ? ["--resume", this.resumeChatId] : []),
|
|
336
|
+
...(this.model?.id ? ["--model", this.model.id] : []),
|
|
337
|
+
];
|
|
338
|
+
// P1' 启动包装(docs/aiti-custom-agent-plan.md):extman spawn.command/spawn.argsPrefix,缺省 = 行为不变
|
|
339
|
+
// P2:launchOverride(custom-agent 委托路径)优先于 extman feature
|
|
340
|
+
const ov = this.launchOverride;
|
|
341
|
+
const launch = resolveCliLaunch({
|
|
342
|
+
defaultCommand: "cursor",
|
|
343
|
+
command: ov?.command?.trim() || spawnCommand("aiti-cursor-agent") || undefined,
|
|
344
|
+
argsPrefix: ov?.argsPrefix?.length ? ov.argsPrefix : parseArgsLines(spawnArgsPrefix("aiti-cursor-agent")).args,
|
|
345
|
+
driverArgs: args,
|
|
346
|
+
env: agentSpawnEnv({ extId: "aiti-cursor-agent", envList: spawnEnvList("aiti-cursor-agent") }),
|
|
347
|
+
memberEnv: ov?.env,
|
|
348
|
+
bridge: ov?.bridge,
|
|
349
|
+
cwd: this.cwd || process.cwd(),
|
|
350
|
+
warn: (m) => console.log(`[aiti-cursor-agent] ${m}`),
|
|
351
|
+
});
|
|
352
|
+
const child = spawn(launch.command, launch.args, {
|
|
353
|
+
shell: launch.shell,
|
|
354
|
+
cwd: this.cwd || process.cwd(),
|
|
355
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
356
|
+
env: launch.env,
|
|
357
|
+
});
|
|
358
|
+
this.child = child;
|
|
359
|
+
this.lastText = "";
|
|
360
|
+
this.turnEnded = false;
|
|
361
|
+
this.running = true;
|
|
362
|
+
this.emit({ type: "agent_start", sessionId: this.id, model: this.model });
|
|
363
|
+
|
|
364
|
+
this.detachReader?.();
|
|
365
|
+
this.detachReader = lineReader(child.stdout, (line) => this.consumeLine(line), {
|
|
366
|
+
onOverflow: () => {
|
|
367
|
+
if (!superseded()) this.finishTurn(this.lastText, "cursor agent stdout line overflow");
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
// 三个流必须挂 error 监听(加固 A2):ECONNRESET/EPIPE 无监听器会崩宿主进程。
|
|
371
|
+
child.stdout?.on("error", (err) => {
|
|
372
|
+
if (superseded()) return;
|
|
373
|
+
this.finishTurn(this.lastText, `cursor agent stdout error: ${err.message}`);
|
|
374
|
+
});
|
|
375
|
+
child.stderr?.on("error", (err) => console.log(`[aiti-cursor-agent:${this.id.slice(0, 20)}] stderr error: ${err.message}`));
|
|
376
|
+
child.stdin?.on("error", (err) => console.log(`[aiti-cursor-agent:${this.id.slice(0, 20)}] stdin error: ${err.message}`));
|
|
377
|
+
child.stderr?.on("data", (c: Buffer | string) => {
|
|
378
|
+
const text = String(c).trim();
|
|
379
|
+
if (text) console.log(`[aiti-cursor-agent:${this.id.slice(0, 20)}] ${text.slice(0, 300)}`);
|
|
380
|
+
});
|
|
381
|
+
// 被换代(新一轮已 spawn)的旧子进程事件一律忽略 —— 否则 SIGTERM 残活时
|
|
382
|
+
// 其晚到的 exit 会把新轮次当成自己的收尾(running 清零 / turnEnded 置位 /
|
|
383
|
+
// 重复入史),导致新轮 result 丢失。
|
|
384
|
+
const superseded = () => this.child !== child;
|
|
385
|
+
child.once("error", (err) => {
|
|
386
|
+
if (superseded()) return;
|
|
387
|
+
this.running = false;
|
|
388
|
+
this.aliveFlag = false;
|
|
389
|
+
this.emit({ type: "session_backend_dead", reason: `process error: ${err.message}` });
|
|
390
|
+
});
|
|
391
|
+
child.once("exit", (code) => {
|
|
392
|
+
if (superseded()) return;
|
|
393
|
+
this.running = false;
|
|
394
|
+
// result 帧已收尾则此处幂等空转;未收到 result(崩溃/被杀)时用累积文本兑底
|
|
395
|
+
this.finishTurn(this.lastText, code !== 0 ? `cursor agent exited (${code ?? "signal"})` : undefined);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- SessionHandle mandatory core ----------------------------------------
|
|
400
|
+
|
|
401
|
+
async prompt(text: string, opts?: { streamingBehavior?: string }): Promise<void> {
|
|
402
|
+
if (!this.aliveFlag) throw new Error("[aiti-cursor-agent] session dead");
|
|
403
|
+
if (this.running) {
|
|
404
|
+
// [共享层 backend-queue] 运行中分流:followUp/缺省一律本地队列(可撤回/排序),
|
|
405
|
+
// settle 后自动续轮;cursor print 模式无实时输入通道,不存在 steer 注入分支。
|
|
406
|
+
await this.q.routePrompt(text, opts?.streamingBehavior ? { streamingBehavior: opts.streamingBehavior } : undefined);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
this.history.push({ role: "user", content: text });
|
|
410
|
+
this.runTurn(text);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** @capability followUp —— 运行中入本地队列(settle 后自动续轮,可撤回);空闲等同普通 prompt。 */
|
|
414
|
+
async followUp(text: string): Promise<void> {
|
|
415
|
+
await this.q.routePrompt(text, { streamingBehavior: "followUp" });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @capability queueManage —— queue keeper 队列操作原语(共享层全 CRUD;steering 组
|
|
420
|
+
* 恒空——cursor 无运行中注入能力)。clear 返回清空前文本(pi clearQueue 契约,
|
|
421
|
+
* 前端「全部编辑」回填输入框)。
|
|
422
|
+
*/
|
|
423
|
+
queueManage = (req: {
|
|
424
|
+
action: "list" | "clear" | "move" | "convert" | "remove" | "recall" | "requeue";
|
|
425
|
+
kind?: "steer" | "followUp";
|
|
426
|
+
from?: number;
|
|
427
|
+
to?: number;
|
|
428
|
+
index?: number;
|
|
429
|
+
targetKind?: "steer" | "followUp";
|
|
430
|
+
text?: string;
|
|
431
|
+
}) => this.q.queueManage(req);
|
|
432
|
+
|
|
433
|
+
async abort(): Promise<void> {
|
|
434
|
+
if (!this.running || !this.child) return;
|
|
435
|
+
this.child.kill("SIGTERM");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 运行时切换权限模式(每轮 spawn 参数:下轮生效,同 setModel 语义)。
|
|
440
|
+
* 2026-09-06 对 cursor-agent --help 取证:--mode plan|ask、--yolo(--force 别名)。
|
|
441
|
+
* UI 五档映射:default→无 flag、plan→--mode plan、bypassPermissions→--yolo;
|
|
442
|
+
* acceptEdits/dontAsk 无对应档 fail-loud(print 模式无逐项确认通道,勿静默错映射)。
|
|
443
|
+
*/
|
|
444
|
+
async setPermissionMode(mode: string): Promise<boolean> {
|
|
445
|
+
const supported = ["default", "plan", "bypassPermissions"];
|
|
446
|
+
if (!supported.includes(mode)) {
|
|
447
|
+
throw new Error(`[aiti-cursor-agent] 非法权限模式 "${mode}"(cursor 可选:${supported.join("/")};acceptEdits/dontAsk 无对应档)`);
|
|
448
|
+
}
|
|
449
|
+
this.desiredMode = mode;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
subscribe(listener: Listener): () => void {
|
|
454
|
+
this.listeners.add(listener);
|
|
455
|
+
return () => {
|
|
456
|
+
this.listeners.delete(listener);
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async dispose(): Promise<void> {
|
|
461
|
+
if (this.child && this.running) {
|
|
462
|
+
this.child.kill("SIGTERM");
|
|
463
|
+
}
|
|
464
|
+
this.child = null;
|
|
465
|
+
this.aliveFlag = false;
|
|
466
|
+
this.q.dispose();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
alive(): boolean {
|
|
470
|
+
return this.aliveFlag;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
isRunning(): boolean {
|
|
474
|
+
return this.running;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
getModel(): { id: string; provider: string } | undefined {
|
|
478
|
+
return this.model;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 模型感知(后台切了我们能感知到):init/assistant 帧模型变化时广播
|
|
483
|
+
* model_select(pi 原生 ModelSelectEvent 形状)。首轮初始化不广播。
|
|
484
|
+
*/
|
|
485
|
+
private trackModel(id: string, source: "set" | "restore"): void {
|
|
486
|
+
const next = { id, provider: "aiti-cursor-agent" };
|
|
487
|
+
const prev = this.model;
|
|
488
|
+
if (prev && prev.id === id) return;
|
|
489
|
+
this.model = next;
|
|
490
|
+
if (prev) {
|
|
491
|
+
this.emit({ type: "model_select", model: next, previousModel: prev, source });
|
|
492
|
+
this.persistModelState();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 机会式 usage 提取(result 帧):cursor stream-json wire 官方未定稿,不假设
|
|
498
|
+
* usage 一定在——claude 形态字段(input_tokens/cache_read_input_tokens/
|
|
499
|
+
* cache_creation_input_tokens)逐个探测,数值齐才记账;拿不到不编数据。
|
|
500
|
+
*/
|
|
501
|
+
private extractUsage(msg: Record<string, unknown>): void {
|
|
502
|
+
const u = (msg.usage ?? null) as Record<string, unknown> | null;
|
|
503
|
+
if (!u || typeof u !== "object") return;
|
|
504
|
+
const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
505
|
+
const inputTokens = n(u.input_tokens);
|
|
506
|
+
const outputTokens = n(u.output_tokens);
|
|
507
|
+
const cacheReadTokens = n(u.cache_read_input_tokens);
|
|
508
|
+
const cacheWriteTokens = n(u.cache_creation_input_tokens);
|
|
509
|
+
if (inputTokens === undefined && outputTokens === undefined) return;
|
|
510
|
+
this.lastUsage = {
|
|
511
|
+
model: this.model?.id,
|
|
512
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
513
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
514
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
515
|
+
...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
|
|
516
|
+
};
|
|
517
|
+
this.usageTotals.inputTokens += inputTokens ?? 0;
|
|
518
|
+
this.usageTotals.outputTokens += outputTokens ?? 0;
|
|
519
|
+
this.usageTotals.cacheReadTokens += cacheReadTokens ?? 0;
|
|
520
|
+
this.usageTotals.cacheWriteTokens += cacheWriteTokens ?? 0;
|
|
521
|
+
this.usageTotals.turns += 1;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @capability contextUsage(机会式)——最近 result 帧 usage 齐全且模型族窗口
|
|
526
|
+
* 可判才报;wire 未定稿/模型未知 → undefined(前端空轨态)。
|
|
527
|
+
*/
|
|
528
|
+
getContextUsage(): { percent: number; contextWindow: number; tokens: number } | undefined {
|
|
529
|
+
const u = this.lastUsage;
|
|
530
|
+
if (!u) return undefined;
|
|
531
|
+
const tokens = (u.inputTokens ?? 0) + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0);
|
|
532
|
+
if (!(tokens > 0)) return undefined;
|
|
533
|
+
const contextWindow = cursorContextWindow(this.model?.id ?? u.model);
|
|
534
|
+
if (!contextWindow) return undefined;
|
|
535
|
+
return { percent: Math.min(100, (tokens / contextWindow) * 100), contextWindow, tokens };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @capability getUsageTotals —— 会话累计(宿主 get_session_stats 真实口径;无 usage 帧 → 全 0)。 */
|
|
539
|
+
getUsageTotals() {
|
|
540
|
+
return { ...this.usageTotals };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 切换模型(每轮单发进程模型:记录 desired model,下一轮 spawn 带 --model 即生效)。
|
|
545
|
+
*/
|
|
546
|
+
async setModel(model: { id: string; provider: string }): Promise<boolean> {
|
|
547
|
+
const prev = this.model;
|
|
548
|
+
this.model = { id: model.id, provider: "aiti-cursor-agent" };
|
|
549
|
+
this.emit({ type: "model_select", model: this.model, ...(prev ? { previousModel: prev } : {}), source: "set" });
|
|
550
|
+
this.persistModelState();
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 模型目录:cursor 内置(auto)+ pi 目录(models.json 实时扁平化,"pi" 分组)。
|
|
556
|
+
* 当前模型置顶;CLI 不认识的模型名在轮次时报错(fail-loud)。
|
|
557
|
+
*/
|
|
558
|
+
async getAvailableModels(): Promise<ExtModelEntry[]> {
|
|
559
|
+
return cursorCatalog(this.model);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** 临时模型状态文件(启动/切换时写:selected + pi 目录快照,引导 spawn --model 与重开恢复)。 */
|
|
563
|
+
persistModelState(): void {
|
|
564
|
+
void this.getAvailableModels().then((models) =>
|
|
565
|
+
writeExtModelState({
|
|
566
|
+
agentKey: "cursor",
|
|
567
|
+
sessionId: this.id,
|
|
568
|
+
...(this.model ? { selected: this.model } : {}),
|
|
569
|
+
models,
|
|
570
|
+
updatedAt: "",
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** 落盘历史 → handle 消息形状(与 runTurn 的入史形状一致)。 */
|
|
575
|
+
private toPrelude(msgs: CursorCliMessage[]): unknown[] {
|
|
576
|
+
return msgs.map((m) =>
|
|
577
|
+
m.role === "user"
|
|
578
|
+
? { role: "user", content: m.text }
|
|
579
|
+
: { role: "assistant", content: [{ type: "text", text: m.text }], model: this.model?.id ?? "", provider: "aiti-cursor-agent" },
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async getMessages(): Promise<unknown[]> {
|
|
584
|
+
// resume 场景:首次调用时从 CLI 落盘读既往轮次(只读一次)。
|
|
585
|
+
// 必须在 liveHistory 为空时读 — 此时 live 轮次尚未入 store.db,拼接不重复;
|
|
586
|
+
// live 已有轮次时跳过(与 claude-code-agent 同一去重权衡)。
|
|
587
|
+
if (this.prelude === null) {
|
|
588
|
+
this.prelude = [];
|
|
589
|
+
const sid = this.chatId ?? this.resumeChatId;
|
|
590
|
+
if (sid && !this.history.length) {
|
|
591
|
+
try {
|
|
592
|
+
const s = readCursorCliChat(sid);
|
|
593
|
+
if (s) this.prelude = this.toPrelude(s.messages);
|
|
594
|
+
} catch { /* 落盘不可读 — 历史为空,fail-soft */ }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return [...this.prelude, ...this.history];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ============================================================================
|
|
602
|
+
// 受控登录 driver(外部 Agent 受控登录标准,共享层 _shared/controlled-login.mjs)
|
|
603
|
+
// 模式 A url-only:spawn `cursor login` + NO_OPEN_BROWSER=1(官方无浏览器通道,URL 抓取实测)。
|
|
604
|
+
// ============================================================================
|
|
605
|
+
|
|
606
|
+
/** login URL 抓取(官方打印格式实测:stdout `navigate to this link: <url>`)。 */
|
|
607
|
+
export function extractCursorLoginUrl(text: string): string | null {
|
|
608
|
+
const m = /navigate to this link:\s*(https?:\/\/\S+)/.exec(text);
|
|
609
|
+
return m ? m[1] : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 结果判定:退出码 0 → success;其余 failed(尾行透出)。 */
|
|
613
|
+
export function interpretCursorLogin(code: number | null, stdout: string, stderr: string): { state: "success" | "failed"; error?: string } {
|
|
614
|
+
if (code === 0) return { state: "success" };
|
|
615
|
+
return { state: "failed", error: tailLine(stderr) || tailLine(stdout) || `cursor login 异常退出(code=${code ?? "signal"})` };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export const cursorLoginDriver = {
|
|
619
|
+
agentId: "cursor",
|
|
620
|
+
mode: "url" as const,
|
|
621
|
+
loginTimeoutMs: 330_000,
|
|
622
|
+
buildLogin: ({ runtime }: { runtime: string }) => ({
|
|
623
|
+
cmd: runtime || "cursor",
|
|
624
|
+
args: ["login"],
|
|
625
|
+
env: cleanEnv({ NO_OPEN_BROWSER: "1" }),
|
|
626
|
+
}),
|
|
627
|
+
extractUrl: extractCursorLoginUrl,
|
|
628
|
+
interpret: interpretCursorLogin,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// ============================================================================
|
|
632
|
+
// 扩展入口
|
|
633
|
+
// ============================================================================
|
|
634
|
+
|
|
635
|
+
/** 受控登录 API(startup 里挂载;/login/* 命中返回 Response,其余 null 放行)。 */
|
|
636
|
+
let loginApi: ((req: Request, ctx: { path: string[]; method: string; searchParams: URLSearchParams }) => Promise<Response | null>) | null = null;
|
|
637
|
+
/** 受控登录控制器引用(shutdown 时终止进行中的登录进程)。 */
|
|
638
|
+
let loginCtl: { dispose(): void } | null = null;
|
|
639
|
+
|
|
640
|
+
/** 队列崩溃恢复 sidecar 根(startup 捕获 <stateDir>;undefined/空 = 不持久化)。 */
|
|
641
|
+
let queueStateDir: string | undefined;
|
|
642
|
+
|
|
643
|
+
/** configDir 兑底(宿主 serverContext 恒带 configDir;按 demo 约定自拼,仅防御性)。 */
|
|
644
|
+
function extDataDir(): string {
|
|
645
|
+
const env = process.env.PI_WEB_DATA_DIR;
|
|
646
|
+
const root = env || join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "web", "data");
|
|
647
|
+
return join(root, "aiti-cursor-agent", "config");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ============================================================================
|
|
651
|
+
// 账号额度(agent-manager 统一「账号额度」行;令牌仅服务端使用)
|
|
652
|
+
// ============================================================================
|
|
653
|
+
|
|
654
|
+
/** 额度缓存(成功 60s / 失败 10s;?force=1 绕过)。 */
|
|
655
|
+
let quotaCache: { at: number; ttl: number; value: Record<string, unknown> } | null = null;
|
|
656
|
+
const QUOTA_TTL_MS = 60_000;
|
|
657
|
+
const QUOTA_ERROR_TTL_MS = 10_000;
|
|
658
|
+
|
|
659
|
+
/** GET /login/quota[?force=1] → 归一化额度视图(凭据缺失/接口失败回落 available:false)。 */
|
|
660
|
+
async function quotaView(force: boolean): Promise<Response> {
|
|
661
|
+
if (!force && quotaCache && Date.now() - quotaCache.at < quotaCache.ttl) return Response.json(quotaCache.value);
|
|
662
|
+
const fail = (reason: string, note?: string): Response => {
|
|
663
|
+
const v = { available: false, reason, ...(note ? { note } : {}), fetchedAt: Date.now() };
|
|
664
|
+
quotaCache = { at: Date.now(), ttl: QUOTA_ERROR_TTL_MS, value: v };
|
|
665
|
+
return Response.json(v);
|
|
666
|
+
};
|
|
667
|
+
const creds = (() => { const tok = readCursorToken(); return tok ? { token: tok } : null; })();
|
|
668
|
+
if (!creds) return fail("no-auth", "未找到 Cursor 登录凭据(~/.config/cursor/auth.json)");
|
|
669
|
+
try {
|
|
670
|
+
const v = await fetchCursorQuota({ token: (creds as { token: string }).token });
|
|
671
|
+
quotaCache = { at: Date.now(), ttl: QUOTA_TTL_MS, value: v };
|
|
672
|
+
return Response.json(v);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
return fail("fetch-failed", e instanceof Error ? e.message : String(e));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 额度路由(先于受控登录层:该层对未知 /login/* 会返回 404 吞掉本路由)。 */
|
|
679
|
+
function quotaRoute(req: Request, ctx: { path: string[]; method: string; searchParams?: URLSearchParams }): Promise<Response> | null {
|
|
680
|
+
const segs = Array.isArray(ctx?.path) ? ctx.path : [];
|
|
681
|
+
if (segs[0] !== "login" || segs[1] !== "quota") return null;
|
|
682
|
+
if (String(ctx?.method ?? "GET").toUpperCase() !== "GET") return null;
|
|
683
|
+
let force = ctx?.searchParams?.get("force") === "1";
|
|
684
|
+
if (!force) {
|
|
685
|
+
try { force = new URL(req.url).searchParams.get("force") === "1"; } catch { /* 相对 URL 兜底 */ }
|
|
686
|
+
}
|
|
687
|
+
return quotaView(force);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export default {
|
|
691
|
+
id: "aiti-cursor-agent",
|
|
692
|
+
name: "Cursor Agent",
|
|
693
|
+
version: "0.1.0",
|
|
694
|
+
// 受控登录统一弹窗(零构建原生 h() client;client/ 下两文件随 deploy 拷贝)
|
|
695
|
+
// 静态插入点声明(与 client registerItems 的 id 一一对应)
|
|
696
|
+
// 登录 UI 已统一到 aiti-agent-manager(受控登录弹窗退役,2026-09-13):不再声明
|
|
697
|
+
// client/ui —— 服务端 /login/* 与 .agent 描述符保留(manager 代理消费)。
|
|
698
|
+
|
|
699
|
+
features: [spawnEnvFeature("Cursor"), spawnCommandFeature("Cursor"), spawnArgsPrefixFeature("Cursor")],
|
|
700
|
+
|
|
701
|
+
server: {
|
|
702
|
+
startup(ctx: {
|
|
703
|
+
/** 扩展数据目录(宿主 serverContext 恒提供;类型上按需声明)。 */
|
|
704
|
+
configDir?: string;
|
|
705
|
+
/** 不可再生持久数据目录(数据分层;旧宿主缺省回退 <configDir>/state)。 */
|
|
706
|
+
stateDir?: string;
|
|
707
|
+
/** 跨扩展服务 slot(P2 协议委托;缺省=旧宿主降级)。 */
|
|
708
|
+
services?: { provide<T>(key: string, impl: T): () => void };
|
|
709
|
+
sessionProviders: {
|
|
710
|
+
register(provider: {
|
|
711
|
+
type: string;
|
|
712
|
+
label: string;
|
|
713
|
+
agentKey?: string;
|
|
714
|
+
priority?: number;
|
|
715
|
+
maxCapabilities: SessionCapabilitiesLike;
|
|
716
|
+
createSession(options: {
|
|
717
|
+
cwd?: string;
|
|
718
|
+
sessionId?: string;
|
|
719
|
+
model?: { provider: string; id: string };
|
|
720
|
+
listModels?: () => Promise<Array<{ id: string; provider: string; label?: string }>>;
|
|
721
|
+
}): Promise<CursorHandle>;
|
|
722
|
+
}): string;
|
|
723
|
+
};
|
|
724
|
+
}) {
|
|
725
|
+
captureSpawnEnvFeatures((ctx as any)?.features ?? null); // D1:spawn.env feature 捕获(否则 spawnEnvList 恒空)
|
|
726
|
+
// 队列崩溃恢复 sidecar 根(<stateDir>/queues/<sessionId>.json;docs/aiti-remote-queue-and-sidecar-plan.md §3)
|
|
727
|
+
queueStateDir = (ctx as { stateDir?: string }).stateDir ?? join((ctx as { configDir?: string }).configDir ?? "", "state");
|
|
728
|
+
try { mkdirSync(join(queueStateDir, "queues"), { recursive: true }); } catch { /* exists */ }
|
|
729
|
+
// 受控登录(工具条 🔑):spawn `cursor login` + NO_OPEN_BROWSER=1
|
|
730
|
+
const login = createControlledLogin({
|
|
731
|
+
driver: cursorLoginDriver,
|
|
732
|
+
configDir: (ctx as { configDir?: string }).configDir ?? extDataDir(),
|
|
733
|
+
resolveRuntime: async () => {
|
|
734
|
+
const bin = process.env.CURSOR_CLI_PATH || "cursor";
|
|
735
|
+
if (!bin.includes("/") && !whichBin(bin)) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`未找到 cursor CLI(PATH 上无 "${bin}",也未设置 CURSOR_CLI_PATH)——请先安装 Cursor Agent CLI;` +
|
|
738
|
+
"或在有浏览器的机器上运行 `cursor login` 完成授权",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return bin;
|
|
742
|
+
},
|
|
743
|
+
log: (m) => console.log(`[aiti-cursor-agent] ${m}`),
|
|
744
|
+
});
|
|
745
|
+
loginApi = mountControlledLoginApi(login);
|
|
746
|
+
loginCtl = login;
|
|
747
|
+
// P2 协议委托:createSession 主体抽出供 launch 服务复用(cwd 解析/模型种子/状态文件同链路)
|
|
748
|
+
const createCursorSession = async (
|
|
749
|
+
options: { cwd?: string; sessionId?: string; model?: { provider: string; id: string } },
|
|
750
|
+
launchOverride?: LaunchOverride,
|
|
751
|
+
) => {
|
|
752
|
+
// sessionId = cursor chatId(All Sessions 历史行 / ?session= URL 恢复)
|
|
753
|
+
const resume = typeof options.sessionId === "string" && options.sessionId ? options.sessionId : undefined;
|
|
754
|
+
const id = resume ? `cursor:${resume}` : `cursor:new-${randomUUID()}`;
|
|
755
|
+
// 尊重会话原 cwd:resume 优先用会话自己落盘的工作区(CLI 按工作区隔离
|
|
756
|
+
// 历史 —— 用页面 cwd 会把续轮分叉到新 workspaceKey 目录)。
|
|
757
|
+
let cwd = "";
|
|
758
|
+
let savedModel: string | undefined;
|
|
759
|
+
if (resume) {
|
|
760
|
+
try {
|
|
761
|
+
const saved = readCursorCliChat(resume);
|
|
762
|
+
cwd = saved?.cwd ?? "";
|
|
763
|
+
// 会话真实模型(store.meta.lastUsedModel):接管时选择器直接展示,
|
|
764
|
+
// cursor print 模式无 init 帧,这是唯一真实模型源
|
|
765
|
+
savedModel = typeof saved?.model === "string" && saved.model ? saved.model : undefined;
|
|
766
|
+
} catch { /* 落盘不可读 */ }
|
|
767
|
+
}
|
|
768
|
+
if (!cwd) cwd = options.cwd || "";
|
|
769
|
+
const handle = new CursorHandle(id, cwd || process.cwd());
|
|
770
|
+
handle.launchOverride = launchOverride;
|
|
771
|
+
handle.resumeChatId = resume;
|
|
772
|
+
handle.chatId = resume;
|
|
773
|
+
// 种子模型:显式指定 > 状态文件恢复 > store 真实模型(优先级递减,zcode 同序)
|
|
774
|
+
if (options.model?.id) {
|
|
775
|
+
handle.model = { id: options.model.id, provider: "aiti-cursor-agent" };
|
|
776
|
+
} else if (resume) {
|
|
777
|
+
const restored = await readExtModelState("cursor", `cursor:${resume}`);
|
|
778
|
+
if (restored?.selected?.id) handle.model = { id: restored.selected.id, provider: "aiti-cursor-agent" };
|
|
779
|
+
else if (savedModel) handle.model = { id: savedModel, provider: "aiti-cursor-agent" };
|
|
780
|
+
}
|
|
781
|
+
// 新会话(无显式模型/无同会话恢复/无 store 真实模型)→ 该 agent 上次使用的模型
|
|
782
|
+
// ⚠ 仅新会话:resume 会话模型以 store/CLI 自身为准,不用 agent 级默认覆盖
|
|
783
|
+
if (!handle.model && !resume) {
|
|
784
|
+
const lastUsed = await readAgentLastUsedModel("cursor");
|
|
785
|
+
if (lastUsed?.id) handle.model = { id: lastUsed.id, provider: "aiti-cursor-agent" };
|
|
786
|
+
}
|
|
787
|
+
// 启动即写状态文件(pi 目录快照 + selected),引导外部 agent
|
|
788
|
+
handle.persistModelState();
|
|
789
|
+
return handle;
|
|
790
|
+
};
|
|
791
|
+
ctx.sessionProviders.register({
|
|
792
|
+
type: "cursor-agent",
|
|
793
|
+
label: "Cursor Agent CLI",
|
|
794
|
+
agentKey: "cursor",
|
|
795
|
+
priority: 10,
|
|
796
|
+
maxCapabilities: CURSOR_CAPABILITIES,
|
|
797
|
+
// 静态目录(新建页选后端时给选择器供数据;无会话即可取)——上次使用的模型置顶
|
|
798
|
+
listModels: async () => withLastUsedFirst(await cursorCatalog(), (await readAgentLastUsedModel("cursor"))?.id),
|
|
799
|
+
createSession: async (options) => createCursorSession(options),
|
|
800
|
+
});
|
|
801
|
+
// agent 描述符:agent 管理面(aiti-agent-manager)动态发现用——manager 不写死各家
|
|
802
|
+
ctx.services?.provide?.("aiti-cursor-agent.agent", {
|
|
803
|
+
agent: "cursor",
|
|
804
|
+
label: "Cursor Agent CLI",
|
|
805
|
+
modes: ["url"],
|
|
806
|
+
accountQuota: true, // 支持账号额度查询(agent-manager 聚合 GET /login/quota)
|
|
807
|
+
dependencyHint: "cursor-agent CLI",
|
|
808
|
+
auth: [
|
|
809
|
+
{ kind: "file", path: [".config", "cursor", "auth.json"], label: "~/.config/cursor/auth.json" },
|
|
810
|
+
{ kind: "file", path: [".cursor", "auth.json"], label: "~/.cursor/auth.json(旧路径)" },
|
|
811
|
+
],
|
|
812
|
+
accountSlots: (c: { home: string }) => {
|
|
813
|
+
// Linux 实测 ~/.config/cursor/auth.json;mac/win 旧路径兜底 ~/.cursor/auth.json
|
|
814
|
+
const primary = join(c.home, ".config", "cursor", "auth.json");
|
|
815
|
+
const legacy = join(c.home, ".cursor", "auth.json");
|
|
816
|
+
return [{ key: "cursor-auth", label: "OAuth 凭据", path: !existsSync(primary) && existsSync(legacy) ? legacy : primary }];
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
ctx.services?.provide?.("aiti-cursor-agent.launch", {
|
|
820
|
+
agentKey: "cursor",
|
|
821
|
+
capabilities: CURSOR_CAPABILITIES,
|
|
822
|
+
listModels: async () => withLastUsedFirst(await cursorCatalog(), (await readAgentLastUsedModel("cursor"))?.id),
|
|
823
|
+
createSessionFromLaunch: (launch: LaunchOverride, options: { cwd?: string; sessionId?: string; model?: { provider: string; id: string } }) =>
|
|
824
|
+
createCursorSession(options, launch),
|
|
825
|
+
});
|
|
826
|
+
console.log("[aiti-cursor-agent] registered session provider: cursor-agent (agentKey: cursor) + launch service");
|
|
827
|
+
},
|
|
828
|
+
|
|
829
|
+
/** 受控登录 API:POST /login/start · GET /login/status · POST /login/cancel · POST /login/submit。 */
|
|
830
|
+
async api(req: Request, ctx: { path: string[]; method: string; searchParams: URLSearchParams }) {
|
|
831
|
+
const quota = quotaRoute(req, ctx);
|
|
832
|
+
if (quota) return quota;
|
|
833
|
+
return loginApi ? await loginApi(req, ctx) : null;
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
/** 扩展停卸/reload:终止进行中的登录进程(状态文件留档,下次 status reconcile 为 failed)。 */
|
|
837
|
+
shutdown() {
|
|
838
|
+
loginCtl?.dispose();
|
|
839
|
+
loginCtl = null;
|
|
840
|
+
loginApi = null;
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
};
|