ai_co_discuss 0.3.93 → 0.3.95

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.
@@ -26,7 +26,9 @@
26
26
  - 收到 `review_handoffs` 时,先用 `review ready ROOM --batch ID` 读取完整委派、交付和报告,确认可交既定评审后用 `review advance ROOM --batch ID --revision REV --submission-id UUID --as 本人` 推进。只处理该批交接,其他请求继续完成;状态变化须重读,不能替换评审者或扩大权限。能力失效、额度不足、交付不完整等原因保留在读取结果和任务中。实际投递前本机复核只读权限与能力版本,失败撤销该次评审并交回主持人。
27
27
  - 收到 `handoff` 后交给其他成员或汇总给用户;成员的回答已完整时只用一两句说明完成,不复述。
28
28
  - 几位成员要按固定先后接力、后一位依赖前一位的结果(写完再审、实现后评审)时,派活加 `--sequential`,`--to` 按接力顺序点名:程序在每位答完后直接交给下一位,最后一位答完交回你,中间不唤醒你。走完一圈算一轮(中间各棒不另计)。派活正文要把每一棒做什么、按什么标准交给下一棒写清楚,后面的成员靠它和前一棒的回答工作。
29
- - 要“改完再审、审到通过为止”时再加 `--loop`:每走完一圈,交回你的 `handoff` 带 `sequence.phase: checkpoint`。本圈最后一位明确要求返工时,用 `sequence continue ROOM --request 该交接请求编号 --as 本人` 开始下一圈(程序直接接着走,你本次即结束);认可、只剩可不采纳的建议、需要你或用户裁定时,不继续,直接验收或说明。开启智能调度时,它会先替你判断检查点,只有明确返工才自动开始下一圈,其余仍交给你;任务的 `scheduler_suggestions` 记着它的判断。`sequence view ROOM ID` 可读本圈各棒原文。每开始新的一圈算一轮,轮数用尽时循环停下,由用户决定是否继续。
29
+ - 某一棒有几位互不依赖、可以同时做的成员(如几位评审各审一类问题)时,把他们写成同一棒:第一位用 `--to`,其余紧跟着用 `--with`(如 `--to 写手 --to 评审甲 --with 评审乙 --sequential --loop`)。这一棒的成员同时开工,全部结束才交下一棒,下一棒能读到他们每一份回答;组里有人失败,接力停下交回你,用户重试失败的那位后接着走。并行的成员看不到彼此的意见,派活正文要分清各自管什么。
30
+ - 派活时想让某位成员同时开工、但不必等它(如评审期间让记录员先整理别的材料),加 `--detach 成员`(可重复,不能与 `--to` 重名):它和这次派活一起开工,交回你或交下一棒都不等它;它做完不交回你,结果在房间里可读;失败时程序通知你。用在依次接力上时,它只随第一棒开工一次。撤回这次派活会一并撤回它。
31
+ - 要“改完再审、审到通过为止”时再加 `--loop`:每走完一圈,交回你的 `handoff` 带 `sequence.phase: checkpoint`。本圈最后一位(最后一棒是同时做的一组时,其中任一位)明确要求返工时,用 `sequence continue ROOM --request 该交接请求编号 --as 本人` 开始下一圈(程序直接接着走,你本次即结束);认可、只剩可不采纳的建议、需要你或用户裁定时,不继续,直接验收或说明。开启智能调度时,它会先替你判断检查点,只有明确返工才自动开始下一圈,其余仍交给你;任务的 `scheduler_suggestions` 记着它的判断。`sequence view ROOM ID` 可读本圈各棒原文。每开始新的一圈算一轮,轮数用尽时循环停下,由用户决定是否继续。
30
32
  - 用户只点名你(旁问,如问近况、是否卡住)时:读 `sequence view`、`room events` 与进行中执行的详情后直接回答,接力和循环照常进行。旁问里的新任务涉及正在接力的成员或同一批文件时,等接力交回后再派,或先撤回原派活;互不相干的可以马上派。用户发言会清除你之前的 `--continue`,仍需要就在这次回答里重新带上。
31
33
  - 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,0 为不限;本链已用与上限见 `free.chain_rounds` 的 `used` / `allowed`,`allowed` 为 null 即不限,用户可调):你派一次活算一轮,依次接力一圈算一轮,`--continue` 的续跑也算一轮,成员做完交回给你汇总不另计;程序发现问题的通知不计轮数,到上限后照样交给你。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
32
34
  - 已知整项任务所需轮数超过剩余轮数时(连写几集、多步实现加评审),在开始派活时带 `--need-rounds N --rounds-reason "…"`(N 为从这次派活起还需要的轮数,含最后汇总):剩余不够时任务链立即停下,用户看到理由并一键批准差额后才发出这次派活,之后不再中途停下;够用时照常派出。不要在回复里让用户改上限或回“继续”,也不要建议超出 `need-rounds` 上限(100)的数值。
@@ -38,7 +38,7 @@ description: AI CoDiscuss 工作流的创建与修改技能。用户要把房间
38
38
  - **只写验证过的做法**:用户直接给了分工、还没跑过的,按用户的分工写,跑过一轮后再用同一 ID 修订。
39
39
  - **权限按职责最小**:只有需要改文件的角色给 `write`;同一份文件同一时刻只让一个角色改。
40
40
  - 主持人不设卡片;主持人的职责写在 SKILL.md 里。
41
- - **固定先后的接力写成依次派活**:几个角色按固定顺序接力、后一位依赖前一位的结果时(写完再审、实现后评审),在 SKILL.md 写明主持人用一次依次派活点名这几位(按接力顺序),需要“改到通过为止”时写成循环,由主持人在每圈结束时决定是否再来一圈;不要写成主持人每一棒都亲自转交。原因:程序会在每棒答完后直接交给下一位,主持人只在开头和验收时出场。
41
+ - **固定先后的接力写成依次派活**:几个角色按固定顺序接力、后一位依赖前一位的结果时(写完再审、实现后评审),在 SKILL.md 写明主持人用一次依次派活点名这几位(按接力顺序),需要“改到通过为止”时写成循环,由主持人在每圈结束时决定是否再来一圈;不要写成主持人每一棒都亲自转交。原因:程序会在每棒答完后直接交给下一位,主持人只在开头和验收时出场。几位评审排在同一圈时,按问题类型分工、各自的“必改”只限本类。互不依赖的评审写成同一棒并行(主持人用 `--with` 点名),这一棒全部审完才交下一位,循环检查点读每位的结论,任一位要求返工就再来一圈;他们看不到彼此的意见,SKILL.md 要分清各自管什么。后一位要参考前一位时排成串行,并让最后一位读完前面的结论后给出合并结论:循环只读最后一棒,不汇总会漏掉前面的返工。
42
42
 
