ai_co_discuss 0.3.97 → 0.3.99

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.
@@ -29,6 +29,7 @@
29
29
  - 某一棒有几位互不依赖、可以同时做的成员(如几位评审各审一类问题)时,把他们写成同一棒:第一位用 `--to`,其余紧跟着用 `--with`(如 `--to 写手 --to 评审甲 --with 评审乙 --sequential --loop`)。这一棒的成员同时开工,全部结束才交下一棒,下一棒能读到他们每一份回答;组里有人失败,接力停下交回你,用户重试失败的那位后接着走。并行的成员看不到彼此的意见,派活正文要分清各自管什么。
30
30
  - 派活时想让某位成员同时开工、但不必等它(如评审期间让记录员先整理别的材料),加 `--detach 成员`(可重复,不能与 `--to` 重名):它和这次派活一起开工,交回你或交下一棒都不等它;它做完不交回你,结果在房间里可读;失败时程序通知你。用在依次接力上时,它只随第一棒开工一次。撤回这次派活会一并撤回它。
31
31
  - 要“改完再审、审到通过为止”时再加 `--loop`:每走完一圈,交回你的 `handoff` 带 `sequence.phase: checkpoint`。本圈最后一位(最后一棒是同时做的一组时,其中任一位)明确要求返工时,用 `sequence continue ROOM --request 该交接请求编号 --as 本人` 开始下一圈(程序直接接着走,你本次即结束);认可、只剩可不采纳的建议、需要你或用户裁定时,不继续,直接验收或说明。开启智能调度时,它会先替你判断检查点,只有明确返工才自动开始下一圈,其余仍交给你;任务的 `scheduler_suggestions` 记着它的判断。`sequence view ROOM ID` 可读本圈各棒原文。每开始新的一圈算一轮,轮数用尽时循环停下,由用户决定是否继续。
32
+ - 要在接力中途停下来由你把关(验收一稿再交下一位),或只让中间一段循环(写 ⇄ 审到通过再交记录员)时,用检查点,均须与 `--sequential` 同用:`--checkpoint 成员` 在该成员那一棒结束后交回你(`sequence.phase: gate`);`--repeat 成员=起点成员` 在该成员那一棒结束后交回你,作为循环检查点(`sequence.phase: checkpoint`),再来一圈从起点成员那一棒开始,起点不能早于前一个检查点。交回时 `sequence.gate` 给出 `repeat_from`(再来一圈从第几棒开始)与 `can_proceed`(后面还有棒)。处理:要返工就 `sequence continue … --request 交接请求编号`(循环检查点再来一圈);认可并往下走就 `sequence continue … --proceed`(普通检查点不加 `--proceed` 也是往下走);要结束接力、裁定或说明时不继续,直接回答。往下走和再来一圈各算一轮。带检查点的接力不能在右栏调整顺序。
32
33
  - 用户只点名你(旁问,如问近况、是否卡住)时:读 `sequence view`、`room events` 与进行中执行的详情后直接回答,接力和循环照常进行。旁问里的新任务涉及正在接力的成员或同一批文件时,等接力交回后再派,或先撤回原派活;互不相干的可以马上派。用户发言会清除你之前的 `--continue`,仍需要就在这次回答里重新带上。
33
34
  - 同一条用户消息最多推进若干轮(`room view` 的 `free.max_rounds`,0 为不限;本链已用与上限见 `free.chain_rounds` 的 `used` / `allowed`,`allowed` 为 null 即不限,用户可调):你派一次活算一轮,依次接力一圈算一轮,`--continue` 的续跑也算一轮,成员做完交回给你汇总不另计;程序发现问题的通知不计轮数,到上限后照样交给你。到上限时你的下一次派活会被暂存、任务链停下,由用户决定继续或发新消息;你不能自己加轮。派活前先想清楚需要几位、几轮,最后一轮留给汇总。(上限防止 AI 之间无人值守地来回转交。)
34
35
  - 已知整项任务所需轮数超过剩余轮数时(连写几集、多步实现加评审),在开始派活时带 `--need-rounds N --rounds-reason "…"`(N 为从这次派活起还需要的轮数,含最后汇总):剩余不够时任务链立即停下,用户看到理由并一键批准差额后才发出这次派活,之后不再中途停下;够用时照常派出。不要在回复里让用户改上限或回“继续”,也不要建议超出 `need-rounds` 上限(100)的数值。
@@ -63,7 +64,7 @@
63
64
 
64
65
  ## 工作流
65
66
 
66
- 本次任务有 `workflow` 字段时,本房间启用了用户的工作流。它像技能一样按需使用:`workflow.description` 是它的用途,请求与用途相符或用户要求按它做时才用,其他请求照常处理。要用时,本会话还没读过、或 `workflow.revision` 与上次读到的不同,才读 `workflow.skill_path`(API 主持人读系统提示中的工作流正文);否则沿用已读内容。按它组织工作时,`workflow.members` 列出每张卡片现在由哪位成员担任、是否在场、项目权限;只点名在场的成员。`workflow.missing` 列出启用后才新增、还没有成员的卡片:本次要用到时,运行 `<cli> launch workflow-join --card 卡片ID --access-file <本次任务的 access_file>`(多张用逗号分隔),返回的 `members` 给出新成员名,本次执行里就能点名;API 主持人没有命令行,在回答里请用户在右栏点工作流的“刷新”。成员离开或被移除的角色不要自行请回,在回答里说明缺谁,不另找成员代替。工作流只带来做法和成员,不扩大你的权限。
67
+ 本次任务有 `workflow` 字段时,本房间启用了用户的工作流。它像技能一样按需使用:`workflow.description` 是它的用途,请求与用途相符或用户要求按它做时才用,其他请求照常处理。要用时,本会话还没读过、或 `workflow.revision` 与上次读到的不同,才读 `workflow.skill_path`(API 主持人读系统提示中的工作流正文);否则沿用已读内容。按它组织工作时,`workflow.members` 列出每张卡片现在由哪位成员担任、是否在场、项目权限;只点名在场的成员。`workflow.flow` 是用户在流程画布上排好的接力:`order` 是顺序说明,`cli` 是照它派活要带的参数(API 主持人用 `api` 中的字段),派活时原样带上,正文写清每一棒做什么;`flow.missing_cards` 列出缺成员的卡片(此时不能照画布派活,按下文补齐或说明缺谁),`flow.errors` 说明画布排不成接力,按 SKILL.md 组织并在回答里提醒用户修改画布。`workflow.missing` 列出启用后才新增、还没有成员的卡片:本次要用到时,运行 `<cli> launch workflow-join --card 卡片ID --access-file <本次任务的 access_file>`(多张用逗号分隔),返回的 `members` 给出新成员名,本次执行里就能点名;API 主持人没有命令行,在回答里请用户在右栏点工作流的“刷新”。成员离开或被移除的角色不要自行请回,在回答里说明缺谁,不另找成员代替。工作流只带来做法和成员,不扩大你的权限。
67
68
 
68
69
  本次任务(本地任务文件或 API 的 `task`)没有 `workflow` 字段时,不按任何工作流做事,即使会话里读过。
69
70
 
@@ -50,6 +50,8 @@ description: AI CoDiscuss 工作流的创建与修改技能。用户要把房间
50
50
  templates/ 等(可选) 主持人要用的模板、参考资料,只认文本文件
