ai_co_discuss 0.3.73 → 0.3.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,7 +115,7 @@ Details: [Architecture](docs/en/architecture.md) · [Setup](docs/en/runner.md)
115
115
  The site can be self-hosted on **Cloudflare Pages + D1** or **Docker**; agents and the runner still run on your own machine. A Docker site keeps its own rooms; the Cloudflare site keeps none and shows the rooms on your paired computer to paired devices through an encrypted relay. See [Deployment](docs/en/deployment.md).
116
116
 
117
117
  > [!WARNING]
118
- > The site does not yet provide user login or read-access isolation. Anyone who can reach the site can read its rooms. Read the [security policy](.github/SECURITY.md) before exposing it publicly.
118
+ > Sites that store rooms (local or Docker) do not yet provide user login or read-access isolation: anyone who can reach such a site can read its rooms. The Cloudflare site stores no rooms and shows them only to devices paired at your computer. Read the [security policy](.github/SECURITY.md) before exposing a site publicly.
119
119
 
120
120
  ## Documentation
121
121
 
package/README.zh-CN.md CHANGED
@@ -109,7 +109,7 @@ flowchart LR
109
109
  网站支持自托管于 **Cloudflare Pages + D1** 或 **Docker**;AI 与接入服务仍在你的电脑运行。Docker 站点自己保存房间;Cloudflare 站点不保存房间,已配对的设备经加密中转看到电脑上的房间。见 [部署指南](docs/deployment.md)。
110
110
 
111
111
  > [!WARNING]
112
- > 网站暂无用户登录与读取权限隔离,能访问站点的人即可阅读其中的房间。公开部署前请阅读 [安全说明](.github/SECURITY.md)。
112
+ > 保存房间的站点(本机或 Docker)暂无用户登录与读取权限隔离,能访问该站点的人即可阅读其中的房间。Cloudflare 站点不保存房间,只向在电脑前配对过的设备显示。公开部署前请阅读 [安全说明](.github/SECURITY.md)。
113
113
 
114
114
  ## 文档
115
115
 
@@ -1885,6 +1885,9 @@ def inbox(connection, room_id, token):
1885
1885
  # The runner compares this with its adapter's effect and reports a change.
1886
1886
  reported = connection.execute("SELECT rewind FROM member_capabilities WHERE member_id=?", (row["id"],)).fetchone()
1887
1887
  member_view["rewind"] = reported[0] if reported else ""
1888
+ # Private chat lines the runner still has to run in this member's session (backend/private_chat.py).
1889
+ member_view["chats"] = [dict(item) for item in connection.execute(
1890
+ "SELECT id,text,status FROM private_chats WHERE member_id=? AND status IN ('pending','running') ORDER BY id", (row["id"],))]
1888
1891
  pending = [{**request, **review_request_context(connection, request), **sequence_request_context(connection, request)} for request in pending]
1889
1892
  (members if row["access"] == "runner" else observers).append({**member_view, "pending": pending})
1890
1893
  # Pending interjections ride along so the runner hands them to the running software at its next check.