43
43
  ## 格式
44
44
 
@@ -96,7 +96,7 @@ project_access: write
96
96
 
97
97
  - **工作流 ID**(文件夹名 = SKILL.md 的 `name`):小写英文和数字,可用单个连字符连接(不在首尾),1—40 位。`label` 最多 32 字。
98
98
  - **`description`** 最多 500 字:写清做什么、什么时候用。主持人只凭它判断一条请求该不该按这个工作流做,写含糊了,该用时会不用、不该用时会套用。
99
- - **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`(网页显示的 Gemini 写 `Antigravity`),须是本机已配置的软件,或已在本机保存 API Key 的模型 API(没有本地软件的平台按官方 API 加入)。成员名由程序拼成 `平台-卡片ID`。
99
+ - **卡片**:最多 30 张。`name` 是卡片 ID,等于文件名,只用小写英文、数字和下划线,最多 16 位。`platform` 是平台 ID:`Claude`、`Codex`、`Antigravity`、`DeepSeek`、`Qwen`、`Grok`、`OpenAI`、`Custom`(网页显示的 Gemini 写 `Antigravity`,自定义 API 写 `Custom`),须是本机已配置的软件,或已配置的模型 API(官方平台可按已保存的 API Key 加入;Custom 须先保存完整的本机 API 配置)。成员名由程序拼成 `平台-卡片ID`。
100
100
  - **卡片可选字段**:`label`、`description`、`project_access`(read / write / auto / full)、`model`、`effort`。不用的字段直接省略;模型和强度省略即用本机默认值。接入方式、可执行文件、密钥不写进卡片,由各自电脑的配置决定。
101
101
  - 头信息只支持单行 `key: value`;不支持多行值、列表和嵌套。
102
102
  - 卡片正文留空时使用该软件的默认提示。
@@ -604,6 +604,10 @@ def resolve_member_fields(connection, room_id, payload):
604
604
  resolve = lambda value: handles.get(value.strip(), value) if isinstance(value, str) else value
605
605
  if isinstance(payload.get("to"), list):
606
606
  payload["to"] = [resolve(item) for item in payload["to"]]
607
+ if isinstance(payload.get("detach"), list):
608
+ payload["detach"] = [resolve(item) for item in payload["detach"]]
609
+ if isinstance(payload.get("parallel"), list):
610
+ payload["parallel"] = [[resolve(item) for item in group] if isinstance(group, list) else group for group in payload["parallel"]]
607
611
  for key in ("next_review", "expert"):
608
612
  if key in payload:
609
613
  payload[key] = resolve(payload[key])
@@ -985,7 +989,7 @@ def recipients(connection, room_id, payload):
985
989
 
986
990
  # What a proactive post may not carry: it answers no request. Only the host's may also dispatch, as an answer does.
987
991
  PROACTIVE_EXCLUDED = ("execution_id", "request_ids", "next_review", "turn_id")
988
- HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "need_rounds", "rounds_reason")
992
+ HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "parallel", "detach", "need_rounds", "rounds_reason")
989
993
 
990
994
 
991
995
  def check_need_rounds(member, payload):
@@ -1058,10 +1062,10 @@ def after_proactive(connection, room_id, message_id, member, payload):
1058
1062
  return
1059
1063
  if payload.get("to") and payload.get("sequential") is True:
1060
1064
  start_sequence(connection, room_id, member, message_id, payload["to"], message_id, payload.get("loop") is True,
1061
- need_plan(payload))
1065
+ need_plan(payload), payload.get("parallel"), payload.get("detach"))
1062
1066
  elif payload.get("to"):
1063
1067
  dispatch(connection, room_id, member, message_id, payload["to"], message_id, payload.get("handoff") is True,
1064
- payload.get("no_handoff") is True, {"need": need_plan(payload)})
1068
+ payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
1065
1069
  remember_continue(connection, room_id, member, message_id, message_id, payload)
1066
1070
  settle_batches(connection, room_id)
1067
1071
 
@@ -1280,14 +1284,17 @@ def continue_chain(connection, room_id, token, chain_id, payload):
1280
1284
  connection.execute("""UPDATE free_chain_holds SET message_id=0,host_id=0,names='[]',handoff=0,asked_rounds=0,asked_reason=''
1281
1285
  WHERE room_id=? AND chain_id=?""", (room_id, chain_id))
1282
1286
  if held["message_id"]:
1283
- dispatch(connection, room_id, host, held["message_id"], json.loads(held["names"]), chain_id, bool(held["handoff"]))
1287
+ names = json.loads(held["names"])
1288
+ names, detach = (names["to"], names["detach"]) if isinstance(names, dict) else (names, None)
1289
+ dispatch(connection, room_id, host, held["message_id"], names, chain_id, bool(held["handoff"]), detach=detach)
1284
1290
  return free_view(connection, room_id)
1285
1291
 
1286
1292
 
1287
- def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask=None):
1293
+ def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask=None, detach=None):
1288
1294
  """Stop a chain at its round limit, keeping the dispatch it held back so the owner can send it by continuing.
1289
1295
 
1290
1296
  `ask` is the host's request for more rounds ({"rounds": the rounds to add, "reason"}), shown to the owner to approve.
1297
+ Members sent along without being waited for (`detach`) are held with it: `names` then keeps {"to", "detach"}.
1291
1298
  """
1292
1299
  halt_chain(connection, room_id, chain_id, "budget")
1293
1300
  asked, reason = (ask["rounds"], ask["reason"]) if ask else (0, "")
@@ -1295,7 +1302,8 @@ def hold_dispatch(connection, room_id, host, message_id, names, chain_id, handof
1295
1302
  VALUES (?,?,?,?,?,?,?,?)
1296
1303
  ON CONFLICT(room_id,chain_id) DO UPDATE SET message_id=excluded.message_id,host_id=excluded.host_id,names=excluded.names,
1297
1304
  handoff=excluded.handoff,asked_rounds=excluded.asked_rounds,asked_reason=excluded.asked_reason""",
1298
- (room_id, chain_id, message_id, host["id"], json.dumps(list(names), ensure_ascii=False), int(handoff), asked, reason))
1305
+ (room_id, chain_id, message_id, host["id"], json.dumps({"to": list(names), "detach": list(detach)} if detach else list(names),
1306
+ ensure_ascii=False), int(handoff), asked, reason))
1299
1307
 
1300
1308
 
1301
1309
  def wake_host(connection, room_id, chain_id, text, key):
@@ -1368,13 +1376,14 @@ def resume_host(connection, room_id):
1368
1376
  wake_host(connection, room_id, row["chain_id"], f"主持人续跑(接 #{row['message_id']}):{row['reason']}", f"continue:{row['message_id']}")
1369
1377
 
1370
1378
 
1371
- def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False, direct=False, plan=None):
1379
+ def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=False, direct=False, plan=None, detach=None):
1372
1380
  """Queue the host's named members as one batch, one round; it hands back to the host when it ends.
1373
1381
 
1374
1382
  `direct` (the host's `no_handoff`) lets a one-member batch that completed reach the user without coming back; a
1375
1383
  failure still comes back. `plan` carries either `relay` (a relay's later step in the same pass: free, never held)
1376
1384
  or `need` (the host's `need_rounds`: {"rounds", "reason"}); a need above the rounds left stops the chain right away
1377
- and asks the owner for the difference.
1385
+ and asks the owner for the difference. Members in `detach` start with the batch but stay outside it: nothing waits for
1386
+ them, and a failure of theirs reaches the host like any request outside a batch.
1378
1387
  """
1379
1388
  plan = plan or {}
1380
1389
  member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
@@ -1384,10 +1393,12 @@ def dispatch(connection, room_id, host, message_id, names, chain_id, handoff=Fal
1384
1393
  remaining = None if plan.get("relay") else rounds_left(connection, room_id, chain_id)
1385
1394
  if remaining is not None and (remaining < 1 or need and remaining < need["rounds"]):
1386
1395
  ask = {"rounds": need["rounds"] - max(remaining, 0), "reason": need["reason"]} if need else None
1387
- hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask)
1396
+ hold_dispatch(connection, room_id, host, message_id, names, chain_id, handoff, ask, detach)
1388
1397
  return
1389
1398
  for member_id in member_ids:
1390
1399
  insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host", batch_id=message_id)
1400
+ for member_id in named_members(active_members(connection, room_id), detach or [], exclude=host["name"]):
1401
+ insert_request(connection, room_id, member_id, message_id, chain_id, "ask", "host")
1391
1402
  connection.execute("INSERT INTO free_batches (room_id,batch_id,chain_id,host_id) VALUES (?,?,?,?)",
1392
1403
  (room_id, message_id, chain_id, host["id"]))
1393
1404
  if plan.get("relay"):
@@ -1467,7 +1478,10 @@ def advance_queues(connection, room_id):
1467
1478
 
1468
1479
  def checked_sequence(actor, payload):
1469
1480
  """Validate a host's one-after-another dispatch (optionally a loop) before its message is saved."""
1481
+ checked_detach(actor, payload)
1470
1482
  sequential, loop = payload.get("sequential", False), payload.get("loop", False)
1483
+ if payload.get("parallel") is not None and sequential is not True:
1484
+ raise ValueError("parallel 只与 sequential 一起使用")
1471
1485
  if not isinstance(actor, dict) or (sequential is False and loop is False):
1472
1486
  return
1473
1487
  if sequential is not True or type(loop) is not bool:
@@ -1479,46 +1493,108 @@ def checked_sequence(actor, payload):
1479
1493
  raise ValueError("依次接力须用 to 按顺序点名至少两位不同成员,每位只出现一次")
1480
1494
  if payload.get("next_review"):
1481
1495
  raise ValueError("依次接力不能同时声明预授权评审;评审者直接排进顺序即可")
1496
+ relay_order(names, payload.get("parallel"))
1482
1497
 
1483
1498
 
1484
- def start_sequence(connection, room_id, host, message_id, names, chain_id, loop, need=None):
1485
- """Start a host's one-after-another dispatch with its first member; the rest follow as each answer arrives."""
1486
- member_ids = named_members(active_members(connection, room_id), names, exclude=host["name"])
1499
+ def checked_detach(actor, payload):
1500
+ """`detach`: members the host sends along with a dispatch without waiting for them; they are not among those in `to`."""
1501
+ detach = payload.get("detach")
1502
+ if detach is None:
1503
+ return
1504
+ if not isinstance(actor, dict) or actor["role"] != "host":
1505
+ raise PermissionError("只有主持人派活时可以带上不等待的成员")
1506
+ names = payload.get("to") or []
1507
+ if (not isinstance(detach, list) or not detach or any(not isinstance(name, str) for name in detach)
1508
+ or len(set(detach)) != len(detach)):
1509
+ raise ValueError("detach 须为不重复的成员名列表")
1510
+ if not names or set(detach) & set(names):
1511
+ raise ValueError("detach 须与 to 同用,且不包含 to 中的成员")
1512
+
1513
+
1514
+ def relay_order(names, parallel):
1515
+ """The relay's steps from `to` and `parallel`: each step a list of names; a group named in `parallel` is one step.
1516
+
1517
+ Each group lists at least two members who stand next to each other in `to`; a member belongs to at most one group.
1518
+ """
1519
+ if parallel is None:
1520
+ return [[name] for name in names]
1521
+ groups = parallel if isinstance(parallel, list) else None
1522
+ if not groups or any(not isinstance(group, list) or len(group) < 2 or len(set(group)) != len(group)
1523
+ or any(name not in names for name in group) for group in groups):
1524
+ raise ValueError("parallel 须为若干组成员名,每组至少两位不同成员,且都在 to 中")
1525
+ grouped = [name for group in groups for name in group]
1526
+ if len(set(grouped)) != len(grouped):
1527
+ raise ValueError("parallel 中每位成员只能属于一组")
1528
+ steps, index = [], 0
1529
+ while index < len(names):
1530
+ group = next((group for group in groups if names[index] in group), None)
1531
+ if group is None:
1532
+ steps.append([names[index]])
1533
+ index += 1
1534
+ continue
1535
+ if set(names[index:index + len(group)]) != set(group):
1536
+ raise ValueError("parallel 的同一组成员须在 to 中相邻")
1537
+ steps.append(names[index:index + len(group)])
1538
+ index += len(group)
1539
+ return steps
1540
+
1541
+
1542
+ def relay_steps(sequence):
1543
+ """The relay's steps as lists of member ids; a step stored as one id (single member) reads as a one-member list."""
1544
+ return [step if isinstance(step, list) else [step] for step in json.loads(sequence["member_ids"])]
1545
+
1546
+
1547
+ def stored_step(member_ids):
1548
+ """How a step is kept in `member_ids` and `steps`: one member as its id, as before groups existed; a group as a list."""
1549
+ return member_ids[0] if len(member_ids) == 1 else member_ids
1550
+
1551
+
1552
+ def start_sequence(connection, room_id, host, message_id, names, chain_id, loop, need=None, parallel=None, detach=None):
1553
+ """Start a host's one-after-another dispatch with its first step; the rest follow as each step ends.
1554
+
1555
+ A step named in `parallel` is a group: its members work at once and the relay goes on only after all of them end.
1556
+ """
1557
+ active = active_members(connection, room_id)
1558
+ named_members(active, names, exclude=host["name"])
1487
1559
  if not chain_id:
1488
1560
  return
1561
+ order = relay_order(names, parallel)
1562
+ steps = [stored_step([active[name]["id"] for name in step]) for step in order]
1489
1563
  connection.execute("""INSERT INTO free_sequences (room_id,message_id,chain_id,host_id,member_ids,loop,batch_id,steps)
1490
- VALUES (?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(member_ids), int(loop), message_id,
1491
- json.dumps([[1, member_ids[0], message_id]])))
1492
- dispatch(connection, room_id, host, message_id, names[:1], chain_id, plan={"need": need})
1564
+ VALUES (?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(steps), int(loop), message_id,
1565
+ json.dumps([[1, steps[0], message_id]])))
1566
+ dispatch(connection, room_id, host, message_id, order[0], chain_id, plan={"need": need}, detach=detach)
1493
1567
 
1494
1568
 
1495
1569
  def sequence_step(connection, room_id, sequence, host, position, pass_number, message_id):
1496
- """Ask the member at `position`, hung on `message_id`; one step is one batch.
1570
+ """Ask the members still here of the step at `position`, hung on `message_id`; one step is one batch.
1497
1571
 
1498
1572
  A pass's first step costs one round (or waits at the limit); later steps of the same pass ride on it.
1499
1573
  """
1500
- member_id = json.loads(sequence["member_ids"])[position]
1501
- name = connection.execute("SELECT name FROM room_members WHERE id=?", (member_id,)).fetchone()[0]
1502
- steps = json.loads(sequence["steps"]) + [[pass_number, member_id, message_id]]
1574
+ present = {row["id"]: name for name, row in active_members(connection, room_id).items()}
1575
+ member_ids = [member_id for member_id in relay_steps(sequence)[position] if member_id in present]
1576
+ steps = json.loads(sequence["steps"]) + [[pass_number, stored_step(member_ids), message_id]]
1503
1577
  connection.execute("UPDATE free_sequences SET status='running',pass=?,position=?,batch_id=?,steps=? WHERE id=?",
1504
1578
  (pass_number, position, message_id, json.dumps(steps), sequence["id"]))
1505
- dispatch(connection, room_id, host, message_id, [name], sequence["chain_id"], plan={"relay": pass_number == sequence["pass"]})
1579
+ dispatch(connection, room_id, host, message_id, [present[member_id] for member_id in member_ids], sequence["chain_id"],
1580
+ plan={"relay": pass_number == sequence["pass"]})
1506
1581
 
1507
1582
 
1508
1583
  def next_present(connection, room_id, sequence, start):
1509
- """The first position from `start` whose member is still here and can be asked; members who left are skipped."""
1584
+ """The first position from `start` with a member still here who can be asked; steps whose members all left are skipped."""
1510
1585
  present = {row["id"] for row in active_members(connection, room_id).values()}
1511
- return next((index for index, member_id in enumerate(json.loads(sequence["member_ids"])) if index >= start and member_id in present), None)
1586
+ return next((index for index, step in enumerate(relay_steps(sequence)) if index >= start and present.intersection(step)), None)
1512
1587
 
1513
1588
 
1514
1589
  def advance_sequence(connection, room_id, sequence, host, completed, failed=False):
1515
- """Pass a finished step on to the next member; after the last one hand back to the host, as a checkpoint for a loop.
1590
+ """Pass a finished step on to the next one; after the last one hand back to the host, as a checkpoint for a loop.
1516
1591
 
1517
- A step nobody completed ends the sequence. A failed step is told to the present host, which decides what follows; a
1518
- stopped one (or one whose member left) halts the chain like any stopped batch.
1592
+ A step nobody completed ends the sequence. A failed step, a group step with one member failed included, is told to
1593
+ the present host, which decides what follows (retrying the failure carries the relay on); a stopped one (or one whose
1594
+ members left) halts the chain like any stopped batch.
1519
1595
  """
1520
- if not completed or not host or host["id"] != sequence["host_id"]:
1521
- if not completed and failed and host:
1596
+ if not completed or failed or not host or host["id"] != sequence["host_id"]:
1597
+ if failed and host:
1522
1598
  wake_host(connection, room_id, sequence["chain_id"], f"依次派活 #{sequence['message_id']} 在第 {sequence['position'] + 1} 棒失败,"
1523
1599
  "接力已停下。请主持人决定重试、改派还是告知用户。", f"sequence:{sequence['id']}:{sequence['batch_id']}")
1524
1600
  elif not completed:
@@ -1550,21 +1626,26 @@ def sequence_view(connection, room_id, sequence_id, token):
1550
1626
  names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
1551
1627
  origin = connection.execute("SELECT id,author,text FROM messages WHERE id=?", (row["message_id"],)).fetchone()
1552
1628
  answers = []
1553
- for pass_number, member_id, batch_id in json.loads(row["steps"]):
1554
- if pass_number != row["pass"]:
1555
- continue
1556
- answer = connection.execute("""SELECT r.status,m.id,m.text FROM member_requests r LEFT JOIN messages m ON m.id=r.answer_id
1557
- WHERE r.room_id=? AND r.batch_id=? AND r.member_id=? AND r.purpose='ask'""", (room_id, batch_id, member_id)).fetchone()
1558
- answers.append({"member": names.get(member_id), "status": answer["status"] if answer else "waiting",
1559
- "message_id": answer["id"] if answer else None, "text": answer["text"] if answer else None})
1629
+ steps_of_pass = [(member_ids, batch_id) for pass_number, member_ids, batch_id in json.loads(row["steps"]) if pass_number == row["pass"]]
1630
+ for step_number, (member_ids, batch_id) in enumerate(steps_of_pass, 1):
1631
+ for member_id in member_ids if isinstance(member_ids, list) else [member_ids]:
1632
+ answer = connection.execute("""SELECT r.status,m.id,m.text FROM member_requests r LEFT JOIN messages m ON m.id=r.answer_id
1633
+ WHERE r.room_id=? AND r.batch_id=? AND r.member_id=? AND r.purpose='ask'""", (room_id, batch_id, member_id)).fetchone()
1634
+ answers.append({"member": names.get(member_id), "step": step_number, "status": answer["status"] if answer else "waiting",
1635
+ "message_id": answer["id"] if answer else None, "text": answer["text"] if answer else None})
1560
1636
  handoff = connection.execute("SELECT status FROM member_requests WHERE id=?", (row["handoff_request"],)).fetchone()
1561
- return {"id": row["id"], "message_id": row["message_id"], "order": [names.get(item) for item in json.loads(row["member_ids"])],
1637
+ return {"id": row["id"], "message_id": row["message_id"], "order": order_names(row, names),
1562
1638
  "loop": bool(row["loop"]), "pass": row["pass"], "position": row["position"], "status": row["status"],
1563
1639
  "chain_id": row["chain_id"], "remaining_rounds": rounds_left(connection, room_id, row["chain_id"]),
1564
1640
  "handoff_request": row["handoff_request"] or None, "handoff_status": handoff[0] if handoff else None,
1565
1641
  "origin": dict(origin) if origin else None, "answers": answers}
1566
1642
 
1567
1643
 
1644
+ def order_names(sequence, names):
1645
+ """The relay's order by name: a one-member step as the name, a group step as the list of its members' names."""
1646
+ return [names.get(step[0]) if len(step) == 1 else [names.get(item) for item in step] for step in relay_steps(sequence)]
1647
+
1648
+
1568
1649
  def continue_sequence(connection, room_id, token, payload):
1569
1650
  """Run a loop's next pass from its checkpoint, closing only that hand-back; bound to the one checkpoint request.
1570
1651
 
@@ -1611,16 +1692,17 @@ def sequence_request_context(connection, request):
1611
1692
  if not sequence:
1612
1693
  return {}
1613
1694
  names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (row["room_id"],))}
1614
- member_ids = json.loads(sequence["member_ids"])
1615
- order = [names.get(item) for item in member_ids]
1695
+ steps = relay_steps(sequence)
1696
+ order = order_names(sequence, names)
1616
1697
  phase = "step" if row["purpose"] == "ask" else "checkpoint" if sequence["loop"] else "end"
1617
- if phase == "step" and row["member_id"] not in member_ids:
1698
+ position = next((index for index, step in enumerate(steps) if row["member_id"] in step), None) if phase == "step" else len(order) - 1
1699
+ if position is None:
1618
1700
  return {}
1619
- position = member_ids.index(row["member_id"]) if phase == "step" else len(order) - 1
1620
1701
  following = order[position + 1] if phase == "step" and position + 1 < len(order) else None
1702
+ together = [names.get(item) for item in steps[position] if item != row["member_id"]] if phase == "step" else []
1621
1703
  return {"sequence": {"id": sequence["id"], "message_id": sequence["message_id"], "order": order, "loop": bool(sequence["loop"]),
1622
1704
  "pass": sequence["pass"], "position": position, "phase": phase, "next": following,
1623
- "answer_id": sequence["answer_id"] or None}}
1705
+ **({"together": together} if together else {}), "answer_id": sequence["answer_id"] or None}}
1624
1706
 
1625
1707
 
1626
1708
  def default_order(connection, room_id, members):
@@ -1652,10 +1734,12 @@ def speaking_order(connection, room):
1652
1734
  if sequence:
1653
1735
  # A host's one-after-another dispatch shows like a queue: this pass's members, done up to the one asked now.
1654
1736
  kind, target = "queue", sequence["message_id"]
1655
- for index, member_id in enumerate(json.loads(sequence["member_ids"])):
1656
- if member_id in by_id:
1657
- state = "done" if sequence["status"] == "checkpoint" or index < sequence["position"] else "speaking" if index == sequence["position"] else "queued"
1658
- items.append({"name": by_id[member_id]["name"], "state": state})
1737
+ for index, step in enumerate(relay_steps(sequence)):
1738
+ state = "done" if sequence["status"] == "checkpoint" or index < sequence["position"] else "speaking" if index == sequence["position"] else "queued"
1739
+ for member_id in step:
1740
+ if member_id in by_id:
1741
+ # Members of a group step share its number: they work at once.
1742
+ items.append({"name": by_id[member_id]["name"], "state": state, **({"step": index + 1} if len(step) > 1 else {})})
1659
1743
  elif not session:
1660
1744
  target = connection.execute("""SELECT MAX(message_id) FROM (
1661
1745
  SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
@@ -1673,7 +1757,8 @@ def speaking_order(connection, room):
1673
1757
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1674
1758
  movable = "queued" if kind != "default" else "idle"
1675
1759
  relay = {"id": sequence["id"], "loop": bool(sequence["loop"]), "pass": sequence["pass"]} if sequence else None
1676
- state = json.dumps([kind, target, [[item["name"], item["state"]] for item in items], later, session_plan_state, relay], ensure_ascii=False)
1760
+ state = json.dumps([kind, target, [[item["name"], item["state"], item.get("step")] for item in items], later, session_plan_state, relay],
1761
+ ensure_ascii=False)
1677
1762
  return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
1678
1763
  "default": [member["name"] for member in default_order(connection, room["id"], active)],
1679
1764
  **({"sequence": relay} if relay else {}),
@@ -1708,13 +1793,18 @@ def reorder_speakers(connection, room, token, payload):
1708
1793
  raise ValueError("新顺序须恰好包含尚未发言的成员" + (f";缺少:{'、'.join(missing)}" if missing else "") + (f";多出:{'、'.join(extra)}" if extra else ""))
1709
1794
  names = {member["name"]: member["id"] for member in room["members"] if member["status"] == "active"}
1710
1795
  if current.get("sequence"):
1711
- # Later passes of a loop keep the new order too; members who left stay in place at the end.
1796
+ # Later passes of a loop keep the new order too; a group moves as one step, placed where its first member is
1797
+ # named; steps whose members all left stay in place at the end.
1712
1798
  sequence = connection.execute("SELECT * FROM free_sequences WHERE id=?", (current["sequence"]["id"],)).fetchone()
1713
- member_ids = json.loads(sequence["member_ids"])
1714
- kept = member_ids[:sequence["position"] + 1]
1715
- moved = [names[name] for name in order]
1716
- member_ids = kept + moved + [item for item in member_ids if item not in kept and item not in moved]
1717
- connection.execute("UPDATE free_sequences SET member_ids=? WHERE id=?", (json.dumps(member_ids), sequence["id"]))
1799
+ steps = relay_steps(sequence)
1800
+ kept, rest, moved = steps[:sequence["position"] + 1], steps[sequence["position"] + 1:], []
1801
+ for name in order:
1802
+ step = next(step for step in rest if names[name] in step)
1803
+ if step not in moved:
1804
+ moved.append(step)
1805
+ steps = kept + moved + [step for step in rest if step not in moved]
1806
+ connection.execute("UPDATE free_sequences SET member_ids=? WHERE id=?",
1807
+ (json.dumps([stored_step(step) for step in steps]), sequence["id"]))
1718
1808
  elif current["kind"] == "queue":
1719
1809
  connection.execute(f"DELETE FROM free_queue WHERE message_id=? AND NOT {QUEUE_ASKED.format(row='free_queue')}", (current["message_id"],))
1720
1810
  for name in order:
@@ -1773,10 +1863,11 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1773
1863
  (message_id, execution_id))
1774
1864
  connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
1775
1865
  if actor["role"] == "host" and payload.get("to") and payload.get("sequential") is True:
1776
- start_sequence(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("loop") is True, need_plan(payload))
1866
+ start_sequence(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("loop") is True, need_plan(payload),
1867
+ payload.get("parallel"), payload.get("detach"))
1777
1868
  elif actor["role"] == "host" and payload.get("to"):
1778
1869
  dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")),
1779
- payload.get("no_handoff") is True, {"need": need_plan(payload)})
1870
+ payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
1780
1871
  if actor["role"] == "host":
1781
1872
  remember_continue(connection, room_id, actor, message_id, chain_id, payload)
1782
1873
  settle_batches(connection, room_id)
@@ -1913,13 +2004,15 @@ def withdraw_dispatch(connection, room_id, token, message_id):
1913
2004
  batches = [message_id] + [row[0] for row in connection.execute(f"SELECT batch_id FROM free_sequences WHERE {sequences}", (room_id, message_id))]
1914
2005
  connection.execute(f"UPDATE free_sequences SET status='stopped' WHERE {sequences}", (room_id, message_id))
1915
2006
  marks = ",".join("?" * len(batches))
2007
+ # The dispatch's members sent along without being waited for (no batch) are taken back too.
2008
+ taken = f"(r.batch_id IN ({marks}) OR r.message_id=? AND r.batch_id=0 AND r.created_by='host' AND r.purpose='ask')"
1916
2009
  live = connection.execute(f"""SELECT DISTINCT e.member_id FROM member_requests r JOIN member_executions e ON e.id=r.execution_id
1917
- WHERE r.room_id=? AND r.batch_id IN ({marks}) AND e.status IN ('dispatched','started')""", (room_id, *batches)).fetchall()
2010
+ WHERE r.room_id=? AND {taken} AND e.status IN ('dispatched','started')""", (room_id, *batches, message_id)).fetchall()
1918
2011
  for row in live:
1919
2012
  stop_member_work(connection, row[0], "主持人撤回派活", "user_stop", keep_pending=True)
1920
- waiting = f"room_id=? AND batch_id IN ({marks}) AND status='pending'"
1921
- record_cancel_reasons(connection, waiting, (room_id, *batches), "user_stop")
1922
- connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {waiting}", (room_id, *batches))
2013
+ waiting = f"id IN (SELECT r.id FROM member_requests r WHERE r.room_id=? AND {taken} AND r.status='pending')"
2014
+ record_cancel_reasons(connection, waiting, (room_id, *batches, message_id), "user_stop")
2015
+ connection.execute(f"UPDATE member_requests SET status='cancelled' WHERE {waiting}", (room_id, *batches, message_id))
1923
2016
  connection.execute(f"UPDATE free_batches SET settled=1 WHERE room_id=? AND batch_id IN ({marks})", (room_id, *batches))
1924
2017
  return free_view(connection, room_id)
1925
2018
 
@@ -2545,8 +2638,11 @@ def retry_failed(connection, room_id, execution):
2545
2638
  [request["id"] for request in requests])
2546
2639
  for request in requests:
2547
2640
  batch_id, chain_id = request["batch_id"], request["chain_id"]
2548
- if batch_id and not connection.execute(
2549
- "SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone():
2641
+ # A relay step stopped by one failed member of its group resumes too: the others' answers wait for this one.
2642
+ if batch_id and (not connection.execute(
2643
+ "SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone()
2644
+ or connection.execute("SELECT 1 FROM free_sequences WHERE room_id=? AND batch_id=? AND status='stopped'",
2645
+ (room_id, batch_id)).fetchone()):
2550
2646
  connection.execute("UPDATE free_batches SET settled=0 WHERE room_id=? AND batch_id=?", (room_id, batch_id))
2551
2647
  connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=? AND reason='all_failed'", (room_id, chain_id))
2552
2648
  connection.execute("UPDATE free_sequences SET status='running' WHERE room_id=? AND batch_id=? AND status='stopped'",
@@ -7,14 +7,14 @@ own session, model and thinking effort. Platform names alone keep their historic
7
7
  import re
8
8
 
9
9
  USER = "用户"
10
- AI_PLATFORMS = ("Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok")
10
+ AI_PLATFORMS = ("Codex", "Claude", "Antigravity", "DeepSeek", "Qwen", "Grok", "OpenAI", "Custom")
11
11
  PARTICIPANTS = [USER, *AI_PLATFORMS]
12
12
  MAX_INSTANCE_SUFFIX = 16
13
13
  # AI instances one room may hold, across platforms.
14
14
  MAX_ROOM_AGENTS = 12
15
15
  INSTANCE_PATTERN = re.compile(rf"({'|'.join(AI_PLATFORMS)})(?:-[0-9A-Za-z_]{{1,{MAX_INSTANCE_SUFFIX}}})?")
16
16
  # The name people and AIs call a platform when it differs from its ID; web/app.js PLATFORM_IDENTITIES shows the same.
17
- PLATFORM_NICKNAMES = {"Antigravity": "Gemini"}
17
+ PLATFORM_NICKNAMES = {"Antigravity": "Gemini", "Custom": "自定义 API"}
18
18
 
19
19
 
20
20
  def is_ai(name):
package/backend/server.py CHANGED
@@ -1132,7 +1132,7 @@ def check_message_references(connection, room_id, message):
1132
1132
  def append_free_message(connection, room, payload, actor):
1133
1133
  """Append a free-room message: the owner's creates requests, a member's completes its execution."""
1134
1134
  if payload.get("kind") == "moderation":
1135
- if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "need_rounds")):
1135
+ if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "parallel", "detach", "need_rounds")):
1136
1136
  raise ValueError("主持说明不能声明评审授权、依次接力或申请轮数;请在派活时提交")
1137
1137
  return append_free_moderation(connection, room, payload, actor)
1138
1138
  if payload.get("kind") not in (None, "chat"):
@@ -1573,7 +1573,7 @@ def protocol_text(room):
1573
1573
  "用户管理和讨论开关使用房主凭据;本地成员使用本人的 --access-file,不借用其他身份。凭据私下交付,不公开 token。API 参与者遵循相同请求与执行约束,source=api;本地 CLI 使用 source=cli。",
1574
1574
  "runner 成员等待本机接入程序交付任务,用 execution view 核对本次执行和请求,再以 message post --as 本人 --kind chat --execution 本次编号 --access-file FILE 回答。执行失效后保留草稿,不能去掉编号改作普通发言;认领、续租与后续投递由接入程序处理。",
1575
1575
  "manual 本地成员首次用 member register 注册,会话 ID 由 CLI 自动取得;回答用 --request 请求编号,不编造执行编号。讨论不使用原生定时;原会话不能托管时由用户触发。",
1576
- "自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。--to 多位并加 --sequential 时按点名顺序依次接力,每棒一轮,最后交回主持人;再加 --loop 则交回为检查点,用 sequence continue --request 交接请求编号 开始下一圈,不继续即直接验收。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
1576
+ "自由协作中的 host 可用 --to 点名成员,route 表示未点名的用户请求,handoff 表示派出任务已结束;链上 AI 派活受房间预算限制。--to 多位并加 --sequential 时按点名顺序依次接力,每棒一轮,最后交回主持人;--with 成员 与紧挨在前的 --to 同一棒同时做,这一组全部结束才交下一棒;--detach 成员 与这次派活同时开工但不等它(失败时通知主持人);再加 --loop 则交回为检查点,用 sequence continue --request 交接请求编号 开始下一圈,不继续即直接验收。普通成员的 --to 或正文 @ 只保存回应建议,不直接唤醒;不点名自己、不扩大授权。",
1577
1577
  "讨论中的用户 @ 直接提问独立排队,不计讨论轮次、不与讨论发言合并。initial 阶段先独立判断;服务端按讨论请求的 read_until 限制读取。直接提问和主持任务不继承该阅读限制。",
1578
1578
  "主持安排前先 turn roster 读取在场专家、waiting、unregistered 和 revision,再 turn create --roster revision;单项和多项都要带凭据,多项计划须覆盖本轮 waiting。继续排队同样先读 roster,再 turn continue --roster revision。状态或新消息变化后旧凭据被拒绝,重新读取再判断;每个回答保存后由程序推进下一项。",
1579
1579
  "局部追问先 turn roster 读取 host_requests 的请求与原消息编号,再 turn ask --request ID --expert NAME --roster revision --submission-id UUID;普通提问不计轮次、不改讨论队列,可问已发言者和主持人,只结束指定请求。",
package/docs/en/runner.md CHANGED
@@ -68,7 +68,9 @@ Once paired, the "协作房间" directory lists the rooms on the computer, the s
68
68
 
69
69
  ## API Access
70
70
 
71
- Flip a member card to **API**, enter a key and choose a model. Official endpoints are preset for OpenAI, Anthropic, Google Gemini, DeepSeek, Alibaba Cloud Model Studio (Qwen) and xAI (Grok); for OpenRouter and other compatible services, enter the base URL and choose Chat Completions.
71
+ Choose **OpenAI** in the member picker, flip its card, enter your official OpenAI API key and choose a model. Its default endpoint is `https://api.openai.com/v1` with the Responses protocol. Existing Codex cards still support API access.
72
+
73
+ Choose **自定义 API** (Custom API) for another service, then enter its base URL, API key and full model ID. It defaults to Chat Completions and also supports Responses, Anthropic Messages and Gemini generateContent. Use the name and description button on the card's back to set a display name; add the slot again to configure multiple independent members. Official presets for Anthropic, Google Gemini, DeepSeek, Alibaba Cloud Model Studio (Qwen) and xAI (Grok) remain available.
72
74
 