51
51
  ```
52
52
 
53
+ **流程画布**(`flow.json`,可选):用户在编辑页画的接力,`{ "nodes": [{ id, type: start|card|host, card?, x, y }], "edges": [{ source, target }] }`。保存时程序检查它排得成接力(从唯一的 `start` 出发一棒接一棒;同一棒并排的卡片由同一棒交来、交给同一棒;`host` 是前一棒之后的检查点;连回前面卡片的边是循环,不能跨过前一个检查点),主持人任务里会拿到按它生成的派活参数。粘贴草稿不带画布;用草稿保存时,已有的 `flow.json` 原样保留。起草时不用写它,SKILL.md 照常写清流程;用户画了画布时,SKILL.md 里的顺序要与画布一致。
54
+
53
55
  `SKILL.md` 和 `agents/` 之外的文件随工作流一起保存和分享;在 SKILL.md 里按相对路径指给主持人,如“照本工作流文件夹下 `templates/` 建项目”。粘贴草稿只能带 SKILL.md 和卡片;用草稿保存时,文件夹里已有的这些文件原样保留。
54
56
 
55
57
  ### 登记命令(给模型 API 成员)
package/README.md CHANGED
@@ -30,7 +30,7 @@ Most multi-agent frameworks spin up fresh agents inside their own runtime. AI Co
30
30
  - **Structured discussions** — set a goal and round / time limits; the host schedules speakers and follow-ups, then the room returns to free collaboration with history and conclusions kept.
31
31
  - **Goal mode** — give the host an objective with `/goal`; whenever the room goes idle the host is woken to carry on, until it confirms the goal is complete, gets blocked, a budget runs out or you end it.
32
32
  - **Smart Scheduling** — optionally let a decision model handle routine routing instead of waking the host.
33
- - **Workflows** — save a way of working as a host playbook (`SKILL.md`) plus a set of agent cards and share it as a folder. Browse and edit workflows when creating a room; start one later by asking the host in the message box or selecting it with `/` and confirming its members. It brings the method and the people, not a guaranteed procedure. See [runner docs](docs/runner.md#工作流可选).
33
+ - **Workflows** — save a way of working as a host playbook (`SKILL.md`) plus a set of agent cards and share it as a folder. Manage them (view, create, import, edit, delete) on the 工作流 page under the channel tools, even with no room open; start one later by asking the host in the message box or selecting it with `/` and confirming its members. Lay the cards out on a flow canvas (who goes after whom, who works at once, host checkpoints, loops) and the room runs that relay when the host follows the workflow; without a canvas it brings the method and the people, not a guaranteed procedure. See [runner docs](docs/runner.md#工作流可选).
34
34
  - **Mix any participant** — local CLIs and desktop apps, model APIs and manually connected sessions in one room.
35
35
  - **Shared workspace** — point a room at a project directory, grant each local agent read-only or writable access, and browse files with text, Markdown and static HTML previews from the room.
36
36
  - **Live roster** — invite, pause, recall or remove members at any time, including resuming an existing agent session.
@@ -170,6 +170,7 @@ LATER_COLUMNS = {"room_members": {"access": "TEXT NOT NULL DEFAULT 'runner'", "s
170
170
  "member_requests": {"chain_id": "INTEGER NOT NULL DEFAULT 0", "batch_id": "INTEGER NOT NULL DEFAULT 0",
171
171
  "purpose": "TEXT NOT NULL DEFAULT 'ask'", "created_by": "TEXT NOT NULL DEFAULT 'user'"},
172
172
  "free_chain_holds": {"asked_rounds": "INTEGER NOT NULL DEFAULT 0", "asked_reason": "TEXT NOT NULL DEFAULT ''"},
173
+ "free_sequences": {"gates": "TEXT NOT NULL DEFAULT '[]'"},
173
174
  "execution_capabilities": {"steer": "INTEGER"}}
174
175
 
175
176
 
@@ -608,6 +609,9 @@ def resolve_member_fields(connection, room_id, payload):
608
609
  payload["detach"] = [resolve(item) for item in payload["detach"]]
609
610
  if isinstance(payload.get("parallel"), list):
610
611
  payload["parallel"] = [[resolve(item) for item in group] if isinstance(group, list) else group for group in payload["parallel"]]
612
+ if isinstance(payload.get("gates"), list):
613
+ payload["gates"] = [{**gate, **{key: resolve(gate[key]) for key in ("after", "repeat_from") if key in gate}}
614
+ if isinstance(gate, dict) else gate for gate in payload["gates"]]
611
615
  for key in ("next_review", "expert"):
612
616
  if key in payload:
613
617
  payload[key] = resolve(payload[key])
@@ -989,7 +993,7 @@ def recipients(connection, room_id, payload):
989
993
 
990
994
  # What a proactive post may not carry: it answers no request. Only the host's may also dispatch, as an answer does.
991
995
  PROACTIVE_EXCLUDED = ("execution_id", "request_ids", "next_review", "turn_id")
992
- HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "parallel", "detach", "need_rounds", "rounds_reason")
996
+ HOST_ONLY_PROACTIVE = ("to", "handoff", "no_handoff", "continue", "sequential", "loop", "parallel", "gates", "detach", "need_rounds", "rounds_reason")
993
997
 
994
998
 
995
999
  def check_need_rounds(member, payload):
@@ -1062,7 +1066,7 @@ def after_proactive(connection, room_id, message_id, member, payload):
1062
1066
  return
1063
1067
  if payload.get("to") and payload.get("sequential") is True:
1064
1068
  start_sequence(connection, room_id, member, message_id, payload["to"], message_id, payload.get("loop") is True,
1065
- need_plan(payload), payload.get("parallel"), payload.get("detach"))
1069
+ need_plan(payload), payload.get("parallel"), payload.get("detach"), payload.get("gates"))
1066
1070
  elif payload.get("to"):
1067
1071
  dispatch(connection, room_id, member, message_id, payload["to"], message_id, payload.get("handoff") is True,
1068
1072
  payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
@@ -1482,6 +1486,8 @@ def checked_sequence(actor, payload):
1482
1486
  sequential, loop = payload.get("sequential", False), payload.get("loop", False)
1483
1487
  if payload.get("parallel") is not None and sequential is not True:
1484
1488
  raise ValueError("parallel 只与 sequential 一起使用")
1489
+ if payload.get("gates") is not None and sequential is not True:
1490
+ raise ValueError("gates 只与 sequential 一起使用")
1485
1491
  if not isinstance(actor, dict) or (sequential is False and loop is False):
1486
1492
  return
1487
1493
  if sequential is not True or type(loop) is not bool:
@@ -1493,7 +1499,7 @@ def checked_sequence(actor, payload):
1493
1499
  raise ValueError("依次接力须用 to 按顺序点名至少两位不同成员,每位只出现一次")
1494
1500
  if payload.get("next_review"):
1495
1501
  raise ValueError("依次接力不能同时声明预授权评审;评审者直接排进顺序即可")
1496
- relay_order(names, payload.get("parallel"))
1502
+ relay_gates(relay_order(names, payload.get("parallel")), payload.get("gates"), loop)
1497
1503
 
1498
1504
 
1499
1505
  def checked_detach(actor, payload):
@@ -1539,6 +1545,51 @@ def relay_order(names, parallel):
1539
1545
  return steps
1540
1546
 
1541
1547
 
1548
+ def relay_gates(order, gates, loop=False):
1549
+ """`gates` by member name turned into step indexes: [{after, repeat_from}], sorted by `after`.
1550
+
1551
+ A gate hands back to the host after the step holding `after`; with `repeat_from` it is a loop's checkpoint whose next
1552
+ pass starts at the step holding that member, which may not reach back past the gate before it. `loop` already is a
1553
+ gate after the last step, so a gate there is refused alongside it.
1554
+ """
1555
+ if gates is None:
1556
+ return []
1557
+ if not isinstance(gates, list) or not gates or any(not isinstance(gate, dict) for gate in gates):
1558
+ raise ValueError("gates 须为检查点列表,每项为 {after, repeat_from?}")
1559
+ step_of = {name: index for index, step in enumerate(order) for name in step}
1560
+ checked = []
1561
+ for gate in gates:
1562
+ after, repeat_from = gate.get("after"), gate.get("repeat_from")
1563
+ if after not in step_of or repeat_from is not None and repeat_from not in step_of:
1564
+ raise ValueError("gates 的 after 与 repeat_from 须是 to 中的成员")
1565
+ checked.append({"after": step_of[after], "repeat_from": None if repeat_from is None else step_of[repeat_from]})
1566
+ checked.sort(key=lambda gate: gate["after"])
1567
+ afters = [gate["after"] for gate in checked]
1568
+ if len(set(afters)) != len(afters):
1569
+ raise ValueError("同一棒之后只能有一个检查点")
1570
+ if loop and afters[-1] == len(order) - 1:
1571
+ raise ValueError("loop 已在最后一棒之后设了检查点,gates 不能再设在最后一棒")
1572
+ for index, gate in enumerate(checked):
1573
+ start = checked[index - 1]["after"] + 1 if index else 0
1574
+ if gate["repeat_from"] is not None and not start <= gate["repeat_from"] <= gate["after"]:
1575
+ raise ValueError("循环检查点的 repeat_from 须在本段内:不早于前一个检查点之后的第一棒,不晚于 after")
1576
+ return checked
1577
+
1578
+
1579
+ def sequence_gates(sequence):
1580
+ """The relay's gates by step index, the loop's checkpoint after the last step included."""
1581
+ gates = json.loads(sequence["gates"] or "[]")
1582
+ last = len(relay_steps(sequence)) - 1
1583
+ if sequence["loop"] and not any(gate["after"] == last for gate in gates):
1584
+ gates = gates + [{"after": last, "repeat_from": 0}]
1585
+ return gates
1586
+
1587
+
1588
+ def current_gate(sequence):
1589
+ """The gate the relay stands at or heads for: the first one at or after its current step."""
1590
+ return next((gate for gate in sequence_gates(sequence) if gate["after"] >= sequence["position"]), None)
1591
+
1592
+
1542
1593
  def relay_steps(sequence):
1543
1594
  """The relay's steps as lists of member ids; a step stored as one id (single member) reads as a one-member list."""
1544
1595
  return [step if isinstance(step, list) else [step] for step in json.loads(sequence["member_ids"])]
@@ -1549,10 +1600,11 @@ def stored_step(member_ids):
1549
1600
  return member_ids[0] if len(member_ids) == 1 else member_ids
1550
1601
 
1551
1602
 
1552
- def start_sequence(connection, room_id, host, message_id, names, chain_id, loop, need=None, parallel=None, detach=None):
1603
+ def start_sequence(connection, room_id, host, message_id, names, chain_id, loop, need=None, parallel=None, detach=None, gates=None):
1553
1604
  """Start a host's one-after-another dispatch with its first step; the rest follow as each step ends.
1554
1605
 
1555
1606
  A step named in `parallel` is a group: its members work at once and the relay goes on only after all of them end.
1607
+ `gates` hand back to the host after the steps they name (see `relay_gates`).
1556
1608
  """
1557
1609
  active = active_members(connection, room_id)
1558
1610
  named_members(active, names, exclude=host["name"])
@@ -1560,24 +1612,25 @@ def start_sequence(connection, room_id, host, message_id, names, chain_id, loop,
1560
1612
  return
1561
1613
  order = relay_order(names, parallel)
1562
1614
  steps = [stored_step([active[name]["id"] for name in step]) for step in order]
1563
- connection.execute("""INSERT INTO free_sequences (room_id,message_id,chain_id,host_id,member_ids,loop,batch_id,steps)
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]])))
1615
+ connection.execute("""INSERT INTO free_sequences (room_id,message_id,chain_id,host_id,member_ids,loop,batch_id,steps,gates)
1616
+ VALUES (?,?,?,?,?,?,?,?,?)""", (room_id, message_id, chain_id, host["id"], json.dumps(steps), int(loop), message_id,
1617
+ json.dumps([[1, steps[0], message_id, 0]]), json.dumps(relay_gates(order, gates, loop))))
1566
1618
  dispatch(connection, room_id, host, message_id, order[0], chain_id, plan={"need": need}, detach=detach)
1567
1619
 
1568
1620
 
1569
- def sequence_step(connection, room_id, sequence, host, position, pass_number, message_id):
1621
+ def sequence_step(connection, room_id, sequence, host, position, pass_number, message_id, new_round=None):
1570
1622
  """Ask the members still here of the step at `position`, hung on `message_id`; one step is one batch.
1571
1623
 
1572
- A pass's first step costs one round (or waits at the limit); later steps of the same pass ride on it.
1624
+ A pass's first step, or the first after the host lets a gate go on (`new_round`), costs one round (or waits at the
1625
+ limit); later steps ride on it.
1573
1626
  """
1574
1627
  present = {row["id"]: name for name, row in active_members(connection, room_id).items()}
1575
1628
  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]]
1629
+ steps = json.loads(sequence["steps"]) + [[pass_number, stored_step(member_ids), message_id, position]]
1577
1630
  connection.execute("UPDATE free_sequences SET status='running',pass=?,position=?,batch_id=?,steps=? WHERE id=?",
1578
1631
  (pass_number, position, message_id, json.dumps(steps), sequence["id"]))
1579
1632
  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"]})
1633
+ plan={"relay": not (pass_number != sequence["pass"] if new_round is None else new_round)})
1581
1634
 
1582
1635
 
1583
1636
  def next_present(connection, room_id, sequence, start):
@@ -1604,13 +1657,17 @@ def advance_sequence(connection, room_id, sequence, host, completed, failed=Fals
1604
1657
  answer_id = connection.execute("""SELECT answer_id FROM member_requests WHERE room_id=? AND batch_id=? AND purpose='ask'
1605
1658
  AND status='completed' ORDER BY id DESC""", (room_id, sequence["batch_id"])).fetchone()[0]
1606
1659
  position = next_present(connection, room_id, sequence, sequence["position"] + 1)
1607
- if position is not None:
1660
+ end = len(relay_steps(sequence)) if position is None else position
1661
+ # A gate after this step, or after steps skipped because their members left, stops the relay there for the host.
1662
+ gate = next((gate for gate in sequence_gates(sequence) if sequence["position"] <= gate["after"] < end), None)
1663
+ if position is not None and not gate:
1608
1664
  sequence_step(connection, room_id, sequence, host, position, sequence["pass"], answer_id)
1609
1665
  return
1610
1666
  insert_request(connection, room_id, host["id"], sequence["batch_id"], sequence["chain_id"], "handoff", "host")
1611
1667
  request_id = connection.execute("SELECT last_insert_rowid()").fetchone()[0]
1612
- connection.execute("UPDATE free_sequences SET status=?,answer_id=?,handoff_request=? WHERE id=?",
1613
- ("checkpoint" if sequence["loop"] else "done", answer_id, request_id, sequence["id"]))
1668
+ connection.execute("UPDATE free_sequences SET status=?,answer_id=?,handoff_request=?,position=? WHERE id=?",
1669
+ ("checkpoint" if gate else "done", answer_id, request_id, gate["after"] if gate else sequence["position"],
1670
+ sequence["id"]))
1614
1671
 
1615
1672
 
1616
1673
  def sequence_view(connection, room_id, sequence_id, token):
@@ -1626,8 +1683,12 @@ def sequence_view(connection, room_id, sequence_id, token):
1626
1683
  names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (room_id,))}
1627
1684
  origin = connection.execute("SELECT id,author,text FROM messages WHERE id=?", (row["message_id"],)).fetchone()
1628
1685
  answers = []
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):
1686
+ # This pass of the stretch the relay is in: steps after the gate before it; older rows lack the step's position.
1687
+ start = max((gate["after"] + 1 for gate in sequence_gates(row) if gate["after"] < row["position"]), default=0)
1688
+ steps_of_pass = [(entry[3] + 1 if len(entry) > 3 else None, entry[1], entry[2]) for entry in json.loads(row["steps"])
1689
+ if entry[0] == row["pass"] and (len(entry) < 4 or entry[3] >= start)]
1690
+ for counted, (position, member_ids, batch_id) in enumerate(steps_of_pass, 1):
1691
+ step_number = position or counted
1631
1692
  for member_id in member_ids if isinstance(member_ids, list) else [member_ids]:
1632
1693
  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
1694
  WHERE r.room_id=? AND r.batch_id=? AND r.member_id=? AND r.purpose='ask'""", (room_id, batch_id, member_id)).fetchone()
@@ -1636,11 +1697,21 @@ def sequence_view(connection, room_id, sequence_id, token):
1636
1697
  handoff = connection.execute("SELECT status FROM member_requests WHERE id=?", (row["handoff_request"],)).fetchone()
1637
1698
  return {"id": row["id"], "message_id": row["message_id"], "order": order_names(row, names),
1638
1699
  "loop": bool(row["loop"]), "pass": row["pass"], "position": row["position"], "status": row["status"],
1700
+ "gates": sequence_gates(row), "gate": gate_view(row) if row["status"] == "checkpoint" else None,
1639
1701
  "chain_id": row["chain_id"], "remaining_rounds": rounds_left(connection, room_id, row["chain_id"]),
1640
1702
  "handoff_request": row["handoff_request"] or None, "handoff_status": handoff[0] if handoff else None,
1641
1703
  "origin": dict(origin) if origin else None, "answers": answers}
1642
1704
 
1643
1705
 
1706
+ def gate_view(sequence):
1707
+ """The gate the relay stands at, as the host decides on it: where another pass starts and whether it may go on."""
1708
+ gate = current_gate(sequence)
1709
+ if not gate:
1710
+ return None
1711
+ return {"after": gate["after"] + 1, "repeat_from": None if gate["repeat_from"] is None else gate["repeat_from"] + 1,
1712
+ "can_proceed": gate["after"] < len(relay_steps(sequence)) - 1}
1713
+
1714
+
1644
1715
  def order_names(sequence, names):
1645
1716
  """The relay's order by name: a one-member step as the name, a group step as the list of its members' names."""
1646
1717
  return [names.get(step[0]) if len(step) == 1 else [names.get(item) for item in step] for step in relay_steps(sequence)]
@@ -1659,9 +1730,12 @@ def continue_sequence(connection, room_id, token, payload):
1659
1730
  request_id = payload.get("request_id")
1660
1731
  if type(request_id) is not int:
1661
1732
  raise ValueError("须提供检查点交接请求 request_id")
1662
- sequence = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND handoff_request=? AND loop=1", (room_id, request_id)).fetchone()
1663
- if not sequence:
1664
- raise LookupError("该请求不是循环接力的检查点")
1733
+ proceed = payload.get("proceed", False)
1734
+ if type(proceed) is not bool:
1735
+ raise ValueError("proceed 须为布尔值")
1736
+ sequence = connection.execute("SELECT * FROM free_sequences WHERE room_id=? AND handoff_request=?", (room_id, request_id)).fetchone()
1737
+ if not sequence or sequence["status"] == "done" or not sequence_gates(sequence):
1738
+ raise LookupError("该请求不是依次接力的检查点")
1665
1739
  request = connection.execute("SELECT * FROM member_requests WHERE id=?", (request_id,)).fetchone()
1666
1740
  if sequence["status"] == "running" and request["status"] == "completed":
1667
1741
  execution = connection.execute("SELECT status FROM member_executions WHERE id=?", (request["execution_id"],)).fetchone()
@@ -1669,13 +1743,19 @@ def continue_sequence(connection, room_id, token, payload):
1669
1743
  "execution_completed": bool(execution and execution[0] == "completed")}
1670
1744
  if sequence["status"] != "checkpoint" or request["status"] not in ("pending", "delivering") or request["member_id"] != host["id"]:
1671
1745
  raise FileExistsError("这个检查点已处理、循环已结束,或主持人已更换")
1672
- position = next_present(connection, room_id, sequence, 0)
1746
+ gate = current_gate(sequence)
1747
+ repeat = gate["repeat_from"] is not None and not proceed
1748
+ if not repeat and gate["after"] >= len(relay_steps(sequence)) - 1:
1749
+ raise ValueError("这是最后一棒之后的检查点,没有下一棒;直接回答即结束接力")
1750
+ position = next_present(connection, room_id, sequence, gate["repeat_from"] if repeat else gate["after"] + 1)
1673
1751
  if position is None:
1674
- raise FileExistsError("循环中的成员都已离开,无法继续")
1752
+ raise FileExistsError("后面的成员都已离开,无法继续")
1675
1753
  connection.execute("UPDATE member_requests SET status='completed' WHERE id=?", (request_id,))
1676
1754
  completed = finish_review_handoff(connection, request, "sequence_continued")
1677
- sequence_step(connection, room_id, sequence, host, position, sequence["pass"] + 1, sequence["answer_id"])
1678
- return {"sequence_id": sequence["id"], "pass": sequence["pass"] + 1, "duplicate": False, "execution_completed": completed}
1755
+ # Another pass counts on; going on past a gate starts the next stretch at its first pass. Either costs one round.
1756
+ pass_number = sequence["pass"] + 1 if repeat else 1
1757
+ sequence_step(connection, room_id, sequence, host, position, pass_number, sequence["answer_id"], new_round=True)
1758
+ return {"sequence_id": sequence["id"], "pass": pass_number, "proceeded": not repeat, "duplicate": False, "execution_completed": completed}
1679
1759
 
1680
1760
 
1681
1761
  def sequence_request_context(connection, request):
@@ -1694,15 +1774,18 @@ def sequence_request_context(connection, request):
1694
1774
  names = {item["id"]: item["name"] for item in connection.execute("SELECT id,name FROM room_members WHERE room_id=?", (row["room_id"],))}
1695
1775
  steps = relay_steps(sequence)
1696
1776
  order = order_names(sequence, names)
1697
- phase = "step" if row["purpose"] == "ask" else "checkpoint" if sequence["loop"] else "end"
1698
- position = next((index for index, step in enumerate(steps) if row["member_id"] in step), None) if phase == "step" else len(order) - 1
1777
+ gate = current_gate(sequence) if row["purpose"] == "handoff" and sequence["status"] == "checkpoint" else None
1778
+ phase = "step" if row["purpose"] == "ask" else "end" if not gate else "checkpoint" if gate["repeat_from"] is not None else "gate"
1779
+ position = next((index for index, step in enumerate(steps) if row["member_id"] in step), None) if phase == "step" \
1780
+ else gate["after"] if gate else len(order) - 1
1699
1781
  if position is None:
1700
1782
  return {}
1701
1783
  following = order[position + 1] if phase == "step" and position + 1 < len(order) else None
1702
1784
  together = [names.get(item) for item in steps[position] if item != row["member_id"]] if phase == "step" else []
1703
1785
  return {"sequence": {"id": sequence["id"], "message_id": sequence["message_id"], "order": order, "loop": bool(sequence["loop"]),
1704
1786
  "pass": sequence["pass"], "position": position, "phase": phase, "next": following,
1705
- **({"together": together} if together else {}), "answer_id": sequence["answer_id"] or None}}
1787
+ **({"together": together} if together else {}), "answer_id": sequence["answer_id"] or None,
1788
+ **({"gate": gate_view(sequence)} if gate else {})}}
1706
1789
 
1707
1790
 
1708
1791
  def default_order(connection, room_id, members):
@@ -1735,7 +1818,8 @@ def speaking_order(connection, room):
1735
1818
  # A host's one-after-another dispatch shows like a queue: this pass's members, done up to the one asked now.
1736
1819
  kind, target = "queue", sequence["message_id"]
1737
1820
  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"
1821
+ at_gate = sequence["status"] == "checkpoint" and index == sequence["position"]
1822
+ state = "done" if at_gate or index < sequence["position"] else "speaking" if index == sequence["position"] else "queued"
1739
1823
  for member_id in step:
1740
1824
  if member_id in by_id:
1741
1825
  # Members of a group step share its number: they work at once.
@@ -1755,8 +1839,10 @@ def speaking_order(connection, room):
1755
1839
  items.append({"name": by_id[row["member_id"]]["name"], "state": state})
1756
1840
  listed = {item["name"] for item in items}
1757
1841
  items += [{"name": name, "state": "idle"} for name in dict.fromkeys(ordered) if name not in listed]
1758
- movable = "queued" if kind != "default" else "idle"
1759
- relay = {"id": sequence["id"], "loop": bool(sequence["loop"]), "pass": sequence["pass"]} if sequence else None
1842
+ # A relay with gates in it keeps its order: moving members would shift where the gates stand.
1843
+ movable = "none" if sequence and json.loads(sequence["gates"] or "[]") else "queued" if kind != "default" else "idle"
1844
+ relay = {"id": sequence["id"], "loop": any(gate["repeat_from"] is not None for gate in sequence_gates(sequence)),
1845
+ "pass": sequence["pass"]} if sequence else None
1760
1846
  state = json.dumps([kind, target, [[item["name"], item["state"], item.get("step")] for item in items], later, session_plan_state, relay],
1761
1847
  ensure_ascii=False)
1762
1848
  return {"kind": kind, "message_id": target, "items": items, "later": later, "movable": [item["name"] for item in items if item["state"] == movable],
@@ -1864,7 +1950,7 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1864
1950
  connection.execute("UPDATE room_members SET read_until=MAX(read_until,?) WHERE id=?", (message_id, actor["id"]))
1865
1951
  if actor["role"] == "host" and payload.get("to") and payload.get("sequential") is True:
1866
1952
  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"))
1953
+ payload.get("parallel"), payload.get("detach"), payload.get("gates"))
1868
1954
  elif actor["role"] == "host" and payload.get("to"):
1869
1955
  dispatch(connection, room_id, actor, message_id, payload["to"], chain_id, payload.get("handoff") is True or bool(payload.get("next_review")),
1870
1956
  payload.get("no_handoff") is True, {"need": need_plan(payload)}, payload.get("detach"))
@@ -589,7 +589,9 @@ CREATE TABLE IF NOT EXISTS free_batches (
589
589
  );
590
590
  -- A host's one-after-another dispatch: each step is a one-member batch hung on the previous answer and costs one chain round.
591
591
  -- After the last member it hands back to the host; a loop's hand-back is a checkpoint from which the host (or smart
592
- -- scheduling for it) may run the next pass. `steps` lists [pass, member_id, batch_id] for every step started.
592
+ -- scheduling for it) may run the next pass. `steps` lists [pass, member_id, batch_id, position] for every step started.
593
+ -- `gates` are hand-backs after a step in the middle or at the end: [{after, repeat_from}] by step index; at one the host
594
+ -- goes on to the next step, or (with repeat_from) runs the steps from repeat_from again. `loop` is a gate after the last step.
593
595
  CREATE TABLE IF NOT EXISTS free_sequences (
594
596
  id INTEGER PRIMARY KEY AUTOINCREMENT,
595
597
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
@@ -604,7 +606,8 @@ CREATE TABLE IF NOT EXISTS free_sequences (
604
606
  answer_id INTEGER NOT NULL DEFAULT 0,
605
607
  handoff_request INTEGER NOT NULL DEFAULT 0,
606
608
  steps TEXT NOT NULL DEFAULT '[]',
607
- status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','checkpoint','done','stopped'))
609
+ status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','checkpoint','done','stopped')),
610
+ gates TEXT NOT NULL DEFAULT '[]'
608
611
  );
609
612
  CREATE INDEX IF NOT EXISTS free_sequences_batch ON free_sequences(room_id,batch_id);
610
613
  CREATE INDEX IF NOT EXISTS free_sequences_handoff ON free_sequences(handoff_request);
package/backend/server.py CHANGED
@@ -1171,7 +1171,7 @@ def check_message_references(connection, room_id, message):
1171
1171
  def append_free_message(connection, room, payload, actor):
1172
1172
  """Append a free-room message: the owner's creates requests, a member's completes its execution."""
1173
1173
  if payload.get("kind") == "moderation":
1174
- if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "parallel", "detach", "need_rounds")):
1174
+ if any(payload.get(field) for field in ("next_review", "review_scope", "review_roster", "sequential", "loop", "parallel", "gates", "detach", "need_rounds")):
1175
1175
  raise ValueError("主持说明不能声明评审授权、依次接力或申请轮数;请在派活时提交")
1176
1176
  return append_free_moderation(connection, room, payload, actor)
1177
1177
  if payload.get("kind") not in (None, "chat"):
@@ -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; 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.
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. A relay can also stop at checkpoints for the host to approve before it goes on, or loop over just part of it (write ⇄ review until it passes, then hand to the recorder); a workflow's flow canvas is laid out with exactly these. 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
@@ -113,15 +113,16 @@ API 成员可以担任专家或主持人。房间指定了项目目录时,它
113
113
 
114
114
  ## 工作流(可选)
115
115
 
116
- 工作流把一种多人协作的做法存下来,供别的房间复用:一份给主持人读的做法说明(SKILL.md),加一组 Agent 卡片(每张就是一个专家预设)。建房或普通邀请时在“工作流”一行选中它,只供查看和编辑,不添加成员,也不随建房启动。建房后,在输入框要求主持人启动(打 `/` 选工作流可以直接写好这句请求,权限见下文);主持人此后按说明组织工作。它只带来做法和成员,不保证工序,具体怎么做仍由主持人判断,你随时可以 @ 任何成员。
116
+ 工作流把一种多人协作的做法存下来,供别的房间复用:一份给主持人读的做法说明(SKILL.md),加一组 Agent 卡片(每张就是一个专家预设)。工作流在左侧“频道工具 → 工作流”页管理(没打开房间时也能用):查看说明、接力顺序和成员卡片,新建、导入、编辑、删除都在这里。要在房间里用时,在输入框要求主持人启动(打 `/` 选工作流可以直接写好这句请求,权限见下文);主持人此后按说明组织工作。它只带来做法和成员;没画流程时不保证工序,具体怎么做由主持人判断。你随时可以 @ 任何成员。
117
117
 
118
118
  - **内置工作流**:安装包自带“小说剧本连载”(`story-serial`:编剧、写手、两位批评家与记录员逐集接力,附项目模板与状态检查脚本,脚本需要 Python 的 PyYAML)。默认不安装。在终端里运行 `codiscuss setup` 时,选完软件后有一步“安装配置”:先显示概览,按 → 展开,用空格勾选要装的工作流,回车继续;不勾就不装。也可以直接加 `--workflows` 安装全部(`install` 同样适用,非交互运行只认这个选项)。装的时候,已有同名的会覆盖为包里的最新版,原来那份先备份到服务目录的 `backups/workflows/`。选择只对这一次有效,不会记住,不选就不动工作流库。
119
- - **保存**:在“工作流”一行点“添加”,选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先在编辑页预览,点保存才写入。选中一个工作流后,下方显示它的描述,可“查看说明”(做法说明与成员卡片)或“编辑”,删除在编辑页底部。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“主持人完整权限”。
119
+ - **保存**:在“工作流”页点列表上方的 +,选“新建”,或导入别人分享的 zip 或文件夹(手机、平板上同样可用)。导入的内容先放上画布预览,点“保存”才写入。复制文件夹路径和删除工作流在说明抽屉底部。编辑页可以“从当前房间成员导入”卡片,提示词保持原文。想让主持人起草,打开下面的“主持人完整权限”。
120
+ - **流程画布**:“工作流”页左边是工作流列表,其余整块就是选中工作流的画布。每位成员是一张小卡(序号、头像、名称、模型与权限),拖动即可移动;单击打开右侧的专家卡片,翻面设置或 × 删除。左上“成员”添加卡片(平台、预设,或当前房间的成员),“检查点”添加主持人检查点;也可以从卡片右侧的圆点拖线到空白处,就地选要加的成员并自动连上。名称在右上角直接改,Ctrl+S 保存。从「开始」右侧的圆点拖线连到卡片,排出谁先谁后;同一列并排的几张(由同一张交来、交给同一张)同时做;检查点放在两棒之间,前一棒做完先交回主持人把关;从后面连回前面的卡片就是循环(金色虚线),走到这里交回主持人,要返工就从连回的那张再来一圈,通过就往下走。底部的胶囊实时显示排出的顺序,排不成接力时变红说明原因。描述和做法说明在右上的说明图标里。主持人按这个工作流派活时照画布交接,程序负责一棒接一棒、检查点和循环;没连线时仍由主持人按做法说明组织。暂不支持按条件分支,需要判断的地方放检查点。
120
121
  - **让主持人自己建**:建房时(第 3 节“协作方式”)或在“成员与设置 → 协作设置”勾选“主持人完整权限”后,直接对主持人说要什么分工(或“把这个房间的做法存成工作流”),它会自己保存、在本房间启用,并邀请工作流里的成员,接着就按流程派活,不需要你导入或添加成员。之后让它改流程,改动从成员下一次执行起生效。打开这个开关后,也可以在输入框输入 `/` 选一个已保存的工作流,输入框里会写好“启用工作流「名称」(ID),”,补上要做的事发给主持人,它就启用这个工作流并带来成员(没打开时该项灰显)。主持人带来的成员项目权限最高为可写;只有本地软件担任的主持人能这样做,API 主持人不行。
121
122
  - **主持人完整权限的其他控制**:同一个开关打开后,主持人会自己处理房间里的问题、持续推进:成员跑偏时插话、卡住时打断或重试、模型不可用时换模型、无法恢复时换人;你也可以直接说“停下写手”“全部停下”“下一个让批评家先说”“请一位 Codex 进来只读评审”“把记录员请出去”“开个 Goal 把剩下几集写完”“暂停讨论”。邀请只用本机已配置的软件,项目权限最高可写;它自己开的 Goal 用你的默认预算(不限时为 8 次续跑、90 分钟)。成员连续 10 分钟没有新输出时,系统会提醒主持人检查;主持人派出长任务时还可以给自己设定时检查(每 5–120 分钟一次,房间空闲后自动结束)。它也能撤回自己派出的活,正在循环的接力会立即停下。以下仍由你决定:对主持人自己操作或更换主持人、批准超出上限的轮数、高于可写的权限或放行工具、改项目目录、撤回你的消息、私聊。API 主持人没有命令行,不能这样做。
122
123
  - **分享**:工作流存在本机服务目录的 `workflows` 文件夹,一个工作流一个文件夹;编辑页可复制路径,把整个文件夹(或打成 zip)发给对方即可分享。导入时会列出每张卡片的提示词和项目权限;要求写文件的卡片默认按只读导入,逐张勾选后才放开。
123
124
  - **给 API 成员登记命令**:以 API 方式加入的成员没有命令行。工作流文件夹里放一个 `commands.json`(写法见 `workflow-creator` 技能),登记它们能运行的命令(例如检查脚本,脚本也放在工作流文件夹里),随工作流一起分享;启用后 API 成员只能按名字运行这些命令,改不了命令行,只读成员只能运行标为只读的命令。命令以你的身份在本机运行,所以导入别人的工作流时,编辑页会列出每条完整命令,勾选“允许这些命令在本机运行”才会带上;主持人起草或修改工作流时不能增删命令。
124
- - **修改与结束**:修改工作流会作用于所有启用它的房间,成员下一次执行就按新内容;新增的卡片不会自动进入房间,主持人用到时会自己补上,也可以在右栏工作流一行点刷新图标立即补齐(删掉的卡片,其成员留作普通成员;与库一致时不显示该图标);工作流卡片的提示词只读,在“工作流”一行点“编辑”修改。工作流进行中时,主持人只在请求和它的用途相符(或你说按工作流做)时才用,其他请求照常处理。用完在“成员与设置 → 协作设置”的工作流一行点请离成员图标(或逐个让成员离开):工作流带来的成员离开(主持人保留),正在进行的工作随之停止,工作流结束,右栏这一块消失;成员会话保留,下次在输入框打 `/` 选它就把原成员请回来,接着原来的上下文。换工作流时直接选另一个,原来的成员留作普通成员。
125
+ - **修改与结束**:修改工作流会作用于所有启用它的房间,成员下一次执行就按新内容;新增的卡片不会自动进入房间,主持人用到时会自己补上,也可以在右栏工作流一行点刷新图标立即补齐(删掉的卡片,其成员留作普通成员;与库一致时不显示该图标);工作流卡片的提示词只读,在“工作流”页的画布上修改。工作流进行中时,主持人只在请求和它的用途相符(或你说按工作流做)时才用,其他请求照常处理。用完在“成员与设置 → 协作设置”的工作流一行点请离成员图标(或逐个让成员离开):工作流带来的成员离开(主持人保留),正在进行的工作随之停止,工作流结束,右栏这一块消失;成员会话保留,下次在输入框打 `/` 选它就把原成员请回来,接着原来的上下文。换工作流时直接选另一个,原来的成员留作普通成员。
125
126
 
126
127
  ## 朗读与语音输入(可选)
127
128
 
package/docs/workflow.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - **点名**:`@成员` 后,它在自己的原会话中处理并回复,多位可并行。用界面上显示的昵称点名即可(如 `@Gemini`),输入 `@` 后从列表选择最方便。
19
19
  - **未点名的消息**:交给主持人分派;只有一位成员时直接交给它。
20
20
  - **主持人派活**:主持人派出的成员做完或失败后都会交回主持人,由它接着安排或汇总;简单问答主持人可以让成员直接回复你,失败时仍交回主持人。程序发现的问题(如成员执行失败)会以“程序通知主持人”卡片交给主持人处理;主持人也可以在回答后要求房间空闲时再被唤醒,自己接着推进。你的一条消息默认最多让主持人推进 10 轮(主持人派活、成员回答、交回汇总为一轮;主持人要求续跑一次也算一轮,程序通知不算),建房时在“推进方式”里或建房后在房间页的“协作设置”里调整(1–100 轮,或勾选“不限”);到上限会停下等你,右栏点“继续”即接着推进。主持人事先知道任务要多少轮(如连写几集)时,会在开始时申请轮数:剩余不够就先停下,右栏显示申请理由,点“批准”后一次跑完,不再中途停下。
21
- - **依次接力与循环**:几位成员要按固定先后做(写完再审、实现后评审)时,主持人会安排依次接力:一位做完直接交给下一位,最后一位做完才交回主持人,走完一圈算一轮。需要“改完再审”时安排成循环:每走完一圈交回主持人一次,评审要求返工就再走一圈,认可或需要取舍时由主持人验收。互不依赖的几位(如分别审不同问题的评审)可以排在同一棒同时做,这一组全部做完才交给下一位。右栏发言顺序显示进行到哪一位、第几圈(同时做的成员共用一个序号)。主持人派活时还可以带上一位不必等待的成员,它同时开工,派出的工作结束时不等它,它失败时主持人会收到通知。右栏可拖动调整还没轮到的成员。循环圈数受上面的轮数限制,需要多改几轮时把轮数调大。
21
+ - **依次接力与循环**:几位成员要按固定先后做(写完再审、实现后评审)时,主持人会安排依次接力:一位做完直接交给下一位,最后一位做完才交回主持人,走完一圈算一轮。需要“改完再审”时安排成循环:每走完一圈交回主持人一次,评审要求返工就再走一圈,认可或需要取舍时由主持人验收。互不依赖的几位(如分别审不同问题的评审)可以排在同一棒同时做,这一组全部做完才交给下一位。接力中途还可以设检查点:某一棒做完先交回主持人把关,主持人认可再往下走;也可以只让中间一段循环(如写 ⇄ 审到通过,再交记录员)。工作流的流程画布就是用这些排出来的(见 [工作流](runner.md#工作流可选))。右栏发言顺序显示进行到哪一位、第几圈(同时做的成员共用一个序号)。主持人派活时还可以带上一位不必等待的成员,它同时开工,派出的工作结束时不等它,它失败时主持人会收到通知。右栏可拖动调整还没轮到的成员(设了检查点的接力不能调整)。循环圈数受上面的轮数限制,需要多改几轮时把轮数调大。
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.97",
3
+ "version": "0.3.99",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -8,7 +8,7 @@
8
8
  "dev": "npm run build && wrangler pages dev --ip 127.0.0.1 --port 8787",
9
9
  "db:local": "wrangler d1 execute DB --local --file backend/schema.sql",
10
10
  "db:remote": "wrangler d1 execute DB --remote --file backend/schema.sql",
11
- "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/admin_account.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_api.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_local.mjs && node --check runner/discussion_runner.mjs && node --check runner/discussion_service.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/execution_progress.mjs && node --check runner/expert_prompts.mjs && node --check runner/files.mjs && node --check runner/free_runner.mjs && node --check runner/remote_launch.mjs && node --check runner/remote_policy.mjs && node --check runner/device_auth.mjs && node --check runner/webauthn.mjs && node --check runner/scheduler.mjs && node --check runner/session_driver.mjs && node --check runner/workflows.mjs && node --check runner/adapters/antigravity_app.mjs && node --check runner/adapters/antigravity_cli.mjs && node --check runner/adapters/antigravity_sidecar.mjs && node --check runner/adapters/api.mjs && node --check runner/adapters/claude.mjs && node --check runner/adapters/cli.mjs && node --check runner/adapters/codex_app.mjs && node --check runner/adapters/codex_app_client.mjs && node --check runner/adapters/codex_cli.mjs && node --check runner/adapters/common.mjs && node --check runner/adapters/index.mjs && node --check runner/adapters/profiles.mjs",
11
+ "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/admin_account.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_api.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_local.mjs && node --check runner/discussion_runner.mjs && node --check runner/discussion_service.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/execution_progress.mjs && node --check runner/expert_prompts.mjs && node --check runner/files.mjs && node --check runner/free_runner.mjs && node --check runner/remote_launch.mjs && node --check runner/remote_policy.mjs && node --check runner/device_auth.mjs && node --check runner/webauthn.mjs && node --check runner/scheduler.mjs && node --check runner/session_driver.mjs && node --check runner/workflows.mjs && node --check runner/workflow_flow.mjs && node --check runner/adapters/antigravity_app.mjs && node --check runner/adapters/antigravity_cli.mjs && node --check runner/adapters/antigravity_sidecar.mjs && node --check runner/adapters/api.mjs && node --check runner/adapters/claude.mjs && node --check runner/adapters/cli.mjs && node --check runner/adapters/codex_app.mjs && node --check runner/adapters/codex_app_client.mjs && node --check runner/adapters/codex_cli.mjs && node --check runner/adapters/common.mjs && node --check runner/adapters/index.mjs && node --check runner/adapters/profiles.mjs",
12
12
  "runner": "node runner/discussion_runner.mjs",
13
13
  "service": "node runner/discussion_service.mjs",
14
14
  "build:ui": "vite build --config frontend/vite.config.mjs --mode markdown && vite build --config frontend/vite.config.mjs --mode backup && vite build --config frontend/vite.config.mjs",
@@ -28,13 +28,17 @@
28
28
  "@nuxt/ui": "4.11.3",
29
29
  "@tailwindcss/vite": "4.3.3",
30
30
  "@vitejs/plugin-vue": "6.0.10",
31
+ "@vue-flow/background": "1.3.2",
32
+ "@vue-flow/controls": "1.1.3",
33
+ "@vue-flow/core": "1.48.2",
34
+ "@vue-flow/minimap": "1.5.4",
31
35
  "fflate": "0.8.3",
36
+ "jsqr": "1.4.0",
32
37
  "katex": "0.18.10",
33
38
  "markdown-it": "15.0.3",
34
39
  "markdown-it-cjk-friendly": "3.0.0",
35
40
  "motion-v": "2.7.0",
36
41
  "pdfjs-dist": "6.4.299",
37
- "jsqr": "1.4.0",
38
42
  "uqr": "0.1.2",
39
43
  "vite": "8.3.4",
40
44
  "vue": "3.5.44",
@@ -54,8 +54,9 @@ export function participant_tools(context) {
54
54
  tool('read_message', '读取指定原文;服务端按本次请求限制讨论中的可见范围', { id: NUMBER }, ['id']),
55
55
  ...file_tools(context.project), ...command_tools(context.commands, context.project)];
56
56
  const answer = { text: { ...TEXT, maxLength: 32000 }, summary: { ...TEXT, maxLength: 1000 }, reply_to: NUMBER };
57
- const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true 则交回为检查点;parallel 列出要同一棒同时做的几组成员(每组至少两位、在 to 中相邻),这一组全部结束才交下一棒,组里有人失败则接力停下交回你;detach 列出与这次派活同时开工、但不等待的成员(不计入交回或下一棒),它失败时程序通知你;主持人要在房间空闲后接着推进时填 continue(写明继续做什么),算本链一轮;派活时可用 need_rounds(本任务从这次派活起还需要的轮数)加 rounds_reason 向用户申请轮数,超过本链剩余时任务链立即停下等用户批准',
58
- { ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, parallel: { type: 'array', items: { type: 'array', items: TEXT } }, detach: { type: 'array', items: TEXT }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
57
+ const submit = tool('submit_chat', '保存本次执行的完整回答并结束;summary 仅在回答较长时填写;只有主持人可用 to 点名成员,派出的工作做完或失败都会交回你(只点名一位、回答已足以直接给用户时可加 no_handoff: true,失败仍交回);to 点名多位并加 sequential: true 时按点名顺序依次接力、最后交回你,依次接力走完一圈算一轮,再加 loop: true 则交回为检查点;parallel 列出要同一棒同时做的几组成员(每组至少两位、在 to 中相邻),这一组全部结束才交下一棒,组里有人失败则接力停下交回你;gates 在接力中途设检查点(每项 {after: 成员, repeat_from?: 成员}):after 所在那一棒结束后交回你,带 repeat_from 时为循环检查点、可从 repeat_from 所在那一棒再来一圈,repeat_from 不能早于前一个检查点;detach 列出与这次派活同时开工、但不等待的成员(不计入交回或下一棒),它失败时程序通知你;主持人要在房间空闲后接着推进时填 continue(写明继续做什么),算本链一轮;派活时可用 need_rounds(本任务从这次派活起还需要的轮数)加 rounds_reason 向用户申请轮数,超过本链剩余时任务链立即停下等用户批准',
58
+ { ...answer, ...(context.role === 'host' ? { to: { type: 'array', items: TEXT }, sequential: { type: 'boolean' }, loop: { type: 'boolean' }, parallel: { type: 'array', items: { type: 'array', items: TEXT } },
59
+ gates: { type: 'array', items: { type: 'object', properties: { after: TEXT, repeat_from: TEXT }, required: ['after'] } }, detach: { type: 'array', items: TEXT }, no_handoff: { type: 'boolean' }, continue: { ...TEXT, maxLength: 300 },
59
60
  need_rounds: { type: 'integer', minimum: 1, maximum: 100 }, rounds_reason: { ...TEXT, maxLength: 300 },
60
61
  next_review: TEXT, review_scope: { ...TEXT, maxLength: 2000 } } : {}) }, ['text']);
61
62
  const discussion_host = context.role === 'host' && context.discussion_request?.purpose === 'discuss_host';
@@ -63,10 +64,10 @@ export function participant_tools(context) {
63
64
  if (context.role === 'host') tools.push(tool('read_review_roster', '先读取只读评审候选,再用 submit_chat 的 next_review 和 review_scope 声明实现后的评审;不可指定本批实现者,程序自取最新 revision'));
64
65
  if (context.role === 'host') tools.push(tool('read_review_ready', '推进前读取本批原委派、完整交付、执行报告、评审资格、额度及不能推进的原因', { batch_id: NUMBER }, ['batch_id']),
65
66
  tool('advance_review', '先 read_review_ready 核对交付,再只推进该批既定只读评审;程序自取最新 revision,只结束对应交接', { batch_id: NUMBER }, ['batch_id']));
66
- if (context.role === 'host' && (context.requests || []).some(item => item.sequence?.phase === 'checkpoint')) tools.push(
67
+ if (context.role === 'host' && (context.requests || []).some(item => ['checkpoint', 'gate'].includes(item.sequence?.phase))) tools.push(
67
68
  tool('read_sequence', '读取依次接力的顺序、当前圈与本圈各棒原文', { id: NUMBER }, ['id']),
68
- tool('continue_sequence', '只用于 sequence.phase 为 checkpoint 的交接请求:本圈最后一位要求返工时开始下一圈,只结束这条交接;认可或需要裁定时不调用,直接用 submit_chat 验收或说明',
69
- { request_id: NUMBER }, ['request_id']));
69
+ tool('continue_sequence', '只用于 sequence.phase 为 checkpoint 或 gate 的交接请求,只结束这条交接:checkpoint 处要返工时再来一圈,认可且 sequence.gate.can_proceed 为真时加 proceed: true 往下一棒;gate 处往下一棒;要结束接力、裁定或说明时不调用,直接用 submit_chat 回答',
70
+ { request_id: NUMBER, proceed: { type: 'boolean' } }, ['request_id']));
70
71
  if (goal_host(context)) tools.push(tool('read_goal', '读取 Goal 的目标、状态、续跑次数、用时与是否空闲'),
71
72
  tool('start_goal', '只用于任务中 purpose 为 goal_start 的请求:用户发了 /goal,补全目标后开启;用户带了文本以原文为准,提炼不出明确目标就不开启并在 submit_chat 说明原因',
72
73
  { request_id: NUMBER, objective: { ...TEXT, maxLength: 2000 } }, ['request_id', 'objective']),
@@ -146,7 +147,7 @@ function write_action(context, name, args) {
146
147
  ...(context.role === 'host' && args.no_handoff === true ? { no_handoff: true } : {}),
147
148
  ...(context.role === 'host' && Array.isArray(args.detach) && args.detach.length ? { detach: args.detach } : {}),
148
149
  ...(context.role === 'host' && Number.isInteger(args.need_rounds) ? { need_rounds: args.need_rounds, rounds_reason: args.rounds_reason } : {}),
149
- ...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}), ...(Array.isArray(args.parallel) && args.parallel.length ? { parallel: args.parallel } : {}) } : {}),
150
+ ...(context.role === 'host' && args.sequential === true ? { sequential: true, ...(args.loop === true ? { loop: true } : {}), ...(Array.isArray(args.parallel) && args.parallel.length ? { parallel: args.parallel } : {}), ...(Array.isArray(args.gates) && args.gates.length ? { gates: args.gates } : {}) } : {}),
150
151
  ...(context.role === 'host' && args.next_review ? { next_review: args.next_review, review_scope: args.review_scope, review_roster: args.review_roster } : {}),
151
152
  }, terminal: true };
152
153
  const actions = {
@@ -157,7 +158,7 @@ function write_action(context, name, args) {
157
158
  continue_plan: ['/turns/continue', { revision: args.roster }, true],
158
159
  ask_interjection: ['/turns/ask', { revision: args.roster, request_id: args.request_id, expert: args.expert, submission_id: randomUUID() }, false],
159
160
  advance_review: ['/reviews/advance', { batch_id: args.batch_id, revision: args.review_revision, submission_id: randomUUID() }, false],
160
- continue_sequence: ['/sequences/continue', { request_id: args.request_id }, false],
161
+ continue_sequence: ['/sequences/continue', { request_id: args.request_id, ...(args.proceed === true ? { proceed: true } : {}) }, false],
161
162
  finish_discussion: ['/limits', { action: 'done', conclusion: args.conclusion }, true],
162
163
  declare_done: ['/limits', { action: 'done', conclusion: args.conclusion }, true],
163
164
  start_goal: ['/goal', { action: 'start', request_id: args.request_id, objective: args.objective }, false],
@@ -247,10 +248,18 @@ function workflow_text(workflow) {
247
248
  return `本房间启用了工作流「${workflow.label}」${workflow.description ? `(用途:${workflow.description})` : ''}。请求与它的用途相符或用户要求按它做时,按下面的工作流说明组织工作;其他请求照常处理。只点名下列在场成员,缺少的角色在回答里说明,不另找成员代替。
