ai_co_discuss 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,19 +21,21 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
21
21
 
22
22
  网站读写优先用 `codiscuss room …`、`codiscuss message …` 等统一命令;任务或用户显式指定 CLI 路径时沿用指定入口。未安装统一命令时,用已核实的 `python 路径/runner/discuss.py …`。统一入口需 Node.js 24+,网站读写需 Python 3.10+。裸 `codiscuss` 会启动服务;查询用法使用 `codiscuss --help` / `codiscuss schema …`,下文命令均省略入口前缀。
23
23
 
24
- 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as`。首次接入读一次 `room protocol`。
24
+ 从实际任务或用户指定值核实站点、房间和身份,不从当前网页或最近聊天猜测。站点用 `--site` 或当前进程 `DISCUSS_URL`,房间始终显式指定,业务写入显式 `--as`。手动首次接入读一次 `room protocol`;自动任务不读,需要字段时只查 `schema 具体命令`,不列全部命令。
25
25
 
26
26
  项目与 cwd 可缺省,有值时用于本次获准资料;CLI 路径只定位工具,不把 CLI 仓库当作讨论项目或扩大文件权限。
27
27
 
28
28
  **自动任务(收到 `*.task.json`):**
29
29
 
30
- - 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter,直接沿用,不再执行第二次**。会话 ID 由接入程序提供,不编造、不打印凭据内容。
30
+ - 从任务文件取得身份、职责、事件、安排、CLI 和 `access_file`。按任务指令以 `execution enter ROOM EVENT --access-file FILE` 认领一次;**若当前调用刚已成功 enter,直接沿用,不再执行第二次**。若提示词写明接入程序已进入,则跳过 enter。会话 ID 由接入程序提供,不编造、不打印凭据内容。
31
31
  - 进入失败或重复交付时,用 `execution view` 和实际业务结果核对;仅凭事件显示 started,不能认定可以再做一遍。暂停、到期或失效的旧任务不得继续写入。
32
32
  - 本次受保护写入统一附同一 `--access-file`。只处理交付的职责和安排;专家提交答案即完成此执行,主持人的交接边界见主持技能。
33
33
  - 通知程序负责续租与后续交付。AI 不创建定时任务、不操作其他软件,不另发“就绪/完成”回执。确实无法继续时保留草稿,用 `execution fail ROOM EVENT --reason 原因 --access-file FILE` 暂停;已失效时不再写回执或清理消息。
34
34
 
35
35
  **自由协作任务(任务文件含 `mode: "free"`):** 没有发言权、安排和阶段,不执行 `execution enter`。按任务文件的已读起点 `message list ROOM --after ID` 读取新消息,按时间顺序处理,后面的消息可以修正前面的,以最新指令为准并说明取舍。完成后用 `message post ROOM --as 本人 --kind chat --execution 执行编号 --access-file FILE` 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 `--execution` 改作普通发言。手动接入的自由成员没有执行编号:从 `room view` 的 `free.requests` 找到自己的待处理请求,回答时用 `--request 请求编号` 关闭。任务文件 `role` 为 `host` 时你是本房间主持人:`route` 请求是用户未点名的消息,`handoff` 表示你派出的成员都已结束;先 `room view` 看成员,需要他人时在回答中加 `--to 成员名` 派活并说明各自负责的文件或模块,不需要时不加,本次即结束;不点名自己,不扩大用户给的权限。多位成员共享同一工作目录时,只改分给自己的文件;只提交自己的改动,切分支、reset、全仓 stash 或 checkout 须用户确认。
36
36
 
37
+ 自由房间的普通成员可在回答中用 `--to 成员名` 或正文 `@成员` 提出回应建议,不能点名自己;这不会派发任务。网页显示“建议请 X 回应”,只有房主点击“转为请求”后才建立关联原回答的新用户任务链。任务的 `request_messages` 可能指向已读游标之前的原回答,须用 `message view ROOM ID` 补读;用户确认消息不代替原建议。不要把建议显示或复制入口当作已经通知;代码与引用里的正文 @ 不生成建议。
38
+
37
39
  **手动接入:** 用户已选手动时沿用人工触发或原生定时;需要持续参与才读 [手动持续参与](references/manual.md)。没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。
38
40
 
39
41
  ## 先读什么
@@ -11,13 +11,16 @@ launched here.
11
11
 
12
12
  import hmac
13
13
  import json
14
+ import re
14
15
  import time
16
+ import uuid
15
17
 
16
18
  from .identity import MAX_ROOM_AGENTS, is_ai
17
19
  from .runtime import LEASE_SECONDS, begin_write, require_owner, token_hash
18
20
 
19
21
  FREE_EXECUTION_MINUTES = 20
20
22
  RECENT_REQUESTS = 100
23
+ RECENT_EXECUTIONS = 100
21
24
  RECENT_CHANGES = 50
22
25
  MAX_CHANGED_FILES = 200
23
26
  MAX_PATH_LENGTH = 500
@@ -157,6 +160,18 @@ def public_execution(row):
157
160
  return {key: row[key] for key in EXECUTION_FIELDS} if row else None
158
161
 
159
162
 
163
+ def recent_executions(connection, room_id):
164
+ """Return bounded public reports, including requests no longer in the recent request list."""
165
+ rows = connection.execute("""SELECT e.*,
166
+ (SELECT json_group_array(id) FROM member_requests WHERE execution_id=e.id) AS request_ids,
167
+ (SELECT MAX(answer_id) FROM member_requests WHERE execution_id=e.id) AS answer_id,
168
+ (SELECT MIN(message_id) FROM member_requests WHERE execution_id=e.id) AS request_message_id
169
+ FROM member_executions e WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_EXECUTIONS))
170
+ return [{**public_execution(row), "cwd": row["cwd"], "changes": json.loads(row["changes"] or "[]"),
171
+ "changes_recorded": bool(row["changes"]), "request_ids": json.loads(row["request_ids"]),
172
+ "answer_id": row["answer_id"], "request_message_id": row["request_message_id"]} for row in rows]
173
+
174
+
160
175
  def possible_conflicts(connection, room_id, names):
161
176
  """Report files changed by executions of different members whose time windows overlapped.
162
177
 
@@ -204,7 +219,7 @@ def free_view(connection, room_id):
204
219
  WHERE room_id=? ORDER BY id DESC LIMIT ?""", (room_id, RECENT_REQUESTS))]
205
220
  chains = [dict(row) for row in connection.execute(
206
221
  "SELECT chain_id,reason FROM free_chains WHERE room_id=? ORDER BY chain_id DESC LIMIT 10", (room_id,))]
207
- return {"members": members, "requests": requests[::-1], "execution_minutes": execution_minutes(connection, room_id),
222
+ return {"members": members, "requests": requests[::-1], "executions": recent_executions(connection, room_id), "execution_minutes": execution_minutes(connection, room_id),
208
223
  "conflicts": possible_conflicts(connection, room_id, names), "halted_chains": chains, "max_auto_hops": MAX_AUTO_HOPS}
209
224
 
210
225
 
@@ -278,11 +293,9 @@ def proxy_member(connection, room_id, author):
278
293
  def check_answer(connection, room_id, member, payload):
279
294
  """A runner member completes a live execution; a manual member may close its own open requests.
280
295
 
281
- Only the AI host may name members in its answer; online members are forwarded by the owner.
296
+ Only the AI host dispatches work; other members' targets remain suggestions.
282
297
  """
283
298
  if payload.get("to") is not None:
284
- if member["role"] != "host":
285
- raise PermissionError("只有主持人可以在回答中点名成员派活")
286
299
  named_members(active_members(connection, room_id), payload["to"], exclude=member["name"])
287
300
  if member["access"] == "online":
288
301
  raise PermissionError("在线成员只读,回答由用户代发")
@@ -323,6 +336,82 @@ def insert_request(connection, room_id, member_id, message_id, chain_id, purpose
323
336
  VALUES (?,?,?,?,?,?,?,?)""", (room_id, member_id, message_id, chain_id, batch_id, purpose, created_by, int(time.time())))
324
337
 
325
338
 
339
+ def mention_prose(text):
340
+ """Remove Markdown code and quote blocks before interpreting response suggestions."""
341
+ lines, fence, quoted = [], "", False
342
+ for line in text.splitlines():
343
+ marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
344
+ if marker:
345
+ value = marker[1]
346
+ if not fence:
347
+ fence = value
348
+ elif value[0] == fence[0] and len(value) >= len(fence):
349
+ fence = ""
350
+ continue
351
+ if fence or re.match(r"^( {4}|\t)", line):
352
+ continue
353
+ if re.match(r"^ {0,3}>", line):
354
+ quoted = True
355
+ continue
356
+ if not line.strip():
357
+ quoted = False
358
+ if not quoted:
359
+ lines.append(line)
360
+ return re.sub(r"(`+)[\s\S]*?\1(?!`)", "", "\n".join(lines))
361
+
362
+
363
+ def save_suggestions(connection, room_id, message_id, member, payload):
364
+ """Save ordinary members' explicit targets and prose mentions without waking anyone."""
365
+ if member == "用户" or member["role"] == "host":
366
+ return
367
+ active = active_members(connection, room_id)
368
+ targets = set(named_members(active, payload["to"] if payload.get("to") is not None else [], exclude=member["name"]))
369
+ prose = mention_prose(str(payload.get("text", "")))
370
+ for name, target in active.items():
371
+ if name != member["name"] and re.search(r"(^|[\s((,,。;;::])@" + re.escape(name) + r"(?![A-Za-z0-9_\-\u4e00-\u9fff])", prose):
372
+ targets.add(target["id"])
373
+ for target_id in targets:
374
+ connection.execute("INSERT INTO member_suggestions (message_id,member_id) VALUES (?,?)", (message_id, target_id))
375
+
376
+
377
+ def message_suggestions(connection, message_id):
378
+ """Expose suggested members and the durable request, including terminal conversions."""
379
+ return [dict(row) for row in connection.execute("""SELECT s.member_id,m.name AS member,m.status,
380
+ r.id AS request_id,r.status AS request_status FROM member_suggestions s
381
+ JOIN room_members m ON m.id=s.member_id
382
+ LEFT JOIN member_requests r ON r.message_id=s.message_id AND r.member_id=s.member_id
383
+ WHERE s.message_id=? ORDER BY s.member_id""", (message_id,))]
384
+
385
+
386
+ def convert_suggestion(connection, room_id, token, payload):
387
+ """Let the owner turn a saved suggestion into one request and a fresh user chain."""
388
+ begin_write(connection)
389
+ require_owner(connection, room_id, token)
390
+ message_id, member_id = payload.get("message_id"), payload.get("member_id")
391
+ if type(message_id) is not int or type(member_id) is not int:
392
+ raise ValueError("须提供原消息编号 message_id 与成员编号 member_id")
393
+ target = connection.execute("""SELECT m.name,m.status FROM member_suggestions s
394
+ JOIN messages source ON source.id=s.message_id JOIN room_members m ON m.id=s.member_id
395
+ WHERE source.room_id=? AND m.room_id=? AND s.message_id=? AND s.member_id=?""",
396
+ (room_id, room_id, message_id, member_id)).fetchone()
397
+ if not target:
398
+ raise LookupError("这条消息没有该成员的回应建议")
399
+ if target["status"] != "active":
400
+ raise FileExistsError("该成员已离开,不能转为请求")
401
+ if connection.execute("SELECT 1 FROM room_directory WHERE room_id=? AND archived=1", (room_id,)).fetchone():
402
+ raise ValueError("频道已存档;恢复后才能继续协作")
403
+ previous = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
404
+ if previous:
405
+ return {"request": dict(previous), "duplicate": True}
406
+ confirmation = connection.execute("""INSERT INTO messages (room_id,author,text,reply_to,source,submission_id,created_at)
407
+ VALUES (?,'用户',?,?,'user',?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))""",
408
+ (room_id, f"请 @{target['name']} 回应 #{message_id} 的建议。", message_id, uuid.uuid4().hex))
409
+ connection.execute("INSERT INTO message_details (message_id,kind,submitted_by) VALUES (?,'chat','用户')", (confirmation.lastrowid,))
410
+ insert_request(connection, room_id, member_id, message_id, confirmation.lastrowid, "ask", "user")
411
+ request = connection.execute("SELECT * FROM member_requests WHERE message_id=? AND member_id=?", (message_id, member_id)).fetchone()
412
+ return {"request": dict(request), "duplicate": False}
413
+
414
+
326
415
  def halt_chain(connection, room_id, chain_id, reason):
327
416
  """Stop automatic progress on a chain; the user's next message starts a new one."""
328
417
  connection.execute("INSERT OR IGNORE INTO free_chains (room_id,chain_id,reason) VALUES (?,?,?)", (room_id, chain_id, reason))
@@ -412,10 +501,12 @@ def after_free_message(connection, room_id, message_id, actor, payload):
412
501
  def expire_executions(connection, room_id):
413
502
  """Fail executions whose lease lapsed; their requests stay visible as failed, never silently retried."""
414
503
  now = int(time.time())
415
- stale = [row["id"] for row in connection.execute(
416
- "SELECT id FROM member_executions WHERE room_id=? AND status IN ('dispatched','started') AND lease_until<=?", (room_id, now))]
417
- for execution_id in stale:
418
- finish_execution(connection, execution_id, "failed", "执行租约到期,接入程序未续租", "failed")
504
+ limit_seconds = execution_minutes(connection, room_id) * 60
505
+ stale = connection.execute(
506
+ "SELECT id,created_at,lease_until FROM member_executions WHERE room_id=? AND status IN ('dispatched','started') AND lease_until<=?", (room_id, now)).fetchall()
507
+ for row in stale:
508
+ reason = "已达到单次执行时限" if row["lease_until"] >= row["created_at"] + limit_seconds else "执行租约到期,接入程序未续租"
509
+ finish_execution(connection, row["id"], "failed", reason, "failed")
419
510
  if stale:
420
511
  settle_batches(connection, room_id)
421
512
 
@@ -550,6 +550,12 @@ CREATE TABLE IF NOT EXISTS member_requests (
550
550
  UNIQUE(message_id,member_id)
551
551
  );
552
552
  CREATE INDEX IF NOT EXISTS member_requests_open ON member_requests(member_id,status);
553
+ -- Ordinary members can suggest a response without creating work or spending a chain budget.
554
+ CREATE TABLE IF NOT EXISTS member_suggestions (
555
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
556
+ member_id INTEGER NOT NULL REFERENCES room_members(id) ON DELETE CASCADE,
557
+ PRIMARY KEY (message_id,member_id)
558
+ );
553
559
  -- A host's dispatch: its named requests form one batch that hands back to the host once.
554
560
  CREATE TABLE IF NOT EXISTS free_batches (
555
561
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
package/backend/server.py CHANGED
@@ -22,6 +22,7 @@ from urllib.parse import parse_qs, quote, urlsplit
22
22
  from .free_room import (add_member, after_free_message, authorize_free_write, check_answer, check_proxy, execution_action, free_view,
23
23
  inbox, interrupt_member, member_action, member_values, migrate_free_tables, proxy_member, reserve_execution,
24
24
  discussion_members, room_mode, save_room_access, sync_discussion_members, view_execution)
25
+ from .free_room import convert_suggestion, message_suggestions, save_suggestions
25
26
  from .identity import MAX_ROOM_AGENTS, PARTICIPANTS, is_ai, is_participant
26
27
  from .runtime import (authorize_write, begin_write, configure_runtime, event_action,
27
28
  LIMIT_DEFAULTS, LIMITS_SQL, limit_values, limits_view, require_within_limits,
@@ -85,6 +86,7 @@ FREE_BACKUP_QUERIES = {
85
86
  "room_modes": "SELECT room_id,mode FROM room_modes WHERE room_id=?",
86
87
  "room_members": "SELECT id,room_id,name,adapter,role,status,read_until,created_at FROM room_members WHERE room_id=? ORDER BY id",
87
88
  "member_requests": "SELECT * FROM member_requests WHERE room_id=? ORDER BY id",
89
+ "member_suggestions": "SELECT s.* FROM member_suggestions s JOIN messages m ON m.id=s.message_id WHERE m.room_id=? ORDER BY s.message_id,s.member_id",
88
90
  }
89
91
  MESSAGE_QUERY = """SELECT messages.*, COALESCE(details.kind,'opinion') AS kind,
90
92
  COALESCE(details.submitted_by, CASE WHEN messages.source IN ('cli','url','user')
@@ -667,6 +669,7 @@ def attachment_view(row):
667
669
  def message_with_attachments(connection, row):
668
670
  """Attach the published file list to an original message."""
669
671
  message = dict(row)
672
+ message["suggestions"] = message_suggestions(connection, message["id"])
670
673
  message["attachments"] = [attachment_view(item) for item in connection.execute(
671
674
  "SELECT * FROM attachments WHERE message_id=? ORDER BY position,id", (message["id"],))]
672
675
  return message
@@ -838,6 +841,7 @@ def append_free_message(connection, room, payload, actor):
838
841
  (cursor.lastrowid, expected["summary"], expected["summary_by"]))
839
842
  for position, identifier in enumerate(identifiers):
840
843
  connection.execute("UPDATE attachments SET message_id=?,position=? WHERE id=?", (cursor.lastrowid, position, identifier))
844
+ save_suggestions(connection, room_id, cursor.lastrowid, proxy or actor, payload)
841
845
  after_free_message(connection, room_id, cursor.lastrowid, proxy or actor, payload)
842
846
  return {"message": get_message(connection, room_id, cursor.lastrowid), "duplicate": False}
843
847
 
@@ -1189,6 +1193,7 @@ def protocol_text(room):
1189
1193
  "manual 成员可由人工或软件原生定时触发,用本人成员文件自行提交。回答带 --request 请求编号(可重复)关闭自己的请求,不带则为普通发言。不得编造 execution 编号。",
1190
1194
  "online 成员只读,在原聊天回答,由用户以房主凭据代发原文;作者仍是在线成员、提交者为用户,不能用成员凭证自行写入。复制说明不表示已送达或接入。",
1191
1195
  "获授 host 职责的本地成员可在回答中用 --to 点名其他成员,不能点名自己或扩大权限;route 表示分配任务,handoff 表示派出的成员已结束。链上 AI 请求受房间预算限制,到限等待用户。",
1196
+ "普通成员的 --to 或正文 @ 只保存为回应建议,不唤醒;代码与引用中的正文 @ 不算。房主在网页转为请求后建立关联原消息的新用户任务链,重复点击不重复派发。",
1192
1197
  f"附件使用 attachment upload/download;上传使用本人成员文件,消息带 attachment_ids。原始接口为 {base}/attachments 与 {base}/messages,自动成员在回答完成前上传。",
1193
1198
  "每次新内容用新 submission_id,同次重试沿用编号、原文、凭据和执行/请求关联;失败先核对服务端结果,不换身份或编号绕过。用户打断、成员离开或移除后停止本次工作。",
1194
1199
  ])
@@ -1528,7 +1533,7 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1528
1533
  room_id = parts[2]
1529
1534
  token = self.headers.get("X-Discussion-Token", "")
1530
1535
  free = room_mode(connection, room_id) == "free"
1531
- if free and parts[3:4] in (["inbox"], ["executions"], ["members"]):
1536
+ if free and parts[3:4] in (["inbox"], ["executions"], ["members"], ["suggestions"]):
1532
1537
  return self.handle_free_api(connection, parts, method)
1533
1538
  if parts[3:4] == ["attachments"]:
1534
1539
  return self.handle_attachments(connection, parts, params, method)
@@ -1659,6 +1664,8 @@ class DiscussionHandler(BaseHTTPRequestHandler):
1659
1664
  token = self.headers.get("X-Discussion-Token", "")
1660
1665
  if parts[3:] == ["inbox"] and method == "GET":
1661
1666
  result = inbox(connection, room_id, token)
1667
+ elif parts[3:] == ["suggestions"] and method == "POST":
1668
+ result = convert_suggestion(connection, room_id, token, self.read_payload())
1662
1669
  elif parts[3:] == ["executions"] and method == "POST":
1663
1670
  result = reserve_execution(connection, room_id, token, self.read_payload())
1664
1671
  elif len(parts) == 5 and parts[3] == "executions":
@@ -134,7 +134,7 @@ Python 在一个只读事务快照中查询;D1 使用同一个 batch 事务读
134
134
  | --- | --- |
135
135
  | `main.js`、`App.vue`、`ChannelWorkspace.vue` | 协作频道入口、主题与整体布局 |
136
136
  | `ChannelDirectory.vue` | 频道、文件夹和存档管理 |
137
- | `ChannelDiscussion.vue` | 议题卡、消息流、原文导航和输入区 |
137
+ | `ChannelDiscussion.vue` | 议题卡、消息流、原文导航和输入区;正文 `@` 在输入框上方弹出成员补全,按光标位置筛选与替换,支持鼠标、方向键和 Enter / Tab,中文输入期间不拦截候选键;自由房间只列在场成员,讨论房间列主持人与专家,选择不自动发送 |
138
138
  | `TopicPanel.vue`、`WorkspaceDialogs.vue` | 话题资料、主持操作、接入步骤及编辑对话框 |
139
139
  | `CreateDiscussion.vue`、`LimitPicker.vue`、`LaunchSettings.vue` | 新建讨论表单及本机启动配置;推进方式与参与专家分组,单选方式使用强调色卡片,参与者通过“邀请 AI”加入并独立移出;轮次和时长独立配置,以“不限”按钮切换各自上限,按需展开滚轮,选择即时生效,并预览到限条件;鼠标滚轮逐格切换且不产生惯性,鼠标及触摸拖动松手后保留惯性,先阻尼滑行再吸附预计停点,轻点中间数字精确输入;键盘聚焦和直接输入时仅高亮中间选中行,输入与显示共用行高,避免整列描边与原生输入框叠加;时长分小时/分钟两列,收起与摘要共用时长格式;支持键盘,减少动态效果时保留拖动惯性并去掉三维翻转 |
140
140
  | `ProgressBoard.vue`、`ProgressPanel.vue` | 共享进度看板及抽屉容器;Codex 参与者菜单内直接打开 App / CLI 切换弹窗,显式暂停后复用本场配置的读取与保存,不跳转接入页或自动重启 |
@@ -218,17 +218,20 @@ running 状态即使只有 pending 事件,也必须先暂停才能重新启动
218
218
 
219
219
  ## 自由协作协议
220
220
 
221
+ 执行报告:`free.executions` 返回最近 100 次执行的白名单字段,不含凭据或 `client_id`;包含 `cwd`、数组 `changes`、`changes_recorded`、完整 `request_ids`、`answer_id` 与首条 `request_message_id`。报告不依赖最近请求列表是否截断:完成的报告挂在回答下,失败/取消的报告挂在原请求下。`changes_recorded=false` 表示非 Git 或未取得报告,不能当成无变化;已上报的 `[]` 才表示未观察到文件变化。单次时限与续租中断使用不同失败原因。
222
+
221
223
  自由房间(`room_modes.mode='free'`)与讨论房间共用房间、成员登记、消息、附件与目录,没有发言权、安排、阶段推进和轮次。没有控制凭据登记的旧房间只供阅读,不支持通过首次运行配置认领;本次不迁移历史房间。
222
224
 
223
225
  - **凭据**:建房时由客户端提供房间控制凭据和每位成员的成员凭证,服务端只存哈希。用户身份的房间写入(发言、附件、存档、恢复、删除、打断)都须控制凭据;频道排序和归入文件夹只改共享目录,与站点级文件夹操作一样不需要凭据;无凭据、错误凭据、成员凭证或他房凭据一律只读。成员凭证只能以该成员身份发言或上传附件。声明字段(`author`、`source`、localhost)不代替凭据。
224
226
  - **成员**:`room_members` 以 `id` 为稳定主键,`access` 为 `runner`(接入程序投递)、`manual`(成员自行读写)或 `online`(只读、原文代发),`session_mode` 标记是否借用用户正在使用的会话。房主用控制凭据 `POST members` 运行中加入成员,`POST members/ID {action}` 让成员离开(`away`,作废其进行中执行与未投递请求)、请回(`active`,必须提供不同于原凭证的新凭证,旧凭证副本不再有效)或移除(`remove`,保留历史发言)。同一成员凭证不能复用于其他成员或房主;相同凭证与配置的邀请、请回重试返回已有结果。会话续接由本机接入程序负责,服务端不保存会话绑定。
225
227
  - **请求**:用户消息按结构化接收者 `to`(成员名)为每位成员生成一条请求;省略且仅有一位在场成员时默认点名该成员,多位成员时不点名只作留言,不广播。网页从点名按钮和正文中代码与引用之外的 `@成员` 生成 `to`。请求状态为 `pending`(待投递)→ `delivering`(处理中)→ `completed` / `cancelled` / `failed`。
226
228
  - **执行**:`member_executions` 每位成员最多一个 `dispatched/started`(部分唯一索引),不同成员可并行。接入程序用控制凭据 `GET inbox`、`POST executions {member_id, client_id}`(同一 `client_id` 返回同一执行)把成员的全部待投递请求打包;`POST executions/ID {action}` 支持 `renew`(租约 120 秒,不超过单次执行时限,默认 20 分钟)、`fail`(请求标记失败)和 `release`(仅确认未送达时退回队列)。租约过期的执行在下次读取或领取时标记失败,不自动重发。
227
- - **主持人与任务链**:房主用 `POST members/ID {action:"role", role:"host"}` 指定至多一位主持人(在线成员不能担任)。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己,其他成员带 `to` 返回 403),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
229
+ - **主持人与任务链**:房主用 `POST members/ID {action:"role", role:"host"}` 指定至多一位主持人(在线成员不能担任)。多位成员时未点名的房主消息生成给主持人的 `route` 请求。主持人的回答可带 `to` 点名其他成员(不能点名自己),这些请求组成一个批次(`free_batches`);批次内全部进入终态后,只要有一个完成就给主持人生成一次 `handoff` 请求,全部失败则停下该任务链。每条房主消息开启一条任务链(`chain_id` 为该消息编号),链上 AI 发起的请求(派活与回交)最多 8 次,超出即停下(`free_chains`,原因 `budget` / `all_failed`),房主发新消息开启新链。Worker 用同一批次内的集合 SQL 完成回交,回交请求按(派活消息,主持人)唯一,重复执行不会重复唤醒。
230
+ - **回应建议**:普通成员新发言的结构化 `to` 与正文在场成员 @ 合并保存到 `member_suggestions`(原消息编号、稳定成员编号),排除自己、代码与引用;不会创建请求。消息读取返回 `suggestions`,包含目标在场状态及已转换的请求编号。房主用 `POST suggestions {message_id,member_id}` 转换:同一事务记录用户确认消息,调用现有建请求路径,`message_id` 保留原回答、`chain_id` 使用用户确认消息编号、`created_by=user`。`UNIQUE(message_id,member_id)` 使重复或并发点击只生成一个请求及一条确认,终态不重新派发。权限、在场状态和未存档条件在写入事务内校验;Worker 使用批次断言。升级须应用包含新表的 schema;Python 重启时自动建表,D1 发布前显式执行。已有历史消息不补写建议。
228
231
  - **在线成员**:`access=online` 的成员只读,不进入 `inbox`,不能用自己的凭证发言;房主以控制凭据代发其回答(`author` 为该成员、`submitted_by` 为用户、`source=proxy`),可带 `request_ids` 关闭它的待处理请求。
229
232
  - **关联房间**:从自由房间发起的讨论房间在建房时带 `parent_room_id`,记录在 `room_links`;讨论房间视图有 `parent_room`,自由房间视图列出 `linked_rooms`。头脑风暴的轮次、时长与名单因此天然独立于自由房间。
230
233
  - **手动成员**:手动成员不进入 `inbox`,也不能被领取执行;它用成员凭证读写,回答时可带 `request_ids` 关闭自己的待处理请求,不带则是普通发言、不关闭任何请求。
231
- - **改动记录**:接入程序在执行前后对成员 cwd 做 Git 工作区快照(状态与内容哈希),执行结束且进程退出后用 `report {changes}` 记录一次。房间视图的 `free.conflicts` 列出不同成员在同一 cwd、时段重叠的执行都改动过的文件,只作“可能冲突”提示;非 Git 目录不记录。
234
+ - **改动记录**:接入程序在执行前后对成员 cwd 做 Git 工作区快照(状态与内容哈希),执行结束且进程退出后用 `report {changes}` 记录一次。房间视图的 `free.conflicts` 列出不同成员在同一 cwd、时段重叠的执行均观察到变化的文件,只作“可能冲突”提示;非 Git 目录不记录。快照也会包含其他进程在同一目录的写入,不能确定修改者;分工修改不同文件仍可能出现提示,不代表已发生覆盖。
232
235
  - **回答**:接入程序投递的成员回答须带 `execution_id` 并使用成员凭证;消息保存、执行完成、请求完成和成员已读游标在同一事务中写入。执行结束、被打断或过期后的回答返回 409;缺少执行编号返回 400,不能改作普通发言。消息类型为 `chat`,不计入讨论轮次。
233
236
  - **打断**:`POST members/ID/interrupt`(控制凭据)作废该成员的进行中执行及所有未投递请求,不会自动重新开始;成员的已读游标不前移,下一次请求从同一位置读起。执行失败只影响该成员的请求,房间不暂停。
234
237
  - **讨论成员变更**:房主修改讨论名单或读写能力时,为新增或改变能力的成员提供新凭证,未变成员保留原凭证;删去的成员立即停用。成员主持不能自行扩大名单或更改主持职责。
package/docs/cli.md CHANGED
@@ -153,7 +153,7 @@ codiscuss message post ROOM --as Claude --kind chat --execution 执行编号 --b
153
153
 
154
154
  手动接入的成员没有执行编号:`room view` 的 `free.requests` 列出它的待处理请求,回答时用 `--request 请求编号`(可重复)关闭对应请求;不带 `--request` 的回答是普通发言,不关闭任何请求。`--request` 与 `--execution` 不能同用。
155
155
 
156
- 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己,非主持人使用会被拒绝。被点名的成员都结束后,主持人会再收到一次 `handoff` 请求。
156
+ 主持人在回答中用 `--to 成员名`(可重复)点名派活,例如 `--to Codex --to Gemini`;不能点名自己。被点名的成员都结束后,主持人会再收到一次 `handoff` 请求。普通成员也可用 `--to` 或正文 `@成员` 提出回应建议,但不会派发;房主在网页点击“转为请求”后才建立关联原消息的新用户任务链。代码与引用里的正文 @ 不生成建议。
157
157
 
158
158
  ## 写入核对与错误处理
159
159
 
package/docs/runner.md CHANGED
@@ -36,7 +36,7 @@ codiscuss local stop
36
36
 
37
37
  安装实现保持固定软件表和本地文件复制。路径查找参考 [npm/node-which](https://github.com/npm/node-which/blob/main/lib/index.js) 的 PATH 顺序与执行权限检查,只覆盖所需命令和后缀;技能复制参考 [OpenAI 单文件安装脚本](https://github.com/openai/skills/blob/main/skills/.system/skill-installer/scripts/install-skill-from-github.py) 的整目录复制与同名保护思路,保留本项目的内容哈希更新判断。不引入这些项目作为依赖,也不增加在线技能下载、插件注册或后台更新。
38
38
 
39
- 默认 `setup` 最后执行 `local open`:后台启动 Python 回环网站(`--local-launch --no-demo`)与讨论服务,再打开浏览器;首次为空库。网站地址默认为 `http://127.0.0.1:8765`。`local start` 具有相同启动行为但不打开浏览器,重复启动复用原进程;真正绑定成功并通过健康检查后才返回就绪。端口占用会失败,不接管或停止占用者。网站管理进程脱离启动它的终端在后台运行,关闭终端不会停止网站或讨论服务;本机网站不设登录自启,重启电脑后运行 `local open` 重新启动。安装摘要会同时提示这些状态与停止命令。`local open/start/status/stop` 在终端显示中文结果与网站地址;输出被重定向或加 `--json` 时在 stdout 输出 JSON。改端口或升级程序前先 `local stop`,再重新启动。
39
+ 默认 `setup` 最后执行 `local open`:后台启动 Python 回环网站(`--local-launch --no-demo`)与讨论服务,再打开浏览器;首次为空库。网站地址默认为 `http://127.0.0.1:8765`。`local start` 具有相同启动行为但不打开浏览器,重复启动复用原进程;真正绑定成功并通过健康检查后才返回就绪。端口占用会失败,不接管或停止占用者。网站管理进程脱离启动它的终端在后台运行,关闭终端不会停止网站或讨论服务;本机网站不设登录自启,重启电脑后运行 `local open` 重新启动。安装摘要会同时提示这些状态与停止命令。`setup` 启动前若发现网站已在运行但版本(记录在 `local/status.json`)或端口与当前安装不同,交互终端询问是否重启(默认重启,会暂停托管中的讨论);`--yes` 或非交互运行不停止服务,报告待处理及 `local stop` / `local open` 命令。版本与端口一致时直接复用。`local open/start/status/stop` 在终端显示中文结果与网站地址;输出被重定向或加 `--json` 时在 stdout 输出 JSON。改端口或升级程序前先 `local stop`,再重新启动。
40
40
 
41
41
  网站数据库为服务目录下 `local/discuss.sqlite3`,配置、登记表和讨论执行数据也位于包外;服务目录位置及 `DISCUSS_SERVICE_HOME` 见下文。源码运行的 `data/discuss.sqlite3` 不自动迁移。网站仍在而讨论服务已停止时,显式 `local open/start` 会要求网站管理进程恢复服务;不会自动恢复已暂停的房间。`local status` 的 `running` 要求网站和讨论服务同时运行,`website_running` 单独表示网站状态。`local stop` 关闭它创建的网站,并停止它启动或恢复且仍由相同 PID 持有的讨论服务;预先运行或后来独立启动的服务会保留。停止服务会暂停其托管中的活动讨论,数据库保留。日志在 `local/website.log`、`local/supervisor.log` 与原 `service.log`。
42
42
 
@@ -228,7 +228,7 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
228
228
 
229
229
  进度看板的参与者姓名下显示本场已登记的模型与思考强度,不使用新建草稿或本机默认配置代替。由本机桥接的只读 `room-models` 返回 `adapter/model/effort`,按当前站点与房间匹配;本机页面和已授权连接的线上页面均可读取,不要求暂停或浏览器持有房间控制凭据,不返回路径、令牌或会话编号。CLI 未显式指定时显示“默认模型 / 默认强度”,agy 显示“强度随模型”,App 和手动参与提示在原软件内设置;未连接本机、未登记或旧版服务不支持时显示未获取。它展示启动配置,不能证明软件最终采用的默认值,也不提供讨论中改模型的入口。
230
230
 
231
- 两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后 AI 按任务文件自行 `execution enter` 并通过讨论 CLI 保存业务结果;程序不把终端回答直接代发。Codex App / CLI 可通过上述暂停后配置修正保留原绑定切换;其他适配器或项目、会话变更仍须使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话自动出现在桌面 App 的项目列表。
231
+ 两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后由接入程序在会话启动时直接 `execution enter`(`codex_cli` 与 `claude`,任务内容内联进提示词,省去模型读文件和进入的往返;其余适配器仍由 AI 自行进入),AI 通过讨论 CLI 保存业务结果;Codex CLI 输出 `turn.completed` 1.5 秒后若进程未退出即终止,避免同会话的下一次投递等待收尾。每次执行在任务目录写 `event-ID.trace.jsonl`(时间戳加阶段:`event_seen`、`blocked_by_running_process`、`reserve_*`、`permission_check_*`、`cli_spawned`、`enter_*`、Codex 的 `thread.started` / `turn.started` / 工具命令 / `turn.completed`、`kill_after_completed`、`cli_closed`),仅用于定位各阶段耗时,不含凭据;程序不把终端回答直接代发。Codex App / CLI 可通过上述暂停后配置修正保留原绑定切换;其他适配器或项目、会话变更仍须使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话自动出现在桌面 App 的项目列表。
232
232
 
233
233
  `codex_cli` 的 `exec resume` 需要取得该会话的写入权;已有聊天仍被 Codex App 或另一进程持有时,会报 `thread-store conflict / already has an active writer`,App 显示空闲也不代表已释放。重复重试或仅关闭窗口不能保证解除占用。需要沿用 App 原聊天时,暂停后在本场接入设置中切到 `codex`,由 `codex queue` 向原聊天交付;需要独立 CLI 时,使用未被占用的 CLI 会话或新建讨论时省略会话 ID。接入程序保留这一根因,不让后续模型列表刷新超时覆盖它,也不自动换会话或切换适配器。
234
234
 
@@ -273,12 +273,16 @@ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地
273
273
 
274
274
  ## 自由协作房间
275
275
 
276
+ 执行结束后,Git 快照成功时即使没有变化也上报 `changes: []`,供报告卡显示“未观察到文件变化”;非 Git 目录或快照失败不伪造空报告。上报记录是工作区观察,可能包含其他并行进程写入;报告卡不归因修改者。服务重启导致未上报的历史记录仍显示“未提供自动改动检测”。
277
+
276
278
  网页选择“自由协作”及自动接入后,启动器沿用浏览器生成的房间控制凭据和各成员凭证,保存到本机私有配置 `members` 并登记常驻服务,不启用讨论运行。私有配置带 `mode: "free"`,服务据此使用 `free_runner.mjs`。手动建房直接调用网站,不启动或要求本机服务;浏览器保存私有接入资料,按成员分别下载凭据文件。
277
279
 
278
- `codiscuss launch room-member` 在运行中的自由房间邀请(`add`)或请回(`rejoin`)自动成员:先用房间控制凭据核对房主身份,在登记配置中写入成员的软件配置与新凭证,再通知网站;网页通过 `member_token` 提供其已保存的新凭证,结果不明时沿用同一凭证重试。该命令也可为手动成员生成配置旁的 `manual-成员名.access.json`,回执只含文件路径;网页手动邀请和请回则直接在网站完成。常驻服务读取更新后的配置时逐个成员核对:新成员直接采用,已绑定会话的成员若项目、cwd 或接入方式变化,只阻止该成员并报告原因,不影响其他成员。
280
+ `codiscuss launch room-member` 在运行中的自由房间邀请(`add`)或请回(`rejoin`)自动成员:先用房间控制凭据核对房主身份,在登记配置中写入成员的软件配置与新凭证,再通知网站;网页通过 `member_token` 提供其已保存的新凭证,结果不明时沿用同一凭证重试。网页邀请默认项目只读,显式选择可写后通过该成员的 `participants[name].project_access` 传递,不改变其他成员;重试保留最初选择。该命令也可为手动成员生成配置旁的 `manual-成员名.access.json`,回执只含文件路径;网页手动邀请和请回则直接在网站完成。常驻服务读取更新后的配置时逐个成员核对:新成员直接采用,已绑定会话的成员若项目、cwd 或接入方式变化,只阻止该成员并报告原因,不影响其他成员。
279
281
 
280
282
  每次检查读取 `inbox`:成员没有进行中的执行且有待投递消息时,先持久化本地 `client_id` 再领取执行,然后用原适配器续接该成员的绑定会话;任务文件列出执行编号、待处理消息和已读起点,凭据文件只含成员凭证。进程退出而网站未收到回答、软件报错或超过单次执行时限时报告失败;只有确认未送达才退回队列重试,最多 3 次。领取后、调用前中断的投递不自动重发。主持人的任务文件带 `role: "host"` 和每条请求的 `request_purposes`(`ask` / `route` / `handoff`),说明里写明如何用 `--to` 派活与何时结束。网站上执行被打断、成员离开或被移除后,只停止该成员的进程;不同成员各自投递,可以并行。成员配置了 cwd 且位于 Git 工作区时,执行前后各做一次快照,执行结束且进程退出后报告改动的文件,供网站提示可能冲突;服务重启会丢失尚未报告的快照。只能在网页创建自由房间,不支持对话入口。
281
283
 
284
+ 普通成员的回应建议不会出现在待投递队列。房主转换后沿用同一请求和执行路径:任务文件的 `request_messages` 指向原成员回答,`chain_id` 属于这次用户确认;重复转换不会再次投递,手动与在线目标仍不进入自动投递。
285
+
282
286
  Antigravity App 的 Sidecar 按房间用法校验任务:讨论读取对应事件,自由房间读取对应 `executions/ID` 并核对有效租约。自由执行的受理回执由服务核对并绑定真实会话,即使回答已快速完成也保留绑定;取消、过期或结果不明的投递不重新发送。本次使用替身 Sidecar 与临时服务验证,未重新调用真实 App。
283
287
 
284
288
  ## 常驻讨论服务
package/docs/workflow.md CHANGED
@@ -12,12 +12,16 @@
12
12
 
13
13
  ## 自由协作房间
14
14
 
15
- 自由协作房间从同一角色库选择一位或多位成员,默认由用户主持,也可在创建时指定一位本地 AI 主持人。只有一位成员时,每条消息都交给它;有多位时,在输入框上方点选成员,或在正文中 `@成员`(代码和引用中的 @ 不算),被点名的成员各自处理;未点名且没有 AI 主持人时只作留言。自动成员空闲时,服务把它名下所有未处理的消息打包成一次执行交给原会话;执行期间再发的消息排队,下一次一起投递。手动成员自行读取自己的请求,在线成员由用户转交。成员按时间顺序处理,后面的消息可以修正前面的。
15
+ 成员回答下的执行报告列出请求编号、耗时、终态与工作区观察文件;失败或取消的报告保留在原请求下。报告展示最近 100 次执行;“未提供自动改动检测”表示非 Git 目录或未上报,不能据此判断没有文件变化。
16
16
 
17
- 运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
17
+ 自由协作房间从同一角色库选择一位或多位成员,默认由用户主持,也可在创建时指定一位本地 AI 主持人。只有一位成员时,每条消息都交给它;有多位时,在输入框上方点选成员,或在正文中 `@成员`(代码和引用中的 @ 不算),被点名的成员各自处理;未点名且没有 AI 主持人时只作留言。输入 `@` 后,输入框上方弹出在场成员菜单,继续输入可筛选;用方向键与 Enter / Tab 或点击补全,Esc 关闭。选择只修改草稿,Ctrl / Cmd + Enter 仍发送消息。自动成员空闲时,服务把它名下所有未处理的消息打包成一次执行交给原会话;执行期间再发的消息排队,下一次一起投递。手动成员自行读取自己的请求,在线成员由用户转交。成员按时间顺序处理,后面的消息可以修正前面的。
18
+
19
+ 运行中可在右侧成员栏邀请 AI(沿用本机软件配置和本房间工作目录),也可让成员离开、请回或移除。自动成员的项目文件权限默认只读;需要它修改项目时,在邀请前选择“项目文件可写”,权限只应用于这位成员。离开会立即停止它的工作;请回时换发新凭证并续接它原来的会话。邀请时可选“手动接入”:成员不会被自动唤醒,而是得到一份私有凭据文件,由你在它的会话中按参与技能读写。绑定你正在使用的会话的成员会标注“借用会话”:它工作时不要在原窗口继续操作。
18
20
 
19
21
  可以把一位本机成员设为主持人。之后没有点名的消息交给主持人分配;主持人在回答中点名成员派活,被点名的成员都结束后主持人再被唤醒一次,继续汇总或安排下一步。派出的成员全部失败时不再唤醒主持人;同一条消息引出的 AI 请求最多 8 次,用完即停,成员栏会提示,发一条新消息即重新开始。
20
22
 
23
+ 普通成员回答中的 `@成员` 或结构化 `to` 只保存为“建议请 X 回应”,不自动唤醒;正文里的代码与引用不算建议。房主可在原回答下点击“转为请求”,网站记录一条用户确认,建立新任务链,并把请求关联到原回答。重复点击或重试返回同一条请求,已完成、失败或取消的请求也不会重新派发;目标成员离开后不能转换。自动、手动和在线成员沿用各自的接入方式。
24
+
21
25
  在线 AI 以“在线只读”成员加入:点名后,在成员栏复制阅读通知交给它;拿到回答后,在输入框选择“代发”该成员并粘贴原文,记录保留它为作者、你为提交者,并关闭它的待处理请求。
22
26
 
23
27
  需要深入讨论时,在成员栏点“发起头脑风暴”:用当前成员新建一场讨论房间(有在线成员时为手动推进),轮次和时长独立。讨论收束后,在议题卡片点“把结论发回”,结论会作为留言回到自由房间,不唤醒任何 AI。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/runner/discuss.py CHANGED
@@ -227,7 +227,7 @@ def message_commands(subcommands, registry):
227
227
  post.add_argument("--turn", type=positive_int, help="所回答的安排编号,仅 opinion 可用")
228
228
  post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号,仅 chat 可用;执行失效后不能改作普通发言")
229
229
  post.add_argument("--to", type=ai_name, action="append", default=[],
230
- help="自由房间主持人:在回答中点名派活的成员,可重复;其他成员不能使用")
230
+ help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
231
231
  post.add_argument("--request", type=positive_int, action="append", default=[],
232
232
  help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
233
233
  post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
@@ -10,6 +10,8 @@ import { CodexAppClient } from './codex_app_client.mjs';
10
10
 
11
11
  const CALL_TIMEOUT_MS = 30_000;
12
12
  const CLI_DIAGNOSTIC_CHARS = 1000;
13
+ /** Codex exec may linger after its final event; the finished turn is already saved, so stop it to free the session. */
14
+ const CLI_EXIT_GRACE_MS = 1500;
13
15
  const CODEX_PERMISSIONS_MIN_MINOR = 163;
14
16
  /** Website subcommands an automatic task may run; service, launch, run, api and setup stay unapproved. */
15
17
  export const TASK_SUBCOMMANDS = ['room', 'message', 'turn', 'state', 'floor', 'execution', 'health', 'schema'];
@@ -369,7 +371,7 @@ function bind_cli_session(state, session_id, task) {
369
371
  function cli_event(adapter, state, event, task) {
370
372
  if (adapter === 'codex_cli') {
371
373
  if (event.type === 'thread.started') bind_cli_session(state, event.thread_id, task);
372
- if (event.type === 'turn.completed') state.completed = true;
374
+ if (event.type === 'turn.completed') { state.completed = true; state.finish_timer = setTimeout(() => { state.finished = true; task.on_trace?.('kill_after_completed'); state.child.kill(); }, CLI_EXIT_GRACE_MS); }
373
375
  if (event.type === 'turn.failed') state.error = event.error?.message || 'Codex CLI 执行失败';
374
376
  if (event.type === 'error') state.diagnostic = event.message || 'Codex CLI 返回错误';
375
377
  remember_writer_conflict(state, state.error || state.diagnostic);
@@ -384,6 +386,14 @@ function cli_event(adapter, state, event, task) {
384
386
  }
385
387
  }
386
388
 
389
+ /** Record lifecycle events and tool commands with timestamps so slow stages can be located from the trace file. */
390
+ function trace_cli_event(event, task) {
391
+ if (!task.on_trace) return;
392
+ const item = event.item;
393
+ const command = item?.command ? String(item.command).replace(/^.*?-Command\s+/, '').slice(0, 100) : '';
394
+ task.on_trace(item ? `${event.type}:${item.type}` : event.type, command ? { command } : {});
395
+ }
396
+
387
397
  /** Keep the actual resume failure even when later model-refresh logs replace the diagnostic tail. */
388
398
  function remember_writer_conflict(state, diagnostic) {
389
399
  if (/thread-store conflict:[\s\S]*already has an active writer/.test(diagnostic)) state.writer_conflict = true;
@@ -393,6 +403,7 @@ function remember_writer_conflict(state, diagnostic) {
393
403
  function cli_exit_error(state, code, signal) {
394
404
  if (state.writer_conflict) return `Codex 会话 ${state.session_id} 已被另一进程持有(active writer),CLI 无法接管。若为 App 中的聊天,请使用 Codex App 接入;独立 CLI 须使用未被占用的会话。重复点击重试不会解除占用`;
395
405
  if (state.error) return state.error;
406
+ if (state.finished && state.completed) return '';
396
407
  if (code !== 0) return `CLI ${signal ? `被信号 ${signal} 终止` : `退出码 ${code}`}:${state.diagnostic}`;
397
408
  if (!state.initialized) return `CLI 未确认实际会话 ID:${state.diagnostic}`;
398
409
  if (!state.completed) return `CLI 未返回成功结束事件:${state.diagnostic}`;
@@ -401,7 +412,7 @@ function cli_exit_error(state, code, signal) {
401
412
 
402
413
  /** Parse complete UTF-8 JSON lines while keeping raw output in the private task log. */
403
414
  function monitor_cli(child, config, binding, task) {
404
- const state = { session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
415
+ const state = { child, session_id: binding.session_id || '', initialized: false, completed: false, error: '', diagnostic: '' };
405
416
  const log_path = resolve(task.directory, `${config.adapter}-${task.id}.log`);
406
417
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
407
418
  child.stdout.on('data', chunk => task.on_output(log_path, chunk));
@@ -416,10 +427,13 @@ function monitor_cli(child, config, binding, task) {
416
427
  let event;
417
428
  try { event = JSON.parse(line); }
418
429
  catch { state.diagnostic = 'CLI 输出中存在无效 JSON,详情见本机日志'; return; }
430
+ trace_cli_event(event, task);
419
431
  try { cli_event(config.adapter, state, event, task); }
420
432
  catch (error) { state.error = error.message; child.kill(); }
421
433
  });
422
434
  child.once('close', (code, signal) => {
435
+ clearTimeout(state.finish_timer);
436
+ task.on_trace?.('cli_closed', { code, signal, killed_after_completed: Boolean(state.finished) });
423
437
  if (!state.spawned) return;
424
438
  const error = cli_exit_error(state, code, signal);
425
439
  if (error) task.on_failure(error);
@@ -437,7 +451,11 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
437
451
  let invocation;
438
452
  try { invocation = cli_invocation(config, binding, task, user_home); }
439
453
  catch (error) { throw not_sent(error); }
440
- if (invocation.permissions) await check_codex_permissions(config, task, invocation.permissions, spawn_process);
454
+ if (invocation.permissions) {
455
+ task.on_trace?.('permission_check_start');
456
+ await check_codex_permissions(config, task, invocation.permissions, spawn_process);
457
+ task.on_trace?.('permission_check_done');
458
+ }
441
459
  if (invocation.project) {
442
460
  const on_binding = task.on_binding;
443
461
  task = { ...task, on_binding: session_id => { invocation.project.bind(session_id); on_binding(session_id); } };
@@ -451,11 +469,12 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
451
469
  const state = monitor_cli(child, config, binding, task);
452
470
  task.on_process(child);
453
471
  await new Promise((resolve_spawn, reject) => {
454
- child.once('spawn', () => { state.spawned = true; resolve_spawn(); });
472
+ child.once('spawn', () => { state.spawned = true; task.on_trace?.('cli_spawned'); resolve_spawn(); });
455
473
  child.once('error', error => state.spawned ? task.on_failure(error.message) : reject(not_sent(error)));
456
474
  });
457
475
  child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
458
476
  child.stdin.end(invocation.input);
477
+ task.on_trace?.('prompt_sent');
459
478
  return { session_id: state.session_id, status: 'accepted' };
460
479
  }
461
480
 
@@ -55,13 +55,16 @@ conversation 还须 current_session_id,当前会话接手首个主持任务。
55
55
  详见 docs/runner.md 的“两种讨论入口与启动配置”。
56
56
  `;
57
57
 
58
- // Windows PowerShell 5.1 ships WinForms; a topmost owner keeps the dialog above the browser that asked for it.
58
+ // Windows PowerShell 5.1 ships WinForms. A background process cannot take the foreground, so the owner must be
59
+ // shown (off-screen) as a topmost window; the dialog it owns then stays above the browser that asked for it.
59
60
  const WINDOWS_PICKER = [
60
61
  'Add-Type -AssemblyName System.Windows.Forms',
61
62
  '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
62
- '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }',
63
+ '$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; FormBorderStyle = "None"; StartPosition = "Manual"; Location = New-Object System.Drawing.Point(-32000, -32000); Size = New-Object System.Drawing.Size(1, 1) }',
64
+ '$owner.Show(); $owner.Activate()',
63
65
  '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = "选择项目目录"; ShowNewFolderButton = $true }',
64
66
  'if ($dialog.ShowDialog($owner) -eq "OK") { $dialog.SelectedPath }',
67
+ '$owner.Close()',
65
68
  ].join('; ');
66
69
  const PICKERS = {
67
70
  win32: ['powershell.exe', ['-NoProfile', '-STA', '-Command', WINDOWS_PICKER]],
@@ -15,6 +15,7 @@ import { tone } from './discussion_tui.mjs';
15
15
 
16
16
  const SCRIPT = fileURLToPath(import.meta.url);
17
17
  const SERVER = fileURLToPath(new URL('../server.py', import.meta.url));
18
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
19
  const START_WAIT_MS = 15000;
19
20
  const STOP_WAIT_MS = 25000;
20
21
  const POLL_MS = 200;
@@ -149,7 +150,7 @@ async function supervise(home) {
149
150
  const stop_path = join(directory, 'stop.request');
150
151
  const settings = local_settings(home);
151
152
  const url = `http://127.0.0.1:${settings.port || 8765}`;
152
- const status = { pid: process.pid, ready: false, url };
153
+ const status = { pid: process.pid, ready: false, url, version: VERSION };
153
154
  if (existsSync(stop_path)) unlinkSync(stop_path);
154
155
  atomic_json(status_path, status);
155
156
  let website, owned_service, stopped = false;
@@ -208,6 +209,16 @@ async function start_local(home) {
208
209
  throw new Error(`本机启动未完成,请查看 ${join(directory, 'supervisor.log')}`);
209
210
  }
210
211
 
212
+ /** Explain why a running website must restart before this installation's version or port takes effect. */
213
+ export function restart_reason(home = service_home()) {
214
+ const current = local_status(home);
215
+ if (!current.website_running) return null;
216
+ const url = `http://127.0.0.1:${local_settings(home).port || 8765}`;
217
+ if (current.url !== url) return `本机网站正在 ${current.url} 运行,端口配置已改为 ${url}`;
218
+ if (current.version !== VERSION) return `本机网站正在运行${current.version ? `版本 ${current.version}` : '未记录版本的旧程序'},当前安装为 ${VERSION}`;
219
+ return null;
220
+ }
221
+
211
222
  /** Ask the owned supervisor to stop, retaining its database and user configuration. */
212
223
  async function stop_local(home) {
213
224
  if (!local_status(home).pid) return local_status(home);