73
75
  API members can act as experts or hosts. They have room tools only and no access to local files. Keys stay on your machine, and calls are billed to your account.
74
76
 
@@ -20,7 +20,7 @@ How collaboration works inside a room. For installation, see [Setup](runner.md).
20
20
  - **Mention** — `@member` and it works in its own original session; several members can run in parallel.
21
21
  - **Unaddressed messages** go to the host. With a single member, they go straight to it.
22
22
  - **Expert presets** — a second row under the platforms lists presets; one click adds a member with its description, prompt, project access (and optionally model and effort) set. `Codex-review` (read-only reviewer) and `Gemini-docs` (writes docs only, via agy) are built in; the bookmark on a card's back saves it as your own preset on the computer running the local service.
23
- - **Host delegation** — when the host mentions one member, that member replies to you directly, unless the host asks for the work back to hand it on (for example to a reviewer); when it mentions several, the host summarizes after they finish. One message of yours lets the host run up to 10 rounds by default (a round: the host dispatches, the members answer, the work comes back for a summary); change it (1–100 rounds, or 不限 for unlimited) when creating the room, or later under 协作设置 (Collaboration settings) on the room page. At the limit it stops and waits for you; 继续 (Continue) in the side panel lets it run on. When the host knows up front how many rounds a task needs (say, several episodes in a row), it asks for them at the start: if too few are left it stops at once, the side panel shows its reason, and 批准 (Approve) lets it run through without stopping again. A one-after-another relay counts one round per full pass.
23
+ - **Host delegation** — when the host mentions one member, that member replies to you directly, unless the host asks for the work back to hand it on (for example to a reviewer); when it mentions several, the host summarizes after they finish. One message of yours lets the host run up to 10 rounds by default (a round: the host dispatches, the members answer, the work comes back for a summary); change it (1–100 rounds, or 不限 for unlimited) when creating the room, or later under 协作设置 (Collaboration settings) on the room page. At the limit it stops and waits for you; 继续 (Continue) in the side panel lets it run on. When the host knows up front how many rounds a task needs (say, several episodes in a row), it asks for them at the start: if too few are left it stops at once, the side panel shows its reason, and 批准 (Approve) lets it run through without stopping again. A one-after-another relay counts one round per full pass; members who do not depend on each other (say, reviewers checking different things) can share one step, working at once, and the relay moves on once all of them finish. The host can also send a member along with a dispatch without waiting for it: it starts at the same time, the dispatch comes back without it, and the host is told if it fails.
24
24
  - **Everyone** — `@全体成员` asks all members to answer; `@全体依次` asks them one after another in speaking order.