@@ -0,0 +1,108 @@
1
+ """
2
+ Private chat: the owner talks to one room member directly, in the member's own session, outside the room's messages.
3
+
4
+ Each row of `private_chats` is one thing the owner said and the member's reply. While the member works on a room
5
+ execution that takes interjections, a new line goes in as an interjection of that execution; otherwise it waits until
6
+ the member is idle and the runner runs it as a turn of its own, taking the reply from the software's output. Only the
7
+ owner reads or writes chats; the runner, which holds the owner credential, reports their progress.
8
+ """
9
+ import time
10
+
11
+ from .free_room import ACTIVE, add_interjection
12
+ from .runtime import begin_write, require_owner
13
+
14
+ MAX_CHAT_LENGTH = 4000
15
+ MAX_REPLY_LENGTH = 20000
16
+ MAX_REASON_LENGTH = 500
17
+ SHOWN_CHATS = 200
18
+ # Interjections made from a chat line carry its submission id under this prefix, so the two are found together.
19
+ INTERJECTION_PREFIX = "chat-"
20
+ # Runner reports and the owner's stop, each with the states it may leave.
21
+ TRANSITIONS = {"start": ("pending", "running"), "steered": ("pending", "interjected"), "reply": ("running", "answered"),
22
+ "fail": (("pending", "running"), "failed"), "stop": (("pending", "running"), "stopped")}
23
+
24
+
25
+ def chat_member(connection, room_id, member_id):
26
+ """The active member the runner wakes; a manual member is reached only in its own window."""
27
+ member = connection.execute("SELECT * FROM room_members WHERE room_id=? AND id=? AND status='active'", (room_id, member_id)).fetchone()
28
+ if not member:
29
+ raise LookupError("成员不存在或已离开")
30
+ if member["access"] != "runner":
31
+ raise FileExistsError("这位成员是手动接入的,只能在它自己的窗口里对话")
32
+ return member
33
+
34
+
35
+ def public_chat(connection, row):
36
+ """One chat line as the page shows it; a line passed in as an interjection shows how that went."""
37
+ chat = {key: row[key] for key in ("id", "member_id", "text", "status", "reply", "reason", "execution_id", "created_at", "updated_at")}
38
+ if row["status"] == "interjected":
39
+ interjection = connection.execute("""SELECT i.status,i.reason,e.status AS execution_status FROM execution_interjections i
40
+ JOIN member_executions e ON e.id=i.execution_id WHERE i.room_id=? AND i.submission_id=?""",
41
+ (row["room_id"], INTERJECTION_PREFIX + row["submission_id"])).fetchone()
42
+ if interjection:
43
+ ended = interjection["status"] == "pending" and interjection["execution_status"] not in ACTIVE
44
+ chat["interjection"] = "failed" if ended else interjection["status"]
45
+ chat["reason"] = "送达前那次执行已结束" if ended else interjection["reason"]
46
+ return chat
47
+
48
+
49
+ def member_chat(connection, room_id, member_id, token, payload=None):
50
+ """Read one member's private chat, or add the owner's next line to it."""
51
+ require_owner(connection, room_id, token)
52
+ if payload is None:
53
+ chat_member(connection, room_id, member_id)
54
+ rows = connection.execute("SELECT * FROM (SELECT * FROM private_chats WHERE room_id=? AND member_id=? ORDER BY id DESC LIMIT ?) ORDER BY id",
55
+ (room_id, member_id, SHOWN_CHATS)).fetchall()
56
+ return {"member_id": member_id, "chats": [public_chat(connection, row) for row in rows]}
57
+ begin_write(connection)
58
+ member = chat_member(connection, room_id, member_id)
59
+ text, submission_id = payload.get("text"), payload.get("submission_id")
60
+ if not isinstance(text, str) or not 0 < len(text.strip()) <= MAX_CHAT_LENGTH:
61
+ raise ValueError(f"私聊内容须为 1–{MAX_CHAT_LENGTH} 字")
62
+ if not isinstance(submission_id, str) or not 0 < len(submission_id) <= 100:
63
+ raise ValueError("私聊须带 submission_id;重试沿用原编号")
64
+ earlier = connection.execute("SELECT * FROM private_chats WHERE room_id=? AND submission_id=?", (room_id, submission_id)).fetchone()
65
+ if earlier:
66
+ if earlier["member_id"] != member_id or earlier["text"] != text:
67
+ raise FileExistsError("submission_id 已用于另一句私聊")
68
+ return public_chat(connection, earlier)
69
+ now = int(time.time())
70
+ # A room execution that takes interjections hears it now; one that cannot waits, like a turn already running.
71
+ execution = connection.execute("""SELECT e.* FROM member_executions e JOIN execution_capabilities c ON c.execution_id=e.id
72
+ WHERE e.member_id=? AND e.status IN ('dispatched','started') AND c.steer=1 ORDER BY e.id DESC LIMIT 1""", (member["id"],)).fetchone()
73
+ status, execution_id = "pending", None
74
+ if execution:
75
+ add_interjection(connection, room_id, execution, {"text": text, "submission_id": INTERJECTION_PREFIX + submission_id}, now)
76
+ status, execution_id = "interjected", execution["id"]
77
+ cursor = connection.execute("""INSERT INTO private_chats (room_id,member_id,submission_id,text,status,execution_id,created_at,updated_at)
78
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, submission_id, text, status, execution_id, now, now))
79
+ return public_chat(connection, connection.execute("SELECT * FROM private_chats WHERE id=?", (cursor.lastrowid,)).fetchone())
80
+
81
+
82
+ def chat_action(connection, room_id, chat_id, token, payload):
83
+ """The runner's progress on one chat line (start, steered, reply, fail), or the owner's stop."""
84
+ require_owner(connection, room_id, token)
85
+ begin_write(connection)
86
+ action = payload.get("action")
87
+ if action not in TRANSITIONS:
88
+ raise ValueError(f"action 须为 {'、'.join(TRANSITIONS)}")
89
+ row = connection.execute("SELECT * FROM private_chats WHERE room_id=? AND id=?", (room_id, chat_id)).fetchone()
90
+ if not row:
91
+ raise LookupError("私聊不存在")
92
+ allowed, target = TRANSITIONS[action]
93
+ if row["status"] == target:
94
+ return public_chat(connection, row)
95
+ if row["status"] not in (allowed if isinstance(allowed, tuple) else (allowed,)):
96
+ raise FileExistsError(f"这句私聊当前为 {row['status']},不能 {action}")
97
+ reply, reason = payload.get("text", ""), payload.get("reason", "")
98
+ if not isinstance(reply, str) or len(reply) > MAX_REPLY_LENGTH or not isinstance(reason, str) or len(reason) > MAX_REASON_LENGTH:
99
+ raise ValueError(f"text 最多 {MAX_REPLY_LENGTH} 字,reason 最多 {MAX_REASON_LENGTH} 字")
100
+ connection.execute("UPDATE private_chats SET status=?,reply=?,reason=?,updated_at=? WHERE id=?",
101
+ (target, reply if action == "reply" else row["reply"], reason, int(time.time()), chat_id))
102
+ return public_chat(connection, connection.execute("SELECT * FROM private_chats WHERE id=?", (chat_id,)).fetchone())
103
+
104
+
105
+ def open_chats(connection, member_id):
106
+ """The lines the runner still has to run or is running for one member, in order."""
107
+ return [dict(row) for row in connection.execute(
108
+ "SELECT id,text,status FROM private_chats WHERE member_id=? AND status IN ('pending','running') ORDER BY id", (member_id,))]
@@ -461,6 +461,26 @@ CREATE TABLE IF NOT EXISTS execution_interjections (
461
461
  UNIQUE(room_id,submission_id)
462
462
  );
463
463
  CREATE INDEX IF NOT EXISTS execution_interjections_execution ON execution_interjections(execution_id);
464
+ -- The owner's private chat with one room member: each row is one thing the owner said and the member's reply, outside
465
+ -- the room's messages, so other members never read it. It runs in the member's own session: while the member works on
466
+ -- a room execution that takes interjections it goes in as one (`interjected`, execution_id); otherwise it waits
467
+ -- (`pending`) until the member is idle and the runner runs it as its own turn (`running`), taking the reply from the
468
+ -- software's own output (`answered`). The owner may stop it (`stopped`); the runner reports `failed` with a reason.
469
+ CREATE TABLE IF NOT EXISTS private_chats (
470
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
471
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
472
+ member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE,
473
+ submission_id TEXT NOT NULL,
474
+ text TEXT NOT NULL,
475
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','answered','failed','stopped','interjected')),
476
+ reply TEXT NOT NULL DEFAULT '',
477
+ reason TEXT NOT NULL DEFAULT '',
478
+ execution_id INTEGER,
479
+ created_at INTEGER NOT NULL,
480
+ updated_at INTEGER NOT NULL,
481
+ UNIQUE(room_id,submission_id)
482
+ );
483
+ CREATE INDEX IF NOT EXISTS private_chats_member ON private_chats(member_id,id);
464
484
  -- What a rewind does to each runner member's session, as its adapter reported: cut by the runner, rebuilt each time
465
485
  -- (fresh), or kept as it is. A member without a report is kept; manual and online members are always kept.
466
486
  CREATE TABLE IF NOT EXISTS member_capabilities (
@@ -1131,6 +1151,19 @@ BEGIN
1131
1151
  INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1132
1152
  WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1133
1153
  END;
1154
+ CREATE TRIGGER IF NOT EXISTS revision_chat_insert AFTER INSERT ON private_chats
1155
+ BEGIN
1156
+ UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;
1157
+ INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1158
+ WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1159
+ END;
1160
+ CREATE TRIGGER IF NOT EXISTS revision_chat_update AFTER UPDATE OF status ON private_chats
1161
+ WHEN OLD.status IS NOT NEW.status
1162
+ BEGIN
1163
+ UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;
1164
+ INSERT INTO room_revisions (room_id,revision) SELECT NEW.room_id,(SELECT COALESCE(MAX(revision),0)+1 FROM room_revisions)
1165
+ WHERE NOT EXISTS (SELECT 1 FROM room_revisions WHERE room_id=NEW.room_id);
1166
+ END;
1134
1167
  CREATE TRIGGER IF NOT EXISTS revision_interjection_insert AFTER INSERT ON execution_interjections
1135
1168
  BEGIN
1136
1169
  UPDATE room_revisions SET revision=(SELECT MAX(revision)+1 FROM room_revisions) WHERE room_id=NEW.room_id;
package/backend/server.py CHANGED
@@ -32,6 +32,7 @@ from .free_room import (add_member, after_free_message, after_proactive, authori
32
32
  save_room_access, view_execution)
33
33
  from .free_room import convert_suggestion, message_suggestions, save_suggestions, sync_participants
34
34
  from .free_room import adopt_recommendation, recommend_member, register_member, require_member
35
+ from .private_chat import chat_action, member_chat
35
36
  from .free_room import describe_members, execution_notes, label_members, member_descriptions, member_display_names, member_labels, reorder_speakers, resolve_member_fields, set_chain_rounds, speaking_order, stop_room, stop_room_work
36
37
  from .free_room import (ADAPTER_VALUE, ANCHOR_SQL, continue_chain, execution_timeline, group_route_plan, retract_message, rewind_plan, rewind_room,
37
38
  room_event_log, withdrawals)
@@ -2294,7 +2295,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2294
2295
  result = queue_action(connection, room_id, token, self.read_payload(), post_queued, check_queued, interrupt_for_message)
2295
2296
  connection.commit()
2296
2297
  return self.respond(result)
2297
- if parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"], ["recommendations"], ["stop"]):
2298
+ if parts[3:4] in (["inbox"], ["executions"], ["members"], ["chats"], ["suggestions"], ["recommendations"], ["stop"]):
2298
2299
  return self.handle_free_api(connection, parts, method)
2299
2300
  if parts[3:4] == ["attachments"]:
2300
2301
  return self.handle_attachments(connection, parts, params, method)
@@ -2495,6 +2496,10 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2495
2496
  result = member_action(connection, room_id, int(parts[4]), token, self.read_payload())
2496
2497
  elif len(parts) == 6 and parts[3] == "members" and parts[5] == "interrupt" and method == "POST":
2497
2498
  result = interrupt_member(connection, room_id, int(parts[4]), token)
2499
+ elif len(parts) == 6 and parts[3] == "members" and parts[5] == "chat" and method in ("GET", "POST"):
2500
+ result = member_chat(connection, room_id, int(parts[4]), token, self.read_payload() if method == "POST" else None)
2501
+ elif len(parts) == 5 and parts[3] == "chats" and method == "POST":
2502
+ result = chat_action(connection, room_id, int(parts[4]), token, self.read_payload())
2498
2503
  elif len(parts) == 6 and parts[3] == "members" and parts[5] == "capabilities" and method == "POST":
2499
2504
  result = member_capability(connection, room_id, int(parts[4]), token, self.read_payload())
2500
2505
  elif parts[3:] == ["stop"] and method == "POST":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.73",
3
+ "version": "0.3.74",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -1,21 +1,30 @@
1
1
  /**
2
2
  * Antigravity App adapter: each task is written as a request file for the CoDiscuss Sidecar the App runs
3
- * (`antigravity_sidecar.mjs`), which starts the turn in the App; its receipt is read back on a later check. The turn runs
4
- * inside the App, so the runner can neither stop it nor see its progress. Conversations are read through the agy adapter.
3
+ * (`antigravity_sidecar.mjs`), which starts the turn in the App; its receipt is read back on a later check. A room turn
4
+ * runs inside the App, so the runner can neither stop it nor see its progress. A private chat turn is followed through
5
+ * the App's local language server, whose address and token the Sidecar hands over (unpublished internal RPC, tested on
6
+ * App 2.29.0, 2026-11-06). Conversations are read through the agy adapter.
5
7
  */
6
8
  import { createHash } from 'node:crypto';
9
+ import { EventEmitter } from 'node:events';
7
10
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
8
11
  import { homedir } from 'node:os';
9
12
  import { join, resolve } from 'node:path';
13
+ import { setTimeout as sleep } from 'node:timers/promises';
10
14
  import { fileURLToPath } from 'node:url';
11
15
  import { atomic_json } from '../files.mjs';
12
16
  import { antigravity_project, database_settings } from './antigravity_cli.mjs';
13
- import { bind_access, not_sent } from './common.mjs';
17
+ import { bind_access, EDIT_TOOLS, not_sent } from './common.mjs';
14
18
 
15
19
  const IN_APP = { stoppable: false, progress: false };
20
+ const FOLLOWED = { stoppable: true, progress: true };
21
+ const SERVICE = 'exa.language_server_pb.LanguageServerService';
22
+ const UNPUBLISHED = 'App 内部接口未公开,App 版本可能已变';
23
+ // How a chat turn is waited for; tests shorten these.
24
+ export const CHAT_TIMING = { receipt_ms: 30000, poll_ms: 1000, start_ms: 30000, report_ms: 60000, wait_ms: 2 * 60 * 60 * 1000, rpc_ms: 15000, read_failures: 3 };
16
25
 
17
- /** Queue one immutable request for the official App-managed Sidecar. */
18
- async function deliver(config, binding, task) {
26
+ /** The running Sidecar's ready record, checked before anything is queued; errors mean nothing was sent. */
27
+ function ready_sidecar(config, binding, task) {
19
28
  const directory = resolve(config.ipc_directory);
20
29
  try {
21
30
  mkdirSync(directory, { recursive: true });
@@ -27,15 +36,195 @@ async function deliver(config, binding, task) {
27
36
  }
28
37
  try { process.kill(ready.pid, 0); } catch { throw new Error('Antigravity Sidecar 尚未运行'); }
29
38
  if (binding.session_id) bind_access(task.access_path, binding.session_id);
39
+ return ready;
30
40
  } catch (error) { throw not_sent(error); }
31
- const path = join(directory, `${task.id}.request.json`);
41
+ }
42
+
43
+ /** Write one immutable request; an existing one must be settled by its receipt, never sent twice. */
44
+ function queue_request(config, request) {
45
+ const path = join(resolve(config.ipc_directory), `${request.id}.request.json`);
32
46
  if (existsSync(path)) throw new Error('Sidecar 请求已存在;必须核对回执,不自动重复发送');
33
- atomic_json(path, { id: task.id,
34
- event_id: Number(task.id.replace(/^free-/, '')), prompt: task.prompt,
47
+ atomic_json(path, request);
48
+ }
49
+
50
+ /** Queue one immutable request for the official App-managed Sidecar. */
51
+ async function deliver(config, binding, task) {
52
+ ready_sidecar(config, binding, task);
53
+ queue_request(config, { id: task.id, event_id: Number(task.id.replace(/^free-/, '')), prompt: task.prompt,
35
54
  session_id: binding.session_id || '', access_path: task.access_path });
36
55
  return { session_id: binding.session_id || '', status: 'sidecar_pending', capabilities: IN_APP };
37
56
  }
38
57
 
58
+ /** Queue a chat message and wait for the Sidecar to pass it to the App; the conversation ID it went into. */
59
+ async function send_chat(config, request) {
60
+ queue_request(config, { ...request, chat: true });
61
+ for (const until = Date.now() + CHAT_TIMING.receipt_ms; Date.now() < until; await sleep(Math.min(CHAT_TIMING.poll_ms, 500))) {
62
+ const result = receipt(config, request.id);
63
+ if (result?.status === 'accepted') return result.session_id;
64
+ if (result) throw new Error(`Antigravity Sidecar 未能发送:${result.error || '状态不明'}`);
65
+ }
66
+ throw new Error(`Antigravity Sidecar ${Math.round(CHAT_TIMING.receipt_ms / 1000)} 秒内没有回执,发送状态不明;它稍后仍可能发到 App`);
67
+ }
68
+
69
+ /** Call one method of the App's language server; every failure names the method and why. */
70
+ async function app_rpc(server, method, body) {
71
+ const base = /^https?:\/\//.test(server.address) ? server.address : `http://${server.address}`;
72
+ let response;
73
+ try {
74
+ response = await fetch(`${base}/${SERVICE}/${method}`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(CHAT_TIMING.rpc_ms),
75
+ headers: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1', 'x-codeium-csrf-token': server.csrf_token },
76
+ body: JSON.stringify(body) });
77
+ } catch (error) { throw new Error(`连不上 Antigravity App 的本机服务(${method}:${error.cause?.code || error.message});App 可能已退出或重启`); }
78
+ const text = await response.text();
79
+ if (!response.ok) throw new Error(`Antigravity App 的 ${method} 返回 HTTP ${response.status}${text ? `:${text.slice(0, 200)}` : ''}(${UNPUBLISHED})`);
80
+ try { return JSON.parse(text || '{}'); }
81
+ catch { throw new Error(`Antigravity App 的 ${method} 返回的不是 JSON(${UNPUBLISHED})`); }
82
+ }
83
+
84
+ /** A conversation's run status and steps, read from the App; a missing field means the App changed. */
85
+ async function read_conversation(server, id) {
86
+ const { status } = await app_rpc(server, 'GetCascadeTrajectory', { cascadeId: id });
87
+ const { steps } = await app_rpc(server, 'GetCascadeTrajectorySteps', { cascadeId: id });
88
+ if (typeof status !== 'string' || !Array.isArray(steps)) throw new Error(`Antigravity App 返回的对话状态缺少 status 或 steps(${UNPUBLISHED})`);
89
+ return { running: status === 'CASCADE_RUN_STATUS_RUNNING', idle: status === 'CASCADE_RUN_STATUS_IDLE', steps };
90
+ }
91
+
92
+ function step_state(step, failed = false) {
93
+ if (/CANCELED|ERROR/.test(step.status || '') || failed) return 'failed';
94
+ return step.status === 'CORTEX_STEP_STATUS_DONE' ? 'done' : 'running';
95
+ }
96
+
97
+ /** One App step as process log entries: a response's remark and tool calls (commands show as their own step), commands run. */
98
+ function step_entries(step, index) {
99
+ // A command moved to the background shows as a generic running step until it finishes, then as the command itself.
100
+ if (step.type === 'CORTEX_STEP_TYPE_GENERIC' && step.taskDetails) {
101
+ return [{ id: `step-${index}`, kind: 'command', title: String(step.taskDetails.description || step.taskDetails.title || ''), state: step_state(step) }];
102
+ }
103
+ if (step.type === 'CORTEX_STEP_TYPE_RUN_COMMAND') {
104
+ const command = step.runCommand || {};
105
+ return [{ id: `step-${index}`, kind: 'command', title: String(command.commandLine || ''), body: String(command.combinedOutput?.full || ''),
106
+ state: step_state(step, step.status === 'CORTEX_STEP_STATUS_DONE' && (command.exitCode ?? 0) !== 0) }];
107
+ }
108
+ if (step.type !== 'CORTEX_STEP_TYPE_PLANNER_RESPONSE') return [];
109
+ const response = step.plannerResponse || {};
110
+ const calls = (response.toolCalls || []).filter(call => call.name !== 'run_command');
111
+ return [...((response.toolCalls || []).length && response.response ? [{ id: `step-${index}`, kind: 'message', body: response.response, state: 'done' }] : []),
112
+ ...calls.map(call => ({ id: `step-${index}-${call.id}`, kind: EDIT_TOOLS.test(call.name || '') ? 'edit' : 'tool',
113
+ title: `${call.name} ${call.argumentsJson || ''}`.trim(), state: step_state(step) }))];
114
+ }
115
+
116
+ /**
117
+ * Whether an idle App is still inside this turn. A command moved to the background leaves the App idle with an interim
118
+ * remark; when it finishes, the App reports it as a message from `<conversation>/task-N` and runs again for the reply.
119
+ * So the turn goes on while a command or task step runs, while a finished task has not reported back (up to
120
+ * `report_ms` after it was first seen finished, as a report may never come), or while the last step is an unanswered
121
+ * message. `finished` remembers when each task was first seen finished.
122
+ */
123
+ function turn_pending(steps, finished, now = Date.now()) {
124
+ if (steps.at(-1)?.type === 'CORTEX_STEP_TYPE_SYSTEM_MESSAGE') return true;
125
+ const messages = steps.filter(step => step.type === 'CORTEX_STEP_TYPE_SYSTEM_MESSAGE').map(step => String(step.systemMessage?.message || ''));
126
+ return steps.some(step => {
127
+ if (!['CORTEX_STEP_TYPE_GENERIC', 'CORTEX_STEP_TYPE_RUN_COMMAND'].includes(step.type)) return false;
128
+ if (/RUNNING|GENERATING|PENDING/.test(step.status || '')) return true;
129
+ const task = step.taskDetails?.id;
130
+ if (!task || /CANCELED|ERROR/.test(step.status || '') || messages.some(message => message.includes(`sender=${task} `))) return false;
131
+ if (!finished.has(task)) finished.set(task, now);
132
+ return now - finished.get(task) < CHAT_TIMING.report_ms;
133
+ });
134
+ }
135
+
136
+ /** Follow the turn from its first step until the App goes idle with nothing pending, it is stopped, or the wait runs out. */
137
+ async function follow_chat(context) {
138
+ const { server, task, state, start } = context;
139
+ const log_path = resolve(task.directory, `antigravity-${task.id}.log`);
140
+ const logged = new Map(), noted = new Set(), finished = new Map();
141
+ const log = entry => {
142
+ const line = JSON.stringify({ type: 'codiscuss.entry', entry });
143
+ if (logged.get(entry.id) !== line) { logged.set(entry.id, line); task.on_output?.(log_path, `${line}\n`); }
144
+ };
145
+ const began = Date.now();
146
+ let failures = 0, started = false;
147
+ try {
148
+ for (;;) {
149
+ if (state.stop) return task.on_failure?.(await state.stop);
150
+ if (Date.now() - began > CHAT_TIMING.wait_ms) return task.on_failure?.('私聊超过等待上限(2 小时)仍未结束,已不再等待;这一轮可能仍在 Antigravity App 中运行');
151
+ let conversation = null;
152
+ try { conversation = await read_conversation(server, state.session_id); failures = 0; }
153
+ catch (error) { if (++failures >= CHAT_TIMING.read_failures) return task.on_failure?.(`读取 Antigravity App 对话失败:${error.message}`); }
154
+ if (conversation) {
155
+ conversation.steps.slice(start).forEach((step, offset) => {
156
+ const index = start + offset;
157
+ for (const entry of step_entries(step, index)) log(entry);
158
+ const text = step.type === 'CORTEX_STEP_TYPE_PLANNER_RESPONSE' && step.status === 'CORTEX_STEP_STATUS_DONE' && step.plannerResponse?.response?.trim();
159
+ if (text && !noted.has(index)) { noted.add(index); task.on_note?.(text); }
160
+ });
161
+ started ||= conversation.running || noted.size > 0;
162
+ if (started && conversation.idle && !turn_pending(conversation.steps.slice(start), finished)) return state.stop ? task.on_failure?.(await state.stop) : task.on_exit?.();
163
+ if (!started && Date.now() - began > CHAT_TIMING.start_ms) {
164
+ return task.on_failure?.(`Antigravity App 受理了消息,但 ${Math.round(CHAT_TIMING.start_ms / 1000)} 秒内没有开始这一轮;请在 App 里查看`);
165
+ }
166
+ }
167
+ await sleep(CHAT_TIMING.poll_ms);
168
+ }
169
+ } catch (error) { return task.on_failure?.(error.message); }
170
+ }
171
+
172
+ /**
173
+ * Stop the turn and its background commands: Cancel ends a running turn (it refuses an idle one), ForceStopCascadeTree
174
+ * also ends commands moved to the background, which Cancel leaves running. Resolves to the reason the turn reports.
175
+ */
176
+ async function stop_turn(server, session_id) {
177
+ const cancel = await app_rpc(server, 'CancelCascadeInvocation', { cascadeId: session_id }).then(() => null, error => error);
178
+ try { await app_rpc(server, 'ForceStopCascadeTree', { conversationId: session_id }); return '已停止'; }
179
+ catch (error) {
180
+ return cancel ? `未能停止:${error.message};这一轮可能仍在 Antigravity App 中运行`
181
+ : `已停止这一轮,但未能停止后台命令(${error.message}),后台命令可能仍在运行`;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * A private chat turn in the member's App conversation: the line goes in through the Sidecar's agentapi, and the turn is
187
+ * followed, stopped and steered through the App's language server. A line sent while the turn runs joins it at the next
188
+ * step boundary. Without the server's address and token (an older Sidecar, or an App that no longer passes them) the line
189
+ * is still sent, but the turn reports that no reply can be read.
190
+ */
191
+ async function chat(config, binding, task) {
192
+ const ready = ready_sidecar(config, binding, task);
193
+ const server = ready.language_server?.address && ready.language_server?.csrf_token ? ready.language_server : null;
194
+ let start = 0;
195
+ if (server && binding.session_id) {
196
+ let conversation;
197
+ try { conversation = await read_conversation(server, binding.session_id); }
198
+ catch (error) { throw not_sent(error); }
199
+ if (conversation.running) throw not_sent(new Error('这个 Antigravity App 对话正在运行,等它结束再私聊,或直接在 App 里对话'));
200
+ start = conversation.steps.length;
201
+ }
202
+ const session_id = await send_chat(config, { id: task.id, prompt: task.prompt, session_id: binding.session_id || '', access_path: task.access_path });
203
+ if (!server) {
204
+ setImmediate(() => task.on_failure?.('消息已发到 Antigravity App 对话,但拿不到回复:Sidecar 没有提供 App 本机服务的地址与令牌'
205
+ + '(旧版 Sidecar 需重启 App 后更新,或 App 版本已变);请在 App 里查看这一轮'));
206
+ return { session_id, status: 'accepted', capabilities: IN_APP };
207
+ }
208
+ const state = { session_id, running: true, stop: null, steers: 0 };
209
+ const child = new EventEmitter();
210
+ child.kill = () => {
211
+ state.stop ||= stop_turn(server, session_id);
212
+ return true;
213
+ };
214
+ task.on_process?.(child);
215
+ follow_chat({ server, task, state, start }).finally(() => { state.running = false; child.emit('close', 0); });
216
+ // An interjection the App cannot take now (the turn ended, or reading or sending failed) waits as the next chat line.
217
+ const steer = async text => {
218
+ try {
219
+ if (!state.running || state.stop || !(await read_conversation(server, session_id)).running) return false;
220
+ await send_chat(config, { id: `${task.id}-steer-${++state.steers}`, prompt: text, session_id, access_path: task.access_path });
221
+ } catch { return false; }
222
+ task.on_output?.(resolve(task.directory, `antigravity-${task.id}.log`), `${JSON.stringify({ type: 'codiscuss.interjection', text })}\n`);
223
+ return true;
224
+ };
225
+ return { session_id, status: 'accepted', capabilities: FOLLOWED, steer };
226
+ }
227
+
39
228
  /** The App keeps its own conversation store, apart from agy's. */
40
229
  function session_path(session_id, env = process.env, home = homedir()) {
41
230
  const file = join(home, '.gemini', 'antigravity', 'conversations', `${session_id}.db`);
@@ -82,7 +271,7 @@ export const antigravity = {
82
271
  },
83
272
  // Explicit install of the Sidecar the App runs for this member (the setup-sidecar command).
84
273
  setup: setup_sidecar,
85
- deliver, receipt,
274
+ deliver, receipt, chat,
86
275
  // The App's conversations are read like agy's for the model they ran with; they cannot be branched from here.
87
276
  session: { path: session_path, exists: session_exists, owns: session_exists, branch: null,
88
277
  settings: (id, { env = process.env, home = homedir() } = {}) => database_settings(session_path(id, env, home)) },