@linkshell/host 0.4.35 → 0.4.37

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.
@@ -1,12 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { accessSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
6
6
  import { DatabaseSync } from "node:sqlite";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { RpcError, } from "@linkshell/wire";
10
+ import { QUESTION_OPTIONS } from "../../questions.js";
11
+ import { nestUnder } from "../nesting.js";
10
12
  import { AcpDriver } from "./driver.js";
11
13
  const execFileAsync = promisify(execFile);
12
14
  /**
@@ -168,6 +170,25 @@ export function resolveAntigravityLaunch(env = process.env) {
168
170
  version,
169
171
  };
170
172
  }
173
+ function readHead(filePath, bytes) {
174
+ let fd;
175
+ try {
176
+ fd = openSync(filePath, "r");
177
+ const buffer = Buffer.alloc(bytes);
178
+ const read = readSync(fd, buffer, 0, bytes, 0);
179
+ return buffer.subarray(0, read).toString("utf8");
180
+ }
181
+ catch {
182
+ return "";
183
+ }
184
+ finally {
185
+ if (fd !== undefined)
186
+ closeSync(fd);
187
+ }
188
+ }
189
+ // Watchers, discovery and every RPC resolve the desktop; the answer only changes when the app is installed or moved.
190
+ const DESKTOP_CACHE_MS = 5000;
191
+ let desktopCache;
171
192
  /**
172
193
  * Resolves an installed Antigravity Desktop app (`/Applications/Antigravity.app`)
173
194
  * and its conversation store (`~/.gemini/antigravity`).
@@ -175,6 +196,14 @@ export function resolveAntigravityLaunch(env = process.env) {
175
196
  export function resolveAntigravityDesktop(env = process.env) {
176
197
  if (env.PATH === "" && !env.ANTIGRAVITY_APP_DATA_DIR)
177
198
  return undefined;
199
+ const key = `${env.HOME ?? ""}\n${env.ANTIGRAVITY_APP_DATA_DIR ?? ""}`;
200
+ if (desktopCache && desktopCache.key === key && Date.now() - desktopCache.at < DESKTOP_CACHE_MS)
201
+ return desktopCache.value;
202
+ const value = resolveAntigravityDesktopUncached(env);
203
+ desktopCache = value ? { key, at: Date.now(), value } : undefined;
204
+ return value;
205
+ }
206
+ function resolveAntigravityDesktopUncached(env) {
178
207
  const home = env.HOME || homedir();
179
208
  const dataCandidates = [
180
209
  env.ANTIGRAVITY_APP_DATA_DIR?.trim(),
@@ -197,15 +226,9 @@ export function resolveAntigravityDesktop(env = process.env) {
197
226
  const logPath = logCandidates.find((candidate) => existsSync(candidate));
198
227
  let version = "desktop";
199
228
  if (logPath) {
200
- try {
201
- const head = readFileSync(logPath, "utf8").slice(0, 2048);
202
- const match = /Language server version:\s*([^\s\r\n]+)/i.exec(head);
203
- if (match?.[1])
204
- version = match[1];
205
- }
206
- catch {
207
- // ignore unreadable log
208
- }
229
+ const match = /Language server version:\s*([^\s\r\n]+)/i.exec(readHead(logPath, 16 * 1024));
230
+ if (match?.[1])
231
+ version = match[1];
209
232
  }
210
233
  if (version === "desktop" && process.platform === "darwin") {
211
234
  try {
@@ -250,16 +273,20 @@ export function antigravitySpec() {
250
273
  authStatus: antigravityAuthStatus,
251
274
  };
252
275
  }
276
+ /** Used only while the language server can't be asked (GetSlashCommands); mirrors its system commands. */
253
277
  const ANTIGRAVITY_DESKTOP_COMMANDS = [
254
278
  { name: "goal", description: "设定持续目标,让 Antigravity 持续推进直到完成", hint: "<目标描述>" },
255
279
  { name: "plan", description: "先制定分步执行计划,再动手实现", hint: "<任务描述>" },
256
280
  { name: "boost", description: "深度思考、多视角分析与严格验证模式", hint: "<复杂问题或任务>" },
257
281
  { name: "teamwork-preview", description: "启动多 Agent 协作团队共同完成大型项目", hint: "<项目任务>" },
258
282
  { name: "grill-me", description: "通过互动提问对齐方案细节与设计决策", hint: "<主题>" },
283
+ { name: "browser", description: "调用浏览器子 Agent 完成网页任务", hint: "<网页任务>" },
259
284
  { name: "compact", description: "压缩并总结当前对话上下文以释放窗口空间" },
260
285
  { name: "schedule", description: "创建定时任务或后台周期自动化", hint: "<定时指令>" },
261
286
  { name: "learn", description: "总结并持久化当前经验与约定供后续会话使用" },
262
287
  ];
288
+ /** Chinese descriptions for the language server's system commands; skills keep their own (they are user-authored). */
289
+ const SYSTEM_COMMAND_TEXT = new Map(ANTIGRAVITY_DESKTOP_COMMANDS.map((command) => [command.name, command]));
263
290
  function unquoteArg(value) {
264
291
  if (typeof value !== "string")
265
292
  return value;
@@ -291,6 +318,13 @@ function parseToolArgs(raw) {
291
318
  function stripTruncationMarkers(text) {
292
319
  return text.replace(/\n?\.\.\.\s*<truncated\s+\d+\s+bytes>\s*\.\.\.\n?/gi, "\n").replace(/<truncated\s+\d+\s+bytes>/gi, "");
293
320
  }
321
+ function stripStepHeader(text) {
322
+ return text.replace(/^Created At: [^\n]*\n(?:Completed At: [^\n]*\n)?/, "");
323
+ }
324
+ function toolOutputText(step) {
325
+ const raw = typeof step.content === "string" && step.content.trim() ? step.content : step.error ?? "";
326
+ return stripStepHeader(stripTruncationMarkers(raw)).trim();
327
+ }
294
328
  export function cleanAntigravityUserInput(content) {
295
329
  const closedMatch = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/i.exec(content);
296
330
  if (closedMatch)
@@ -395,7 +429,112 @@ function readStepMediaBlocks(media) {
395
429
  }
396
430
  return blocks;
397
431
  }
398
- function mapAntigravityToolCall(stepIndex, callIndex, call, outputText) {
432
+ function parseAskQuestionEntries(args) {
433
+ const rawQuestions = Array.isArray(args.questions) ? args.questions : [];
434
+ const entries = [];
435
+ for (const raw of rawQuestions) {
436
+ if (!raw || typeof raw !== "object")
437
+ continue;
438
+ const rec = raw;
439
+ const question = typeof rec.question === "string" ? rec.question.trim() : "";
440
+ if (!question)
441
+ continue;
442
+ const isMultiSelect = rec.is_multi_select === true || rec.isMultiSelect === true;
443
+ const rawOpts = Array.isArray(rec.options) ? rec.options : [];
444
+ const options = rawOpts
445
+ .map((opt, idx) => {
446
+ if (typeof opt === "string")
447
+ return { id: String(idx + 1), text: opt };
448
+ if (opt && typeof opt === "object") {
449
+ const o = opt;
450
+ const text = typeof o.text === "string" ? o.text : typeof o.label === "string" ? o.label : "";
451
+ const id = typeof o.id === "string" ? o.id : String(idx + 1);
452
+ return text ? { id, text } : undefined;
453
+ }
454
+ return undefined;
455
+ })
456
+ .filter((entry) => Boolean(entry));
457
+ entries.push({
458
+ question,
459
+ ...(isMultiSelect ? { isMultiSelect: true } : {}),
460
+ ...(options.length > 0 ? { options } : {}),
461
+ });
462
+ }
463
+ return entries;
464
+ }
465
+ function str(value) {
466
+ return typeof value === "string" && value.trim() ? value : undefined;
467
+ }
468
+ /** Diffs bigger than this stay out of the event log; the phone shows the file path and summary instead. */
469
+ const MAX_DIFF_CHARS = 200_000;
470
+ function editContent(name, path, args) {
471
+ const diffs = [];
472
+ if (name === "write_to_file") {
473
+ const newText = typeof args.CodeContent === "string" ? args.CodeContent : undefined;
474
+ if (newText !== undefined)
475
+ diffs.push({ type: "diff", path, oldText: null, newText });
476
+ }
477
+ else if (name === "replace_file_content") {
478
+ const oldText = typeof args.TargetContent === "string" ? args.TargetContent : undefined;
479
+ const newText = typeof args.ReplacementContent === "string" ? args.ReplacementContent : undefined;
480
+ if (oldText !== undefined && newText !== undefined)
481
+ diffs.push({ type: "diff", path, oldText, newText });
482
+ }
483
+ else if (name === "multi_replace_file_content") {
484
+ const chunks = Array.isArray(args.ReplacementChunks) ? args.ReplacementChunks : [];
485
+ for (const chunk of chunks) {
486
+ if (!chunk || typeof chunk !== "object")
487
+ continue;
488
+ const rec = chunk;
489
+ if (typeof rec.TargetContent === "string" && typeof rec.ReplacementContent === "string") {
490
+ diffs.push({ type: "diff", path, oldText: rec.TargetContent, newText: rec.ReplacementContent });
491
+ }
492
+ }
493
+ }
494
+ const size = diffs.reduce((total, diff) => total + (diff.type === "diff" ? diff.newText.length + (diff.oldText?.length ?? 0) : 0), 0);
495
+ return diffs.length > 0 && size <= MAX_DIFF_CHARS ? diffs : undefined;
496
+ }
497
+ /** The sub-agent conversations a tool output names (invoke_subagent answers with their ids). */
498
+ function subagentIdsFromOutput(output) {
499
+ if (!output)
500
+ return [];
501
+ return [...output.matchAll(/"conversationId":\s*"([0-9a-f-]{36})"/gi)].map((match) => match[1]);
502
+ }
503
+ /**
504
+ * invoke_subagent can start several sub-agents at once; the call itself carries
505
+ * the first, and each further one gets a call of its own so its work nests apart.
506
+ */
507
+ function extraSpawns(call, toolCallId, output) {
508
+ if (call.name !== "invoke_subagent")
509
+ return [];
510
+ const ids = subagentIdsFromOutput(output);
511
+ const specs = (() => {
512
+ const args = parseToolArgs(call.args);
513
+ return Array.isArray(args.Subagents) ? args.Subagents : [];
514
+ })();
515
+ return ids.slice(1).map((nativeSessionId, offset) => {
516
+ const spec = specs[offset + 1] ?? {};
517
+ const role = str(spec.Role);
518
+ return {
519
+ sessionUpdate: "tool_call",
520
+ toolCallId: `${toolCallId}#${offset + 1}`,
521
+ title: role ?? "Sub-agent",
522
+ name: "invoke_subagent",
523
+ kind: "other",
524
+ status: "completed",
525
+ detail: {
526
+ type: "subagent",
527
+ action: "spawn",
528
+ ...(str(spec.Prompt) ? { task: str(spec.Prompt).slice(0, 2000) } : {}),
529
+ ...(str(spec.TypeName) ? { agentType: str(spec.TypeName) } : {}),
530
+ ...(role ? { name: role } : {}),
531
+ nativeSessionId,
532
+ canCancel: true,
533
+ },
534
+ };
535
+ });
536
+ }
537
+ function mapAntigravityToolCall(stepIndex, callIndex, call, output, pending = false) {
399
538
  const name = call.name ?? "tool";
400
539
  const args = parseToolArgs(call.args);
401
540
  const summary = (typeof args.toolSummary === "string" && args.toolSummary.trim()) ||
@@ -405,6 +544,8 @@ function mapAntigravityToolCall(stepIndex, callIndex, call, outputText) {
405
544
  let kind = "other";
406
545
  let rawInput = args;
407
546
  let locations;
547
+ let detail;
548
+ let content;
408
549
  if (name === "run_command") {
409
550
  kind = "execute";
410
551
  rawInput = {
@@ -412,15 +553,33 @@ function mapAntigravityToolCall(stepIndex, callIndex, call, outputText) {
412
553
  cwd: typeof args.Cwd === "string" ? args.Cwd : undefined,
413
554
  };
414
555
  }
415
- else if (name === "view_file") {
556
+ else if (name === "view_file" || name === "view_code_item") {
416
557
  kind = "read";
417
- const path = typeof args.AbsolutePath === "string" ? args.AbsolutePath : undefined;
558
+ const path = str(args.AbsolutePath) ?? str(args.File);
418
559
  const line = typeof args.StartLine === "number" ? args.StartLine : undefined;
419
560
  if (path) {
420
561
  rawInput = { file_path: path };
421
562
  locations = [{ path, ...(line ? { line } : {}) }];
422
563
  }
423
564
  }
565
+ else if (name === "list_dir") {
566
+ kind = "read";
567
+ const path = str(args.DirectoryPath);
568
+ if (path) {
569
+ rawInput = { path };
570
+ locations = [{ path }];
571
+ }
572
+ }
573
+ else if (name === "grep_search" || name === "find_by_name" || name === "codebase_search") {
574
+ kind = "search";
575
+ const path = str(args.SearchPath) ?? str(args.SearchDirectory) ?? str(args.DirectoryPath);
576
+ rawInput = {
577
+ query: str(args.Query) ?? str(args.Pattern) ?? summary,
578
+ ...(path ? { path } : {}),
579
+ };
580
+ if (path)
581
+ locations = [{ path }];
582
+ }
424
583
  else if (name === "replace_file_content" || name === "multi_replace_file_content" || name === "write_to_file") {
425
584
  kind = "edit";
426
585
  const path = typeof args.TargetFile === "string" ? args.TargetFile : undefined;
@@ -428,124 +587,146 @@ function mapAntigravityToolCall(stepIndex, callIndex, call, outputText) {
428
587
  if (path) {
429
588
  rawInput = { file_path: path };
430
589
  locations = [{ path, ...(line ? { line } : {}) }];
590
+ content = editContent(name, path, args);
431
591
  }
432
592
  }
433
593
  else if (name === "search_web") {
434
594
  kind = "search";
435
- rawInput = { query: typeof args.query === "string" ? args.query : summary };
595
+ const query = typeof args.query === "string" ? args.query : summary;
596
+ rawInput = { query };
597
+ detail = { type: "web_search", query };
436
598
  }
437
599
  else if (name === "read_url_content") {
438
600
  kind = "fetch";
439
601
  rawInput = { url: typeof args.Url === "string" ? args.Url : summary };
440
602
  }
441
- const cleanedOutput = outputText ? stripTruncationMarkers(outputText) : undefined;
603
+ else if (name === "call_mcp_tool" || name === "read_resource" || name === "list_resources") {
604
+ const server = str(args.ServerName) ?? "mcp";
605
+ const tool = str(args.ToolName) ?? (name === "call_mcp_tool" ? "tool" : name);
606
+ rawInput = (args.Arguments && typeof args.Arguments === "object" ? args.Arguments : args);
607
+ detail = { type: "mcp", server, tool };
608
+ }
609
+ else if (name === "invoke_subagent") {
610
+ const first = Array.isArray(args.Subagents) ? args.Subagents[0] : undefined;
611
+ const nativeSessionId = subagentIdsFromOutput(output?.text)[0];
612
+ detail = {
613
+ type: "subagent",
614
+ action: "spawn",
615
+ ...(str(first?.Prompt) ? { task: str(first?.Prompt).slice(0, 2000) } : {}),
616
+ ...(str(first?.TypeName) ? { agentType: str(first?.TypeName) } : {}),
617
+ ...(str(first?.Role) ? { name: str(first?.Role) } : {}),
618
+ ...(nativeSessionId ? { nativeSessionId, canCancel: true } : {}),
619
+ };
620
+ }
621
+ else if (name === "send_message") {
622
+ const recipient = str(args.Recipient);
623
+ detail = { type: "subagent", action: "message", ...(recipient ? { nativeSessionId: recipient } : {}) };
624
+ }
625
+ else if (name === "manage_subagents") {
626
+ const action = str(args.Action);
627
+ detail = { type: "subagent", action: action === "list" ? "list" : "stop" };
628
+ }
629
+ else if (name === "ask_question") {
630
+ const entries = parseAskQuestionEntries(args);
631
+ if (entries.length > 0) {
632
+ detail = {
633
+ type: "question",
634
+ text: entries[0].question,
635
+ ...(entries.length > 1 ? { more: entries.length - 1 } : {}),
636
+ };
637
+ }
638
+ }
639
+ const cleanedOutput = output?.text || undefined;
442
640
  return {
443
641
  sessionUpdate: "tool_call",
444
642
  toolCallId,
445
643
  title: summary,
446
644
  name,
447
645
  kind,
448
- status: "completed",
646
+ status: pending ? "in_progress" : output?.failed ? "failed" : "completed",
449
647
  rawInput,
450
648
  ...(cleanedOutput ? { rawOutput: { output: cleanedOutput } } : {}),
649
+ ...(content ? { content } : {}),
451
650
  ...(locations ? { locations } : {}),
651
+ ...(detail ? { detail } : {}),
452
652
  };
453
653
  }
654
+ export function newTranscriptParseState() {
655
+ return { goal: null };
656
+ }
454
657
  /**
455
- * Converts an Antigravity Desktop `transcript_full.jsonl` (or `transcript.jsonl`)
456
- * file into LinkShell's `HistoryItem[]` list, tracking Goal Mode (`ls_goal`),
457
- * model changes (`<USER_SETTINGS_CHANGE>`), and uploaded media.
658
+ * Turns transcript steps into history items, carrying `state` across calls so
659
+ * the transcript can be read a few lines at a time. Tool calls whose outputs
660
+ * haven't been written yet are `in_progress`; their outputs arrive later as
661
+ * `tool_call_update`s.
458
662
  */
459
- export function parseAntigravityTranscript(transcriptPath, fromLine = 0) {
460
- if (!existsSync(transcriptPath))
461
- return { items: [], nextLine: fromLine };
462
- let lines;
463
- try {
464
- lines = readFileSync(transcriptPath, "utf8").split("\n").filter((line) => line.trim().length > 0);
465
- }
466
- catch {
467
- return { items: [], nextLine: fromLine };
468
- }
469
- let activeGoal = null;
470
- let lastModelLabel;
471
- // Scan lines prior to `fromLine` so incremental parsing retains active goal & model state.
472
- const scanLimit = Math.min(fromLine, lines.length);
473
- for (let i = 0; i < scanLimit; i++) {
474
- try {
475
- const step = JSON.parse(lines[i]);
476
- if (step.type === "USER_INPUT" && typeof step.content === "string") {
477
- const modelLabel = extractModelSelectionFromContent(step.content);
478
- if (modelLabel)
479
- lastModelLabel = modelLabel;
480
- const rawText = cleanAntigravityUserInput(step.content);
481
- if (/^\/goal\s+(?:clear|stop|off|reset|none|cancel)\s*$/i.test(rawText)) {
482
- activeGoal = null;
483
- }
484
- else if (/^\/goal(?:\s+|$)/i.test(rawText) || /\/goal is a \[Slash Command\]:/i.test(step.content)) {
485
- const objective = rawText.replace(/^\/goal(?:\s+|$)/i, "").trim() || "持续目标";
486
- activeGoal = { objective, status: "active" };
487
- }
488
- else if (activeGoal?.status === "complete") {
489
- activeGoal = null;
490
- }
491
- }
492
- else if (step.type === "SYSTEM_MESSAGE") {
493
- const nextGoal = parseGoalSystemMessage(step.content, activeGoal);
494
- if (nextGoal)
495
- activeGoal = nextGoal;
496
- }
497
- else if (step.type === "PLANNER_RESPONSE" && typeof step.content === "string") {
498
- const { goalComplete, goalCancelled } = cleanAntigravityAgentContent(step.content);
499
- if (goalCancelled) {
500
- activeGoal = null;
501
- }
502
- else if (goalComplete) {
503
- activeGoal = { ...(activeGoal ?? { objective: "持续目标" }), status: "complete" };
504
- }
505
- }
506
- }
507
- catch {
508
- // ignore malformed historical line
509
- }
510
- }
511
- if (fromLine >= lines.length)
512
- return { items: [], nextLine: lines.length, lastModelLabel, goal: activeGoal };
513
- const parsed = [];
514
- for (let i = fromLine; i < lines.length; i++) {
515
- try {
516
- const step = JSON.parse(lines[i]);
517
- parsed.push({ index: step.step_index ?? i, step });
518
- }
519
- catch {
520
- // skip partial trailing JSON line
521
- return { items: [], nextLine: i, lastModelLabel, goal: activeGoal };
663
+ export function parseTranscriptSteps(parsed, state) {
664
+ const items = [];
665
+ let pendingAskQuestion;
666
+ let lastPlanner = -1;
667
+ for (let i = parsed.length - 1; i >= 0; i--) {
668
+ if (parsed[i].step.type === "PLANNER_RESPONSE") {
669
+ lastPlanner = i;
670
+ break;
522
671
  }
523
672
  }
524
- const items = [];
525
673
  for (let i = 0; i < parsed.length; i++) {
526
674
  const { index: stepIndex, step } = parsed[i];
675
+ state.lastStepIndex = stepIndex;
676
+ if (step.type === "GENERIC") {
677
+ const open = state.openCalls;
678
+ if (!open)
679
+ continue;
680
+ const callIdx = open.consumed;
681
+ const call = open.calls[callIdx];
682
+ open.consumed += 1;
683
+ if (open.consumed >= open.calls.length)
684
+ state.openCalls = undefined;
685
+ const text = toolOutputText(step);
686
+ if (call) {
687
+ const toolCallId = call.id || `agy:tc:${open.stepIndex}:${callIdx}`;
688
+ // The output is what names a spawned sub-agent's conversation, so its detail is only complete now.
689
+ const settled = mapAntigravityToolCall(open.stepIndex, callIdx, call, { text, failed: step.status === "ERROR" });
690
+ const detail = settled.sessionUpdate === "tool_call" ? settled.detail : undefined;
691
+ items.push({
692
+ itemId: `agy:tc-out:${stepIndex}`,
693
+ updates: [
694
+ {
695
+ sessionUpdate: "tool_call_update",
696
+ toolCallId,
697
+ status: step.status === "ERROR" ? "failed" : "completed",
698
+ ...(detail ? { detail } : {}),
699
+ ...(text ? { rawOutput: { output: text } } : {}),
700
+ },
701
+ ...extraSpawns(call, toolCallId, text),
702
+ ],
703
+ });
704
+ }
705
+ continue;
706
+ }
707
+ state.openCalls = undefined;
527
708
  if (step.type === "USER_INPUT") {
528
709
  if (typeof step.content === "string") {
529
710
  const modelLabel = extractModelSelectionFromContent(step.content);
530
711
  if (modelLabel)
531
- lastModelLabel = modelLabel;
712
+ state.lastModelLabel = modelLabel;
532
713
  }
533
714
  const rawText = typeof step.content === "string" ? cleanAntigravityUserInput(step.content) : "";
534
715
  const mediaBlocks = readStepMediaBlocks(step.media);
535
716
  let displayText = rawText;
536
717
  let goalUpdate;
537
718
  if (/^\/goal\s+(?:clear|stop|off|reset|none|cancel)\s*$/i.test(rawText)) {
538
- activeGoal = null;
719
+ state.goal = null;
539
720
  goalUpdate = { sessionUpdate: "ls_goal", goal: null };
540
721
  }
541
722
  else if (/^\/goal(?:\s+|$)/i.test(rawText) || (typeof step.content === "string" && /\/goal is a \[Slash Command\]:/i.test(step.content))) {
542
723
  const objective = rawText.replace(/^\/goal(?:\s+|$)/i, "").trim() || "持续目标";
543
724
  displayText = objective;
544
- activeGoal = { objective, status: "active" };
545
- goalUpdate = { sessionUpdate: "ls_goal", goal: activeGoal };
725
+ state.goal = { objective, status: "active" };
726
+ goalUpdate = { sessionUpdate: "ls_goal", goal: state.goal };
546
727
  }
547
- else if (activeGoal?.status === "complete") {
548
- activeGoal = null;
728
+ else if (state.goal?.status === "complete") {
729
+ state.goal = null;
549
730
  goalUpdate = { sessionUpdate: "ls_goal", goal: null };
550
731
  }
551
732
  if (!displayText && mediaBlocks.length === 0 && !goalUpdate)
@@ -553,35 +734,36 @@ export function parseAntigravityTranscript(transcriptPath, fromLine = 0) {
553
734
  const messageId = `agy:user:${stepIndex}`;
554
735
  const updates = [];
555
736
  for (const mediaBlock of mediaBlocks) {
556
- updates.push({
557
- sessionUpdate: "user_message_chunk",
558
- messageId,
559
- content: mediaBlock,
560
- });
737
+ updates.push({ sessionUpdate: "user_message_chunk", messageId, content: mediaBlock });
561
738
  }
562
739
  if (displayText) {
563
- updates.push({
564
- sessionUpdate: "user_message_chunk",
565
- messageId,
566
- content: { type: "text", text: displayText },
567
- });
740
+ updates.push({ sessionUpdate: "user_message_chunk", messageId, content: { type: "text", text: displayText } });
568
741
  }
569
- if (goalUpdate) {
742
+ if (goalUpdate)
570
743
  updates.push(goalUpdate);
744
+ items.push({ itemId: messageId, updates });
745
+ }
746
+ else if (step.type === "SYSTEM_MESSAGE") {
747
+ const nextGoal = parseGoalSystemMessage(step.content, state.goal);
748
+ if (nextGoal) {
749
+ state.goal = nextGoal;
750
+ items.push({ itemId: `agy:goal:${stepIndex}`, updates: [{ sessionUpdate: "ls_goal", goal: nextGoal }] });
571
751
  }
752
+ }
753
+ else if (step.type === "CHECKPOINT") {
754
+ // A compaction: the transcript keeps the summary the agent resumes from; the phone shows that it happened.
755
+ const toolCallId = `agy:checkpoint:${stepIndex}`;
572
756
  items.push({
573
- itemId: messageId,
574
- updates,
757
+ itemId: toolCallId,
758
+ updates: [{ sessionUpdate: "tool_call", toolCallId, title: "上下文已压缩", kind: "think", status: "completed", detail: { type: "compaction" } }],
575
759
  });
576
760
  }
577
- else if (step.type === "SYSTEM_MESSAGE") {
578
- const nextGoal = parseGoalSystemMessage(step.content, activeGoal);
579
- if (nextGoal) {
580
- activeGoal = nextGoal;
581
- items.push({
582
- itemId: `agy:goal:${stepIndex}`,
583
- updates: [{ sessionUpdate: "ls_goal", goal: activeGoal }],
584
- });
761
+ else if (step.type === "ERROR_MESSAGE" || step.type === "CORTEX_STEP_TYPE_ERROR_MESSAGE") {
762
+ const errText = typeof step.content === "string" && step.content.trim()
763
+ ? stripTruncationMarkers(step.content).trim()
764
+ : (step.error ?? "").trim();
765
+ if (errText) {
766
+ items.push({ itemId: `agy:err:${stepIndex}`, updates: [{ sessionUpdate: "ls_error", code: "agent_error", message: errText }] });
585
767
  }
586
768
  }
587
769
  else if (step.type === "PLANNER_RESPONSE") {
@@ -590,107 +772,288 @@ export function parseAntigravityTranscript(transcriptPath, fromLine = 0) {
590
772
  const cleanThought = stripTruncationMarkers(step.thinking).trim();
591
773
  if (cleanThought) {
592
774
  const thoughtId = `agy:thought:${stepIndex}`;
593
- updates.push({
594
- sessionUpdate: "agent_thought_chunk",
595
- messageId: thoughtId,
596
- content: { type: "text", text: cleanThought },
597
- }, {
598
- sessionUpdate: "ls_message_done",
599
- messageId: thoughtId,
600
- role: "thought",
601
- });
775
+ updates.push({ sessionUpdate: "agent_thought_chunk", messageId: thoughtId, content: { type: "text", text: cleanThought } }, { sessionUpdate: "ls_message_done", messageId: thoughtId, role: "thought" });
602
776
  }
603
777
  }
604
778
  const calls = Array.isArray(step.tool_calls) ? step.tool_calls : [];
605
779
  if (calls.length > 0) {
606
- // Subsequent GENERIC steps hold the outputs of these tool calls in order.
780
+ // The GENERIC steps that follow hold these calls' outputs, in order.
607
781
  const outputs = [];
608
782
  let lookahead = i + 1;
609
783
  while (lookahead < parsed.length && parsed[lookahead].step.type === "GENERIC" && outputs.length < calls.length) {
610
- outputs.push(typeof parsed[lookahead].step.content === "string" ? parsed[lookahead].step.content : "");
784
+ const output = parsed[lookahead].step;
785
+ outputs.push({ text: toolOutputText(output), failed: output.status === "ERROR" });
611
786
  lookahead++;
612
787
  }
788
+ const atTail = lookahead >= parsed.length;
613
789
  calls.forEach((call, callIdx) => {
614
- updates.push(mapAntigravityToolCall(stepIndex, callIdx, call, outputs[callIdx]));
790
+ const output = outputs[callIdx];
791
+ updates.push(mapAntigravityToolCall(stepIndex, callIdx, call, output, !output && atTail));
792
+ updates.push(...extraSpawns(call, call.id || `agy:tc:${stepIndex}:${callIdx}`, output?.text));
793
+ if (call.name === "ask_question" && !output && atTail) {
794
+ const args = parseToolArgs(call.args);
795
+ const askEntries = parseAskQuestionEntries(args);
796
+ if (askEntries.length > 0) {
797
+ pendingAskQuestion = {
798
+ stepIndex: stepIndex + 1 + callIdx,
799
+ toolCallId: call.id || `agy:tc:${stepIndex}:${callIdx}`,
800
+ title: str(args.toolSummary)?.trim() || str(args.toolAction)?.trim() || "Antigravity 有问题需要你回答",
801
+ questions: askEntries,
802
+ };
803
+ }
804
+ }
615
805
  });
806
+ if (outputs.length < calls.length && atTail) {
807
+ state.openCalls = { stepIndex, calls, consumed: outputs.length };
808
+ }
809
+ if (lookahead > i + 1) {
810
+ for (let j = i + 1; j < lookahead; j++)
811
+ state.lastStepIndex = parsed[j].index;
812
+ i = lookahead - 1;
813
+ }
616
814
  }
617
815
  if (typeof step.content === "string" && step.content.trim()) {
618
816
  const { text: cleanText, goalComplete, goalCancelled } = cleanAntigravityAgentContent(step.content);
619
817
  if (cleanText) {
620
818
  const messageId = `agy:msg:${stepIndex}`;
621
- updates.push({
622
- sessionUpdate: "agent_message_chunk",
623
- messageId,
624
- content: { type: "text", text: cleanText },
625
- }, {
626
- sessionUpdate: "ls_message_done",
627
- messageId,
628
- role: "agent",
629
- });
819
+ updates.push({ sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text: cleanText } }, { sessionUpdate: "ls_message_done", messageId, role: "agent" });
630
820
  }
631
821
  if (goalCancelled) {
632
- activeGoal = null;
822
+ state.goal = null;
633
823
  updates.push({ sessionUpdate: "ls_goal", goal: null });
634
824
  }
635
825
  else if (goalComplete) {
636
- activeGoal = { ...(activeGoal ?? { objective: "持续目标" }), status: "complete" };
637
- updates.push({ sessionUpdate: "ls_goal", goal: activeGoal });
826
+ state.goal = { ...(state.goal ?? { objective: "持续目标" }), status: "complete" };
827
+ updates.push({ sessionUpdate: "ls_goal", goal: state.goal });
638
828
  }
639
829
  }
640
- if (updates.length > 0) {
641
- items.push({
642
- itemId: `agy:step:${stepIndex}`,
643
- updates,
644
- });
830
+ // The context in use is the latest reply's prompt; earlier replies in the batch are superseded.
831
+ if (i === lastPlanner || (lastPlanner >= 0 && parsed[lastPlanner].index === stepIndex)) {
832
+ const input = (step.input_tokens ?? 0) + (step.cache_read_tokens ?? 0);
833
+ if (input > 0) {
834
+ updates.push({
835
+ sessionUpdate: "usage_update",
836
+ usedTokens: input,
837
+ tokens: {
838
+ totalTokens: input + (step.output_tokens ?? 0),
839
+ inputTokens: step.input_tokens ?? 0,
840
+ outputTokens: step.output_tokens ?? 0,
841
+ cachedReadTokens: step.cache_read_tokens ?? 0,
842
+ },
843
+ });
844
+ }
645
845
  }
846
+ if (updates.length > 0)
847
+ items.push({ itemId: `agy:step:${stepIndex}`, updates });
646
848
  }
647
849
  }
648
- return { items, nextLine: lines.length, lastModelLabel, goal: activeGoal };
850
+ return { items, ...(pendingAskQuestion ? { pendingAskQuestion } : {}) };
649
851
  }
650
- async function findRunningLanguageServer(desktop) {
651
- if (!desktop?.logPath || !existsSync(desktop.logPath))
652
- return undefined;
653
- let httpPort;
852
+ function parseTranscriptLines(lines, startLine) {
853
+ const parsed = [];
854
+ lines.forEach((line, offset) => {
855
+ if (!line.trim())
856
+ return;
857
+ try {
858
+ const step = JSON.parse(line);
859
+ parsed.push({ index: step.step_index ?? startLine + offset, step });
860
+ }
861
+ catch {
862
+ // a malformed line can't be recovered; skip it rather than stall every later line
863
+ }
864
+ });
865
+ return parsed;
866
+ }
867
+ /**
868
+ * Converts an Antigravity Desktop `transcript_full.jsonl` (or `transcript.jsonl`)
869
+ * file into LinkShell's `HistoryItem[]` list, tracking Goal Mode (`ls_goal`),
870
+ * model changes (`<USER_SETTINGS_CHANGE>`), and uploaded media. Lines before
871
+ * `fromLine` only rebuild state.
872
+ */
873
+ export function parseAntigravityTranscript(transcriptPath, fromLine = 0) {
874
+ const state = newTranscriptParseState();
875
+ if (!existsSync(transcriptPath))
876
+ return { items: [], nextLine: fromLine, state, endOffset: 0 };
877
+ let text;
654
878
  try {
655
- const head = readFileSync(desktop.logPath, "utf8").slice(0, 4096);
656
- const match = /listening on \w+ port at (\d+) for HTTP\b/i.exec(head);
657
- if (match?.[1])
658
- httpPort = Number(match[1]);
879
+ text = readFileSync(transcriptPath, "utf8");
659
880
  }
660
881
  catch {
661
- return undefined;
882
+ return { items: [], nextLine: fromLine, state, endOffset: 0 };
883
+ }
884
+ // A line still being written has no newline yet: it is read next time, whole.
885
+ const complete = text.endsWith("\n") ? text : text.slice(0, text.lastIndexOf("\n") + 1);
886
+ const lines = complete.split("\n").filter((line) => line.trim().length > 0);
887
+ const scanLimit = Math.min(fromLine, lines.length);
888
+ parseTranscriptSteps(parseTranscriptLines(lines.slice(0, scanLimit), 0), state);
889
+ const result = parseTranscriptSteps(parseTranscriptLines(lines.slice(scanLimit), scanLimit), state);
890
+ return {
891
+ items: result.items,
892
+ nextLine: lines.length,
893
+ lastStepIndex: state.lastStepIndex,
894
+ lastModelLabel: state.lastModelLabel,
895
+ goal: state.goal,
896
+ ...(result.pendingAskQuestion ? { pendingAskQuestion: result.pendingAskQuestion } : {}),
897
+ state,
898
+ endOffset: Buffer.byteLength(complete, "utf8"),
899
+ };
900
+ }
901
+ /**
902
+ * Follows a transcript by byte offset so a long conversation (tens of MB) is
903
+ * never re-read: each poll parses only the lines appended since the last.
904
+ */
905
+ export class TranscriptTail {
906
+ path;
907
+ state;
908
+ offset;
909
+ remainder = Buffer.alloc(0);
910
+ constructor(path, state, offset) {
911
+ this.path = path;
912
+ this.state = state;
913
+ this.offset = offset;
914
+ }
915
+ read(currentPath) {
916
+ let skipThrough;
917
+ if (currentPath !== this.path) {
918
+ // transcript.jsonl gave way to transcript_full.jsonl: re-read, but only emit what is new.
919
+ this.path = currentPath;
920
+ this.offset = 0;
921
+ this.remainder = Buffer.alloc(0);
922
+ skipThrough = this.state.lastStepIndex;
923
+ }
924
+ let fd;
925
+ let chunk;
926
+ try {
927
+ fd = openSync(this.path, "r");
928
+ const size = fstatSync(fd).size;
929
+ if (size < this.offset) {
930
+ this.offset = 0;
931
+ this.remainder = Buffer.alloc(0);
932
+ skipThrough = this.state.lastStepIndex;
933
+ }
934
+ if (size === this.offset)
935
+ return { items: [], changed: false };
936
+ chunk = Buffer.alloc(size - this.offset);
937
+ const read = readSync(fd, chunk, 0, chunk.length, this.offset);
938
+ chunk = chunk.subarray(0, read);
939
+ this.offset += read;
940
+ }
941
+ catch {
942
+ return { items: [], changed: false };
943
+ }
944
+ finally {
945
+ if (fd !== undefined)
946
+ closeSync(fd);
947
+ }
948
+ const data = this.remainder.length ? Buffer.concat([this.remainder, chunk]) : chunk;
949
+ const end = data.lastIndexOf(0x0a) + 1;
950
+ this.remainder = Buffer.from(data.subarray(end));
951
+ if (end === 0)
952
+ return { items: [], changed: true };
953
+ let steps = parseTranscriptLines(data.subarray(0, end).toString("utf8").split("\n"), 0);
954
+ if (skipThrough !== undefined)
955
+ steps = steps.filter((entry) => entry.index > skipThrough);
956
+ const result = parseTranscriptSteps(steps, this.state);
957
+ return { ...result, changed: true };
958
+ }
959
+ }
960
+ // Every watcher tick and every send needs the connection; finding it spawns `ps`, so it is kept while it answers.
961
+ const LS_CACHE_MS = 15_000;
962
+ let lsCache;
963
+ let lsLookup;
964
+ /** Whether the desktop app's language server was found running within the last minute. */
965
+ function languageServerSeen() {
966
+ return Boolean(lsCache && Date.now() - lsCache.at < 60_000);
967
+ }
968
+ async function lsAnswers(conn) {
969
+ try {
970
+ const response = await fetch(`http://127.0.0.1:${conn.httpPort}/exa.language_server_pb.LanguageServerService/GetStatus`, {
971
+ method: "POST",
972
+ headers: { "Content-Type": "application/json", "x-codeium-csrf-token": conn.csrfToken },
973
+ body: "{}",
974
+ signal: AbortSignal.timeout(2000),
975
+ });
976
+ return response.ok;
662
977
  }
663
- if (!httpPort)
978
+ catch {
979
+ return false;
980
+ }
981
+ }
982
+ async function findRunningLanguageServer(desktop) {
983
+ if (!desktop?.logPath || !existsSync(desktop.logPath))
984
+ return undefined;
985
+ const logPath = desktop.logPath;
986
+ if (lsCache && lsCache.logPath === logPath && Date.now() - lsCache.at < LS_CACHE_MS)
987
+ return lsCache.conn;
988
+ lsLookup ??= lookupLanguageServer(desktop, logPath).finally(() => {
989
+ lsLookup = undefined;
990
+ });
991
+ return lsLookup;
992
+ }
993
+ async function lookupLanguageServer(desktop, logPath) {
994
+ // The log starts over with each launch; if it was appended to instead, the latest launch's port is the last one.
995
+ const ports = [...readHead(logPath, 64 * 1024).matchAll(/listening on \w+ port at (\d+) for HTTP\b/gi)].map((match) => Number(match[1]));
996
+ const httpPort = ports.at(-1);
997
+ if (!httpPort) {
998
+ lsCache = undefined;
664
999
  return undefined;
1000
+ }
1001
+ const tokens = [];
665
1002
  try {
666
1003
  const { stdout } = await execFileAsync("ps", ["-ax", "-o", "args="], { timeout: 3000 });
667
- for (const line of stdout.split("\n")) {
668
- if (!line.includes("language_server") || !line.includes("--csrf_token"))
669
- continue;
670
- const tokenMatch = /--csrf_token[=\s]+([^\s]+)/.exec(line);
671
- if (tokenMatch?.[1]) {
672
- return { httpPort, csrfToken: tokenMatch[1] };
673
- }
1004
+ const lines = stdout.split("\n").filter((line) => line.includes("language_server") && line.includes("--csrf_token"));
1005
+ // Another Antigravity product (the IDE) runs its own language_server: the desktop app's binary goes first.
1006
+ const own = (line) => (desktop.languageServerPath && line.includes(desktop.languageServerPath) ? 1 : 0);
1007
+ lines.sort((a, b) => own(b) - own(a));
1008
+ for (const line of lines) {
1009
+ const token = /--csrf_token[=\s]+([^\s]+)/.exec(line)?.[1];
1010
+ if (token && !tokens.includes(token))
1011
+ tokens.push(token);
674
1012
  }
675
1013
  }
676
1014
  catch {
677
- // ignore ps errors
1015
+ // ps unavailable: nothing to authenticate with
678
1016
  }
1017
+ for (const csrfToken of tokens) {
1018
+ const conn = { httpPort, csrfToken };
1019
+ if (tokens.length === 1 || (await lsAnswers(conn))) {
1020
+ lsCache = { logPath, at: Date.now(), conn };
1021
+ return conn;
1022
+ }
1023
+ }
1024
+ lsCache = undefined;
679
1025
  return undefined;
680
1026
  }
681
- async function callLanguageServer(conn, method, body) {
682
- const response = await fetch(`http://127.0.0.1:${conn.httpPort}/exa.language_server_pb.LanguageServerService/${method}`, {
683
- method: "POST",
684
- headers: {
685
- "Content-Type": "application/json",
686
- "x-codeium-csrf-token": conn.csrfToken,
687
- },
688
- body: JSON.stringify(body),
689
- signal: AbortSignal.timeout(10_000),
690
- });
1027
+ async function callLanguageServer(conn, method, body, timeoutMs = 10_000) {
1028
+ let response;
1029
+ try {
1030
+ response = await fetch(`http://127.0.0.1:${conn.httpPort}/exa.language_server_pb.LanguageServerService/${method}`, {
1031
+ method: "POST",
1032
+ headers: {
1033
+ "Content-Type": "application/json",
1034
+ "x-codeium-csrf-token": conn.csrfToken,
1035
+ },
1036
+ body: JSON.stringify(body),
1037
+ signal: AbortSignal.timeout(timeoutMs),
1038
+ });
1039
+ }
1040
+ catch (error) {
1041
+ // The app quit or restarted on another port: look it up again next time.
1042
+ lsCache = undefined;
1043
+ throw RpcError.app("agent_unavailable", `连不上 Antigravity 桌面端(${error instanceof Error ? error.message : String(error)}),请确认电脑上的 Antigravity 已打开`);
1044
+ }
691
1045
  if (!response.ok) {
1046
+ if (response.status === 401 || response.status === 403)
1047
+ lsCache = undefined;
692
1048
  const text = await response.text().catch(() => "");
693
- throw new Error(`Antigravity Desktop RPC ${method} failed (${response.status}): ${text.slice(0, 200)}`);
1049
+ let message = text.slice(0, 300);
1050
+ try {
1051
+ message = JSON.parse(text).message?.slice(0, 300) || message;
1052
+ }
1053
+ catch {
1054
+ // not a Connect error body
1055
+ }
1056
+ throw new Error(`Antigravity 桌面端接口 ${method} 调用失败(${response.status}):${message}`);
694
1057
  }
695
1058
  return (await response.json());
696
1059
  }
@@ -729,21 +1092,77 @@ function extForMimeType(mimeType) {
729
1092
  return "gif";
730
1093
  return "png";
731
1094
  }
1095
+ /** Teamwork orchestrators spawn workers that spawn reviewers; deeper than this is not followed. */
1096
+ const MAX_SUBAGENT_DEPTH = 4;
1097
+ function namespaced(update, prefix) {
1098
+ const next = { ...update };
1099
+ if (typeof next.messageId === "string")
1100
+ next.messageId = `${prefix}${next.messageId}`;
1101
+ if (typeof next.toolCallId === "string")
1102
+ next.toolCallId = `${prefix}${next.toolCallId}`;
1103
+ return next;
1104
+ }
1105
+ /** The sub-agents a conversation's items start: their conversation and the call (in `prefix`'s id space) that started each. */
1106
+ function spawnsIn(items, prefix) {
1107
+ const found = [];
1108
+ for (const item of items) {
1109
+ for (const update of item.updates) {
1110
+ if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update")
1111
+ continue;
1112
+ const detail = update.detail;
1113
+ if (detail?.type !== "subagent" || (detail.action ?? "spawn") !== "spawn" || !detail.nativeSessionId)
1114
+ continue;
1115
+ found.push({ childId: detail.nativeSessionId, toolCallId: `${prefix}${update.toolCallId}` });
1116
+ }
1117
+ }
1118
+ return found;
1119
+ }
1120
+ const RUNNING_STATUSES = new Set(["CASCADE_RUN_STATUS_RUNNING", "CASCADE_RUN_STATUS_BUSY", "CASCADE_RUN_STATUS_CANCELING"]);
1121
+ const GOAL_STOP = /^\/goal\s+(?:clear|stop|off|reset|none|cancel)\s*$/i;
1122
+ const SLASH = /^\/([A-Za-z0-9_.:-]+)(?:\s+([\s\S]*))?$/;
1123
+ function firstUserTitle(transcriptPath) {
1124
+ // Only the head: the first request is near the top and transcripts grow to tens of MB.
1125
+ for (const line of readHead(transcriptPath, 256 * 1024).split("\n")) {
1126
+ if (!line.includes("USER_INPUT"))
1127
+ continue;
1128
+ try {
1129
+ const step = JSON.parse(line);
1130
+ if (step.type !== "USER_INPUT" || typeof step.content !== "string")
1131
+ continue;
1132
+ const title = cleanAntigravityUserInput(step.content).split("\n")[0]?.slice(0, 100).trim();
1133
+ if (title)
1134
+ return title;
1135
+ }
1136
+ catch {
1137
+ // a line cut off by the head limit
1138
+ }
1139
+ }
1140
+ return undefined;
1141
+ }
732
1142
  /**
733
1143
  * Specialized driver for Google Antigravity:
734
1144
  * - Drives `agy_acp_server.par` / `agy-acp` over ACP when installed.
735
1145
  * - Also discovers and interacts with Antigravity Desktop (`/Applications/Antigravity.app`,
736
1146
  * `~/.gemini/antigravity/conversation_summaries.db`, `brain/<id>/.system_generated/logs/transcript_full.jsonl`,
737
1147
  * and its local `language_server` Connect-RPC service) so users with only the Desktop app
738
- * get full session visibility, history, Goal Mode, image messaging, and model selection.
1148
+ * get full session visibility, history, Goal Mode, slash commands and skills, image messaging,
1149
+ * user questions/permissions, model selection, steering, fork, rename, archive and delete.
739
1150
  */
740
1151
  export class AntigravityDriver extends AcpDriver {
741
1152
  desktopMode = false;
742
1153
  desktopSummaryTimer;
1154
+ /** The state last reported to the host for each desktop conversation. */
743
1155
  desktopSessionStates = new Map();
744
1156
  desktopWatchers = new Map();
1157
+ /** Conversations a device has open: their watchers outlive the turn. */
1158
+ watchedSessions = new Set();
745
1159
  desktopModelsCache;
746
1160
  desktopDefaultModel;
1161
+ desktopRowsCache;
1162
+ slashCache = new Map();
1163
+ generatorCache = new Map();
1164
+ archivedCache;
1165
+ untitledCache = new Map();
747
1166
  constructor(options) {
748
1167
  super(antigravitySpec(), options);
749
1168
  }
@@ -755,11 +1174,103 @@ export class AntigravityDriver extends AcpDriver {
755
1174
  ...resolved?.env,
756
1175
  };
757
1176
  }
1177
+ /**
1178
+ * The desktop app takes messages mid-turn (its message_delivery hook hands them
1179
+ * to the running turn), forks at any reply, and its transcript is the history.
1180
+ */
1181
+ get behavior() {
1182
+ if (!this.desktopMode)
1183
+ return super.behavior;
1184
+ return {
1185
+ desktop: this.spec.tier,
1186
+ history: "native-snapshot",
1187
+ turnCompletion: "native-events",
1188
+ busyInput: "concurrent-prompt",
1189
+ fork: "native-turn",
1190
+ detach: "local-only",
1191
+ };
1192
+ }
1193
+ desktop() {
1194
+ return resolveAntigravityDesktop(this.options.env ?? process.env);
1195
+ }
1196
+ async languageServer() {
1197
+ return findRunningLanguageServer(this.desktop());
1198
+ }
1199
+ async requireConnection(action = "继续") {
1200
+ const conn = await this.languageServer();
1201
+ if (!conn)
1202
+ throw RpcError.app("agent_unavailable", `请先在电脑上打开 Antigravity 桌面端应用,再${action}`);
1203
+ return conn;
1204
+ }
1205
+ isDesktopSession(nativeId) {
1206
+ if (this.desktopMode || this.desktopWatchers.has(nativeId))
1207
+ return true;
1208
+ const transcriptPath = this.transcriptPathFor(nativeId);
1209
+ return Boolean(transcriptPath && existsSync(transcriptPath));
1210
+ }
1211
+ /** Every conversation row, read at most every two seconds however many watchers ask. */
1212
+ desktopRows() {
1213
+ if (this.desktopRowsCache && Date.now() - this.desktopRowsCache.at < 2000)
1214
+ return this.desktopRowsCache.rows;
1215
+ const desktop = this.desktop();
1216
+ if (!desktop)
1217
+ return undefined;
1218
+ const dbPath = join(desktop.dataDir, "conversation_summaries.db");
1219
+ if (!existsSync(dbPath))
1220
+ return undefined;
1221
+ let db;
1222
+ try {
1223
+ db = new DatabaseSync(dbPath, { readOnly: true });
1224
+ const rows = db
1225
+ .prepare(`SELECT conversation_id, title, last_modified_time, workspace_uris, status, parent_conversation_id, killed FROM conversation_summaries`)
1226
+ .all();
1227
+ const map = new Map();
1228
+ for (const row of rows) {
1229
+ if (!row.conversation_id)
1230
+ continue;
1231
+ map.set(row.conversation_id, {
1232
+ id: row.conversation_id,
1233
+ title: row.title?.trim() ?? "",
1234
+ lastModified: Date.parse(row.last_modified_time) || 0,
1235
+ workspaceUris: row.workspace_uris,
1236
+ status: row.status ?? "",
1237
+ parent: row.parent_conversation_id ?? "",
1238
+ killed: Boolean(row.killed),
1239
+ });
1240
+ }
1241
+ this.desktopRowsCache = { at: Date.now(), rows: map };
1242
+ return map;
1243
+ }
1244
+ catch {
1245
+ // locked or mid-migration: keep the last good read
1246
+ return this.desktopRowsCache?.rows;
1247
+ }
1248
+ finally {
1249
+ try {
1250
+ db?.close();
1251
+ }
1252
+ catch {
1253
+ // ignore close errors
1254
+ }
1255
+ }
1256
+ }
1257
+ sessionExists(nativeId, _cwd) {
1258
+ const desktop = this.desktop();
1259
+ if (!desktop)
1260
+ return true;
1261
+ if (this.desktopWatchers.has(nativeId))
1262
+ return true;
1263
+ const brainDir = join(desktop.dataDir, "brain", nativeId);
1264
+ const row = this.desktopRows()?.get(nativeId);
1265
+ if (row)
1266
+ return !row.killed && existsSync(brainDir);
1267
+ return existsSync(brainDir);
1268
+ }
758
1269
  async authStatus() {
759
1270
  const base = await super.authStatus();
760
1271
  if (base.state !== "unknown")
761
1272
  return base;
762
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1273
+ const desktop = this.desktop();
763
1274
  if (desktop && (existsSync(join(desktop.dataDir, "antigravity_state.pbtxt")) || existsSync(join(desktop.dataDir, "conversation_summaries.db")))) {
764
1275
  return { state: "ok", method: "antigravity-desktop" };
765
1276
  }
@@ -781,6 +1292,35 @@ export class AntigravityDriver extends AcpDriver {
781
1292
  }
782
1293
  return undefined;
783
1294
  }
1295
+ /**
1296
+ * Reports conversations that started or stopped on the desktop. A running one
1297
+ * is followed (attached), and from then on its watcher reports its turns.
1298
+ */
1299
+ syncDesktopSummaries() {
1300
+ // Keeps languageServerSeen() current (cached: a lookup at most every 15 s).
1301
+ void this.languageServer();
1302
+ for (const s of this.listDesktopSessions(30)) {
1303
+ const watcher = this.desktopWatchers.get(s.nativeId);
1304
+ const next = s.state === "running" || s.state === "waiting" ? s.state : "idle";
1305
+ const prev = this.desktopSessionStates.get(s.nativeId);
1306
+ if (prev === next)
1307
+ continue;
1308
+ this.desktopSessionStates.set(s.nativeId, next);
1309
+ // The first look only records where things stand; an idle conversation has nothing to say.
1310
+ if (prev === undefined && next === "idle")
1311
+ continue;
1312
+ this.host?.sessionSeen(this.id, { ...s, state: next });
1313
+ if (watcher)
1314
+ continue;
1315
+ if (next !== "idle") {
1316
+ void this.host?.follow(this.id, s.nativeId).catch(() => { });
1317
+ }
1318
+ else {
1319
+ this.host?.update(this.id, s.nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "end_turn" });
1320
+ this.host?.update(this.id, s.nativeId, { sessionUpdate: "ls_status", state: "idle", turnActive: false });
1321
+ }
1322
+ }
1323
+ }
784
1324
  async boot() {
785
1325
  const env = this.options.env ?? process.env;
786
1326
  const resolved = resolveAntigravityLaunch(env);
@@ -796,31 +1336,15 @@ export class AntigravityDriver extends AcpDriver {
796
1336
  this.desktopMode = true;
797
1337
  this.capabilityState = {
798
1338
  ...this.capabilityState,
1339
+ permissions: true,
799
1340
  images: true,
800
1341
  models: true,
801
1342
  };
802
1343
  this.current = { installed: true, version: desktop.version };
803
1344
  if (!this.desktopSummaryTimer) {
804
1345
  this.desktopSummaryTimer = setInterval(() => {
805
- for (const s of this.listDesktopSessions(30)) {
806
- const nextState = s.state === "running" ? "running" : "idle";
807
- const prev = this.desktopSessionStates.get(s.nativeId);
808
- this.desktopSessionStates.set(s.nativeId, nextState);
809
- if (prev !== undefined && prev !== nextState) {
810
- if (nextState === "running") {
811
- this.host?.update(this.id, s.nativeId, { sessionUpdate: "ls_turn", state: "started" });
812
- }
813
- else {
814
- this.host?.update(this.id, s.nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "end_turn" });
815
- }
816
- this.host?.update(this.id, s.nativeId, {
817
- sessionUpdate: "ls_status",
818
- state: nextState,
819
- turnActive: nextState === "running",
820
- });
821
- }
822
- }
823
- }, 3000);
1346
+ this.syncDesktopSummaries();
1347
+ }, 2500);
824
1348
  this.desktopSummaryTimer.unref?.();
825
1349
  }
826
1350
  return;
@@ -837,8 +1361,21 @@ export class AntigravityDriver extends AcpDriver {
837
1361
  this.desktopWatchers.clear();
838
1362
  await super.stop();
839
1363
  }
1364
+ watched(nativeId, open) {
1365
+ if (open) {
1366
+ this.watchedSessions.add(nativeId);
1367
+ return;
1368
+ }
1369
+ this.watchedSessions.delete(nativeId);
1370
+ // The last device left an idle conversation: stop polling it until it runs or is opened again.
1371
+ const watcher = this.desktopWatchers.get(nativeId);
1372
+ if (watcher && !watcher.running && !watcher.pendingAction) {
1373
+ this.stopWatching(nativeId);
1374
+ this.host?.detached(this.id, nativeId);
1375
+ }
1376
+ }
840
1377
  transcriptPathFor(nativeId) {
841
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1378
+ const desktop = this.desktop();
842
1379
  if (!desktop)
843
1380
  return undefined;
844
1381
  return resolveDesktopTranscriptPath(desktop.dataDir, nativeId);
@@ -862,128 +1399,101 @@ export class AntigravityDriver extends AcpDriver {
862
1399
  // ignore state write errors
863
1400
  }
864
1401
  }
865
- isConversationRunningInDesktop(nativeId, statusCol) {
866
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1402
+ isConversationRunningInDesktop(nativeId) {
1403
+ const desktop = this.desktop();
867
1404
  if (!desktop)
868
1405
  return false;
869
- const lockPath = join(desktop.dataDir, "presence", `${nativeId}.lock`);
870
- if (!existsSync(lockPath))
1406
+ // With the app closed nothing runs, whatever status it saved last. (presence/<id>.lock only means a window
1407
+ // shows the conversation: sub-agents and conversations started from the phone never get one.)
1408
+ if (!languageServerSeen())
871
1409
  return false;
872
- let status = statusCol;
873
- if (status === undefined) {
874
- const dbPath = join(desktop.dataDir, "conversation_summaries.db");
875
- if (existsSync(dbPath)) {
876
- let db;
877
- try {
878
- db = new DatabaseSync(dbPath, { readOnly: true });
879
- const row = db
880
- .prepare("SELECT status FROM conversation_summaries WHERE conversation_id = ?")
881
- .get(nativeId);
882
- status = row?.status;
883
- }
884
- catch {
885
- // ignore
886
- }
887
- finally {
888
- try {
889
- db?.close();
890
- }
891
- catch {
892
- // ignore
893
- }
894
- }
895
- }
1410
+ const status = this.desktopRows()?.get(nativeId)?.status;
1411
+ if (status)
1412
+ return RUNNING_STATUSES.has(status);
1413
+ // No saved status yet (a brand-new conversation): a transcript still being written is a running one.
1414
+ const transcriptPath = resolveDesktopTranscriptPath(desktop.dataDir, nativeId);
1415
+ try {
1416
+ const ageMs = Date.now() - statSync(transcriptPath).mtimeMs;
1417
+ return ageMs >= 0 && ageMs < 15_000;
896
1418
  }
897
- return status === "CASCADE_RUN_STATUS_RUNNING";
898
- }
1419
+ catch {
1420
+ return false;
1421
+ }
1422
+ }
1423
+ untitledTitle(nativeId, transcriptPath) {
1424
+ try {
1425
+ const mtimeMs = statSync(transcriptPath).mtimeMs;
1426
+ const cached = this.untitledCache.get(nativeId);
1427
+ if (cached && (cached.title || cached.mtimeMs === mtimeMs))
1428
+ return cached.title;
1429
+ const title = firstUserTitle(transcriptPath);
1430
+ this.untitledCache.set(nativeId, { mtimeMs, title });
1431
+ return title;
1432
+ }
1433
+ catch {
1434
+ return undefined;
1435
+ }
1436
+ }
899
1437
  listDesktopSessions(limit) {
900
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1438
+ const desktop = this.desktop();
901
1439
  if (!desktop)
902
1440
  return [];
903
- const dbPath = join(desktop.dataDir, "conversation_summaries.db");
904
1441
  const home = (this.options.env ?? process.env).HOME || homedir();
905
1442
  const results = [];
906
- if (existsSync(dbPath)) {
907
- let db;
908
- try {
909
- db = new DatabaseSync(dbPath, { readOnly: true });
910
- const rows = db
911
- .prepare(`SELECT conversation_id, title, last_modified_time, workspace_uris, status, parent_conversation_id, killed
912
- FROM conversation_summaries
913
- WHERE (parent_conversation_id = '' OR parent_conversation_id IS NULL) AND killed = 0
914
- ORDER BY last_modified_time DESC
915
- LIMIT ?`)
916
- .all(limit);
917
- for (const row of rows) {
918
- if (!row.conversation_id)
919
- continue;
920
- const cwd = parseWorkspaceCwd(row.workspace_uris, home);
921
- const updatedAt = Date.parse(row.last_modified_time) || Date.now();
922
- let title = row.title?.trim() || undefined;
923
- let model;
924
- const transcriptPath = resolveDesktopTranscriptPath(desktop.dataDir, row.conversation_id);
925
- if (!title && existsSync(transcriptPath)) {
926
- const parsed = parseAntigravityTranscript(transcriptPath, 0);
927
- model = parsed.lastModelLabel;
928
- const firstUser = parsed.items.find((entry) => entry.updates.some((u) => u.sessionUpdate === "user_message_chunk"));
929
- const chunk = firstUser?.updates.find((u) => u.sessionUpdate === "user_message_chunk" && u.content.type === "text");
930
- if (chunk?.sessionUpdate === "user_message_chunk" && chunk.content.type === "text") {
931
- title = chunk.content.text.split("\n")[0]?.slice(0, 100).trim() || undefined;
932
- }
933
- }
934
- const running = this.isConversationRunningInDesktop(row.conversation_id, row.status);
935
- const state = running ? "running" : "idle";
936
- if (!this.desktopSessionStates.has(row.conversation_id)) {
937
- this.desktopSessionStates.set(row.conversation_id, state);
938
- }
939
- results.push({
940
- nativeId: row.conversation_id,
941
- cwd,
942
- title,
943
- ...(model ? { model } : {}),
944
- createdAt: updatedAt,
945
- updatedAt,
946
- state,
947
- });
948
- }
949
- }
950
- catch {
951
- // fallback to brain directory scan below if sqlite table is locked or unreadable
952
- }
953
- finally {
954
- try {
955
- db?.close();
956
- }
957
- catch {
958
- // ignore close errors
959
- }
1443
+ const rows = this.desktopRows();
1444
+ if (rows) {
1445
+ // Sub-agent conversations nest under their parent's tool calls rather than listing on their own.
1446
+ const top = [...rows.values()]
1447
+ .filter((row) => !row.parent && !row.killed)
1448
+ .sort((a, b) => b.lastModified - a.lastModified);
1449
+ for (const row of top) {
1450
+ if (results.length >= limit)
1451
+ break;
1452
+ const brainDir = join(desktop.dataDir, "brain", row.id);
1453
+ if (!existsSync(brainDir) && !this.desktopWatchers.has(row.id))
1454
+ continue;
1455
+ const cwd = parseWorkspaceCwd(row.workspaceUris, home);
1456
+ const updatedAt = row.lastModified || Date.now();
1457
+ const title = row.title || this.untitledTitle(row.id, resolveDesktopTranscriptPath(desktop.dataDir, row.id));
1458
+ const watcher = this.desktopWatchers.get(row.id);
1459
+ // A followed conversation's watcher knows best (it also covers a turn the phone just started).
1460
+ const state = watcher
1461
+ ? watcher.pendingAction ? "waiting" : watcher.running ? "running" : "idle"
1462
+ : this.isConversationRunningInDesktop(row.id)
1463
+ ? "running"
1464
+ : "idle";
1465
+ results.push({
1466
+ nativeId: row.id,
1467
+ cwd,
1468
+ ...(title ? { title } : {}),
1469
+ ...(watcher?.lastModelLabel ? { model: watcher.lastModelLabel } : {}),
1470
+ createdAt: updatedAt,
1471
+ updatedAt,
1472
+ state,
1473
+ });
960
1474
  }
1475
+ return results;
961
1476
  }
962
- if (results.length === 0 && existsSync(join(desktop.dataDir, "brain"))) {
1477
+ if (existsSync(join(desktop.dataDir, "brain"))) {
963
1478
  try {
964
- const dirs = readdirSync(join(desktop.dataDir, "brain"));
965
- for (const id of dirs) {
1479
+ const entries = [];
1480
+ for (const id of readdirSync(join(desktop.dataDir, "brain"))) {
966
1481
  const transcriptPath = resolveDesktopTranscriptPath(desktop.dataDir, id);
967
1482
  if (!existsSync(transcriptPath))
968
1483
  continue;
969
- const stat = statSync(transcriptPath);
970
- const parsed = parseAntigravityTranscript(transcriptPath, 0);
971
- const firstUser = parsed.items.find((entry) => entry.updates.some((u) => u.sessionUpdate === "user_message_chunk"));
972
- const chunk = firstUser?.updates.find((u) => u.sessionUpdate === "user_message_chunk" && u.content.type === "text");
973
- const title = chunk?.sessionUpdate === "user_message_chunk" && chunk.content.type === "text"
974
- ? chunk.content.text.split("\n")[0]?.slice(0, 100).trim()
975
- : undefined;
1484
+ entries.push({ id, path: transcriptPath, mtimeMs: statSync(transcriptPath).mtimeMs });
1485
+ }
1486
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
1487
+ for (const entry of entries.slice(0, limit)) {
1488
+ const title = this.untitledTitle(entry.id, entry.path);
976
1489
  results.push({
977
- nativeId: id,
1490
+ nativeId: entry.id,
978
1491
  cwd: home,
979
- title,
980
- ...(parsed.lastModelLabel ? { model: parsed.lastModelLabel } : {}),
981
- createdAt: stat.mtimeMs,
982
- updatedAt: stat.mtimeMs,
983
- state: "idle",
1492
+ ...(title ? { title } : {}),
1493
+ createdAt: entry.mtimeMs,
1494
+ updatedAt: entry.mtimeMs,
1495
+ state: this.isConversationRunningInDesktop(entry.id) ? "running" : "idle",
984
1496
  });
985
- if (results.length >= limit)
986
- break;
987
1497
  }
988
1498
  }
989
1499
  catch {
@@ -994,21 +1504,49 @@ export class AntigravityDriver extends AcpDriver {
994
1504
  }
995
1505
  async listSessions(limit) {
996
1506
  const acpSessions = !this.desktopMode && this.connection?.alive ? await super.listSessions(limit).catch(() => []) : [];
1507
+ await this.languageServer();
997
1508
  const desktopSessions = this.listDesktopSessions(limit);
998
1509
  const merged = new Map();
999
1510
  for (const session of [...acpSessions, ...desktopSessions]) {
1000
1511
  if (!merged.has(session.nativeId))
1001
1512
  merged.set(session.nativeId, session);
1002
1513
  }
1003
- return [...merged.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
1514
+ const sorted = [...merged.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
1515
+ for (const session of sorted) {
1516
+ // Running on the desktop before anyone opened it: follow it so its turn shows live.
1517
+ if ((session.state === "running" || session.state === "waiting") && !this.desktopWatchers.has(session.nativeId)) {
1518
+ void this.host?.follow(this.id, session.nativeId).catch(() => { });
1519
+ }
1520
+ }
1521
+ return sorted;
1522
+ }
1523
+ async archivedSessions() {
1524
+ if (!this.desktopMode)
1525
+ return [];
1526
+ if (this.archivedCache && Date.now() - this.archivedCache.at < 30_000)
1527
+ return this.archivedCache.ids;
1528
+ const conn = await this.languageServer();
1529
+ if (!conn)
1530
+ return this.archivedCache?.ids ?? [];
1531
+ try {
1532
+ const data = await callLanguageServer(conn, "GetAllCascadeTrajectories", {});
1533
+ const ids = Object.entries(data.trajectorySummaries ?? {})
1534
+ .filter(([, summary]) => summary.annotations?.archived === true)
1535
+ .map(([id]) => id);
1536
+ this.archivedCache = { at: Date.now(), ids };
1537
+ return ids;
1538
+ }
1539
+ catch {
1540
+ return this.archivedCache?.ids ?? [];
1541
+ }
1004
1542
  }
1005
1543
  async loadDesktopModels() {
1006
- if (this.desktopModelsCache?.length)
1007
- return this.desktopModelsCache;
1008
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1009
- const conn = await findRunningLanguageServer(desktop);
1544
+ // New models ship with app updates; a running host picks them up within ten minutes.
1545
+ if (this.desktopModelsCache?.models.length && Date.now() - this.desktopModelsCache.at < 10 * 60_000)
1546
+ return this.desktopModelsCache.models;
1547
+ const conn = await this.languageServer();
1010
1548
  if (!conn)
1011
- return [];
1549
+ return this.desktopModelsCache?.models ?? [];
1012
1550
  try {
1013
1551
  const data = await callLanguageServer(conn, "GetCascadeModelConfigData", {});
1014
1552
  const defaultOverride = data.defaultOverrideModelConfig?.modelOrAlias?.model ??
@@ -1042,13 +1580,76 @@ export class AntigravityDriver extends AcpDriver {
1042
1580
  });
1043
1581
  }
1044
1582
  if (models.length > 0)
1045
- this.desktopModelsCache = models;
1583
+ this.desktopModelsCache = { at: Date.now(), models };
1046
1584
  return models;
1047
1585
  }
1048
1586
  catch {
1049
- return [];
1587
+ return this.desktopModelsCache?.models ?? [];
1050
1588
  }
1051
1589
  }
1590
+ /**
1591
+ * The model and command auto-run policy the desktop app last used for a
1592
+ * conversation (or, for a new one, for the latest conversation): sending from
1593
+ * the phone must not quietly change either.
1594
+ */
1595
+ generatorInfo(conn, nativeId) {
1596
+ const sourceId = nativeId || this.latestConversationId();
1597
+ if (!sourceId)
1598
+ return Promise.resolve({});
1599
+ const cached = this.generatorCache.get(sourceId);
1600
+ if (cached && Date.now() - cached.at < 5 * 60_000)
1601
+ return cached.info;
1602
+ // The metadata holds every generation's full config (MBs for a long conversation): one request at a time per conversation.
1603
+ const info = this.fetchGeneratorInfo(conn, sourceId);
1604
+ this.generatorCache.set(sourceId, { at: Date.now(), info });
1605
+ return info;
1606
+ }
1607
+ async fetchGeneratorInfo(conn, sourceId) {
1608
+ try {
1609
+ const meta = await callLanguageServer(conn, "GetCascadeTrajectoryGeneratorMetadata", { cascadeId: sourceId }, 20_000);
1610
+ const entries = meta.generatorMetadata ?? [];
1611
+ for (let i = entries.length - 1; i >= 0; i--) {
1612
+ const planner = entries[i].plannerConfig;
1613
+ if (!planner)
1614
+ continue;
1615
+ return {
1616
+ model: planner.requestedModel?.model ?? planner.requestedModel?.alias ?? planner.planModel ?? entries[i].chatModel?.model,
1617
+ autoExecutionPolicy: planner.toolConfig?.runCommand?.autoCommandConfig?.autoExecutionPolicy,
1618
+ ...(typeof planner.useAiCredits === "boolean" ? { useAiCredits: planner.useAiCredits } : {}),
1619
+ };
1620
+ }
1621
+ }
1622
+ catch {
1623
+ // metadata unavailable: the language server's defaults apply
1624
+ }
1625
+ return {};
1626
+ }
1627
+ /** A conversation the desktop hasn't generated in yet takes the settings the user last used anywhere. */
1628
+ async inheritedSettings(conn, nativeId) {
1629
+ const own = await this.generatorInfo(conn, nativeId);
1630
+ if (own.autoExecutionPolicy)
1631
+ return own;
1632
+ const latest = this.latestConversationId(nativeId);
1633
+ const fallback = latest ? await this.generatorInfo(conn, latest) : {};
1634
+ return {
1635
+ model: own.model ?? fallback.model,
1636
+ autoExecutionPolicy: fallback.autoExecutionPolicy,
1637
+ useAiCredits: own.useAiCredits ?? fallback.useAiCredits,
1638
+ };
1639
+ }
1640
+ latestConversationId(exclude) {
1641
+ const rows = this.desktopRows();
1642
+ if (!rows)
1643
+ return undefined;
1644
+ let latest;
1645
+ for (const row of rows.values()) {
1646
+ if (row.parent || row.killed || row.id === exclude)
1647
+ continue;
1648
+ if (!latest || row.lastModified > latest.lastModified)
1649
+ latest = row;
1650
+ }
1651
+ return latest?.id;
1652
+ }
1052
1653
  async resolveSessionModel(nativeId, models, explicitModel) {
1053
1654
  const matchByValueOrName = (candidate) => {
1054
1655
  if (!candidate)
@@ -1056,37 +1657,23 @@ export class AntigravityDriver extends AcpDriver {
1056
1657
  return models.find((m) => m.value === candidate || m.name === candidate);
1057
1658
  };
1058
1659
  const watcher = this.desktopWatchers.get(nativeId);
1059
- const persistedSelected = this.host?.state(this.id, nativeId).get("agy-selected-model");
1660
+ const persistedSelected = nativeId ? this.host?.state(this.id, nativeId).get("agy-selected-model") : undefined;
1060
1661
  const chosen = matchByValueOrName(explicitModel) ??
1061
1662
  matchByValueOrName(watcher?.selectedModel) ??
1062
- matchByValueOrName(persistedSelected) ??
1063
- matchByValueOrName(watcher?.lastModelLabel);
1663
+ matchByValueOrName(watcher?.lastModelLabel) ??
1664
+ matchByValueOrName(persistedSelected);
1064
1665
  if (chosen)
1065
1666
  return chosen;
1066
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1067
- const conn = await findRunningLanguageServer(desktop);
1667
+ const conn = nativeId ? await this.languageServer() : undefined;
1068
1668
  if (conn) {
1069
- try {
1070
- const meta = await callLanguageServer(conn, "GetCascadeTrajectoryGeneratorMetadata", { cascadeId: nativeId });
1071
- const entries = meta.generatorMetadata ?? [];
1072
- for (let i = entries.length - 1; i >= 0; i--) {
1073
- const entry = entries[i];
1074
- const candidate = entry.plannerConfig?.requestedModel?.model ??
1075
- entry.plannerConfig?.requestedModel?.alias ??
1076
- entry.plannerConfig?.planModel ??
1077
- entry.chatModel?.model;
1078
- const found = matchByValueOrName(candidate);
1079
- if (found)
1080
- return found;
1081
- }
1082
- }
1083
- catch {
1084
- // ignore metadata RPC failure
1085
- }
1669
+ const found = matchByValueOrName((await this.generatorInfo(conn, nativeId)).model);
1670
+ if (found)
1671
+ return found;
1086
1672
  }
1087
1673
  const fromDefault = matchByValueOrName(this.desktopDefaultModel);
1088
1674
  if (fromDefault)
1089
1675
  return fromDefault;
1676
+ const desktop = this.desktop();
1090
1677
  if (desktop && existsSync(join(desktop.dataDir, "antigravity_state.pbtxt"))) {
1091
1678
  try {
1092
1679
  const stateText = readFileSync(join(desktop.dataDir, "antigravity_state.pbtxt"), "utf8");
@@ -1110,36 +1697,69 @@ export class AntigravityDriver extends AcpDriver {
1110
1697
  }
1111
1698
  if (explicitModel?.startsWith("MODEL_"))
1112
1699
  return explicitModel;
1113
- if (nativeId) {
1114
- try {
1115
- const meta = await callLanguageServer(conn, "GetCascadeTrajectoryGeneratorMetadata", { cascadeId: nativeId });
1116
- const entries = meta.generatorMetadata ?? [];
1117
- for (let i = entries.length - 1; i >= 0; i--) {
1118
- const entry = entries[i];
1119
- const candidate = entry.plannerConfig?.planModel ??
1120
- entry.plannerConfig?.requestedModel?.model ??
1121
- entry.chatModel?.model;
1122
- if (candidate?.startsWith("MODEL_"))
1123
- return candidate;
1124
- }
1125
- }
1126
- catch {
1127
- // ignore metadata RPC failure
1128
- }
1129
- }
1700
+ const fromMetadata = (await this.generatorInfo(conn, nativeId)).model;
1701
+ if (fromMetadata?.startsWith("MODEL_"))
1702
+ return fromMetadata;
1130
1703
  if (this.desktopDefaultModel?.startsWith("MODEL_"))
1131
1704
  return this.desktopDefaultModel;
1132
- return "MODEL_PLACEHOLDER_M418";
1705
+ const desktop = this.desktop();
1706
+ try {
1707
+ const stateText = desktop ? readFileSync(join(desktop.dataDir, "antigravity_state.pbtxt"), "utf8") : "";
1708
+ const lastSelected = /last_selected_agent_model:\s*(MODEL_\S+)/.exec(stateText)?.[1];
1709
+ if (lastSelected)
1710
+ return lastSelected;
1711
+ }
1712
+ catch {
1713
+ // no saved selection
1714
+ }
1715
+ throw RpcError.app("agent_unavailable", "没能确定 Antigravity 要用的模型,请在电脑上的 Antigravity 里选一次模型后重试");
1716
+ }
1717
+ /** The system commands and skills the desktop offers for this conversation (its workspace adds skills). */
1718
+ async slashCommands(conn, nativeId, planModel) {
1719
+ const key = nativeId || "";
1720
+ const cached = this.slashCache.get(key);
1721
+ if (cached && Date.now() - cached.at < 60_000)
1722
+ return cached.commands;
1723
+ try {
1724
+ const data = await callLanguageServer(conn, "GetSlashCommands", {
1725
+ ...(nativeId ? { cascadeId: nativeId } : {}),
1726
+ cascadeConfig: { plannerConfig: { planModel } },
1727
+ });
1728
+ const commands = (data.commands ?? []).flatMap((entry) => {
1729
+ const info = entry.info;
1730
+ const name = typeof info?.name === "string" ? info.name : undefined;
1731
+ if (!info || !name)
1732
+ return [];
1733
+ return [{ name, description: entry.description ?? "", type: typeof info.type === "string" ? info.type : undefined, info }];
1734
+ });
1735
+ if (commands.length > 0)
1736
+ this.slashCache.set(key, { at: Date.now(), commands });
1737
+ return commands;
1738
+ }
1739
+ catch {
1740
+ return cached?.commands ?? [];
1741
+ }
1133
1742
  }
1134
1743
  async emitDesktopConfig(nativeId, selectedModel) {
1135
- this.host?.update(this.id, nativeId, {
1136
- sessionUpdate: "available_commands_update",
1137
- availableCommands: ANTIGRAVITY_DESKTOP_COMMANDS,
1138
- });
1139
1744
  const models = await this.loadDesktopModels();
1140
- if (models.length === 0)
1745
+ const conn = await this.languageServer();
1746
+ let commands = ANTIGRAVITY_DESKTOP_COMMANDS;
1747
+ let matched;
1748
+ if (models.length > 0)
1749
+ matched = await this.resolveSessionModel(nativeId, models, selectedModel);
1750
+ if (conn && matched) {
1751
+ const live = await this.slashCommands(conn, nativeId, matched.value);
1752
+ if (live.length > 0) {
1753
+ commands = live.map((command) => {
1754
+ const known = SYSTEM_COMMAND_TEXT.get(command.name);
1755
+ const description = known?.description ?? command.description.split("\n")[0].slice(0, 160);
1756
+ return { name: command.name, description: description || command.name, ...(known && "hint" in known && known.hint ? { hint: known.hint } : {}) };
1757
+ });
1758
+ }
1759
+ }
1760
+ this.host?.update(this.id, nativeId, { sessionUpdate: "available_commands_update", availableCommands: commands });
1761
+ if (!matched)
1141
1762
  return;
1142
- const matched = await this.resolveSessionModel(nativeId, models, selectedModel);
1143
1763
  const options = [
1144
1764
  {
1145
1765
  id: "model",
@@ -1152,111 +1772,419 @@ export class AntigravityDriver extends AcpDriver {
1152
1772
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
1153
1773
  this.host?.update(this.id, nativeId, { sessionUpdate: "session_info_update", model: matched.name });
1154
1774
  }
1155
- startWatchingDesktopTranscript(nativeId, initialLine, initialMeta) {
1156
- const existing = this.desktopWatchers.get(nativeId);
1157
- if (existing)
1158
- clearInterval(existing.timer);
1159
- const activePath = this.transcriptPathFor(nativeId);
1160
- const lastSize = activePath && existsSync(activePath) ? statSync(activePath).size : 0;
1161
- const initialRunning = this.isConversationRunningInDesktop(nativeId);
1162
- this.desktopSessionStates.set(nativeId, initialRunning ? "running" : "idle");
1163
- const watcher = {
1164
- nextLine: initialLine,
1165
- lastSize,
1166
- running: initialRunning,
1167
- detached: false,
1168
- selectedModel: existing?.selectedModel ?? this.host?.state(this.id, nativeId).get("agy-selected-model") ?? undefined,
1169
- lastModelLabel: initialMeta?.lastModelLabel ?? existing?.lastModelLabel,
1170
- goal: initialMeta?.goal !== undefined ? initialMeta.goal : existing?.goal,
1171
- pendingClientMessages: existing?.pendingClientMessages ?? [],
1172
- timer: setInterval(() => {
1173
- const currentPath = this.transcriptPathFor(nativeId);
1174
- if (currentPath && existsSync(currentPath)) {
1175
- try {
1176
- const stat = statSync(currentPath);
1177
- if (stat.size !== watcher.lastSize) {
1178
- watcher.lastSize = stat.size;
1179
- const parsed = parseAntigravityTranscript(currentPath, watcher.nextLine);
1180
- watcher.nextLine = parsed.nextLine;
1181
- if (parsed.lastModelLabel && parsed.lastModelLabel !== watcher.lastModelLabel) {
1182
- watcher.lastModelLabel = parsed.lastModelLabel;
1183
- watcher.selectedModel = parsed.lastModelLabel;
1184
- this.host?.state(this.id, nativeId).set("agy-selected-model", parsed.lastModelLabel);
1185
- void this.emitDesktopConfig(nativeId, parsed.lastModelLabel);
1775
+ emitDesktopPendingAction(nativeId, watcher, action) {
1776
+ watcher.pendingAction = action;
1777
+ this.desktopSessionStates.set(nativeId, "waiting");
1778
+ if (action.kind === "question") {
1779
+ const questions = action.questions.map((q, idx) => ({
1780
+ id: `q_${idx}`,
1781
+ text: q.question,
1782
+ kind: q.options?.length ? (q.isMultiSelect ? "choices" : "choice") : "text",
1783
+ ...(q.options?.length
1784
+ ? {
1785
+ options: q.options.map((o) => ({ value: o.id, label: o.text })),
1786
+ other: true,
1787
+ }
1788
+ : {}),
1789
+ }));
1790
+ this.host?.update(this.id, nativeId, {
1791
+ sessionUpdate: "ls_permission",
1792
+ requestId: action.requestId,
1793
+ toolCallId: action.toolCallId,
1794
+ title: action.title,
1795
+ options: QUESTION_OPTIONS,
1796
+ questions,
1797
+ });
1798
+ }
1799
+ else {
1800
+ this.host?.update(this.id, nativeId, {
1801
+ sessionUpdate: "ls_permission",
1802
+ requestId: action.requestId,
1803
+ toolCallId: action.toolCallId,
1804
+ title: action.title,
1805
+ ...(action.proposedCommandLine ? { detail: action.proposedCommandLine } : {}),
1806
+ tool: {
1807
+ title: action.title,
1808
+ kind: action.interactionType === "runCommand" ? "execute" : action.interactionType === "filePermission" ? "edit" : "other",
1809
+ ...(action.proposedCommandLine ? { rawInput: { command: action.proposedCommandLine } } : {}),
1810
+ ...(action.absolutePathUri ? { locations: [{ path: action.absolutePathUri }] } : {}),
1811
+ },
1812
+ options: [
1813
+ { optionId: "allow", name: "允许", kind: "allow_once" },
1814
+ { optionId: "reject", name: "拒绝", kind: "reject_once" },
1815
+ ],
1816
+ });
1817
+ }
1818
+ this.host?.update(this.id, nativeId, {
1819
+ sessionUpdate: "ls_status",
1820
+ state: "waiting",
1821
+ turnActive: true,
1822
+ });
1823
+ }
1824
+ clearDesktopPendingAction(nativeId, watcher, resolution) {
1825
+ const prev = watcher.pendingAction;
1826
+ if (!prev)
1827
+ return;
1828
+ watcher.pendingAction = undefined;
1829
+ watcher.resolvedStepIndices.add(prev.stepIndex);
1830
+ this.host?.update(this.id, nativeId, {
1831
+ sessionUpdate: "ls_permission_resolved",
1832
+ requestId: prev.requestId,
1833
+ ...(resolution?.optionId ? { optionId: resolution.optionId } : {}),
1834
+ ...(resolution?.answers ? { answers: resolution.answers } : {}),
1835
+ });
1836
+ const nowRunning = this.isConversationRunningInDesktop(nativeId);
1837
+ const nextState = nowRunning ? "running" : "idle";
1838
+ this.desktopSessionStates.set(nativeId, nextState);
1839
+ this.host?.update(this.id, nativeId, {
1840
+ sessionUpdate: "ls_status",
1841
+ state: nextState,
1842
+ turnActive: nowRunning,
1843
+ });
1844
+ }
1845
+ async pollDesktopInteractions(nativeId, watcher) {
1846
+ if (watcher.pollingInteractions)
1847
+ return;
1848
+ watcher.pollingInteractions = true;
1849
+ const transcriptPendingQuestion = watcher.transcriptPending;
1850
+ try {
1851
+ const conn = await this.languageServer();
1852
+ let foundAction;
1853
+ if (conn) {
1854
+ // Steps carry their full content (~20 KB each): ask only for the newest few, where a waiting one would be.
1855
+ const stepOffset = Math.max(0, watcher.lastStepIndex - 4);
1856
+ try {
1857
+ const resp = await callLanguageServer(conn, "GetCascadeTrajectorySteps", { cascadeId: nativeId, stepOffset });
1858
+ const steps = resp.steps ?? [];
1859
+ for (let idx = 0; idx < steps.length; idx++) {
1860
+ const step = steps[idx];
1861
+ const info = step.metadata?.sourceTrajectoryStepInfo;
1862
+ if (info?.trajectoryId)
1863
+ watcher.trajectoryId = info.trajectoryId;
1864
+ const stepIndex = info?.stepIndex ?? stepOffset + idx;
1865
+ if (watcher.resolvedStepIndices.has(stepIndex))
1866
+ continue;
1867
+ const isWaiting = step.status === "CORTEX_STEP_STATUS_WAITING" ||
1868
+ step.status === "CORTEX_STEP_STATUS_PENDING" ||
1869
+ (step.type === "CORTEX_STEP_TYPE_ASK_QUESTION" &&
1870
+ step.status !== "CORTEX_STEP_STATUS_DONE" &&
1871
+ step.status !== "CORTEX_STEP_STATUS_CANCELED" &&
1872
+ step.status !== "CORTEX_STEP_STATUS_ERROR" &&
1873
+ !step.askQuestion?.userResponse &&
1874
+ !(Array.isArray(step.completedInteractions) && step.completedInteractions.length > 0));
1875
+ if (!isWaiting)
1876
+ continue;
1877
+ if (step.type === "CORTEX_STEP_TYPE_ASK_QUESTION" || step.requestedInteraction?.askQuestion) {
1878
+ const rawQuestions = step.askQuestion?.questions ??
1879
+ step.requestedInteraction?.askQuestion?.questions;
1880
+ const askEntries = parseAskQuestionEntries({ questions: rawQuestions });
1881
+ const questions = askEntries.length > 0 ? askEntries : transcriptPendingQuestion?.questions ?? [];
1882
+ if (questions.length > 0) {
1883
+ foundAction = {
1884
+ kind: "question",
1885
+ requestId: `agy-ask:${nativeId}:${stepIndex}`,
1886
+ toolCallId: step.metadata?.toolCall?.id ||
1887
+ transcriptPendingQuestion?.toolCallId ||
1888
+ `agy:tc:${Math.max(0, stepIndex - 1)}:0`,
1889
+ stepIndex,
1890
+ trajectoryId: info?.trajectoryId ?? watcher.trajectoryId,
1891
+ title: transcriptPendingQuestion?.title || questions[0].question.slice(0, 80) || "Antigravity 有问题需要你回答",
1892
+ questions,
1893
+ };
1894
+ }
1895
+ }
1896
+ else if (step.requestedInteraction && (info?.trajectoryId || watcher.trajectoryId)) {
1897
+ const req = step.requestedInteraction;
1898
+ const trajectoryId = (info?.trajectoryId ?? watcher.trajectoryId);
1899
+ const toolCallId = step.metadata?.toolCall?.id || `agy:perm:${stepIndex}`;
1900
+ const base = { kind: "permission", requestId: `agy-perm:${nativeId}:${stepIndex}`, toolCallId, stepIndex, trajectoryId };
1901
+ if (req.runCommand) {
1902
+ const cmd = req.runCommand.proposedCommandLine || req.runCommand.commandLine || step.runCommand?.commandLine || "";
1903
+ foundAction = { ...base, title: cmd ? `确认执行命令:${cmd}` : "确认执行命令", interactionType: "runCommand", proposedCommandLine: cmd };
1904
+ }
1905
+ else if (req.filePermission) {
1906
+ const pathUri = req.filePermission.absolutePathUri || "";
1907
+ foundAction = { ...base, title: pathUri ? `允许访问文件:${pathUri}` : "允许访问文件", interactionType: "filePermission", absolutePathUri: pathUri };
1908
+ }
1909
+ else if (req.mcp) {
1910
+ foundAction = { ...base, title: "允许执行 MCP 工具调用", interactionType: "mcp" };
1911
+ }
1912
+ else if (req.openBrowserUrl) {
1913
+ foundAction = { ...base, title: req.openBrowserUrl.url ? `允许打开链接:${req.openBrowserUrl.url}` : "允许打开浏览器链接", interactionType: "openBrowserUrl" };
1914
+ }
1915
+ else if (req.readUrlContent) {
1916
+ foundAction = { ...base, title: req.readUrlContent.url ? `允许读取网页:${req.readUrlContent.url}` : "允许读取网页内容", interactionType: "readUrlContent" };
1186
1917
  }
1187
- if (parsed.goal !== undefined) {
1188
- watcher.goal = parsed.goal;
1918
+ else if (req.approvalInteraction) {
1919
+ foundAction = { ...base, title: "需要你的确认以继续执行", interactionType: "approvalInteraction" };
1189
1920
  }
1190
- for (const item of parsed.items) {
1191
- if (item.itemId.startsWith("agy:user:") && watcher.pendingClientMessages.length > 0) {
1192
- const pending = watcher.pendingClientMessages.shift();
1193
- const aliases = this.desktopAliasesFor(nativeId);
1194
- aliases.set(item.itemId, `local-${pending.clientMessageId}`);
1195
- this.saveDesktopAliases(nativeId, aliases);
1196
- // Emit only non-user_message_chunk updates (such as ls_goal) since the user message was already echoed with local-${clientMessageId}.
1197
- const nonUserUpdates = item.updates.filter((u) => u.sessionUpdate !== "user_message_chunk");
1198
- for (const update of nonUserUpdates) {
1199
- this.host?.update(this.id, nativeId, update);
1200
- }
1201
- continue;
1202
- }
1203
- item.updates.forEach((update, idx) => {
1204
- this.host?.update(this.id, nativeId, update, idx === item.updates.length - 1 ? item.itemId : undefined);
1205
- });
1921
+ else if (req.permission) {
1922
+ foundAction = { ...base, title: "需要你的权限确认", interactionType: "permission" };
1206
1923
  }
1207
1924
  }
1208
1925
  }
1209
- catch {
1210
- // ignore stat/read errors
1211
- }
1212
1926
  }
1213
- const nowRunning = this.isConversationRunningInDesktop(nativeId);
1214
- if (nowRunning !== watcher.running) {
1215
- watcher.running = nowRunning;
1216
- this.desktopSessionStates.set(nativeId, nowRunning ? "running" : "idle");
1217
- if (nowRunning) {
1218
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "started" });
1219
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "running", turnActive: true });
1927
+ catch {
1928
+ // ignore GetCascadeTrajectorySteps failure and fall back to transcript question detection below
1929
+ }
1930
+ }
1931
+ if (!foundAction && transcriptPendingQuestion && !watcher.resolvedStepIndices.has(transcriptPendingQuestion.stepIndex)) {
1932
+ foundAction = {
1933
+ kind: "question",
1934
+ requestId: `agy-ask:${nativeId}:${transcriptPendingQuestion.stepIndex}`,
1935
+ toolCallId: transcriptPendingQuestion.toolCallId,
1936
+ stepIndex: transcriptPendingQuestion.stepIndex,
1937
+ trajectoryId: watcher.trajectoryId,
1938
+ title: transcriptPendingQuestion.title,
1939
+ questions: transcriptPendingQuestion.questions,
1940
+ };
1941
+ }
1942
+ if (foundAction) {
1943
+ if (watcher.pendingAction?.requestId !== foundAction.requestId) {
1944
+ if (watcher.pendingAction) {
1945
+ this.clearDesktopPendingAction(nativeId, watcher);
1220
1946
  }
1221
- else {
1222
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "end_turn" });
1223
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "idle", turnActive: false });
1224
- if (watcher.detached) {
1225
- clearInterval(watcher.timer);
1226
- this.desktopWatchers.delete(nativeId);
1227
- }
1947
+ this.emitDesktopPendingAction(nativeId, watcher, foundAction);
1948
+ }
1949
+ else if (foundAction.trajectoryId && !watcher.pendingAction.trajectoryId) {
1950
+ watcher.pendingAction.trajectoryId = foundAction.trajectoryId;
1951
+ }
1952
+ }
1953
+ else if (watcher.pendingAction) {
1954
+ // Answered on the desktop, or the turn was stopped there.
1955
+ this.clearDesktopPendingAction(nativeId, watcher);
1956
+ }
1957
+ }
1958
+ finally {
1959
+ watcher.pollingInteractions = false;
1960
+ }
1961
+ }
1962
+ emitTranscriptItems(nativeId, watcher, items) {
1963
+ for (const item of items) {
1964
+ if (item.itemId.startsWith("agy:user:") && watcher.pendingClientMessages.length > 0) {
1965
+ const pending = watcher.pendingClientMessages.shift();
1966
+ const aliases = this.desktopAliasesFor(nativeId);
1967
+ aliases.set(item.itemId, `local-${pending.clientMessageId}`);
1968
+ this.saveDesktopAliases(nativeId, aliases);
1969
+ // The phone already shows this message (echoed as local-<clientMessageId>); only what came with it (ls_goal) is new.
1970
+ for (const update of item.updates.filter((u) => u.sessionUpdate !== "user_message_chunk")) {
1971
+ this.host?.update(this.id, nativeId, update);
1972
+ }
1973
+ continue;
1974
+ }
1975
+ item.updates.forEach((update, idx) => {
1976
+ this.host?.update(this.id, nativeId, update, idx === item.updates.length - 1 ? item.itemId : undefined);
1977
+ });
1978
+ }
1979
+ }
1980
+ /** A sub-agent's items as the parent session carries them: nested under the spawning call, with ids of their own. */
1981
+ nestChildItems(items, follow) {
1982
+ return items.flatMap((item) => {
1983
+ const updates = item.updates.flatMap((update) => nestUnder(namespaced(update, follow.prefix), follow.parentToolCallId) ?? []);
1984
+ return updates.length > 0 ? [{ itemId: `sub:${follow.parentToolCallId}:${item.itemId}`, updates }] : [];
1985
+ });
1986
+ }
1987
+ childTurn(follow, running) {
1988
+ return {
1989
+ // Keyed by where the sub-agent's transcript stood, so attaching again doesn't record the same start or end twice.
1990
+ itemId: `sub:${follow.parentToolCallId}:turn:${running ? "started" : "ended"}:${follow.tail?.state.lastStepIndex ?? -1}`,
1991
+ updates: [{ sessionUpdate: "ls_turn", state: running ? "started" : "ended", ...(running ? {} : { stopReason: "end_turn" }), parentToolCallId: follow.parentToolCallId }],
1992
+ };
1993
+ }
1994
+ /**
1995
+ * Starts following the sub-agents `items` spawned. With `collect`, what each
1996
+ * has done so far is added there (history, or a sub-agent just seen live);
1997
+ * without, only what it does from now on is shown.
1998
+ */
1999
+ followChildren(watcher, items, prefix, depth, collect) {
2000
+ if (depth >= MAX_SUBAGENT_DEPTH)
2001
+ return;
2002
+ for (const spawn of spawnsIn(items, prefix)) {
2003
+ if (watcher.children.has(spawn.childId))
2004
+ continue;
2005
+ const follow = {
2006
+ id: spawn.childId,
2007
+ parentToolCallId: spawn.toolCallId,
2008
+ prefix: `${spawn.childId.slice(0, 8)}/`,
2009
+ depth: depth + 1,
2010
+ running: false,
2011
+ };
2012
+ watcher.children.set(spawn.childId, follow);
2013
+ const path = this.transcriptPathFor(spawn.childId);
2014
+ if (!path || !existsSync(path))
2015
+ continue;
2016
+ const parsed = parseAntigravityTranscript(path, 0);
2017
+ follow.tail = new TranscriptTail(path, parsed.state, parsed.endOffset);
2018
+ follow.running = this.isConversationRunningInDesktop(spawn.childId);
2019
+ if (collect) {
2020
+ collect.push(...this.nestChildItems(parsed.items, follow), this.childTurn(follow, follow.running));
2021
+ }
2022
+ this.followChildren(watcher, parsed.items, follow.prefix, follow.depth, collect);
2023
+ }
2024
+ }
2025
+ /** New work from every followed sub-agent, and when each starts or stops. */
2026
+ tickChildren(nativeId, watcher) {
2027
+ for (const follow of [...watcher.children.values()]) {
2028
+ const path = this.transcriptPathFor(follow.id);
2029
+ const out = [];
2030
+ if (path && existsSync(path)) {
2031
+ // A sub-agent whose transcript appeared after its spawn was seen: everything in it is new.
2032
+ follow.tail ??= new TranscriptTail(path, newTranscriptParseState(), 0);
2033
+ try {
2034
+ const read = follow.tail.read(path);
2035
+ if (read.items.length > 0) {
2036
+ out.push(...this.nestChildItems(read.items, follow));
2037
+ this.followChildren(watcher, read.items, follow.prefix, follow.depth, out);
1228
2038
  }
1229
2039
  }
1230
- }, 1500),
2040
+ catch {
2041
+ // ignore unreadable sub-agent transcript
2042
+ }
2043
+ }
2044
+ const running = this.isConversationRunningInDesktop(follow.id);
2045
+ if (running !== follow.running) {
2046
+ follow.running = running;
2047
+ out.push(this.childTurn(follow, running));
2048
+ }
2049
+ for (const item of out) {
2050
+ item.updates.forEach((update, idx) => {
2051
+ this.host?.update(this.id, nativeId, update, idx === item.updates.length - 1 ? item.itemId : undefined);
2052
+ });
2053
+ }
2054
+ }
2055
+ }
2056
+ stopWatching(nativeId) {
2057
+ const watcher = this.desktopWatchers.get(nativeId);
2058
+ if (!watcher)
2059
+ return;
2060
+ clearInterval(watcher.timer);
2061
+ this.desktopWatchers.delete(nativeId);
2062
+ }
2063
+ startWatchingDesktopTranscript(nativeId, initial) {
2064
+ const existing = this.desktopWatchers.get(nativeId);
2065
+ if (existing)
2066
+ clearInterval(existing.timer);
2067
+ const path = initial?.path ?? this.transcriptPathFor(nativeId) ?? "";
2068
+ const state = initial?.state ?? newTranscriptParseState();
2069
+ const initialRunning = this.isConversationRunningInDesktop(nativeId);
2070
+ const watcher = {
2071
+ tail: new TranscriptTail(path, state, initial?.offset ?? 0),
2072
+ lastStepIndex: state.lastStepIndex ?? existing?.lastStepIndex ?? 0,
2073
+ running: initialRunning,
2074
+ detached: false,
2075
+ pollingInteractions: false,
2076
+ trajectoryId: existing?.trajectoryId,
2077
+ pendingAction: existing?.pendingAction,
2078
+ transcriptPending: initial?.pendingAskQuestion,
2079
+ resolvedStepIndices: existing?.resolvedStepIndices ?? new Set(),
2080
+ selectedModel: existing?.selectedModel ?? this.host?.state(this.id, nativeId).get("agy-selected-model") ?? undefined,
2081
+ lastModelLabel: state.lastModelLabel ?? existing?.lastModelLabel,
2082
+ goal: initial ? state.goal : existing?.goal,
2083
+ pendingClientMessages: existing?.pendingClientMessages ?? [],
2084
+ children: new Map(),
2085
+ timer: setInterval(() => this.tickWatcher(nativeId, watcher), 1500),
1231
2086
  };
1232
2087
  watcher.timer.unref?.();
1233
2088
  this.desktopWatchers.set(nativeId, watcher);
2089
+ this.desktopSessionStates.set(nativeId, watcher.pendingAction ? "waiting" : initialRunning ? "running" : "idle");
2090
+ if (initialRunning || watcher.transcriptPending)
2091
+ void this.pollDesktopInteractions(nativeId, watcher);
1234
2092
  return watcher;
1235
2093
  }
2094
+ tickWatcher(nativeId, watcher) {
2095
+ void this.languageServer();
2096
+ let transcriptChanged = false;
2097
+ const currentPath = this.transcriptPathFor(nativeId);
2098
+ if (currentPath && existsSync(currentPath)) {
2099
+ try {
2100
+ const read = watcher.tail.read(currentPath);
2101
+ transcriptChanged = read.changed;
2102
+ const state = watcher.tail.state;
2103
+ if (state.lastStepIndex !== undefined)
2104
+ watcher.lastStepIndex = state.lastStepIndex;
2105
+ if (read.items.length > 0) {
2106
+ watcher.transcriptPending = read.pendingAskQuestion;
2107
+ watcher.goal = state.goal;
2108
+ if (state.lastModelLabel && state.lastModelLabel !== watcher.lastModelLabel) {
2109
+ watcher.lastModelLabel = state.lastModelLabel;
2110
+ watcher.selectedModel = state.lastModelLabel;
2111
+ this.host?.state(this.id, nativeId).set("agy-selected-model", state.lastModelLabel);
2112
+ void this.emitDesktopConfig(nativeId, state.lastModelLabel).catch(() => { });
2113
+ }
2114
+ this.emitTranscriptItems(nativeId, watcher, read.items);
2115
+ const spawned = [];
2116
+ this.followChildren(watcher, read.items, "", 0, spawned);
2117
+ this.emitTranscriptItems(nativeId, watcher, spawned);
2118
+ }
2119
+ }
2120
+ catch {
2121
+ // ignore stat/read errors
2122
+ }
2123
+ }
2124
+ this.tickChildren(nativeId, watcher);
2125
+ let nowRunning = this.isConversationRunningInDesktop(nativeId);
2126
+ if (nowRunning) {
2127
+ watcher.sentAt = undefined;
2128
+ }
2129
+ else if (watcher.sentAt && Date.now() - watcher.sentAt < 15_000) {
2130
+ // Sent from the phone a moment ago: the desktop records the run a little later, so this isn't the end of it.
2131
+ nowRunning = true;
2132
+ }
2133
+ if (nowRunning || watcher.pendingAction || transcriptChanged || watcher.transcriptPending) {
2134
+ void this.pollDesktopInteractions(nativeId, watcher);
2135
+ }
2136
+ if (nowRunning === watcher.running)
2137
+ return;
2138
+ watcher.running = nowRunning;
2139
+ const nextState = watcher.pendingAction ? "waiting" : nowRunning ? "running" : "idle";
2140
+ this.desktopSessionStates.set(nativeId, nextState);
2141
+ if (nowRunning) {
2142
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "started" });
2143
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: nextState, turnActive: true });
2144
+ return;
2145
+ }
2146
+ if (watcher.pendingAction)
2147
+ return;
2148
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "end_turn" });
2149
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "idle", turnActive: false });
2150
+ // Followed only for its turn and nobody is looking: let go, and attach again on next use.
2151
+ if (watcher.detached || !this.watchedSessions.has(nativeId)) {
2152
+ this.stopWatching(nativeId);
2153
+ this.host?.detached(this.id, nativeId);
2154
+ }
2155
+ }
1236
2156
  async attach(nativeId, context) {
1237
2157
  const transcriptPath = this.transcriptPathFor(nativeId);
1238
2158
  if (this.desktopMode || (transcriptPath && existsSync(transcriptPath))) {
1239
2159
  await this.ensureStarted();
1240
- const parsed = transcriptPath ? parseAntigravityTranscript(transcriptPath, 0) : { items: [], nextLine: 0 };
2160
+ const parsed = transcriptPath ? parseAntigravityTranscript(transcriptPath, 0) : undefined;
1241
2161
  const aliases = this.desktopAliasesFor(nativeId);
1242
- const items = parsed.items.map((item) => {
2162
+ const items = (parsed?.items ?? []).map((item) => {
1243
2163
  const aliasedId = aliases.get(item.itemId);
1244
2164
  return aliasedId ? { ...item, itemId: aliasedId } : item;
1245
2165
  });
1246
- this.startWatchingDesktopTranscript(nativeId, parsed.nextLine, {
1247
- lastModelLabel: parsed.lastModelLabel,
1248
- goal: parsed.goal,
1249
- });
1250
- const running = this.isConversationRunningInDesktop(nativeId);
1251
- this.desktopSessionStates.set(nativeId, running ? "running" : "idle");
1252
- if (!running) {
2166
+ const watcher = this.startWatchingDesktopTranscript(nativeId, parsed && transcriptPath
2167
+ ? { path: transcriptPath, state: parsed.state, offset: parsed.endOffset, pendingAskQuestion: parsed.pendingAskQuestion }
2168
+ : undefined);
2169
+ // Sub-agents' work so far, after the parent's (whose spawning calls it nests under).
2170
+ const childItems = [];
2171
+ this.followChildren(watcher, parsed?.items ?? [], "", 0, childItems);
2172
+ items.push(...childItems);
2173
+ const running = watcher.running;
2174
+ const state = watcher.pendingAction ? "waiting" : running ? "running" : "idle";
2175
+ this.desktopSessionStates.set(nativeId, state);
2176
+ if (state === "idle") {
1253
2177
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "end_turn" });
1254
2178
  }
1255
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: running ? "running" : "idle", turnActive: running });
1256
- if (parsed.goal !== undefined) {
2179
+ this.host?.update(this.id, nativeId, {
2180
+ sessionUpdate: "ls_status",
2181
+ state,
2182
+ turnActive: state === "running" || state === "waiting",
2183
+ });
2184
+ if (parsed?.goal !== undefined) {
1257
2185
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: parsed.goal });
1258
2186
  }
1259
- void this.emitDesktopConfig(nativeId, this.desktopWatchers.get(nativeId)?.selectedModel);
2187
+ void this.emitDesktopConfig(nativeId, watcher.selectedModel).catch(() => { });
1260
2188
  return items;
1261
2189
  }
1262
2190
  return super.attach(nativeId, context);
@@ -1264,26 +2192,155 @@ export class AntigravityDriver extends AcpDriver {
1264
2192
  async detach(nativeId) {
1265
2193
  const watcher = this.desktopWatchers.get(nativeId);
1266
2194
  if (watcher) {
1267
- if (watcher.running) {
1268
- // Keep watching until the active turn completes so turn end and final messages are recorded.
2195
+ if (watcher.running || watcher.pendingAction) {
2196
+ // Keep watching until the active turn completes so turn end, pending questions, and final messages are recorded.
1269
2197
  watcher.detached = true;
1270
2198
  }
1271
2199
  else {
1272
- clearInterval(watcher.timer);
1273
- this.desktopWatchers.delete(nativeId);
2200
+ this.stopWatching(nativeId);
1274
2201
  }
1275
2202
  }
1276
2203
  await super.detach(nativeId);
1277
2204
  }
2205
+ async resolveDesktopTrajectory(conn, nativeId, watcher, fallbackStepIndex) {
2206
+ let trajectoryId = watcher.pendingAction?.trajectoryId ?? watcher.trajectoryId;
2207
+ let stepIndex = fallbackStepIndex;
2208
+ if (!trajectoryId) {
2209
+ const resp = await callLanguageServer(conn, "GetCascadeTrajectorySteps", { cascadeId: nativeId, stepOffset: Math.max(0, watcher.lastStepIndex - 10) });
2210
+ for (let idx = 0; idx < (resp.steps ?? []).length; idx++) {
2211
+ const s = resp.steps[idx];
2212
+ const info = s.metadata?.sourceTrajectoryStepInfo;
2213
+ if (info?.trajectoryId)
2214
+ trajectoryId = info.trajectoryId;
2215
+ if (s.type === "CORTEX_STEP_TYPE_ASK_QUESTION" && info?.stepIndex !== undefined) {
2216
+ stepIndex = info.stepIndex;
2217
+ }
2218
+ }
2219
+ }
2220
+ if (!trajectoryId) {
2221
+ throw RpcError.app("agent_unavailable", "未能定位当前 Antigravity 交互轨迹,请在桌面端确认");
2222
+ }
2223
+ return { trajectoryId, stepIndex };
2224
+ }
2225
+ async answerQuestion(nativeId, requestId, answers) {
2226
+ const watcher = this.desktopWatchers.get(nativeId);
2227
+ if (watcher?.pendingAction?.kind === "question" && watcher.pendingAction.requestId === requestId) {
2228
+ const pending = watcher.pendingAction;
2229
+ const conn = await this.requireConnection("回答");
2230
+ const { trajectoryId, stepIndex } = await this.resolveDesktopTrajectory(conn, nativeId, watcher, pending.stepIndex);
2231
+ const responses = pending.questions.map((q, idx) => {
2232
+ const qId = `q_${idx}`;
2233
+ const ans = answers.find((a) => a.id === qId) ?? answers[idx];
2234
+ const opts = q.options ?? [];
2235
+ const chosenValues = (ans?.values ?? []).map((v) => v.trim()).filter(Boolean);
2236
+ const selectedOptionIds = [];
2237
+ const writeInParts = [];
2238
+ for (const val of chosenValues) {
2239
+ const matchedOpt = opts.find((o) => o.id === val || o.text === val);
2240
+ if (matchedOpt) {
2241
+ selectedOptionIds.push(matchedOpt.id);
2242
+ }
2243
+ else {
2244
+ writeInParts.push(val);
2245
+ }
2246
+ }
2247
+ if (ans?.other?.trim()) {
2248
+ writeInParts.push(ans.other.trim());
2249
+ }
2250
+ const writeInResponse = writeInParts.join("\n");
2251
+ const skipped = selectedOptionIds.length === 0 && !writeInResponse;
2252
+ return {
2253
+ question: q.question,
2254
+ isMultiSelect: Boolean(q.isMultiSelect),
2255
+ options: opts.map((o) => ({ id: o.id, text: o.text })),
2256
+ selectedOptionIds,
2257
+ ...(writeInResponse ? { writeInResponse } : {}),
2258
+ skipped,
2259
+ };
2260
+ });
2261
+ await callLanguageServer(conn, "HandleCascadeUserInteraction", {
2262
+ cascadeId: nativeId,
2263
+ interaction: {
2264
+ trajectoryId,
2265
+ stepIndex,
2266
+ askQuestion: {
2267
+ cancelled: false,
2268
+ responses,
2269
+ },
2270
+ },
2271
+ });
2272
+ this.clearDesktopPendingAction(nativeId, watcher, { answers });
2273
+ return;
2274
+ }
2275
+ await super.answerQuestion(nativeId, requestId, answers);
2276
+ }
2277
+ async respondPermission(nativeId, requestId, optionId) {
2278
+ const watcher = this.desktopWatchers.get(nativeId);
2279
+ if (watcher?.pendingAction && watcher.pendingAction.requestId === requestId) {
2280
+ const pending = watcher.pendingAction;
2281
+ const conn = await this.requireConnection("确认");
2282
+ if (pending.kind === "question") {
2283
+ const { trajectoryId, stepIndex } = await this.resolveDesktopTrajectory(conn, nativeId, watcher, pending.stepIndex);
2284
+ const cancelled = optionId === "cancel";
2285
+ const responses = pending.questions.map((q) => ({
2286
+ question: q.question,
2287
+ isMultiSelect: Boolean(q.isMultiSelect),
2288
+ options: (q.options ?? []).map((o) => ({ id: o.id, text: o.text })),
2289
+ selectedOptionIds: [],
2290
+ skipped: true,
2291
+ }));
2292
+ await callLanguageServer(conn, "HandleCascadeUserInteraction", {
2293
+ cascadeId: nativeId,
2294
+ interaction: {
2295
+ trajectoryId,
2296
+ stepIndex,
2297
+ askQuestion: {
2298
+ cancelled,
2299
+ responses,
2300
+ },
2301
+ },
2302
+ });
2303
+ this.clearDesktopPendingAction(nativeId, watcher, { optionId });
2304
+ return;
2305
+ }
2306
+ const allow = optionId !== "reject" && optionId !== "cancel";
2307
+ const interactionPayload = {
2308
+ trajectoryId: pending.trajectoryId,
2309
+ stepIndex: pending.stepIndex,
2310
+ };
2311
+ if (pending.interactionType === "runCommand") {
2312
+ interactionPayload.runCommand = {
2313
+ confirm: allow,
2314
+ proposedCommandLine: pending.proposedCommandLine ?? "",
2315
+ submittedCommandLine: pending.proposedCommandLine ?? "",
2316
+ };
2317
+ }
2318
+ else if (pending.interactionType === "filePermission") {
2319
+ interactionPayload.filePermission = {
2320
+ allow,
2321
+ ...(pending.absolutePathUri ? { absolutePathUri: pending.absolutePathUri } : {}),
2322
+ };
2323
+ }
2324
+ else if (pending.interactionType === "permission") {
2325
+ interactionPayload.permission = { allow };
2326
+ }
2327
+ else {
2328
+ interactionPayload[pending.interactionType] = { confirm: allow };
2329
+ }
2330
+ await callLanguageServer(conn, "HandleCascadeUserInteraction", {
2331
+ cascadeId: nativeId,
2332
+ interaction: interactionPayload,
2333
+ });
2334
+ this.clearDesktopPendingAction(nativeId, watcher, { optionId });
2335
+ return;
2336
+ }
2337
+ await super.respondPermission(nativeId, requestId, optionId);
2338
+ }
1278
2339
  async createSession(options) {
1279
2340
  if (!this.desktopMode)
1280
2341
  return super.createSession(options);
1281
2342
  await this.ensureStarted();
1282
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1283
- const conn = await findRunningLanguageServer(desktop);
1284
- if (!conn) {
1285
- throw RpcError.app("agent_unavailable", "请先在电脑上打开 Antigravity 桌面端应用");
1286
- }
2343
+ const conn = await this.requireConnection("新建会话");
1287
2344
  const requestedModel = await this.resolvePlanModelEnum(conn, "", options.model);
1288
2345
  const response = await callLanguageServer(conn, "StartCascade", {
1289
2346
  source: "CORTEX_TRAJECTORY_SOURCE_CASCADE_CLIENT",
@@ -1293,16 +2350,99 @@ export class AntigravityDriver extends AcpDriver {
1293
2350
  const nativeId = response.cascadeId;
1294
2351
  if (!nativeId)
1295
2352
  throw RpcError.app("agent_unavailable", "Antigravity 未返回新会话 ID");
1296
- const watcher = this.startWatchingDesktopTranscript(nativeId, 0);
2353
+ const watcher = this.startWatchingDesktopTranscript(nativeId);
1297
2354
  const selectedModel = options.model ?? requestedModel;
1298
2355
  watcher.selectedModel = selectedModel;
1299
2356
  this.host?.state(this.id, nativeId).set("agy-selected-model", selectedModel);
1300
- void this.emitDesktopConfig(nativeId, selectedModel);
2357
+ void this.emitDesktopConfig(nativeId, selectedModel).catch(() => { });
1301
2358
  const now = Date.now();
1302
2359
  return { nativeId, cwd: options.cwd, createdAt: now, updatedAt: now, state: "idle" };
1303
2360
  }
2361
+ /** The transcript step an item of ours came from (the phone's own messages are aliased to theirs). */
2362
+ stepOfItem(nativeId, itemId) {
2363
+ let id = itemId;
2364
+ if (id.startsWith("local-"))
2365
+ id = [...this.desktopAliasesFor(nativeId)].find(([, alias]) => alias === itemId)?.[0] ?? id;
2366
+ const match = /^agy:[a-z-]+:(\d+)/.exec(id);
2367
+ if (!match)
2368
+ throw RpcError.app("not_supported", "Antigravity 只能从它自己记录的消息处分叉");
2369
+ return Number(match[1]);
2370
+ }
2371
+ /**
2372
+ * The step a fork ends at: the end of the turn holding `upTo` (or of the
2373
+ * whole conversation). Antigravity forks only at a turn's last step, right
2374
+ * before the next user message; the trajectory (not the transcript, which
2375
+ * skips some steps) says which that is.
2376
+ */
2377
+ async forkStepFor(conn, nativeId, upTo) {
2378
+ const from = upTo ? this.stepOfItem(nativeId, upTo.itemId) : undefined;
2379
+ const transcriptPath = this.transcriptPathFor(nativeId);
2380
+ const lastKnown = this.desktopWatchers.get(nativeId)?.lastStepIndex
2381
+ ?? (transcriptPath && existsSync(transcriptPath) ? parseAntigravityTranscript(transcriptPath, 0).lastStepIndex : undefined)
2382
+ ?? 0;
2383
+ const resp = await callLanguageServer(conn, "GetCascadeTrajectorySteps", { cascadeId: nativeId, stepOffset: from ?? Math.max(0, lastKnown - 4) }, 30_000);
2384
+ const base = from ?? Math.max(0, lastKnown - 4);
2385
+ const steps = (resp.steps ?? []).map((step, idx) => ({
2386
+ index: step.metadata?.sourceTrajectoryStepInfo?.stepIndex ?? base + idx,
2387
+ user: step.type === "CORTEX_STEP_TYPE_USER_INPUT",
2388
+ }));
2389
+ const nextUser = from === undefined ? undefined : steps.find((step) => step.user && step.index > from);
2390
+ const end = nextUser ? steps.filter((step) => step.index < nextUser.index).at(-1) : steps.at(-1);
2391
+ if (!end || end.user)
2392
+ throw RpcError.app("not_supported", "这条消息之后还没有回复,无法从这里分叉");
2393
+ return end.index;
2394
+ }
2395
+ async fork(nativeId, options) {
2396
+ if (!this.isDesktopSession(nativeId))
2397
+ return super.fork(nativeId, options);
2398
+ // A fork into a worktree is made by replaying the conversation into a new session there.
2399
+ if (options.cwd !== options.sourceCwd)
2400
+ throw RpcError.app("not_supported", "Antigravity 的原生分叉只能留在同一目录");
2401
+ const conn = await this.requireConnection("分叉");
2402
+ if (!options.upTo && (this.desktopWatchers.get(nativeId)?.running || this.isConversationRunningInDesktop(nativeId))) {
2403
+ throw RpcError.app("busy", "这个会话正在运行:等这一轮结束再分叉");
2404
+ }
2405
+ const forkAtStepIndex = await this.forkStepFor(conn, nativeId, options.upTo);
2406
+ let response;
2407
+ try {
2408
+ response = await callLanguageServer(conn, "ForkConversation", { sourceCascadeId: nativeId, forkAtStepIndex });
2409
+ }
2410
+ catch (error) {
2411
+ throw RpcError.app("not_supported", error instanceof Error ? error.message : String(error));
2412
+ }
2413
+ if (!response.newCascadeId)
2414
+ throw RpcError.app("not_supported", "Antigravity 未返回分叉后的会话 ID");
2415
+ const now = Date.now();
2416
+ return { nativeId: response.newCascadeId, cwd: options.cwd, createdAt: now, updatedAt: now, state: "idle" };
2417
+ }
2418
+ async rename(nativeId, title) {
2419
+ if (!this.isDesktopSession(nativeId))
2420
+ return;
2421
+ const conn = await this.requireConnection("改名");
2422
+ await callLanguageServer(conn, "UpdateConversationAnnotations", { cascadeId: nativeId, annotations: { title }, mergeAnnotations: true });
2423
+ this.desktopRowsCache = undefined;
2424
+ }
2425
+ async archive(nativeId, archived) {
2426
+ if (!this.isDesktopSession(nativeId))
2427
+ return;
2428
+ const conn = await this.languageServer();
2429
+ // Archiving still holds in LinkShell when the app is closed; the desktop catches up next time.
2430
+ if (!conn)
2431
+ return;
2432
+ await callLanguageServer(conn, "UpdateConversationAnnotations", { cascadeId: nativeId, annotations: { archived }, mergeAnnotations: true }).catch(() => { });
2433
+ this.archivedCache = undefined;
2434
+ }
2435
+ async delete(nativeId) {
2436
+ if (!this.isDesktopSession(nativeId))
2437
+ return super.delete(nativeId);
2438
+ const conn = await this.requireConnection("删除");
2439
+ await callLanguageServer(conn, "DeleteCascadeTrajectory", { cascadeId: nativeId });
2440
+ this.stopWatching(nativeId);
2441
+ this.desktopSessionStates.delete(nativeId);
2442
+ this.desktopRowsCache = undefined;
2443
+ }
1304
2444
  async goal(nativeId, change) {
1305
- if (!this.desktopMode && !this.desktopWatchers.has(nativeId)) {
2445
+ if (!this.isDesktopSession(nativeId)) {
1306
2446
  throw RpcError.app("not_supported", "这个 Agent 通过 /goal 命令管理目标");
1307
2447
  }
1308
2448
  const watcher = this.desktopWatchers.get(nativeId);
@@ -1310,77 +2450,61 @@ export class AntigravityDriver extends AcpDriver {
1310
2450
  return watcher?.goal ?? null;
1311
2451
  }
1312
2452
  if (change.action === "clear") {
1313
- if (watcher)
1314
- watcher.goal = null;
1315
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: null });
2453
+ await this.prompt(nativeId, [{ type: "text", text: "/goal clear" }], `goal-${Date.now()}`);
1316
2454
  return null;
1317
2455
  }
1318
2456
  if (change.action === "set") {
1319
2457
  const nextGoal = { objective: change.objective, status: "active" };
1320
- if (watcher)
1321
- watcher.goal = nextGoal;
1322
2458
  await this.prompt(nativeId, [{ type: "text", text: `/goal ${change.objective}` }], `goal-${Date.now()}`);
1323
2459
  return nextGoal;
1324
2460
  }
1325
2461
  return watcher?.goal ?? null;
1326
2462
  }
2463
+ echoDesktopMessage(nativeId, clientMessageId, blocks) {
2464
+ for (const block of blocks) {
2465
+ this.host?.update(this.id, nativeId, { sessionUpdate: "user_message_chunk", messageId: `local-${clientMessageId}`, content: block });
2466
+ }
2467
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_message_done", messageId: `local-${clientMessageId}`, role: "user" }, `local-${clientMessageId}`);
2468
+ }
1327
2469
  async prompt(nativeId, content, clientMessageId, context) {
1328
- const transcriptPath = this.transcriptPathFor(nativeId);
1329
- if (!this.desktopMode && !(transcriptPath && existsSync(transcriptPath))) {
2470
+ if (!this.isDesktopSession(nativeId)) {
1330
2471
  return super.prompt(nativeId, content, clientMessageId, context);
1331
2472
  }
1332
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1333
- const conn = await findRunningLanguageServer(desktop);
1334
- if (!conn) {
1335
- throw RpcError.app("agent_unavailable", "请先在电脑上打开 Antigravity 桌面端应用,再发送消息");
1336
- }
1337
- const text = content
2473
+ const desktop = this.desktop();
2474
+ const conn = await this.requireConnection("发送消息");
2475
+ let text = content
1338
2476
  .map((block) => (block.type === "text" ? block.text : ""))
1339
2477
  .join("\n")
1340
2478
  .trim();
1341
2479
  const imageBlocks = content.filter((block) => block.type === "image");
1342
2480
  if (!text && imageBlocks.length === 0)
1343
2481
  throw RpcError.app("invalid_params", "nothing to send");
1344
- if (/^\/goal\s+(?:clear|stop|off|reset|none|cancel)\s*$/i.test(text) && imageBlocks.length === 0) {
1345
- const watcher = this.desktopWatchers.get(nativeId);
1346
- if (watcher)
1347
- watcher.goal = null;
1348
- this.host?.update(this.id, nativeId, {
1349
- sessionUpdate: "user_message_chunk",
1350
- messageId: `local-${clientMessageId}`,
1351
- content: { type: "text", text },
1352
- });
1353
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_message_done", messageId: `local-${clientMessageId}`, role: "user" }, `local-${clientMessageId}`);
1354
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: null });
1355
- return "started";
1356
- }
1357
2482
  let watcher = this.desktopWatchers.get(nativeId);
1358
2483
  if (!watcher) {
1359
- const parsed = transcriptPath ? parseAntigravityTranscript(transcriptPath, 0) : undefined;
1360
- watcher = this.startWatchingDesktopTranscript(nativeId, parsed?.nextLine ?? 0, parsed);
2484
+ const transcriptPath = this.transcriptPathFor(nativeId);
2485
+ const parsed = transcriptPath && existsSync(transcriptPath) ? parseAntigravityTranscript(transcriptPath, 0) : undefined;
2486
+ watcher = this.startWatchingDesktopTranscript(nativeId, parsed && transcriptPath ? { path: transcriptPath, state: parsed.state, offset: parsed.endOffset, pendingAskQuestion: parsed.pendingAskQuestion } : undefined);
2487
+ this.followChildren(watcher, parsed?.items ?? [], "", 0);
1361
2488
  }
1362
- // Echo user message with `local-${clientMessageId}` so the mobile client's optimistic bubble is confirmed in-place.
1363
- watcher.pendingClientMessages.push({ clientMessageId, text });
1364
- const displayBlocks = content.map((block) => {
1365
- if (block.type === "text" && /^\/goal(?:\s+|$)/i.test(block.text.trim())) {
1366
- const cleaned = block.text.trim().replace(/^\/goal(?:\s+|$)/i, "").trim() || block.text;
1367
- return { type: "text", text: cleaned };
2489
+ let displayBlocks = content;
2490
+ if (GOAL_STOP.test(text) && imageBlocks.length === 0) {
2491
+ const hadGoal = watcher.goal?.status === "active";
2492
+ watcher.goal = null;
2493
+ if (!hadGoal) {
2494
+ // Nothing for the agent to stop: only the phone's record changes.
2495
+ this.echoDesktopMessage(nativeId, clientMessageId, content);
2496
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: null });
2497
+ return "started";
1368
2498
  }
1369
- return block;
1370
- });
1371
- for (const block of displayBlocks) {
1372
- this.host?.update(this.id, nativeId, {
1373
- sessionUpdate: "user_message_chunk",
1374
- messageId: `local-${clientMessageId}`,
1375
- content: block,
1376
- });
2499
+ // The goal is enforced by the agent's stop hook: only the agent can end it (with <!-- GOAL_CANCELLED -->).
2500
+ text = "请停止并取消当前的 /goal 目标。";
2501
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: null });
1377
2502
  }
1378
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_message_done", messageId: `local-${clientMessageId}`, role: "user" }, `local-${clientMessageId}`);
1379
- if (/^\/goal(?:\s+|$)/i.test(text)) {
2503
+ else if (/^\/goal(?:\s+|$)/i.test(text)) {
1380
2504
  const objective = text.replace(/^\/goal(?:\s+|$)/i, "").trim() || "持续目标";
1381
- const nextGoal = { objective, status: "active" };
1382
- watcher.goal = nextGoal;
1383
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: nextGoal });
2505
+ watcher.goal = { objective, status: "active" };
2506
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_goal", goal: watcher.goal });
2507
+ displayBlocks = content.map((block) => block.type === "text" && /^\/goal(?:\s+|$)/i.test(block.text.trim()) ? { type: "text", text: objective } : block);
1384
2508
  }
1385
2509
  else if (watcher.goal?.status === "complete") {
1386
2510
  watcher.goal = null;
@@ -1412,55 +2536,108 @@ export class AntigravityDriver extends AcpDriver {
1412
2536
  media.push({
1413
2537
  mimeType: img.mimeType,
1414
2538
  inlineData: img.data,
2539
+ thumbnail: img.data,
1415
2540
  ...(savedUri ? { uri: savedUri } : {}),
1416
2541
  });
1417
2542
  }
1418
2543
  }
1419
- const fullText = context ? (text ? `${context}\n\n${text}` : context) : text;
1420
2544
  const planModel = await this.resolvePlanModelEnum(conn, nativeId, watcher.selectedModel);
1421
- watcher.running = true;
1422
- this.desktopSessionStates.set(nativeId, "running");
1423
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "started" });
1424
- this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "running", turnActive: true });
1425
- await callLanguageServer(conn, "SendUserCascadeMessage", {
1426
- cascadeId: nativeId,
1427
- items: [{ text: fullText || "请查看附图" }],
1428
- ...(media.length > 0 ? { media } : {}),
1429
- cascadeConfig: {
1430
- plannerConfig: {
1431
- supportsLatexRendering: true,
1432
- toolConfig: {
1433
- runCommand: {
1434
- autoCommandConfig: {
1435
- autoExecutionPolicy: "CASCADE_COMMANDS_AUTO_EXECUTION_EAGER",
1436
- },
1437
- },
1438
- notifyUser: {},
2545
+ const inherited = await this.inheritedSettings(conn, nativeId);
2546
+ // A known command goes as the desktop sends it (a slash-command item), so the agent gets its instructions.
2547
+ const items = [];
2548
+ if (context)
2549
+ items.push({ text: `${context}\n\n` });
2550
+ const slash = SLASH.exec(text);
2551
+ const command = slash ? (await this.slashCommands(conn, nativeId, planModel)).find((entry) => entry.name === slash[1]) : undefined;
2552
+ if (command) {
2553
+ items.push({ item: { slashCommand: { info: command.info } } });
2554
+ const rest = slash?.[2]?.trim();
2555
+ if (rest)
2556
+ items.push({ text: ` ${rest}` });
2557
+ }
2558
+ else if (text) {
2559
+ items.push({ text });
2560
+ }
2561
+ else {
2562
+ items.push({ text: "请查看附图" });
2563
+ }
2564
+ watcher.pendingClientMessages.push({ clientMessageId, text });
2565
+ this.echoDesktopMessage(nativeId, clientMessageId, displayBlocks);
2566
+ const steering = watcher.running || this.isConversationRunningInDesktop(nativeId);
2567
+ if (!steering) {
2568
+ watcher.sentAt = Date.now();
2569
+ this.desktopRowsCache = undefined;
2570
+ watcher.running = true;
2571
+ this.desktopSessionStates.set(nativeId, "running");
2572
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "started" });
2573
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "running", turnActive: true });
2574
+ }
2575
+ try {
2576
+ await callLanguageServer(conn, "SendUserCascadeMessage", {
2577
+ cascadeId: nativeId,
2578
+ items,
2579
+ ...(media.length > 0 ? { media } : {}),
2580
+ cascadeConfig: {
2581
+ plannerConfig: {
2582
+ supportsLatexRendering: true,
2583
+ ...(inherited.autoExecutionPolicy
2584
+ ? { toolConfig: { runCommand: { autoCommandConfig: { autoExecutionPolicy: inherited.autoExecutionPolicy } } } }
2585
+ : {}),
2586
+ planModel,
2587
+ useAiCredits: inherited.useAiCredits ?? false,
1439
2588
  },
1440
- planModel,
1441
- useAiCredits: false,
1442
- },
1443
- executorConfig: {
1444
- useCoreDirect: true,
2589
+ executorConfig: {
2590
+ useCoreDirect: true,
2591
+ },
2592
+ conversationHistoryConfig: {},
1445
2593
  },
1446
- conversationHistoryConfig: {},
1447
- },
1448
- customAgentSpec: {
1449
- builtinAgent: {
1450
- defaultAgent: {
1451
- isGoogle: false,
1452
- isInteractive: true,
1453
- surface: "AGENT_SURFACE_HUB",
2594
+ customAgentSpec: {
2595
+ builtinAgent: {
2596
+ defaultAgent: {
2597
+ isGoogle: false,
2598
+ isInteractive: true,
2599
+ surface: "AGENT_SURFACE_HUB",
2600
+ },
1454
2601
  },
1455
2602
  },
1456
- },
1457
- });
1458
- return "started";
2603
+ });
2604
+ }
2605
+ catch (error) {
2606
+ const pendingIdx = watcher.pendingClientMessages.findIndex((p) => p.clientMessageId === clientMessageId);
2607
+ if (pendingIdx >= 0)
2608
+ watcher.pendingClientMessages.splice(pendingIdx, 1);
2609
+ this.host?.update(this.id, nativeId, {
2610
+ sessionUpdate: "ls_error",
2611
+ code: "send_failed",
2612
+ message: error instanceof Error ? error.message : String(error),
2613
+ });
2614
+ if (!steering) {
2615
+ watcher.sentAt = undefined;
2616
+ watcher.running = false;
2617
+ this.desktopSessionStates.set(nativeId, "idle");
2618
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "error" });
2619
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "idle", turnActive: false });
2620
+ }
2621
+ throw error;
2622
+ }
2623
+ return steering ? "steered" : "started";
2624
+ }
2625
+ async cancelSubagent(nativeId, childId) {
2626
+ if (!this.isDesktopSession(nativeId))
2627
+ return super.cancelSubagent(nativeId, childId);
2628
+ // Only a conversation this one spawned (directly or through its sub-agents) may be stopped from here.
2629
+ const rows = this.desktopRows();
2630
+ let parent = rows?.get(childId)?.parent;
2631
+ for (let depth = 0; parent && parent !== nativeId && depth < 8; depth++)
2632
+ parent = rows?.get(parent)?.parent;
2633
+ if (parent !== nativeId)
2634
+ throw RpcError.app("not_supported", "这个子代理不属于当前会话");
2635
+ const conn = await this.requireConnection("停止子代理");
2636
+ await callLanguageServer(conn, "CancelCascadeInvocation", { cascadeId: childId, killBackgroundTasks: false });
1459
2637
  }
1460
2638
  async cancel(nativeId) {
1461
- if (this.desktopMode || this.desktopWatchers.has(nativeId)) {
1462
- const desktop = resolveAntigravityDesktop(this.options.env ?? process.env);
1463
- const conn = await findRunningLanguageServer(desktop);
2639
+ if (this.isDesktopSession(nativeId)) {
2640
+ const conn = await this.languageServer();
1464
2641
  if (conn) {
1465
2642
  await callLanguageServer(conn, "CancelCascadeInvocation", {
1466
2643
  cascadeId: nativeId,
@@ -1468,8 +2645,13 @@ export class AntigravityDriver extends AcpDriver {
1468
2645
  }).catch(() => { });
1469
2646
  }
1470
2647
  const watcher = this.desktopWatchers.get(nativeId);
1471
- if (watcher)
2648
+ if (watcher) {
2649
+ if (watcher.pendingAction) {
2650
+ this.clearDesktopPendingAction(nativeId, watcher, { optionId: "cancel" });
2651
+ }
2652
+ watcher.sentAt = undefined;
1472
2653
  watcher.running = false;
2654
+ }
1473
2655
  this.desktopSessionStates.set(nativeId, "idle");
1474
2656
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_turn", state: "ended", stopReason: "cancelled" });
1475
2657
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_status", state: "idle", turnActive: false });
@@ -1478,7 +2660,7 @@ export class AntigravityDriver extends AcpDriver {
1478
2660
  await super.cancel(nativeId);
1479
2661
  }
1480
2662
  async setConfig(nativeId, optionId, value) {
1481
- if (this.desktopMode || this.desktopWatchers.has(nativeId)) {
2663
+ if (this.isDesktopSession(nativeId)) {
1482
2664
  if (optionId !== "model")
1483
2665
  throw RpcError.app("not_supported", `Antigravity 暂不支持设置 ${optionId}`);
1484
2666
  const watcher = this.desktopWatchers.get(nativeId);