25
25
  - **Stop and retry** — stop a member's current run from its card or menu, or stop all running and queued work from the side panel. “Stopped” requires a confirmed process exit; otherwise the card says “Stop unconfirmed” or “Voided, may still be generating.” Late replies stay out of the transcript; only the owner can expand their text. New tasks wait for termination confirmation, a late reply or the original 10-minute deadline. Resuming a discussion or resending does not bypass or restart that wait. Retry puts the original message back in the composer for you to send.
26
26
  - **Retract and edit** — a message no member has started can be retracted from its “…” menu, and its text returns to the input. To change a message that was already handled, click “Edit” and resend: it and everything after it fold into a “rewound” line that AI members no longer read, and the sessions of Claude Code, Codex and agy members this machine wakes go back to before it. Manual and app members keep their own windows, so their sessions cannot go back. Files members changed are not restored. Neither works while a discussion is open.
package/docs/runner.md CHANGED
@@ -99,7 +99,9 @@ codiscuss local connect https://你的站点
99
99
 
100
100
  ## API 接入
101
101
 
102
- 翻开成员卡片选择 API,填写密钥并选择模型。默认提供 OpenAI、Anthropic、Google Gemini、DeepSeek、阿里云百炼(Qwen)、xAI(Grok)的官方地址;OpenRouter 等兼容服务填写其 Base URL 并选择 Chat Completions。
102
+ 在“添加”行选择 **OpenAI**,翻开卡片填写 OpenAI 官方 API Key 并选择模型;已预填官方地址 `https://api.openai.com/v1` 和 Responses 协议。原有 Codex 卡片仍可选择 API 接入。
103
+
104
+ 选择 **自定义 API** 可接入其他服务:翻开卡片填写 Base URL、API Key 和完整模型 ID,默认使用 Chat Completions,也可选择 Responses、Anthropic Messages 或 Gemini generateContent。卡片背面的“名称与描述”按钮可修改显示名称;重复点击添加可配置多个不同的 API 成员。Anthropic、Google Gemini、DeepSeek、阿里云百炼(Qwen)、xAI(Grok)也保留官方地址预设。
103
105
 
