ai_co_discuss 0.3.96 → 0.3.98

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"))
@@ -15,8 +15,10 @@ BEGIN
15
15
  INSERT INTO room_directory (room_id,position)
16
16
  VALUES (NEW.id,(SELECT COALESCE(MAX(position),0)+1 FROM room_directory));
17
17
  END;
18
+ -- `directories` is a JSON array of absolute paths: the first is the main project directory of rooms created in the
19
+ -- folder, the rest their other project directories. Rooms keep their own copy; editing the folder only changes new rooms.
18
20
  CREATE TABLE IF NOT EXISTS topic_folders (
19
- id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0
21
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, directories TEXT NOT NULL DEFAULT '[]'
20
22
  );
21
23
  -- Site-wide preferences of the person managing the site; `last_room` is the room they last opened on any device.
22
24
  CREATE TABLE IF NOT EXISTS site_preferences (
@@ -587,7 +589,9 @@ CREATE TABLE IF NOT EXISTS free_batches (
587
589
  );
588
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.
589
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
590
- -- 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.
591
595
  CREATE TABLE IF NOT EXISTS free_sequences (
592
596
  id INTEGER PRIMARY KEY AUTOINCREMENT,
593
597
  room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
@@ -602,7 +606,8 @@ CREATE TABLE IF NOT EXISTS free_sequences (
602
606
  answer_id INTEGER NOT NULL DEFAULT 0,
603
607
  handoff_request INTEGER NOT NULL DEFAULT 0,
604
608
  steps TEXT NOT NULL DEFAULT '[]',
605
- 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 '[]'
606
611
  );
607
612
  CREATE INDEX IF NOT EXISTS free_sequences_batch ON free_sequences(room_id,batch_id);
608
613
  CREATE INDEX IF NOT EXISTS free_sequences_handoff ON free_sequences(handoff_request);
package/backend/server.py CHANGED
@@ -80,6 +80,10 @@ PAGE_SIZE = 20
80
80
  # A reader may ask for larger pages (`limit`) so a long transcript loads in one request instead of many.
81
81
  MAX_PAGE_SIZE = 200
82
82
  UNCATEGORIZED_FOLDER_ID = "__uncategorized__"
83
+ # A folder's project paths: one main directory plus MAX_EXTRA_DIRECTORIES (runner/discussion_runner.mjs) others.
84
+ MAX_FOLDER_DIRECTORIES = 9
85
+ MAX_DIRECTORY_PATH = 1000
86
+ ABSOLUTE_PATH_PATTERN = re.compile(r"(?:[A-Za-z]:[\\/]|/)")
83
87
  ASSETS = {"/": ("index.html", "text/html"),
84
88
  "/app.js": ("app.js", "text/javascript"),
85
89
  "/modern.js": ("modern.js", "text/javascript"),
@@ -348,6 +352,8 @@ def init_database(database, seed=True):
348
352
  if "changes" not in columns:
349
353
  connection.execute("ALTER TABLE member_executions ADD COLUMN changes TEXT NOT NULL DEFAULT ''")
350
354
  connection.executescript(SCHEMA)
355
+ if "directories" not in {row[1] for row in connection.execute("PRAGMA table_info(topic_folders)")}:
356
+ connection.execute("ALTER TABLE topic_folders ADD COLUMN directories TEXT NOT NULL DEFAULT '[]'")
351
357
  migrate_free_tables(connection)
352
358
  if seed and is_new:
353
359
  seed_demo(connection)
@@ -722,11 +728,40 @@ def remember_last_room(connection, payload):
722
728
  return {"last_room": room_id}
723
729
 
724
730
 
731
+ def folder_record(row):
732
+ """Return one folder with its project paths decoded."""
733
+ return {**dict(row), "directories": json.loads(row["directories"])}
734
+
735
+
725
736
  def folder_directory(connection):
726
737
  """Return real folders and the virtual group's index (null keeps it last)."""
727
738
  settings = connection.execute("SELECT uncategorized_position FROM directory_settings WHERE id=1").fetchone()
728
- return {"folders": [dict(row) for row in connection.execute("SELECT id,name,position FROM topic_folders ORDER BY position,name,id")],
729
- "uncategorized_position": settings[0] if settings else None}
739
+ rows = connection.execute("SELECT id,name,position,directories FROM topic_folders ORDER BY position,name,id")
740
+ return {"folders": [folder_record(row) for row in rows], "uncategorized_position": settings[0] if settings else None}
741
+
742
+
743
+ def same_path_key(path):
744
+ """Compare paths as Windows does: without case, separator style or a trailing separator."""
745
+ return path.replace("\\", "/").rstrip("/").lower()
746
+
747
+
748
+ def folder_directories_value(payload):
749
+ """Validate a folder's project paths: absolute, distinct, the first being the main directory.
750
+
751
+ The paths are only checked for form here; the local service checks they exist when a room is created from them.
752
+ """
753
+ directories = payload.get("directories", [])
754
+ if not isinstance(directories, list) or len(directories) > MAX_FOLDER_DIRECTORIES:
755
+ raise ValueError(f"项目路径须为最多 {MAX_FOLDER_DIRECTORIES} 项的列表")
756
+ paths = []
757
+ for item in directories:
758
+ path = item.strip() if isinstance(item, str) else ""
759
+ if not path or len(path) > MAX_DIRECTORY_PATH or not ABSOLUTE_PATH_PATTERN.match(path):
760
+ raise ValueError("项目路径须为完整的绝对路径")
761
+ if same_path_key(path) in [same_path_key(known) for known in paths]:
762
+ raise ValueError(f"项目路径重复:{path}")
763
+ paths.append(path)
764
+ return paths
730
765
 
731
766
 
732
767
  def folder_name_value(payload):
@@ -741,12 +776,16 @@ def folder_name_value(payload):
741
776
 
742
777
 
743
778
  def rename_folder(connection, folder_id, payload):
744
- """Rename one folder, retaining its position and all room assignments."""
779
+ """Rename one folder and, when given, replace its project paths; positions and room assignments stay."""
745
780
  begin_write(connection)
746
781
  name = folder_name_value(payload)
747
- if connection.execute("UPDATE topic_folders SET name=? WHERE id=?", (name, folder_id)).rowcount == 0:
782
+ changes = {"name": name}
783
+ if "directories" in payload:
784
+ changes["directories"] = json.dumps(folder_directories_value(payload), ensure_ascii=False)
785
+ assignments = ",".join(f"{column}=?" for column in changes)
786
+ if connection.execute(f"UPDATE topic_folders SET {assignments} WHERE id=?", (*changes.values(), folder_id)).rowcount == 0:
748
787
  raise LookupError("文件夹不存在")
749
- return dict(connection.execute("SELECT id,name,position FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
788
+ return folder_record(connection.execute("SELECT id,name,position,directories FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
750
789
 
751
790
 
752
791
  def delete_folder(connection, folder_id):
@@ -1132,7 +1171,7 @@ def check_message_references(connection, room_id, message):
1132
1171
  def append_free_message(connection, room, payload, actor):
1133
1172
  """Append a free-room message: the owner's creates requests, a member's completes its execution."""
1134
1173
  if payload.get("kind") == "moderation":
1135
- 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")):
1136
1175
  raise ValueError("主持说明不能声明评审授权、依次接力或申请轮数;请在派活时提交")
1137
1176
  return append_free_moderation(connection, room, payload, actor)
1138
1177
  if payload.get("kind") not in (None, "chat"):
@@ -2168,12 +2207,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
2168
2207
  connection.commit()
2169
2208
  return self.respond(result, 200 if result.get("skipped") else 201)
2170
2209
  if parts == ["api", "folders"] and method == "POST":
2171
- name = folder_name_value(self.read_payload())
2210
+ payload = self.read_payload()
2211
+ name = folder_name_value(payload)
2212
+ directories = folder_directories_value(payload)
2172
2213
  folder_id = uuid.uuid4().hex
2173
2214
  position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM topic_folders").fetchone()[0]
2174
- connection.execute("INSERT INTO topic_folders (id,name,position) VALUES (?,?,?)", (folder_id, name, position))
2215
+ connection.execute("INSERT INTO topic_folders (id,name,position,directories) VALUES (?,?,?,?)",
2216
+ (folder_id, name, position, json.dumps(directories, ensure_ascii=False)))
2175
2217
  connection.commit()
2176
- return self.respond({"id": folder_id, "name": name, "position": position}, 201)
2218
+ return self.respond({"id": folder_id, "name": name, "position": position, "directories": directories}, 201)
2177
2219
  if len(parts) == 4 and parts[:2] == ["api", "folders"] and parts[3] == "move" and method == "POST":
2178
2220
  result = move_folder(connection, parts[2], self.read_payload())
2179
2221
  connection.commit()
@@ -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
+ - **流程画布**:编辑页的成员卡片默认显示为流程画布(可切回“卡片列表”)。卡片就是平时用的专家卡片,拖标题栏移动,点卡片翻面设置;没放上画布的卡片列在画布上方,点一下放到接力末尾并自动连上。从「开始」右侧的圆点拖线连到卡片,排出谁先谁后;同一列并排的几张(由同一张交来、交给同一张)同时做;“主持人检查点”放在两棒之间,前一棒做完先交回主持人把关;从后面连回前面的卡片就是循环(虚线),走到这里交回主持人,要返工就从连回的那张再来一圈,通过就往下走。画布下方实时显示排出的顺序,排不成接力时说明原因。保存后,主持人按这个工作流派活时照画布交接,程序负责一棒接一棒、检查点和循环;画布留空时仍由主持人按做法说明组织。暂不支持按条件分支,需要判断的地方放主持人检查点。
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 的会话在各自窗口中,无法回退。成员已改动的文件不会撤销。讨论进行中不能撤回或编辑。