248
249
  成员:
249
250
  ${members.join('\n') || '(无)'}
250
-
251
+ ${flow_text(workflow.flow)}
251
252
  ${workflow.skill}`;
252
253
  }
253
254
 
255
+ /** The relay the workflow's canvas lays out, for an API host to dispatch with submit_chat as given. */
256
+ function flow_text(flow) {
257
+ if (!flow) return '';
258
+ if (flow.errors) return `流程画布排不成接力(${flow.errors.join(';')}),按下面的说明组织,并在回答里提醒用户修改画布。\n`;
259
+ if (flow.missing_cards) return `流程画布要用的卡片 ${flow.missing_cards.join('、')} 现在没有在场成员,不能照画布派活;在回答里说明缺谁。\n`;
260
+ return `流程画布排好的接力(照它派活时把下面的字段原样放进 submit_chat,正文写清每一棒做什么):\n${flow.order.join('\n')}\n字段:${JSON.stringify(flow.api)}\n`;
261
+ }
262
+
254
263
  /** Map the shared skills onto the API environment, which has room tools and, with a project, file tools. */
255
264
  export function system_prompt(context) {
256
265
  const host = context.role === 'host';
@@ -259,7 +268,7 @@ export function system_prompt(context) {
259
268
  下文是本项目的参与技能${host ? '与主持技能' : ''},与本地软件参与者使用同一份。你经本机接入程序以 API 参与,环境差异如下,与技能冲突时以本段为准:
260
269
  - 只能使用提供的${context.project ? '房间工具和项目文件工具' : '房间工具'},没有${command_tools(context.commands, context.project).length ? '通用' : ''}命令行、浏览器、附件下载或其他软件。接入程序负责执行认领、续租和凭据管理;你的房间写入工具都绑定本次执行,不要重试结果不明的写入。
261
270
  - 命令与工具对应:room view → read_room;message list / view → read_messages / read_message;message post --kind chat → submit_chat。${host ? 'state decision → read_decision(按任务的决策编号读取完整判断)。' : ''}${discussion && host
262
- ? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop / --with / --detach → submit_chat 的 sequential / loop / parallel / detach;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
271
+ ? '主持讨论工具还包括 state set → save_state;room update → update_topic;turn create --plan-file → arrange_turns;turn continue → continue_plan;turn roster → read_roster,读后 turn ask → ask_interjection;取消安排 → cancel_turn;room limits --done → finish_discussion / declare_done。' : ''}${host ? ' 派活用 to,派出的工作结束后都会交回你;房间空闲后要接着推进时填 continue;message post --sequential / --loop / --with / --checkpoint / --repeat / --detach → submit_chat 的 sequential / loop / parallel / gates / detach;sequence view → read_sequence;sequence continue → continue_sequence。' : ''}${goal_host(context) ? ' goal view → read_goal;goal start → start_goal;goal done / block → finish_goal。' : ''}
263
272
  ${project_text(context.project, context.commands)}
264
273
  - ${max_requests(context) ? `本次执行最多 ${max_requests(context)} 次模型请求,每轮都计入;` : ''}互不依赖的读取或修改(如同一文件的多处 edit)在同一轮一次调用多个工具,减少模型请求次数。
265
274
  - 必须通过写入工具保存真实结果,普通文本输出不会保存。确实无法继续时不调用写入工具直接结束,本次执行会暂停等待用户。