104
106
  API 成员可以担任专家或主持人。房间指定了项目目录时,它可以用接入程序提供的文件工具读取、搜索项目文件;项目权限设为可写时还能新建和编辑文件,但没有命令行,不能运行测试或构建。`.env`、私钥等密钥文件、`.git` 目录和本机服务目录始终不开放,读到的内容会发给你配置的模型服务商。密钥只保存在本机,调用费用按你的账号计费。
105
107
 
@@ -159,7 +161,7 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
159
161
 
160
162
  ## 常见问题
161
163
 
162
- - **软件送达失败**:查看成员状态与原位失败卡,在对应软件中处理登录或权限;倒计时期间可以立即重试或停止。三次重试用尽后,讨论专家的失败交给主持人处理;也可跳过本次发言。
164
+ - **软件送达失败**:查看成员状态与原位失败卡,在对应软件中处理登录或权限;倒计时期间可以立即重试或停止。本机接入程序重启或异常退出、导致进行中的软件进程随之结束时,同样自动重新投递同一执行,间隔缩短为 5 秒。三次重试用尽后,讨论专家的失败交给主持人处理;也可跳过本次发言。
163
165
  - **Codex 提示会话被占用**:原聊天仍被 Codex App 占用,可在进度看板切换到 App 接入。
164
166
  - **Windows 报告两处数据路径**:从普通终端管理服务,或设置环境变量 `DISCUSS_SERVICE_HOME`。
165
167
  - **结果不明**:已交付但无法确认的任务不会自动重发,请先在原会话与房间中核对。
package/docs/workflow.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - **点名**:`@成员` 后,它在自己的原会话中处理并回复,多位可并行。用界面上显示的昵称点名即可(如 `@Gemini`),输入 `@` 后从列表选择最方便。
19
19
  - **未点名的消息**:交给主持人分派;只有一位成员时直接交给它。
20
20
  - **主持人派活**:主持人派出的成员做完或失败后都会交回主持人,由它接着安排或汇总;简单问答主持人可以让成员直接回复你,失败时仍交回主持人。程序发现的问题(如成员执行失败)会以“程序通知主持人”卡片交给主持人处理;主持人也可以在回答后要求房间空闲时再被唤醒,自己接着推进。你的一条消息默认最多让主持人推进 10 轮(主持人派活、成员回答、交回汇总为一轮;主持人要求续跑一次也算一轮,程序通知不算),建房时在“推进方式”里或建房后在房间页的“协作设置”里调整(1–100 轮,或勾选“不限”);到上限会停下等你,右栏点“继续”即接着推进。主持人事先知道任务要多少轮(如连写几集)时,会在开始时申请轮数:剩余不够就先停下,右栏显示申请理由,点“批准”后一次跑完,不再中途停下。
21
- - **依次接力与循环**:几位成员要按固定先后做(写完再审、实现后评审)时,主持人会安排依次接力:一位做完直接交给下一位,最后一位做完才交回主持人,走完一圈算一轮。需要“改完再审”时安排成循环:每走完一圈交回主持人一次,评审要求返工就再走一圈,认可或需要取舍时由主持人验收。右栏发言顺序显示进行到哪一位、第几圈,可拖动调整还没轮到的成员。循环圈数受上面的轮数限制,需要多改几轮时把轮数调大。
21
+ - **依次接力与循环**:几位成员要按固定先后做(写完再审、实现后评审)时,主持人会安排依次接力:一位做完直接交给下一位,最后一位做完才交回主持人,走完一圈算一轮。需要“改完再审”时安排成循环:每走完一圈交回主持人一次,评审要求返工就再走一圈,认可或需要取舍时由主持人验收。互不依赖的几位(如分别审不同问题的评审)可以排在同一棒同时做,这一组全部做完才交给下一位。右栏发言顺序显示进行到哪一位、第几圈(同时做的成员共用一个序号)。主持人派活时还可以带上一位不必等待的成员,它同时开工,派出的工作结束时不等它,它失败时主持人会收到通知。右栏可拖动调整还没轮到的成员。循环圈数受上面的轮数限制,需要多改几轮时把轮数调大。
22
22
  - **全体回答**:`@全体成员` 请所有成员各自回答,`@全体依次` 按发言顺序逐个回答。
23
23
  - **停止与重发**:处理中卡片或成员菜单可停止该成员当前这一次;右栏“全部停止”停止所有成员并取消还没送达的请求。本机确认进程退出后才显示“已停止”,无法确认时显示“停止未确认”;自己窗口或 App 里的会话停不了时显示“已作废,可能仍在生成”。后续回答不进入记录,只有房主展开“未采纳的回答”时能读取正文。停止未确认或停不了时,新消息等待结束确认、迟到回答或原有 10 分钟期限;继续讨论、主动重发不跳过等待,也不重新计时。被停止的成员下次工作时会被告知。失败卡的“重发”把原用户消息填回输入框,确认后再发送。
24
24
  - **撤回与编辑**:还没有成员开始处理的消息可撤回,原文回到输入框;最新一条消息即使已开始处理也可撤回,相当于停止对它的处理并回退到它之前。输入框为空时连按两下 Esc 也可撤回最近一条。已被处理的消息点“编辑”改写后重发:这条消息及之后的记录折叠为“已回退”,AI 不再读到它们,由本机唤醒的 Claude Code、Codex、agy 会话会退回到这条消息之前;手动接入和 App 的会话在各自窗口中,无法回退。成员已改动的文件不会撤销。讨论进行中不能撤回或编辑。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.93",
3
+ "version": "0.3.95",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },