@kubitnodes/stellaris 0.3.10 → 0.3.11

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.
Files changed (46) hide show
  1. package/dist/cli/index.js +533 -117
  2. package/dist/server/index.js +900 -197
  3. package/package.json +1 -1
  4. package/web/dist/assets/BackLink-C-vad6K2.js +1 -0
  5. package/web/dist/assets/ChannelForms-jNbbNnIC.js +1 -0
  6. package/web/dist/assets/ChannelView-kLoegYUQ.js +1 -0
  7. package/web/dist/assets/CitizenView-Cb8f2mB7.js +2 -0
  8. package/web/dist/assets/{CitizensView-DttP5FqR.js → CitizensView-Ds2rojfa.js} +1 -1
  9. package/web/dist/assets/KnowledgeView-k10TU0jc.js +1 -0
  10. package/web/dist/assets/{Markdown-4W4xjIq7.js → Markdown-J2pAPfCa.js} +3 -3
  11. package/web/dist/assets/{MessageItem-Bfpve1_Y.js → MessageItem-BjM8BdpK.js} +1 -1
  12. package/web/dist/assets/{MetricsView-Bp486U0v.js → MetricsView-D9N54rV7.js} +1 -1
  13. package/web/dist/assets/{NeedsYouView-CJ4COcNn.js → NeedsYouView-ML8i7Pl7.js} +1 -1
  14. package/web/dist/assets/{Overview-BnO1K52h.js → Overview-BBXvRhfR.js} +1 -1
  15. package/web/dist/assets/{Pane-xzIpwnBD.js → Pane-caYWCiyo.js} +1 -1
  16. package/web/dist/assets/ProjectView-sDpFNjcW.js +1 -0
  17. package/web/dist/assets/ProposalView-Bc91NjKC.js +1 -0
  18. package/web/dist/assets/{ProposalsView-XrraDhwt.js → ProposalsView-Dg_bEryX.js} +1 -1
  19. package/web/dist/assets/{RunnersView-H3kCdSQB.js → RunnersView-Dv5nbwYv.js} +1 -1
  20. package/web/dist/assets/SocietyView-ClaEWgOq.js +1 -0
  21. package/web/dist/assets/SubjectThread-D_L3vB-A.js +1 -0
  22. package/web/dist/assets/{TaskFileView-CRwwL5K8.js → TaskFileView-B19YtrtW.js} +2 -2
  23. package/web/dist/assets/{TaskView-A9IgTxQ0.js → TaskView-FYSoCb3T.js} +1 -1
  24. package/web/dist/assets/{TasksView-CnhTIldG.js → TasksView-CniXTU3R.js} +1 -1
  25. package/web/dist/assets/{ThreadCard-fvf8cY_l.js → ThreadCard-DCpvhSlc.js} +1 -1
  26. package/web/dist/assets/{ThreadForms-D09Ykgj9.js → ThreadForms-DUGkAwTp.js} +1 -1
  27. package/web/dist/assets/ThreadView-D8eXlVbC.js +1 -0
  28. package/web/dist/assets/citizen-BtOZq62_.js +1 -0
  29. package/web/dist/assets/{format-lz-5NQWT.js → format-EfoceZkm.js} +1 -1
  30. package/web/dist/assets/index-BeOGtAz0.css +2 -0
  31. package/web/dist/assets/index-D75IxLme.js +23 -0
  32. package/web/dist/assets/{tasks-DK1cnXqs.js → tasks-Duke4U7N.js} +1 -1
  33. package/web/dist/assets/{useStickyScroll-CfP3Yl_4.js → useStickyScroll-n5d7sdPZ.js} +1 -1
  34. package/web/dist/index.html +4 -4
  35. package/web/dist/assets/BackLink-B3tz8OFm.js +0 -1
  36. package/web/dist/assets/ChannelView-Dkzr1zkF.js +0 -1
  37. package/web/dist/assets/CitizenView-BLgyEvYz.js +0 -2
  38. package/web/dist/assets/KnowledgeView-DFAC95aF.js +0 -1
  39. package/web/dist/assets/ProjectView-w0mcF0KW.js +0 -1
  40. package/web/dist/assets/ProposalView-CyJCwUYK.js +0 -1
  41. package/web/dist/assets/SocietyView-Cf8ENDAw.js +0 -1
  42. package/web/dist/assets/SubjectThread-ClMmgVVd.js +0 -1
  43. package/web/dist/assets/ThreadView-BqNSssCf.js +0 -1
  44. package/web/dist/assets/citizen-C27Wvimj.js +0 -1
  45. package/web/dist/assets/index-CoKyPk79.js +0 -23
  46. package/web/dist/assets/index-t2fXHB5Y.css +0 -2
@@ -57,7 +57,9 @@ var VERB_NAMES = [
57
57
  "advance_task",
58
58
  "configure_project",
59
59
  "archive_project",
60
- "remove_knowledge"
60
+ "remove_knowledge",
61
+ "create_channel",
62
+ "archive_channel"
61
63
  ];
62
64
  var VerbNameSchema = z2.enum(VERB_NAMES);
63
65
  var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
@@ -107,7 +109,11 @@ var MEMBER_VERBS = [
107
109
  ];
108
110
  var GOVERNANCE_VERBS = ["approve", "reject"];
109
111
  var FRONT_DESK_VERBS = ["create_project"];
110
- var PLANNING_VERBS = ["configure_project"];
112
+ var PLANNING_VERBS = [
113
+ "configure_project",
114
+ "create_channel",
115
+ "archive_channel"
116
+ ];
111
117
  var ARCHIVE_VERBS = ["archive_project"];
112
118
  var SEED_ROLES = [
113
119
  {
@@ -129,7 +135,7 @@ var SEED_ROLES = [
129
135
  },
130
136
  {
131
137
  name: "steward",
132
- purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time, a channel when a topic needs one. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one. Never approves its own proposals; hiring, roles, and retirements are decided by the user, and channels, reallocations, and skills the steward may decide. Curates society knowledge and skills.",
138
+ purpose: "Watches the operations signals and the task board for capacity, skill, and capability gaps, and turns signals into proposals: a member when a project's plans need a role nobody fills or a backlog persists, a role when a project's work needs one no existing role covers, a retirement when a member has been idle for a long time. Opens a channel with create_channel when a topic or a workstream needs one, and archives it once its work is done. Owns how work is shaped over time: answers stages waiting too long, stages assigned to roles nobody fills, and work sent back again and again by replanning with plan_task, adjusting gates, or proposing the role a plan needs; sets each project's completion effect with configure_project; turns recurring plan shapes into society skills and planning norms into the society's norms. Prefers scaling an existing role over inventing a new one. Never approves its own proposals; hiring, roles, and retirements are decided by the user, and channels, reallocations, and skills the steward may decide. Curates society knowledge and skills.",
133
139
  verbs: [...MEMBER_VERBS, ...GOVERNANCE_VERBS, ...PLANNING_VERBS],
134
140
  wakeTriggers: ["ops_event", "heartbeat"],
135
141
  maxReplicas: 1,
@@ -140,7 +146,7 @@ var SEED_ROLES = [
140
146
  },
141
147
  {
142
148
  name: "concierge",
143
- purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
149
+ purpose: "The society's front desk. Wakes on every post the user makes and routes it: a question gets an answer where it was asked, in its thread when it came in one; work in an area an existing project covers gets a task there, planned when it is created and filed in the channel of its workstream when it belongs to one, such as a release, which the concierge opens with create_channel and archives once the workstream is done, since a project is a lasting area of work and not one request; work in a new area gets a project, created on the spot, and the member or role proposals it needs for the user to approve. Reorganizes projects when the user asks: creates or picks the project the work belongs in, adds the citizens it needs, files a task there to bring over what the old projects hold (their task branches and their knowledge), and once that task is done proposes archiving each old project for the user to approve. Plans every task it routes: names stages by the work, assigns each to a role or a citizen, and gates where a second pair of eyes is worth it: before anything lands in a shared deliverable, so always before a merge; before effects outside the society; and before results are presented as findings. When a task it created is done, tells the user. Reads the roster in every digest to choose citizens by role, reach, availability, and profile, and adds a citizen to a project when the work needs it. Mentions a citizen with @ only to hand it work outside a plan, since a mention wakes it and costs a turn; lists and describes citizens by plain name. Stays silent when the user already addressed a citizen and nothing else is needed. Never does the work itself and never decides hiring.",
144
150
  verbs: [...MEMBER_VERBS, ...FRONT_DESK_VERBS, ...PLANNING_VERBS],
145
151
  wakeTriggers: ["user_post", "heartbeat"],
146
152
  maxReplicas: 1,
@@ -156,16 +162,28 @@ import { z as z3 } from "zod";
156
162
  var ASK_CHANNEL = "asks";
157
163
  var SOCIETY_CHANNELS = ["general", "governance", ASK_CHANNEL];
158
164
  var PROJECT_DEFAULT_CHANNELS = ["general"];
165
+ function isBoardChannel(project, name) {
166
+ const used = project === null ? SOCIETY_CHANNELS : PROJECT_DEFAULT_CHANNELS;
167
+ return used.includes(name);
168
+ }
159
169
  var SOCIETY_SCOPE = "society";
160
170
  function wakeScope(member, project) {
161
171
  return project !== null && member.memberships.includes(project) ? project : SOCIETY_SCOPE;
162
172
  }
163
173
  var CliKindSchema = z3.enum(["claude", "codex"]);
174
+ var ArchivedChannelSchema = z3.object({
175
+ name: NameSchema,
176
+ at: IsoDateTimeSchema,
177
+ by: NameSchema,
178
+ reason: z3.string().min(1)
179
+ });
164
180
  var SocietySchema = z3.object({
165
181
  name: z3.string().min(1),
166
182
  version: z3.literal(1),
167
183
  createdAt: IsoDateTimeSchema,
168
- channels: z3.array(NameSchema)
184
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
185
+ channels: z3.array(NameSchema),
186
+ archivedChannels: z3.array(ArchivedChannelSchema).default([])
169
187
  });
170
188
  var CompletionEffectSchema = z3.enum(["none", "merge"]);
171
189
  var DefaultBranchSchema = z3.string().trim().min(1).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "a branch name is letters, digits, and . _ / -").refine((branch) => !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.endsWith(".") && !branch.endsWith(".lock"), "not a branch name git takes").refine((branch) => !branch.startsWith("task/") && !branch.startsWith("agent/"), "task/ and agent/ hold the board's own branches");
@@ -189,7 +207,9 @@ var ProjectSchema = z3.object({
189
207
  name: z3.string().min(1),
190
208
  repo: z3.string().nullable(),
191
209
  defaultBranch: z3.string().min(1),
210
+ /** The channels that take posts; an archived one moves to `archivedChannels`. */
192
211
  channels: z3.array(NameSchema),
212
+ archivedChannels: z3.array(ArchivedChannelSchema).default([]),
193
213
  members: z3.array(NameSchema),
194
214
  approvers: z3.array(NameSchema),
195
215
  requiredCapabilities: z3.array(z3.string()),
@@ -260,6 +280,8 @@ var TaskReturnSchema = z3.object({
260
280
  var TaskFrontmatterSchema = z3.object({
261
281
  id: UlidSchema,
262
282
  project: NameSchema,
283
+ /** The channel of the project the task is filed in, where its thread hangs. */
284
+ channel: NameSchema.default("general"),
263
285
  title: z3.string().min(1),
264
286
  status: TaskStatusSchema,
265
287
  createdBy: NameSchema,
@@ -606,7 +628,9 @@ var VerbInputs = {
606
628
  parent_id: UlidSchema.optional(),
607
629
  required_capabilities: z4.array(z4.string()).default([]),
608
630
  /** The plan; without it the task gets one stage, work, that anyone in the project may take. */
609
- stages: z4.array(PlanStageSchema).min(1).optional()
631
+ stages: z4.array(PlanStageSchema).min(1).optional(),
632
+ /** A channel of the project the task belongs to, such as a release's; its thread opens there. */
633
+ channel: NameSchema.default("general")
610
634
  }),
611
635
  claim_task: z4.object({ task_id: UlidSchema }),
612
636
  release_task: z4.object({ task_id: UlidSchema }),
@@ -664,7 +688,14 @@ var VerbInputs = {
664
688
  /** The branch tasks start from and land on from now; tasks in play keep their branches. */
665
689
  default_branch: DefaultBranchSchema.optional()
666
690
  }),
667
- archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) })
691
+ archive_project: z4.object({ project: NameSchema, reason: z4.string().min(1) }),
692
+ create_channel: z4.object({
693
+ /** A project, or null for a channel of the society. */
694
+ project: NameSchema.nullable().default(null),
695
+ name: NameSchema,
696
+ purpose: z4.string().min(1)
697
+ }),
698
+ archive_channel: z4.object({ channel: ChannelRefSchema, reason: z4.string().min(1) })
668
699
  };
669
700
  var VERB_DESCRIPTIONS = {
670
701
  post_message: "Post a markdown message to a channel, or into a thread when thread_id is given; a thread's message goes to the thread's channel.",
@@ -672,7 +703,7 @@ var VERB_DESCRIPTIONS = {
672
703
  search: "Search messages, tasks, and knowledge by text, optionally within a project or channel.",
673
704
  open_thread: "Open a thread on a channel with a title, for a topic of its own. Tasks and proposals have theirs already. Its messages reach only its participants and anyone mentioned.",
674
705
  close_thread: "Close a thread with a summary that is posted to the thread's channel. A task's thread closes with its task, a proposal's with its decision.",
675
- create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. Its thread opens with it, under the task's id, for everything said about the work.",
706
+ create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
676
707
  claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
677
708
  release_task: "Let go of the stage you hold so someone else can take it.",
678
709
  update_task: "Move a task back to an earlier stage, abandon it, or set what it is blocked by. A note is posted to the task's thread.",
@@ -683,14 +714,16 @@ var VERB_DESCRIPTIONS = {
683
714
  approve: "Approve a proposal; the board then provisions it, posts the decision in the proposal's thread, and closes it. User and steward only, never on your own proposal.",
684
715
  reject: "Reject a proposal with a reason, posted in the proposal's thread, which closes. User and steward only, never on your own proposal.",
685
716
  create_project: "Create a project with its general channel: a slug, a display name, a git remote when one exists, and optionally a completion effect (none or merge).",
686
- join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn.",
717
+ join_project: "Join a project, or add another citizen to one when your role allows it. Membership gives the pair a worktree and an onboarding turn, and follows the project's general; the result lists its other open channels, to subscribe to when the work needs them.",
687
718
  leave_project: "Leave a project, or remove another citizen from one when your role allows it.",
688
719
  write_knowledge: "Write or replace a knowledge topic: durable facts every member of a project should know, or with project null the society's shared knowledge (steward and user). Not a message; use post_message for those.",
689
720
  remove_knowledge: "Remove a knowledge topic that is wrong, superseded, or folded into another, so no turn or search finds it again. Whoever may write the topic may remove it; the board keeps its text aside for the user to restore.",
690
721
  plan_task: "Reshape a task's plan from the current stage onward, or after it while someone holds it: keep a stage by passing its id, drop it by leaving it out, add one without an id. Gated stages and on_done: user, steward, concierge.",
691
722
  advance_task: "Finish the stage you hold, with a note for the task's thread on what you did. The next stage becomes current; past the last one the task is done.",
692
723
  configure_project: "Set a project's completion effect (none, or merge to land each finished task's branch on the default branch), its display name, or its default branch, any of them at once. The slug never changes.",
693
- archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play."
724
+ archive_project: "Archive a project whose work is finished or has moved: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay. Refused while a task there is in play.",
725
+ create_channel: "Open a channel in a project, or with project null in the society, for a workstream such as a release, whose tasks are filed there. You follow it; a notice in the place's general announces it, and members subscribe when it concerns them. User, steward, concierge.",
726
+ archive_channel: "Archive a channel whose workstream is done: its open threads close, its followers stop following it, and nothing more is posted or filed there, while its history stays readable. Refused while a task filed there is in play, and for the channels the board itself uses. User, steward, concierge."
694
727
  };
695
728
 
696
729
  // packages/shared/dist/events.js
@@ -723,6 +756,7 @@ var BOARD_EVENT_TYPES = [
723
756
  "proposal.decided",
724
757
  "proposal.provisioned",
725
758
  "channel.added",
759
+ "channel.archived",
726
760
  "role.added",
727
761
  "agent.retired",
728
762
  "agent.joined",
@@ -744,7 +778,8 @@ var BOARD_EVENT_TYPES = [
744
778
  "turn.completed",
745
779
  "turn.failed",
746
780
  "merge.completed",
747
- "merge.failed"
781
+ "merge.failed",
782
+ "workspace.leftovers"
748
783
  ];
749
784
  var BoardEventTypeSchema = z5.enum(BOARD_EVENT_TYPES);
750
785
  var BoardEventSchema = z5.object({
@@ -824,6 +859,8 @@ var LiveTurnEventSchema = z5.object({
824
859
  project: NameSchema,
825
860
  /** The thread whose conversation the turn is in; absent for the home conversation. */
826
861
  thread: UlidSchema.optional(),
862
+ /** The channel, never general, whose conversation the turn is in. */
863
+ channel: NameSchema.optional(),
827
864
  event: AgentEventSchema
828
865
  });
829
866
 
@@ -839,6 +876,7 @@ var PATH_TOKENS = {
839
876
  worktree: "{{stellaris:worktree}}"
840
877
  };
841
878
  var SERVER_TOKEN = "{{stellaris:server}}";
879
+ var WORKSPACE_TOKEN = "{{stellaris:workspace}}";
842
880
  var HOME_SCRATCH = "scratch";
843
881
  var HOME_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
844
882
  var RunnerHelloSchema = z6.object({
@@ -899,12 +937,17 @@ var ProjectRepoSchema = z6.object({
899
937
  defaultBranch: z6.string().min(1)
900
938
  });
901
939
  var TurnWorkspaceSchema = z6.discriminatedUnion("kind", [
902
- z6.object({ kind: z6.literal("home"), thread: UlidSchema.optional() }),
940
+ z6.object({
941
+ kind: z6.literal("home"),
942
+ thread: UlidSchema.optional(),
943
+ channel: NameSchema.optional()
944
+ }),
903
945
  z6.object({
904
946
  kind: z6.literal("project"),
905
947
  repo: ProjectRepoSchema,
906
948
  branches: z6.array(z6.string().min(1)),
907
- thread: UlidSchema.optional()
949
+ thread: UlidSchema.optional(),
950
+ channel: NameSchema.optional()
908
951
  }),
909
952
  z6.object({ kind: z6.literal("task"), repo: ProjectRepoSchema, taskId: UlidSchema })
910
953
  ]);
@@ -920,6 +963,8 @@ var TurnJobSchema = z6.object({
920
963
  scope: NameSchema,
921
964
  /** The thread whose conversation the turn is in; absent for the home conversation. */
922
965
  thread: UlidSchema.optional(),
966
+ /** The channel, never general, whose conversation the turn is in. */
967
+ channel: NameSchema.optional(),
923
968
  /**
924
969
  * The session to resume, or null to start one: the CLI's adapter picks a fresh session's id, and
925
970
  * the outcome reports it.
@@ -953,7 +998,12 @@ var TurnOutcomeSchema = z6.object({
953
998
  session: z6.string().min(1),
954
999
  /** The model the CLI reported, when it reported one. */
955
1000
  model: z6.string().nullable(),
956
- work: TurnWorkSchema.nullable()
1001
+ work: TurnWorkSchema.nullable(),
1002
+ /**
1003
+ * Whether the turn's own workspace, a thread's, a channel's, or a task's, holds work on no
1004
+ * branch after the hand-back: uncommitted changes, or commits no branch contains.
1005
+ */
1006
+ leftovers: z6.boolean().default(false)
957
1007
  });
958
1008
  var TurnAckSchema = z6.object({ dropWorkspace: z6.boolean() });
959
1009
  var LandRequestSchema = z6.object({
@@ -1012,6 +1062,23 @@ var BranchChangesSchema = z6.object({
1012
1062
  files: z6.array(BranchFileChangeSchema),
1013
1063
  total: z6.number().int().nonnegative()
1014
1064
  });
1065
+ var WorkspaceConversationSchema = z6.discriminatedUnion("kind", [
1066
+ z6.object({ kind: z6.literal("thread"), id: UlidSchema }),
1067
+ z6.object({ kind: z6.literal("channel"), scope: NameSchema, name: NameSchema })
1068
+ ]);
1069
+ function workspaceConversationOf(turn) {
1070
+ if (turn.thread !== void 0) {
1071
+ return { kind: "thread", id: turn.thread };
1072
+ }
1073
+ return turn.channel === void 0 ? null : { kind: "channel", scope: turn.scope, name: turn.channel };
1074
+ }
1075
+ var HeldWorkspaceSchema = z6.object({
1076
+ agent: NameSchema,
1077
+ scope: NameSchema,
1078
+ conversation: WorkspaceConversationSchema
1079
+ });
1080
+ var HeldWorkspacesSchema = z6.object({ workspaces: z6.array(HeldWorkspaceSchema) });
1081
+ var SweepResultSchema = z6.object({ kept: z6.array(HeldWorkspaceSchema) });
1015
1082
  var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
1016
1083
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
1017
1084
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
@@ -1033,7 +1100,16 @@ var RunnerMessageSchema = z6.discriminatedUnion("type", [
1033
1100
  steer: TurnSteerSchema
1034
1101
  }),
1035
1102
  /** Answered with whether the turn was there to stop; it ends `stopped`. */
1036
- z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
1103
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema }),
1104
+ /**
1105
+ * A conversation ended: the runner removes every citizen's workspace for it but those a turn
1106
+ * still uses, and answers with a `SweepResult` naming those it kept for holding work on no branch.
1107
+ */
1108
+ z6.object({
1109
+ type: z6.literal("sweep"),
1110
+ request: z6.string().min(1),
1111
+ conversation: WorkspaceConversationSchema
1112
+ })
1037
1113
  ]);
1038
1114
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
1039
1115
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -1061,7 +1137,8 @@ var TriggerKindSchema = z7.enum([
1061
1137
  "onboarding",
1062
1138
  "manual",
1063
1139
  "ops_event",
1064
- "user_post"
1140
+ "user_post",
1141
+ "closing"
1065
1142
  ]);
1066
1143
  var TriggerSchema = z7.object({
1067
1144
  kind: TriggerKindSchema,
@@ -1078,12 +1155,32 @@ var TurnDispatchSchema = z7.object({
1078
1155
  agent: NameSchema,
1079
1156
  project: NameSchema,
1080
1157
  thread: TurnThreadSchema.optional(),
1158
+ channel: NameSchema.optional(),
1081
1159
  trigger: TriggerSchema,
1082
1160
  priority: z7.number().int().min(0).max(2),
1083
1161
  onboarding: z7.boolean().default(false)
1084
1162
  });
1085
- function sessionKey(agent, scope, thread) {
1086
- return thread === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${thread}`;
1163
+ function conversationPart(conversation) {
1164
+ return conversation.thread ?? (conversation.channel === void 0 ? void 0 : `#${conversation.channel}`);
1165
+ }
1166
+ function sessionKey(agent, scope, part) {
1167
+ return part === void 0 ? `${agent}/${scope}` : `${agent}/${scope}/${part}`;
1168
+ }
1169
+ function parseSessionKey(key) {
1170
+ const [agent, scope, part, rest] = key.split("/");
1171
+ if (agent === void 0 || scope === void 0 || rest !== void 0) {
1172
+ return null;
1173
+ }
1174
+ if (part === void 0) {
1175
+ return { agent, scope };
1176
+ }
1177
+ return part.startsWith("#") ? { agent, scope, channel: part.slice(1) } : { agent, scope, thread: part };
1178
+ }
1179
+ function channelConversation(scope, channel) {
1180
+ const at = channel.indexOf("/");
1181
+ const place = at < 0 ? SOCIETY_SCOPE : channel.slice(0, at);
1182
+ const name = at < 0 ? channel : channel.slice(at + 1);
1183
+ return place === scope && name !== "general" ? name : void 0;
1087
1184
  }
1088
1185
  var TurnRecordSchema = z7.object({
1089
1186
  /** Assigned by the board when the turn begins; the turn's transcript is filed under it. */
@@ -1092,6 +1189,8 @@ var TurnRecordSchema = z7.object({
1092
1189
  project: NameSchema,
1093
1190
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1094
1191
  thread: UlidSchema.optional(),
1192
+ /** The channel, never general, whose conversation the turn was in. */
1193
+ channel: NameSchema.optional(),
1095
1194
  runner: NameSchema,
1096
1195
  cli: CliKindSchema.nullable(),
1097
1196
  session: z7.string().nullable(),
@@ -1121,8 +1220,9 @@ var RunningTurnSchema = z7.object({
1121
1220
  agent: NameSchema,
1122
1221
  scope: NameSchema,
1123
1222
  thread: UlidSchema.optional(),
1223
+ channel: NameSchema.optional(),
1124
1224
  cli: CliKindSchema,
1125
- channel: ChannelRefSchema.optional(),
1225
+ askedIn: ChannelRefSchema.optional(),
1126
1226
  steerable: z7.boolean(),
1127
1227
  stoppable: z7.boolean()
1128
1228
  });
@@ -1149,6 +1249,8 @@ var TurnHistoryEntrySchema = z7.object({
1149
1249
  project: NameSchema,
1150
1250
  /** The thread whose conversation the turn was in; absent for the home conversation. */
1151
1251
  thread: UlidSchema.optional(),
1252
+ /** The channel, never general, whose conversation the turn was in. */
1253
+ channel: NameSchema.optional(),
1152
1254
  trigger: z7.string(),
1153
1255
  exitReason: z7.string().nullable(),
1154
1256
  costUsd: z7.number(),
@@ -1175,7 +1277,8 @@ var OPS_SIGNAL_KINDS = [
1175
1277
  "turn_cost",
1176
1278
  "scaled",
1177
1279
  "runner",
1178
- "home_conflict"
1280
+ "home_conflict",
1281
+ "stuck_workspace"
1179
1282
  ];
1180
1283
  var OpsSignalKindSchema = z8.enum(OPS_SIGNAL_KINDS);
1181
1284
  var WAKING_SIGNAL_KINDS = [
@@ -1382,7 +1485,7 @@ var RunnerConfigSchema = z10.object({
1382
1485
  });
1383
1486
 
1384
1487
  // packages/shared/dist/version.js
1385
- var STELLARIS_VERSION = "0.3.10";
1488
+ var STELLARIS_VERSION = "0.3.11";
1386
1489
 
1387
1490
  // packages/board-core/dist/errors.js
1388
1491
  var BoardError = class extends Error {
@@ -1656,6 +1759,7 @@ var HOME_GITIGNORE = [
1656
1759
  "/projects/*/sessions*.json",
1657
1760
  "/projects/*/last-turn.json",
1658
1761
  "/projects/*/threads/",
1762
+ "/projects/*/channels/",
1659
1763
  `/${HOME_SCRATCH}/`,
1660
1764
  "*.tmp",
1661
1765
  "node_modules/",
@@ -1673,7 +1777,7 @@ var HOME_GITIGNORE = [
1673
1777
  var IGNORE_FILE = `${HOME_GITIGNORE.join("\n")}
1674
1778
  `;
1675
1779
  var PRE_RECEIVE = `#!/bin/sh
1676
- forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/))'
1780
+ forbidden='^(agent\\.json|cursors\\.json|role\\.md|turns/|\\.claude/|\\.codex/|projects/[^/]+/(sessions[^/]*\\.json|last-turn\\.json|threads/|channels/))'
1677
1781
  while read old new ref; do
1678
1782
  if [ "$ref" != "refs/heads/main" ]; then
1679
1783
  echo "a home takes pushes to main only" >&2
@@ -1934,6 +2038,7 @@ var TurnPayload = z11.object({
1934
2038
  turnId: z11.string().optional(),
1935
2039
  project: z11.string(),
1936
2040
  thread: z11.string().optional(),
2041
+ channel: z11.string().optional(),
1937
2042
  trigger: z11.string()
1938
2043
  });
1939
2044
  var TaskPayload = z11.object({
@@ -1976,7 +2081,14 @@ function answers(mention, turn) {
1976
2081
  if (turn.thread !== null) {
1977
2082
  return mention.thread === turn.thread;
1978
2083
  }
1979
- return mention.scopes.includes(turn.project) && (mention.thread === null || turn.trigger === "mention");
2084
+ if (!mention.scopes.includes(turn.project)) {
2085
+ return false;
2086
+ }
2087
+ const own = mention.thread === null ? channelConversation(turn.project, mention.channel) : void 0;
2088
+ if (turn.channel !== null) {
2089
+ return mention.thread === null && own === turn.channel;
2090
+ }
2091
+ return mention.thread === null && own === void 0 || turn.trigger === "mention";
1980
2092
  }
1981
2093
  function computeMetrics(input) {
1982
2094
  const { events, now, window } = input;
@@ -2041,6 +2153,7 @@ function computeMetrics(input) {
2041
2153
  agent: event.actor,
2042
2154
  at,
2043
2155
  thread: turn.data.thread ?? null,
2156
+ channel: turn.data.channel ?? null,
2044
2157
  project: turn.data.project,
2045
2158
  trigger: turn.data.trigger
2046
2159
  };
@@ -2137,6 +2250,7 @@ function computeMetrics(input) {
2137
2250
  at: Date.parse(event.ts),
2138
2251
  message: message.data.id ?? null,
2139
2252
  thread,
2253
+ channel: message.data.channel,
2140
2254
  scopes: [project, wakeScope(citizen, project)].filter((scope) => scope !== null)
2141
2255
  });
2142
2256
  }
@@ -2412,9 +2526,12 @@ var BoardPaths = class {
2412
2526
  agentProject(name, slug) {
2413
2527
  return path5.join(this.agentProjects(name), slug);
2414
2528
  }
2415
- /** A thread conversation's session and last turn, beside the scope's home conversation. */
2416
- agentThread(name, slug, thread) {
2417
- return path5.join(this.agentProject(name, slug), "threads", thread);
2529
+ /**
2530
+ * A thread's or a channel's conversation's session and last turn, beside the scope's home
2531
+ * conversation; `part` is a thread's id or `#channel`.
2532
+ */
2533
+ agentConversation(name, slug, part) {
2534
+ return part.startsWith("#") ? path5.join(this.agentProject(name, slug), "channels", part.slice(1)) : path5.join(this.agentProject(name, slug), "threads", part);
2418
2535
  }
2419
2536
  agentCursors(name) {
2420
2537
  return path5.join(this.agent(name), "cursors.json");
@@ -2448,6 +2565,13 @@ function hashToken(token) {
2448
2565
 
2449
2566
  // packages/board-core/dist/board.js
2450
2567
  var SYSTEM_ACTOR = { name: "board", role: USER_ROLE };
2568
+ var CLOSING_ATTEMPTS = 3;
2569
+ function ownTurn(actor, turn) {
2570
+ return turn.agent === actor.name && turn.scope === actor.scope && turn.thread === actor.thread && turn.channel === actor.channel;
2571
+ }
2572
+ function namesList(names) {
2573
+ return names.length <= 1 ? names[0] ?? "" : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
2574
+ }
2451
2575
  var SKIPPED_IN_HOME = /* @__PURE__ */ new Set([".git", "turns", ".claude", ".codex"]);
2452
2576
  var RunnerTokensSchema = z12.object({
2453
2577
  runners: z12.record(z12.string(), z12.object({ tokenHash: z12.string().min(1) }))
@@ -2465,6 +2589,7 @@ var SignInsSchema = z12.object({
2465
2589
  var TurnHistoryPayloadSchema = z12.object({
2466
2590
  project: NameSchema,
2467
2591
  thread: UlidSchema.optional(),
2592
+ channel: NameSchema.optional(),
2468
2593
  trigger: z12.string().default("manual"),
2469
2594
  exitReason: z12.string().nullable().default(null),
2470
2595
  costUsd: z12.number().default(0),
@@ -2480,12 +2605,23 @@ var CursorsSchema = z12.object({
2480
2605
  digest: z12.string().nullable(),
2481
2606
  scopes: z12.record(z12.string(), z12.string()).default({}),
2482
2607
  threadsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2608
+ channelsFrom: z12.record(z12.string(), z12.string().nullable()).default({}),
2483
2609
  /** How far a work role has read news, which goes to whichever of its general conversations reads first. */
2484
2610
  news: z12.string().optional()
2485
2611
  });
2486
- var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {} };
2487
- function conversationKey(scope, thread) {
2488
- return thread === null ? scope : `${scope}/${thread}`;
2612
+ var NO_CURSORS = { digest: null, scopes: {}, threadsFrom: {}, channelsFrom: {} };
2613
+ function conversationKey(scope, part) {
2614
+ return part === null ? scope : `${scope}/${part}`;
2615
+ }
2616
+ function channelOfPart(part) {
2617
+ return part?.startsWith("#") === true ? part.slice(1) : void 0;
2618
+ }
2619
+ function threadOfPart(part) {
2620
+ return part === null || part === void 0 || part.startsWith("#") ? void 0 : part;
2621
+ }
2622
+ function channelStart(cursors, scope) {
2623
+ const from = cursors.channelsFrom[scope];
2624
+ return from === void 0 ? cursors.digest : from;
2489
2625
  }
2490
2626
  function threadStart(cursors, scope) {
2491
2627
  const from = cursors.threadsFrom[scope];
@@ -2498,11 +2634,11 @@ function newsStart(cursors) {
2498
2634
  const homes = Object.entries(cursors.scopes).filter(([key]) => !key.includes("/")).map(([, id]) => id).toSorted();
2499
2635
  return homes.at(-1) ?? cursors.digest;
2500
2636
  }
2501
- function cursorOf(cursors, scope, thread = null) {
2502
- if (thread === null) {
2637
+ function cursorOf(cursors, scope, part = null) {
2638
+ if (part === null) {
2503
2639
  return cursors.scopes[scope] ?? cursors.digest;
2504
2640
  }
2505
- return cursors.scopes[conversationKey(scope, thread)] ?? threadStart(cursors, scope);
2641
+ return cursors.scopes[conversationKey(scope, part)] ?? (part.startsWith("#") ? channelStart(cursors, scope) : threadStart(cursors, scope));
2506
2642
  }
2507
2643
  function byId(a, b) {
2508
2644
  return a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0;
@@ -2513,6 +2649,9 @@ function turnTime(record) {
2513
2649
  var PausedSchema = z12.object({ paused: z12.boolean() });
2514
2650
  var DEFAULT_LEASE_MS = 30 * 60 * 1e3;
2515
2651
  var MENTION_PATTERN = /(^|[^\w@])@([a-z0-9][a-z0-9-]{0,31})(?![\w-])/g;
2652
+ function withoutMentions(text) {
2653
+ return text.replaceAll(MENTION_PATTERN, "$1$2");
2654
+ }
2516
2655
  var CURATING_ROLES = [USER_ROLE, "steward"];
2517
2656
  var SEED_INSTRUCTIONS_HEADING = "## Seed instructions";
2518
2657
  var OPS_WAKE_TRIGGER = "ops_event";
@@ -2529,6 +2668,7 @@ var DECISIONS_CHANNEL_RETIRED = "decisions-channel-retired";
2529
2668
  var THREAD_CONVERSATIONS = "thread-conversations";
2530
2669
  var ASKS_CHANNEL_OPENED = "asks-channel-opened";
2531
2670
  var KNOWLEDGE_REMOVAL = "knowledge-removal-granted";
2671
+ var CHANNEL_CONVERSATIONS = "channel-conversations";
2532
2672
  var PLANNING_ROLES = [USER_ROLE, "steward", "concierge"];
2533
2673
  var REALLOCATING_ROLES = [USER_ROLE, "steward", "concierge"];
2534
2674
  var PROFILE_TEMPLATE = "# Profile\n\nOne short paragraph, kept current: what I do well, what I am working on, and what to send my way. The board projects this into the roster the front desk reads.\n";
@@ -2659,6 +2799,8 @@ function snippetAround(text, query, radius = 80) {
2659
2799
  var Board = class _Board {
2660
2800
  paths;
2661
2801
  mutex = new Mutex();
2802
+ /** The turns in flight, which the server hosting them supplies; none until it does. */
2803
+ turnsInFlight = () => [];
2662
2804
  newId = monotonicFactory();
2663
2805
  events;
2664
2806
  actionCounts = /* @__PURE__ */ new Map();
@@ -2719,7 +2861,8 @@ var Board = class _Board {
2719
2861
  DECISIONS_CHANNEL_RETIRED,
2720
2862
  THREAD_CONVERSATIONS,
2721
2863
  ASKS_CHANNEL_OPENED,
2722
- KNOWLEDGE_REMOVAL
2864
+ KNOWLEDGE_REMOVAL,
2865
+ CHANNEL_CONVERSATIONS
2723
2866
  ].filter((name) => !applied.includes(name));
2724
2867
  if (pending.length === 0) {
2725
2868
  return;
@@ -2751,6 +2894,9 @@ var Board = class _Board {
2751
2894
  if (pending.includes(KNOWLEDGE_REMOVAL)) {
2752
2895
  await this.grantKnowledgeRemoval();
2753
2896
  }
2897
+ if (pending.includes(CHANNEL_CONVERSATIONS)) {
2898
+ await this.startChannelCursors();
2899
+ }
2754
2900
  await writeJson(file, { applied: [...applied, ...pending] });
2755
2901
  }
2756
2902
  /**
@@ -2925,6 +3071,27 @@ var Board = class _Board {
2925
3071
  }
2926
3072
  }
2927
3073
  }
3074
+ /**
3075
+ * Every channel of a scope was once read in its home conversation, so a member had read each up
3076
+ * to its scope's cursor. Each channel beside general starts from that cursor now, as each takes a
3077
+ * conversation of its own.
3078
+ */
3079
+ async startChannelCursors() {
3080
+ for (const agent of await this.listAgents()) {
3081
+ const file = this.paths.agentCursors(agent.name);
3082
+ if (!await exists(file)) {
3083
+ continue;
3084
+ }
3085
+ const cursors = await readJson(file, CursorsSchema);
3086
+ const scopes = /* @__PURE__ */ new Set([
3087
+ ...Object.keys(cursors.scopes).filter((key) => !key.includes("/")),
3088
+ ...agent.memberships,
3089
+ SOCIETY_SCOPE
3090
+ ]);
3091
+ const channelsFrom = Object.fromEntries([...scopes].map((scope) => [scope, cursorOf(cursors, scope)]));
3092
+ await writeJson(file, { ...cursors, channelsFrom });
3093
+ }
3094
+ }
2928
3095
  /**
2929
3096
  * Threads were once read in the scope's single conversation, so a member had read every thread
2930
3097
  * up to its scope's cursor. Each scope's threads start from that cursor now, as each thread's
@@ -3058,17 +3225,20 @@ ${charter.purpose}
3058
3225
  /**
3059
3226
  * A short-lived token for one turn, handed to the CLI by the runner. Only its hash is kept,
3060
3227
  * in memory, until it expires, so no raw agent token ever needs to exist at rest. The turn's
3061
- * scope and thread ride on the actor it resolves to, so `read_inbox` reads that conversation's
3062
- * digest.
3228
+ * scope and conversation part, a thread's id or `#channel`, ride on the actor it resolves to, so
3229
+ * `read_inbox` reads that conversation's digest.
3063
3230
  */
3064
- issueTurnToken(agent, role, ttlMs, scope, thread) {
3231
+ issueTurnToken(agent, role, ttlMs, scope, part) {
3065
3232
  const token = mintToken();
3233
+ const thread = threadOfPart(part);
3234
+ const channel = channelOfPart(part);
3066
3235
  this.turnTokens.set(hashToken(token), {
3067
3236
  actor: {
3068
3237
  name: agent,
3069
3238
  role,
3070
3239
  ...scope === void 0 ? {} : { scope },
3071
- ...thread === void 0 ? {} : { thread }
3240
+ ...thread === void 0 ? {} : { thread },
3241
+ ...channel === void 0 ? {} : { channel }
3072
3242
  },
3073
3243
  expiresAt: this.now().getTime() + ttlMs
3074
3244
  });
@@ -3368,34 +3538,44 @@ ${charter.purpose}
3368
3538
  }
3369
3539
  return (await readJson(file, PausedSchema)).paused;
3370
3540
  }
3371
- /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3372
- /** Every channel, the society's first, with how many messages it holds and its newest one. */
3541
+ /**
3542
+ * Every channel, the society's first, with how many messages it holds and its newest one; each
3543
+ * place's archived channels follow its open ones.
3544
+ */
3373
3545
  async listChannels() {
3374
3546
  const summaries = [];
3375
- const scopes = [
3376
- { project: null, names: (await this.society()).channels },
3547
+ const society = await this.society();
3548
+ const places = [
3549
+ { project: null, names: society.channels, archived: society.archivedChannels },
3377
3550
  ...(await this.listProjects()).map((project) => ({
3378
3551
  project: project.slug,
3379
- names: project.channels
3552
+ names: project.channels,
3553
+ archived: project.archivedChannels
3380
3554
  }))
3381
3555
  ];
3382
- for (const { project, names } of scopes) {
3383
- for (const name of names) {
3384
- const ref = channelRef(project, name);
3556
+ for (const { project, names, archived } of places) {
3557
+ const entries = [
3558
+ ...names.map((name) => ({ name, archived: void 0 })),
3559
+ ...archived.map((each) => ({ name: each.name, archived: each }))
3560
+ ];
3561
+ for (const entry of entries) {
3562
+ const ref = channelRef(project, entry.name);
3385
3563
  const files = await listFiles(this.paths.channelDir(ref));
3386
3564
  const last = files.at(-1)?.slice(0, 26) ?? null;
3387
3565
  summaries.push({
3388
3566
  ref,
3389
3567
  project,
3390
- name,
3568
+ name: entry.name,
3391
3569
  messages: files.length,
3392
3570
  lastMessageId: last,
3393
- lastAt: last === null ? null : new Date(decodeTime(last)).toISOString()
3571
+ lastAt: last === null ? null : new Date(decodeTime(last)).toISOString(),
3572
+ ...entry.archived === void 0 ? {} : { archived: entry.archived }
3394
3573
  });
3395
3574
  }
3396
3575
  }
3397
3576
  return summaries;
3398
3577
  }
3578
+ /** Every message in a channel, oldest first. Thread messages are listed with `listThread`. */
3399
3579
  async listChannel(ref, limit2 = 200) {
3400
3580
  await this.assertChannelExists(ref);
3401
3581
  const messages = await this.readMessagesIn(this.paths.channelDir(ref), null);
@@ -3548,12 +3728,27 @@ Agents may edit this file. It is rendered by the board UI.
3548
3728
  return (await this.writeRoleUnlocked(actor.name, parsed, {})).charter;
3549
3729
  });
3550
3730
  }
3731
+ /** Opens a channel in a project or the society for a workstream, such as a release's tasks. */
3732
+ async createChannel(actor, input) {
3733
+ const args = VerbInputs.create_channel.parse(input);
3734
+ await this.authorize(actor, "create_channel");
3735
+ return this.mutex.run(async () => {
3736
+ await this.validateAddChannel(args);
3737
+ return this.addChannelUnlocked(actor.name, args, {}, actor.name);
3738
+ });
3739
+ }
3740
+ /** Archives a channel whose workstream is done; refused while a task filed there is in play. */
3741
+ async archiveChannel(actor, input) {
3742
+ const args = VerbInputs.archive_channel.parse(input);
3743
+ await this.authorize(actor, "archive_channel");
3744
+ return this.mutex.run(() => this.archiveChannelUnlocked(actor, args.channel, args.reason));
3745
+ }
3551
3746
  /** Adds a channel to a project or to the society. Also what an approved channel proposal executes. */
3552
3747
  async addChannel(actor, input) {
3553
3748
  this.assertAdmin(actor);
3554
3749
  return this.mutex.run(async () => {
3555
3750
  await this.validateAddChannel(input);
3556
- return this.addChannelUnlocked(actor.name, input, {});
3751
+ return this.addChannelUnlocked(actor.name, input, {}, actor.name);
3557
3752
  });
3558
3753
  }
3559
3754
  /**
@@ -3751,19 +3946,19 @@ Agents may edit this file. It is rendered by the board UI.
3751
3946
  return this.mutex.run(async () => {
3752
3947
  const stored = await this.readCursors(actor.name);
3753
3948
  const from = args.since_cursor === void 0 ? stored : { ...NO_CURSORS, digest: args.since_cursor };
3754
- const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, thread: actor.thread ?? null };
3949
+ const conversation = actor.scope === void 0 ? void 0 : { scope: actor.scope, part: conversationPart(actor) ?? null };
3755
3950
  const filed = (await this.digestSince(actor, from, conversation)).slice(0, args.limit);
3756
3951
  if (args.advance) {
3757
3952
  await this.advanceCursors(actor.name, filed);
3758
3953
  }
3759
3954
  const last = filed.at(-1)?.message.id;
3760
- const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.thread));
3955
+ const cursor = last ?? (conversation === void 0 ? from.digest : cursorOf(from, conversation.scope, conversation.part));
3761
3956
  return { messages: filed.map((entry) => entry.message), cursor };
3762
3957
  });
3763
3958
  }
3764
3959
  /**
3765
3960
  * How many unread digest messages a member has in each conversation: a scope's home, for its
3766
- * channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3961
+ * general channel, one of its other channels, or one of its threads. The heartbeat asks, so that it wakes a member where its unread
3767
3962
  * messages are and nowhere else. Society channels count only for a member woken in the society
3768
3963
  * scope; a project member reads them at its next turn. Two kinds of post are in the digest but not
3769
3964
  * counted, and are read when the member wakes for something else: a task step's, which wakes
@@ -3774,7 +3969,7 @@ Agents may edit this file. It is rendered by the board UI.
3774
3969
  return this.mutex.run(async () => {
3775
3970
  const counts = /* @__PURE__ */ new Map();
3776
3971
  const unscoped = { name: actor.name, role: actor.role };
3777
- for (const { scope, thread, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3972
+ for (const { scope, part, message, news } of await this.digestSince(unscoped, await this.readCursors(actor.name))) {
3778
3973
  if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
3779
3974
  continue;
3780
3975
  }
@@ -3782,8 +3977,13 @@ Agents may edit this file. It is rendered by the board UI.
3782
3977
  if (posted !== scope && scope !== SOCIETY_SCOPE) {
3783
3978
  continue;
3784
3979
  }
3785
- const key = conversationKey(scope, thread);
3786
- const entry = counts.get(key) ?? { scope, thread, count: 0 };
3980
+ const key = conversationKey(scope, part);
3981
+ const entry = counts.get(key) ?? {
3982
+ scope,
3983
+ thread: threadOfPart(part) ?? null,
3984
+ channel: channelOfPart(part) ?? null,
3985
+ count: 0
3986
+ };
3787
3987
  counts.set(key, { ...entry, count: entry.count + 1 });
3788
3988
  }
3789
3989
  return [...counts.values()];
@@ -3799,7 +3999,7 @@ Agents may edit this file. It is rendered by the board UI.
3799
3999
  const scopes = { ...cursors.scopes };
3800
4000
  let news = newsStart(cursors);
3801
4001
  let moved = false;
3802
- for (const { scope, thread, message, news: isNews } of filed) {
4002
+ for (const { scope, part, message, news: isNews } of filed) {
3803
4003
  if (isNews === true) {
3804
4004
  if (news === null || message.id > news) {
3805
4005
  news = message.id;
@@ -3807,8 +4007,8 @@ Agents may edit this file. It is rendered by the board UI.
3807
4007
  }
3808
4008
  continue;
3809
4009
  }
3810
- const key = conversationKey(scope, thread);
3811
- const current = scopes[key] ?? cursorOf(cursors, scope, thread);
4010
+ const key = conversationKey(scope, part);
4011
+ const current = scopes[key] ?? cursorOf(cursors, scope, part);
3812
4012
  if (current === null || message.id > current) {
3813
4013
  scopes[key] = message.id;
3814
4014
  moved = true;
@@ -3827,29 +4027,32 @@ Agents may edit this file. It is rendered by the board UI.
3827
4027
  * message is filed in: what mentions the member, sits in a channel it follows, or belongs to a
3828
4028
  * thread it takes part in, and for the front desk, which every post by the user wakes, every post
3829
4029
  * by the user wherever it is. With a conversation, only what is filed there: for a thread, every
3830
- * message of the thread, since the conversation is about it; for a scope's home, its channel
3831
- * messages, since each thread is a conversation of its own.
4030
+ * message of the thread, since the conversation is about it; for a channel's, that channel's
4031
+ * top-level messages; for a scope's home, general's and those of a project the member is not in,
4032
+ * since each thread and each other channel is a conversation of its own.
3832
4033
  */
3833
4034
  async digestSince(actor, cursors, conversation) {
3834
- if (conversation !== void 0 && conversation.thread !== null) {
3835
- const { scope, thread } = conversation;
3836
- const found = await this.tryFindThread(thread);
4035
+ const conversationThread = threadOfPart(conversation?.part);
4036
+ if (conversation !== void 0 && conversationThread !== void 0) {
4037
+ const { scope, part } = conversation;
4038
+ const found = await this.tryFindThread(conversationThread);
3837
4039
  if (found === null) {
3838
4040
  return [];
3839
4041
  }
3840
- const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, thread), cursorOf(cursors, scope, thread));
3841
- return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, thread, message })).toSorted(byId);
4042
+ const messages = await this.readMessagesIn(this.paths.threadMessages(found.threads, conversationThread), cursorOf(cursors, scope, part));
4043
+ return messages.filter((message) => message.author !== actor.name).map((message) => ({ scope, part, message })).toSorted(byId);
3842
4044
  }
3843
4045
  const agent = await this.readAgent(actor.name);
3844
4046
  const charter = await this.readRole(agent.role);
3845
4047
  const subscribed = new Set(agent.subscriptions);
3846
4048
  const frontDesk = charter.wakeTriggers.includes(FRONT_DESK_TRIGGER);
3847
4049
  const threadParticipation = /* @__PURE__ */ new Map();
4050
+ const openChannels = /* @__PURE__ */ new Map();
3848
4051
  const collected = [];
3849
4052
  const newsFrom = charter.societyScope ? void 0 : newsStart(cursors);
3850
4053
  const scopes = conversation !== void 0 ? [conversation.scope] : [...agent.memberships, SOCIETY_SCOPE];
3851
4054
  const starts = [
3852
- ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each)] : [cursorOf(cursors, each)]),
4055
+ ...scopes.flatMap((each) => conversation === void 0 ? [cursorOf(cursors, each), threadStart(cursors, each), channelStart(cursors, each)] : [cursorOf(cursors, each, conversation.part)]),
3853
4056
  ...newsFrom === void 0 ? [] : [newsFrom]
3854
4057
  ];
3855
4058
  const known = starts.filter((start) => start !== null);
@@ -3862,20 +4065,24 @@ Agents may edit this file. It is rendered by the board UI.
3862
4065
  if ((newsFrom === null || message.id > newsFrom) && subscribed.has(message.channel) && message.author !== actor.name) {
3863
4066
  collected.push({
3864
4067
  scope: conversation?.scope ?? SOCIETY_SCOPE,
3865
- thread: null,
4068
+ part: null,
3866
4069
  message,
3867
4070
  news: true
3868
4071
  });
3869
4072
  }
3870
4073
  continue;
3871
4074
  }
3872
- if (conversation !== void 0 && filedIn !== conversation.scope) {
3873
- continue;
4075
+ const named = thread === null ? channelConversation(filedIn, message.channel) : void 0;
4076
+ let open = named === void 0 ? void 0 : openChannels.get(message.channel);
4077
+ if (named !== void 0 && open === void 0) {
4078
+ open = await this.channelOpen(message.channel);
4079
+ openChannels.set(message.channel, open);
3874
4080
  }
3875
- if (conversation !== void 0 && thread !== null) {
4081
+ const part = thread ?? (open === true ? `#${named}` : null);
4082
+ if (conversation !== void 0 && (filedIn !== conversation.scope || part !== conversation.part)) {
3876
4083
  continue;
3877
4084
  }
3878
- const cursor = cursorOf(cursors, filedIn, thread);
4085
+ const cursor = cursorOf(cursors, filedIn, part);
3879
4086
  if (cursor !== null && message.id <= cursor) {
3880
4087
  continue;
3881
4088
  }
@@ -3893,7 +4100,7 @@ Agents may edit this file. It is rendered by the board UI.
3893
4100
  include = participates;
3894
4101
  }
3895
4102
  if (include && message.author !== actor.name) {
3896
- collected.push({ scope: filedIn, thread, message });
4103
+ collected.push({ scope: filedIn, part, message });
3897
4104
  }
3898
4105
  }
3899
4106
  return collected.toSorted(byId);
@@ -4132,7 +4339,7 @@ ${task.body}`;
4132
4339
  taskThreadOpening(task) {
4133
4340
  return {
4134
4341
  id: task.id,
4135
- channel: channelRef(task.project, "general"),
4342
+ channel: channelRef(task.project, task.channel),
4136
4343
  title: task.title,
4137
4344
  subject: { kind: "task", id: task.id }
4138
4345
  };
@@ -4178,7 +4385,7 @@ ${task.body}`;
4178
4385
  if (task.status === "done" || task.status === "abandoned") {
4179
4386
  throw new BoardError("INVALID_STATE", `task ${task.id} is ${task.status}`);
4180
4387
  }
4181
- const channel = args.channel ?? channelRef(project, "general");
4388
+ const channel = args.channel ?? channelRef(project, task.channel);
4182
4389
  if (parseChannelRef(channel).project !== project) {
4183
4390
  throw new BoardError("VALIDATION", `a task's thread hangs off a channel of its project, ${project}`);
4184
4391
  }
@@ -4211,6 +4418,44 @@ ${task.body}`;
4211
4418
  await this.assertChannelOpen(args.channel);
4212
4419
  return { id: this.newId(), channel: args.channel, title: args.title };
4213
4420
  }
4421
+ /**
4422
+ * Where the board learns which turns are in flight, from the server that hosts them, so it
4423
+ * refuses to end a conversation under someone else's turn.
4424
+ */
4425
+ watchTurns(source) {
4426
+ this.turnsInFlight = source;
4427
+ }
4428
+ /**
4429
+ * Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
4430
+ * turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
4431
+ * from inside it is how a citizen finishes there.
4432
+ */
4433
+ assertNoTurnCut(actor, what, cut) {
4434
+ const busy = [
4435
+ ...new Set(this.turnsInFlight().filter((turn) => cut(turn) && !ownTurn(actor, turn)).map((turn) => turn.agent))
4436
+ ].toSorted((a, b) => a.localeCompare(b));
4437
+ if (busy.length === 0) {
4438
+ return;
4439
+ }
4440
+ const one = busy.length === 1;
4441
+ throw new BoardError("INVALID_STATE", `${what} while ${namesList(busy)} ${one ? "is in a turn" : "are in turns"} there; try again once ${one ? "it ends" : "they end"}, or stop ${one ? "it" : "them"}`);
4442
+ }
4443
+ /** The open threads in a folder of threads that `keep` picks. */
4444
+ async openThreadIds(dir, keep) {
4445
+ const ids = /* @__PURE__ */ new Set();
4446
+ for (const file of await listFiles(dir)) {
4447
+ const doc = await readMarkdown(path6.join(dir, file), ThreadFrontmatterSchema);
4448
+ if (doc.data.state === "open" && keep({ ...doc.data, body: doc.body })) {
4449
+ ids.add(doc.data.id);
4450
+ }
4451
+ }
4452
+ return ids;
4453
+ }
4454
+ /** Refuses to archive a project while a turn runs in it or in a thread its archive closes. */
4455
+ async assertProjectQuiet(actor, slug) {
4456
+ const threads = await this.openThreadIds(this.paths.threads(slug), () => true);
4457
+ this.assertNoTurnCut(actor, `project ${slug} cannot be archived`, (turn) => turn.scope === slug || turn.thread !== void 0 && threads.has(turn.thread));
4458
+ }
4214
4459
  async closeThread(actor, input) {
4215
4460
  const args = VerbInputs.close_thread.parse(input);
4216
4461
  await this.authorize(actor, "close_thread");
@@ -4225,6 +4470,7 @@ ${task.body}`;
4225
4470
  if (!PLANNING_ROLES.includes(actor.role) && !await this.participatesInThread(actor, thread, threads)) {
4226
4471
  throw new BoardError("FORBIDDEN", "only the thread's participants, the user, the steward, or the concierge may close it");
4227
4472
  }
4473
+ this.assertNoTurnCut(actor, `thread ${thread.id} cannot close`, (turn) => turn.thread === thread.id);
4228
4474
  const summary = await this.writeMessage(this.paths.channelDir(thread.channel), { author: actor.name, channel: thread.channel, closes: thread.id }, `Thread closed: "${thread.title}" (${thread.id}).
4229
4475
 
4230
4476
  ${args.summary}`);
@@ -4263,10 +4509,12 @@ ${args.summary}`);
4263
4509
  if (first === void 0) {
4264
4510
  throw new BoardError("VALIDATION", "a plan needs at least one stage");
4265
4511
  }
4512
+ await this.assertChannelOpen(channelRef(args.project, args.channel));
4266
4513
  const ts = this.now().toISOString();
4267
4514
  const frontmatter = TaskFrontmatterSchema.parse({
4268
4515
  id: this.newId(),
4269
4516
  project: args.project,
4517
+ channel: args.channel,
4270
4518
  title: args.title,
4271
4519
  status: "open",
4272
4520
  createdBy: actor.name,
@@ -4282,11 +4530,11 @@ ${args.summary}`);
4282
4530
  onDone: project.onDone,
4283
4531
  completing: false
4284
4532
  });
4285
- await this.assertChannelExists(channelRef(args.project, "general"));
4286
4533
  const task = await this.writeTask(args.project, { ...frontmatter, body: args.body });
4287
4534
  await this.events.append("task.created", actor.name, {
4288
4535
  taskId: task.id,
4289
4536
  project: task.project,
4537
+ channel: task.channel,
4290
4538
  title: task.title,
4291
4539
  parentId: task.parentId ?? null,
4292
4540
  stage: task.stage
@@ -4402,6 +4650,9 @@ ${args.summary}`);
4402
4650
  completedAt: ts
4403
4651
  };
4404
4652
  const next = current.stages[index + 1];
4653
+ if (next === void 0 && current.onDone === "none") {
4654
+ this.assertNoTurnCut(actor, `task ${current.id} cannot end`, (turn) => turn.thread === current.id);
4655
+ }
4405
4656
  const reworkDone = current.returned === void 0 || next === void 0 || index + 1 >= stageIndex(current, current.returned.from);
4406
4657
  const base = {
4407
4658
  ...current,
@@ -4565,6 +4816,7 @@ ${args.summary}`);
4565
4816
  if (!mayAbandon) {
4566
4817
  throw new BoardError("FORBIDDEN", "only the holder, the creator, the user, the steward, or the concierge may abandon a task");
4567
4818
  }
4819
+ this.assertNoTurnCut(actor, `task ${current.id} cannot be abandoned`, (turn) => turn.thread === current.id);
4568
4820
  next = {
4569
4821
  ...next,
4570
4822
  status: "abandoned",
@@ -4766,6 +5018,7 @@ ${rationale}`}`);
4766
5018
  await this.authorize(actor, "archive_project");
4767
5019
  return this.mutex.run(async () => {
4768
5020
  await this.validateArchive(args.project);
5021
+ await this.assertProjectQuiet(actor, args.project);
4769
5022
  return (await this.archiveUnlocked(actor.name, args.project, args.reason, {})).project;
4770
5023
  });
4771
5024
  }
@@ -4773,6 +5026,10 @@ ${rationale}`}`);
4773
5026
  * A citizen joins a project itself, or a reallocating role adds it. The pair gets its project
4774
5027
  * directory now and, through the `agent.joined` event, an onboarding turn.
4775
5028
  */
5029
+ /**
5030
+ * A member follows the project's general from the start; the result names the project's other
5031
+ * open channels, which it subscribes to when its work there needs them.
5032
+ */
4776
5033
  async joinProject(actor, input) {
4777
5034
  const args = VerbInputs.join_project.parse(input);
4778
5035
  await this.authorize(actor, "join_project");
@@ -4785,7 +5042,7 @@ ${rationale}`}`);
4785
5042
  throw new BoardError("INVALID_STATE", `${target} is retired`);
4786
5043
  }
4787
5044
  if (current.memberships.includes(args.project)) {
4788
- return current;
5045
+ return { ...current, otherChannels: await this.otherChannels(args.project, current) };
4789
5046
  }
4790
5047
  await this.assertMayJoinProjects(target, current.role);
4791
5048
  const agent = await this.updateAgent(target, (a) => ({
@@ -4804,9 +5061,13 @@ ${rationale}`}`);
4804
5061
  project: args.project,
4805
5062
  cli: agent.cli
4806
5063
  });
4807
- return agent;
5064
+ return { ...agent, otherChannels: await this.otherChannels(args.project, agent) };
4808
5065
  });
4809
5066
  }
5067
+ /** The open channels of a project a citizen does not follow. */
5068
+ async otherChannels(project, agent) {
5069
+ return (await this.readProject(project)).channels.map((name) => channelRef(project, name)).filter((ref) => !agent.subscriptions.includes(ref));
5070
+ }
4810
5071
  async leaveProject(actor, input) {
4811
5072
  const args = VerbInputs.leave_project.parse(input);
4812
5073
  await this.authorize(actor, "leave_project");
@@ -4911,6 +5172,10 @@ ${rationale}`}`);
4911
5172
  return this.configureProject(actor, VerbInputs.configure_project.parse(input));
4912
5173
  case "archive_project":
4913
5174
  return this.archiveProject(actor, VerbInputs.archive_project.parse(input));
5175
+ case "create_channel":
5176
+ return this.createChannel(actor, VerbInputs.create_channel.parse(input));
5177
+ case "archive_channel":
5178
+ return this.archiveChannel(actor, VerbInputs.archive_channel.parse(input));
4914
5179
  default:
4915
5180
  throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
4916
5181
  }
@@ -5021,29 +5286,30 @@ ${rationale}`}`);
5021
5286
  /**
5022
5287
  * Moves a conversation's cursor forward to `cursor` once a turn there has delivered up to it. It
5023
5288
  * never moves back, so a turn that ends after a later one in the same conversation cannot rewind it.
5289
+ * `part` names the conversation beside the scope's home: a thread's id or `#channel`.
5024
5290
  */
5025
- async setDigestCursor(agent, scope, cursor, thread) {
5291
+ async setDigestCursor(agent, scope, cursor, part) {
5026
5292
  await this.mutex.run(async () => {
5027
5293
  if (cursor === null) {
5028
5294
  return;
5029
5295
  }
5030
5296
  const cursors = await this.readCursors(agent);
5031
- const current = cursorOf(cursors, scope, thread ?? null);
5297
+ const current = cursorOf(cursors, scope, part ?? null);
5032
5298
  const news = newsStart(cursors);
5033
- const readNews = thread === void 0 && (news === null || cursor > news);
5299
+ const readNews = part === void 0 && (news === null || cursor > news);
5034
5300
  if (current !== null && cursor <= current && !readNews) {
5035
5301
  return;
5036
5302
  }
5037
5303
  await writeJson(this.paths.agentCursors(agent), {
5038
5304
  ...cursors,
5039
- scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, thread ?? null)]: cursor },
5305
+ scopes: current !== null && cursor <= current ? cursors.scopes : { ...cursors.scopes, [conversationKey(scope, part ?? null)]: cursor },
5040
5306
  ...readNews ? { news: cursor } : {}
5041
5307
  });
5042
5308
  });
5043
5309
  }
5044
5310
  /** Where a conversation's digest resumes: the newest message a turn there was shown. */
5045
- async digestCursor(agent, scope, thread) {
5046
- return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, thread ?? null));
5311
+ async digestCursor(agent, scope, part) {
5312
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, part ?? null));
5047
5313
  }
5048
5314
  /** Records that a running turn took posts delivered into it, which wake latency counts to. */
5049
5315
  async recordSteered(input) {
@@ -5052,25 +5318,29 @@ ${rationale}`}`);
5052
5318
  turnId: input.turnId,
5053
5319
  project: input.project,
5054
5320
  ...input.thread === void 0 ? {} : { thread: input.thread },
5321
+ ...input.channel === void 0 ? {} : { channel: input.channel },
5055
5322
  messages: [...input.messages]
5056
5323
  });
5057
5324
  });
5058
5325
  }
5059
- /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
5060
- sessionDir(agent, scope, thread) {
5061
- return thread === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentThread(agent, scope, thread);
5326
+ /**
5327
+ * Where a conversation's session ids and last turn are kept: the scope's home, or beside it a
5328
+ * thread's or a channel's, named by `part` as `conversationPart` gives it.
5329
+ */
5330
+ sessionDir(agent, scope, part) {
5331
+ return part === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentConversation(agent, scope, part);
5062
5332
  }
5063
- async readSessions(agent, project, thread) {
5064
- const file = path6.join(this.sessionDir(agent, project, thread), "sessions.json");
5333
+ async readSessions(agent, project, part) {
5334
+ const file = path6.join(this.sessionDir(agent, project, part), "sessions.json");
5065
5335
  return await exists(file) ? readJson(file, SessionsFileSchema) : {};
5066
5336
  }
5067
5337
  /**
5068
5338
  * Records a conversation's session for a CLI and the runner it began on. A session started on
5069
5339
  * another runner replaces the record, since sessions do not move between machines.
5070
5340
  */
5071
- async writeSession(agent, project, cli, sessionId, runner, thread) {
5341
+ async writeSession(agent, project, cli, sessionId, runner, part) {
5072
5342
  await this.mutex.run(async () => {
5073
- const dir = this.sessionDir(agent, project, thread);
5343
+ const dir = this.sessionDir(agent, project, part);
5074
5344
  await ensureDir(dir);
5075
5345
  const file = path6.join(dir, "sessions.json");
5076
5346
  const current = await exists(file) ? await readJson(file, SessionsFileSchema) : {};
@@ -5078,17 +5348,21 @@ ${rationale}`}`);
5078
5348
  await writeJson(file, { ...kept, [cli]: sessionId, runner });
5079
5349
  });
5080
5350
  }
5081
- /** The last turn of one conversation: the scope's home, or a thread's. */
5082
- async readLastTurn(agent, project, thread) {
5083
- const file = path6.join(this.sessionDir(agent, project, thread), "last-turn.json");
5351
+ /** The last turn of one conversation: the scope's home, a channel's, or a thread's. */
5352
+ async readLastTurn(agent, project, part) {
5353
+ const file = path6.join(this.sessionDir(agent, project, part), "last-turn.json");
5084
5354
  return await exists(file) ? readJson(file, TurnRecordSchema) : null;
5085
5355
  }
5086
5356
  /** The latest turn a citizen took in a scope, in any of its conversations there. */
5087
5357
  async readLatestTurn(agent, project) {
5088
5358
  let latest = await this.readLastTurn(agent, project);
5089
- const threads = path6.join(this.paths.agentProject(agent, project), "threads");
5090
- for (const thread of await listDirs(threads)) {
5091
- const turn = await this.readLastTurn(agent, project, thread);
5359
+ const place = this.paths.agentProject(agent, project);
5360
+ const parts = [
5361
+ ...await listDirs(path6.join(place, "threads")),
5362
+ ...(await listDirs(path6.join(place, "channels"))).map((name) => `#${name}`)
5363
+ ];
5364
+ for (const part of parts) {
5365
+ const turn = await this.readLastTurn(agent, project, part);
5092
5366
  if (turn !== null && (latest === null || turnTime(turn) > turnTime(latest))) {
5093
5367
  latest = turn;
5094
5368
  }
@@ -5105,6 +5379,7 @@ ${rationale}`}`);
5105
5379
  turnId: parsed.id,
5106
5380
  project: parsed.project,
5107
5381
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5382
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5108
5383
  trigger: parsed.trigger.kind,
5109
5384
  session: parsed.session,
5110
5385
  runner: parsed.runner
@@ -5130,13 +5405,15 @@ ${rationale}`}`);
5130
5405
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5131
5406
  agent: parsed.agent,
5132
5407
  project: parsed.project,
5133
- ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
5408
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5409
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel }
5134
5410
  });
5135
5411
  }
5136
5412
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
5137
5413
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
5138
5414
  project: parsed.project,
5139
5415
  ...parsed.thread === void 0 ? {} : { thread: parsed.thread },
5416
+ ...parsed.channel === void 0 ? {} : { channel: parsed.channel },
5140
5417
  trigger: parsed.trigger.kind,
5141
5418
  session: parsed.session,
5142
5419
  exitReason: parsed.exitReason,
@@ -5160,6 +5437,47 @@ ${rationale}`}`);
5160
5437
  });
5161
5438
  });
5162
5439
  }
5440
+ /**
5441
+ * Records that a citizen's workspace in a conversation that has ended holds work on no branch, so
5442
+ * the scheduler gives the citizen a closing turn there to decide about it. False, recording
5443
+ * nothing, once `CLOSING_ATTEMPTS` closing turns have been given for it.
5444
+ */
5445
+ async recordLeftovers(input) {
5446
+ const { conversation } = input;
5447
+ const payload = {
5448
+ agent: input.agent,
5449
+ project: input.scope,
5450
+ ...conversation.kind === "thread" ? { thread: conversation.id } : { channel: conversation.name }
5451
+ };
5452
+ return this.mutex.run(async () => {
5453
+ const earlier = (await this.events.readSince(null, Number.MAX_SAFE_INTEGER)).filter((event) => event.type === "workspace.leftovers" && Object.entries(payload).every(([key, value]) => event.payload[key] === value)).length;
5454
+ if (earlier >= CLOSING_ATTEMPTS) {
5455
+ return false;
5456
+ }
5457
+ await this.events.append("workspace.leftovers", SYSTEM_ACTOR.name, payload);
5458
+ return true;
5459
+ });
5460
+ }
5461
+ /**
5462
+ * Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
5463
+ * (for one from before tasks had threads), or a channel archived.
5464
+ */
5465
+ async conversationEnded(conversation) {
5466
+ if (conversation.kind === "channel") {
5467
+ const place = conversation.scope === SOCIETY_SCOPE ? null : conversation.scope;
5468
+ return !await this.channelOpen(channelRef(place, conversation.name));
5469
+ }
5470
+ const found = await this.tryFindThread(conversation.id);
5471
+ if (found !== null) {
5472
+ return found.thread.state === "closed";
5473
+ }
5474
+ try {
5475
+ const { task } = await this.findTask(conversation.id);
5476
+ return task.status === "done" || task.status === "abandoned";
5477
+ } catch {
5478
+ return false;
5479
+ }
5480
+ }
5163
5481
  /**
5164
5482
  * Records the outcome of a completing task's effect: done, or waiting again at its last stage so
5165
5483
  * its participants can reshape the plan. A merge's outcome is posted to the task's thread, and a
@@ -5195,7 +5513,7 @@ ${rationale}`}`);
5195
5513
  await this.refreshMember(name);
5196
5514
  }
5197
5515
  if (merged) {
5198
- await this.appendMessage(actor.name, channelRef(location.project, "general"), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5516
+ await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${sentence(input.detail)}.`);
5199
5517
  }
5200
5518
  return task2;
5201
5519
  }
@@ -5432,7 +5750,7 @@ ${rationale}`}`);
5432
5750
  return this.files.read(this.paths.board, paths, () => true);
5433
5751
  }
5434
5752
  async writeTurnRecord(record) {
5435
- const dir = this.sessionDir(record.agent, record.project, record.thread);
5753
+ const dir = this.sessionDir(record.agent, record.project, conversationPart(record));
5436
5754
  await ensureDir(dir);
5437
5755
  await writeJson(path6.join(dir, "last-turn.json"), record);
5438
5756
  }
@@ -5736,7 +6054,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5736
6054
  };
5737
6055
  }
5738
6056
  case "channel": {
5739
- const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta);
6057
+ const channel = await this.addChannelUnlocked(by, ChannelProposalSchema.parse(proposal.charter), meta, proposal.proposedBy);
5740
6058
  return { channel };
5741
6059
  }
5742
6060
  case "role": {
@@ -5842,18 +6160,20 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5842
6160
  return { agent, token };
5843
6161
  }
5844
6162
  async validateAddChannel(input) {
5845
- if (input.project === null) {
5846
- if ((await this.society()).channels.includes(input.name)) {
5847
- throw new BoardError("ALREADY_EXISTS", `society channel ${input.name} already exists`);
5848
- }
5849
- return;
6163
+ const place = input.project === null ? await this.society() : await this.readActiveProject(input.project);
6164
+ const ref = channelRef(input.project, input.name);
6165
+ if (place.channels.includes(input.name)) {
6166
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} already exists`);
5850
6167
  }
5851
- const project = await this.readActiveProject(input.project);
5852
- if (project.channels.includes(input.name)) {
5853
- throw new BoardError("ALREADY_EXISTS", `channel ${channelRef(input.project, input.name)} already exists`);
6168
+ if (place.archivedChannels.some((each) => each.name === input.name)) {
6169
+ throw new BoardError("ALREADY_EXISTS", `channel ${ref} was archived; open the new one under another name`);
5854
6170
  }
5855
6171
  }
5856
- async addChannelUnlocked(by, input, meta) {
6172
+ /**
6173
+ * Opens a channel, which `opener` follows from the start: the citizen who opened it or proposed
6174
+ * it. Nobody else is subscribed, and the user, who reads channels directly, follows nothing.
6175
+ */
6176
+ async addChannelUnlocked(by, input, meta, opener) {
5857
6177
  const ref = channelRef(input.project, input.name);
5858
6178
  if (input.project === null) {
5859
6179
  const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
@@ -5867,14 +6187,104 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
5867
6187
  }));
5868
6188
  await ensureDir(this.paths.projectChannel(project, input.name));
5869
6189
  }
6190
+ const follower = await this.readAgent(opener).catch(() => null);
6191
+ const follows = follower !== null && follower.status === "active" && follower.role !== USER_ROLE;
6192
+ if (follows) {
6193
+ await this.updateAgent(opener, (agent) => ({
6194
+ ...agent,
6195
+ subscriptions: [.../* @__PURE__ */ new Set([...agent.subscriptions, ref])]
6196
+ }));
6197
+ await this.refreshMember(opener);
6198
+ }
5870
6199
  await this.events.append("channel.added", by, {
5871
6200
  channel: ref,
5872
6201
  purpose: input.purpose,
6202
+ ...follows ? { followedBy: opener } : {},
5873
6203
  ...meta
5874
6204
  });
5875
- await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${input.purpose}`);
6205
+ const purpose = withoutMentions(input.purpose);
6206
+ await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
6207
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}. Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
5876
6208
  return ref;
5877
6209
  }
6210
+ /**
6211
+ * Ends a channel: its open threads close, its followers stop following it, and it leaves the
6212
+ * place's channels for its archived ones, where its history stays readable. The notice is the
6213
+ * board's and leaves the reason out, since a board-authored mention would wake whoever it names.
6214
+ */
6215
+ async archiveChannelUnlocked(actor, ref, reason) {
6216
+ const by = actor.name;
6217
+ const { project, channel } = parseChannelRef(ref);
6218
+ if (isBoardChannel(project, channel)) {
6219
+ throw new BoardError("FORBIDDEN", `the board itself uses ${ref}, so it is never archived`);
6220
+ }
6221
+ await this.assertChannelOpen(ref);
6222
+ if (project !== null) {
6223
+ const inPlay2 = (await this.listTasks(project)).filter((task) => task.channel === channel && task.status !== "done" && task.status !== "abandoned");
6224
+ if (inPlay2.length > 0) {
6225
+ throw new BoardError("INVALID_STATE", `${inPlay2.length} task(s) filed in ${ref} are still in play: ${inPlay2.map((task) => task.id).join(", ")}`);
6226
+ }
6227
+ }
6228
+ const ts = this.now().toISOString();
6229
+ const threads = project === null ? this.paths.societyThreads() : this.paths.threads(project);
6230
+ const closing = await this.openThreadIds(threads, (thread) => thread.channel === ref);
6231
+ const scope = project ?? SOCIETY_SCOPE;
6232
+ this.assertNoTurnCut(actor, `${ref} cannot be archived`, (turn) => turn.channel === channel && turn.scope === scope || turn.thread !== void 0 && closing.has(turn.thread));
6233
+ const threadsClosed = [];
6234
+ for (const file of await listFiles(threads)) {
6235
+ const doc = await readMarkdown(path6.join(threads, file), ThreadFrontmatterSchema);
6236
+ if (doc.data.channel !== ref || doc.data.state !== "open") {
6237
+ continue;
6238
+ }
6239
+ await this.writeThread(threads, {
6240
+ ...doc.data,
6241
+ body: doc.body,
6242
+ state: "closed",
6243
+ closedBy: by,
6244
+ closedAt: ts
6245
+ });
6246
+ await this.events.append("thread.closed", by, {
6247
+ threadId: doc.data.id,
6248
+ channel: ref,
6249
+ ended: "archived"
6250
+ });
6251
+ threadsClosed.push(doc.data.id);
6252
+ }
6253
+ const unsubscribed = [];
6254
+ for (const agent of await this.listAgents()) {
6255
+ if (agent.subscriptions.includes(ref)) {
6256
+ await this.updateAgent(agent.name, (a) => ({
6257
+ ...a,
6258
+ subscriptions: a.subscriptions.filter((each) => each !== ref)
6259
+ }));
6260
+ await this.refreshMember(agent.name);
6261
+ unsubscribed.push(agent.name);
6262
+ }
6263
+ }
6264
+ const archived = { name: channel, at: ts, by, reason };
6265
+ if (project === null) {
6266
+ const doc = await readMarkdown(this.paths.societyFile(), SocietySchema);
6267
+ await writeMarkdown(this.paths.societyFile(), {
6268
+ ...doc.data,
6269
+ channels: doc.data.channels.filter((name) => name !== channel),
6270
+ archivedChannels: [...doc.data.archivedChannels, archived]
6271
+ }, doc.body);
6272
+ } else {
6273
+ await this.updateProject(project, (current) => ({
6274
+ ...current,
6275
+ channels: current.channels.filter((name) => name !== channel),
6276
+ archivedChannels: [...current.archivedChannels, archived]
6277
+ }));
6278
+ }
6279
+ await this.events.append("channel.archived", by, {
6280
+ channel: ref,
6281
+ reason,
6282
+ threadsClosed,
6283
+ unsubscribed
6284
+ });
6285
+ await this.appendMessage(SYSTEM_ACTOR.name, channelRef(project, "general"), `Channel ${ref} is archived by ${by}. Its posts, threads, and tasks stay readable; nothing more is posted or filed there.`);
6286
+ return archived;
6287
+ }
5878
6288
  /** A role becomes a society role only while none of its members is in a project. */
5879
6289
  async validateRole(charter) {
5880
6290
  if (charter.name === USER_ROLE) {
@@ -6087,23 +6497,35 @@ ${charter.purpose}
6087
6497
  async publishSignalUnlocked(signal) {
6088
6498
  return this.events.append("ops.signal", SYSTEM_ACTOR.name, OpsSignalSchema.parse(signal));
6089
6499
  }
6500
+ /** A channel that exists, open or archived, and the record of its archive when it has one. */
6090
6501
  async assertChannelExists(ref) {
6091
6502
  const parsed = parseChannelRef(ref);
6092
- if (parsed.project === null) {
6093
- const society = await this.society();
6094
- if (!society.channels.includes(parsed.channel)) {
6095
- throw new BoardError("NOT_FOUND", `society channel ${ref} not found`);
6096
- }
6097
- return;
6503
+ const place = parsed.project === null ? await this.society() : await this.readProject(parsed.project);
6504
+ if (place.channels.includes(parsed.channel)) {
6505
+ return void 0;
6098
6506
  }
6099
- const project = await this.readProject(parsed.project);
6100
- if (!project.channels.includes(parsed.channel)) {
6101
- throw new BoardError("NOT_FOUND", `channel ${ref} not found`);
6507
+ const archived = place.archivedChannels.find((each) => each.name === parsed.channel);
6508
+ if (archived === void 0) {
6509
+ throw new BoardError("NOT_FOUND", parsed.project === null ? `society channel ${ref} not found` : `channel ${ref} not found`);
6510
+ }
6511
+ return archived;
6512
+ }
6513
+ /** Whether a channel exists and is not archived, its conversations still going. */
6514
+ async channelOpen(ref) {
6515
+ try {
6516
+ return await this.assertChannelExists(ref) === void 0;
6517
+ } catch {
6518
+ return false;
6102
6519
  }
6103
6520
  }
6104
- /** A channel that takes new posts, threads, and followers: it exists, and its project is not archived. */
6521
+ /**
6522
+ * A channel that takes new posts, threads, tasks, and followers: it exists, it is not archived,
6523
+ * and its project is not archived.
6524
+ */
6105
6525
  async assertChannelOpen(ref) {
6106
- await this.assertChannelExists(ref);
6526
+ if (await this.assertChannelExists(ref) !== void 0) {
6527
+ throw new BoardError("INVALID_STATE", `channel ${ref} is archived`);
6528
+ }
6107
6529
  const { project } = parseChannelRef(ref);
6108
6530
  if (project !== null) {
6109
6531
  await this.readActiveProject(project);
@@ -6203,6 +6625,10 @@ ${charter.purpose}
6203
6625
  if (outcome === "approved") {
6204
6626
  await this.validateProvision(proposal.kind, proposal.charter);
6205
6627
  }
6628
+ this.assertNoTurnCut(actor, `proposal ${proposalId} cannot be decided`, (turn) => turn.thread === proposalId);
6629
+ if (outcome === "approved" && proposal.kind === "archive") {
6630
+ await this.assertProjectQuiet(actor, ArchiveProposalSchema.parse(proposal.charter).project);
6631
+ }
6206
6632
  const ts = this.now().toISOString();
6207
6633
  const decision = DecisionSchema.parse({
6208
6634
  id: this.newId(),
@@ -6389,6 +6815,8 @@ function decideWake(input) {
6389
6815
  return { wake: true, reason: "operations signal", priority: 0 };
6390
6816
  case "user_post":
6391
6817
  return { wake: true, reason: "user post", priority: 2 };
6818
+ case "closing":
6819
+ return { wake: true, reason: "an ended conversation left work on no branch", priority: 1 };
6392
6820
  case "heartbeat":
6393
6821
  break;
6394
6822
  }
@@ -6404,6 +6832,16 @@ function decideWake(input) {
6404
6832
 
6405
6833
  // packages/scheduler/dist/scheduler.js
6406
6834
  import { z as z13 } from "zod";
6835
+ function besideFields(beside) {
6836
+ if (beside === void 0) {
6837
+ return {};
6838
+ }
6839
+ return "thread" in beside ? { thread: beside.thread } : { channel: beside.channel };
6840
+ }
6841
+ function conversationKey2(agent, scope, beside) {
6842
+ const fields = besideFields(beside);
6843
+ return sessionKey(agent, scope, conversationPart({ thread: fields.thread?.id, channel: fields.channel }));
6844
+ }
6407
6845
  var DEFAULT_TIMINGS = Object.freeze({
6408
6846
  pollMs: 1e3,
6409
6847
  debounceMs: 3e4,
@@ -6529,6 +6967,12 @@ var Scheduler = class {
6529
6967
  /** Tasks whose completion effect waits for the turn that finished their last stage to end. */
6530
6968
  completions = /* @__PURE__ */ new Map();
6531
6969
  completing = /* @__PURE__ */ new Set();
6970
+ /** Running turns that must run alone among their citizen's, by session key: see `heldBack`. */
6971
+ exclusive = /* @__PURE__ */ new Map();
6972
+ /** Tasks being landed now; a turn in a task's thread waits until its landing is done. */
6973
+ landing = /* @__PURE__ */ new Set();
6974
+ /** Sweeps of ended conversations' workspaces under way, which `drain` and `stop` wait for. */
6975
+ sweeping = /* @__PURE__ */ new Set();
6532
6976
  state = StateSchema.parse({
6533
6977
  cursor: null,
6534
6978
  lastHeartbeat: {}
@@ -6581,7 +7025,8 @@ var Scheduler = class {
6581
7025
  await Promise.allSettled([
6582
7026
  ...this.running.values(),
6583
7027
  ...this.completing,
6584
- ...this.steering.values()
7028
+ ...this.steering.values(),
7029
+ ...this.sweeping
6585
7030
  ]);
6586
7031
  }
6587
7032
  /** One pass, unless one is under way already. */
@@ -6630,7 +7075,8 @@ var Scheduler = class {
6630
7075
  await Promise.allSettled([
6631
7076
  ...this.steering.values(),
6632
7077
  ...this.running.values(),
6633
- ...this.completing
7078
+ ...this.completing,
7079
+ ...this.sweeping
6634
7080
  ]);
6635
7081
  }
6636
7082
  async load() {
@@ -6678,7 +7124,7 @@ var Scheduler = class {
6678
7124
  reason: `mentioned by ${event.actor}`,
6679
7125
  ...messageId === void 0 ? {} : { messageId },
6680
7126
  ...channel === null ? {} : { channel }
6681
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7127
+ }, now, await this.postedIn(scope, channel, threadId));
6682
7128
  }
6683
7129
  const step = payload["step"];
6684
7130
  if (event.actor === USER_NAME && (step === void 0 || step === null)) {
@@ -6733,6 +7179,21 @@ var Scheduler = class {
6733
7179
  if (creator !== null && creator !== event.actor) {
6734
7180
  await this.wakeCreator(taskId, project, creator, event.actor, now);
6735
7181
  }
7182
+ const threaded = await this.board.readThread(taskId).then(() => true, () => false);
7183
+ if (!threaded) {
7184
+ this.sweep({ kind: "thread", id: taskId });
7185
+ }
7186
+ return;
7187
+ }
7188
+ case "thread.closed": {
7189
+ const threadId = stringOf(payload["threadId"]);
7190
+ if (threadId !== null) {
7191
+ this.sweep({ kind: "thread", id: threadId });
7192
+ }
7193
+ return;
7194
+ }
7195
+ case "workspace.leftovers": {
7196
+ await this.wakeClosing(payload, now);
6736
7197
  return;
6737
7198
  }
6738
7199
  case "task.updated": {
@@ -6810,6 +7271,26 @@ var Scheduler = class {
6810
7271
  await this.wakeNewcomer(name, project, now);
6811
7272
  return;
6812
7273
  }
7274
+ case "channel.archived": {
7275
+ const channel = stringOf(payload["channel"]);
7276
+ if (channel === null) {
7277
+ return;
7278
+ }
7279
+ const archived = parseChannelRef(channel);
7280
+ this.sweep({
7281
+ kind: "channel",
7282
+ scope: archived.project ?? SOCIETY_SCOPE,
7283
+ name: archived.channel
7284
+ });
7285
+ for (const [key, item] of this.pending) {
7286
+ const { dispatch } = item;
7287
+ if (dispatch.channel !== void 0 && channelConversation(dispatch.project, channel) === dispatch.channel) {
7288
+ this.pending.delete(key);
7289
+ this.log.info({ agent: dispatch.agent, project: dispatch.project, channel }, "dropping channel wake, the channel is archived");
7290
+ }
7291
+ }
7292
+ return;
7293
+ }
6813
7294
  case "agent.left": {
6814
7295
  const name = stringOf(payload["name"]);
6815
7296
  const project = stringOf(payload["project"]);
@@ -6880,7 +7361,7 @@ var Scheduler = class {
6880
7361
  reason: `the user posted in ${channel ?? "a channel"}`,
6881
7362
  ...messageId === void 0 ? {} : { messageId },
6882
7363
  ...channel === null ? {} : { channel }
6883
- }, now, threadId === void 0 ? void 0 : await this.conversationOf(threadId, scope));
7364
+ }, now, await this.postedIn(scope, channel, threadId));
6884
7365
  }
6885
7366
  }
6886
7367
  /**
@@ -6954,7 +7435,7 @@ var Scheduler = class {
6954
7435
  from,
6955
7436
  reason: `stage "${name}" of task ${task.id} "${task.title}" ${state}`,
6956
7437
  taskId: task.id
6957
- }, now, { id: task.id, task: scope === task.project });
7438
+ }, now, { thread: { id: task.id, task: scope === task.project } });
6958
7439
  return sessionKey(agent.name, scope, task.id);
6959
7440
  }
6960
7441
  /**
@@ -7015,6 +7496,49 @@ var Scheduler = class {
7015
7496
  const scope = this.scopeForProject(agent, project);
7016
7497
  this.enqueue(agent.name, scope, { kind: "task_done", from, reason: `task ${taskId} you created is done`, taskId }, now);
7017
7498
  }
7499
+ /**
7500
+ * Whether a dispatch is in the thread of a task whose landing is queued or under way: it waits,
7501
+ * so a stream of turns there cannot hold the landing off, nor commit to the branch during it.
7502
+ */
7503
+ landingIn(dispatch) {
7504
+ const thread = dispatch.thread?.id;
7505
+ return thread !== void 0 && (this.completions.has(thread) || this.landing.has(thread));
7506
+ }
7507
+ /** Asks the runners to remove an ended conversation's workspaces, without holding up the pass. */
7508
+ sweep(conversation) {
7509
+ const sweep = this.runner.sweep?.bind(this.runner);
7510
+ if (sweep === void 0) {
7511
+ return;
7512
+ }
7513
+ const promise = sweep(conversation).catch((error) => {
7514
+ this.log.warn({ conversation, error: String(error) }, "sweeping workspaces failed");
7515
+ }).finally(() => {
7516
+ this.sweeping.delete(promise);
7517
+ });
7518
+ this.sweeping.add(promise);
7519
+ }
7520
+ /**
7521
+ * A citizen's workspace in a conversation that has ended holds work on no branch: one closing turn
7522
+ * there, in the same session and workspace, decides whether it matters and whose it is.
7523
+ */
7524
+ async wakeClosing(payload, now) {
7525
+ const name = stringOf(payload["agent"]);
7526
+ const scope = stringOf(payload["project"]);
7527
+ const thread = stringOf(payload["thread"]);
7528
+ const channel = stringOf(payload["channel"]);
7529
+ const agent = name === null ? null : await this.tryReadAgent(name);
7530
+ if (agent === null || scope === null) {
7531
+ return;
7532
+ }
7533
+ const beside = thread !== null ? { thread: await this.conversationOf(thread, scope) } : channel !== null ? { channel } : null;
7534
+ if (beside === null) {
7535
+ return;
7536
+ }
7537
+ this.enqueue(agent.name, scope, {
7538
+ kind: "closing",
7539
+ reason: "a conversation you took part in has ended, and its workspace holds work on no branch"
7540
+ }, now, beside);
7541
+ }
7018
7542
  /** A decided proposal wakes the citizen that made it, to carry on with what it enables. */
7019
7543
  async wakeProposer(proposalId, proposer, event, now) {
7020
7544
  const agent = await this.tryReadAgent(proposer);
@@ -7044,6 +7568,17 @@ var Scheduler = class {
7044
7568
  return { id: threadId, task: false };
7045
7569
  }
7046
7570
  }
7571
+ /**
7572
+ * The conversation a post wakes a citizen in, for a turn in `scope`: its thread's, else its
7573
+ * channel's when that is a channel of the scope other than general, else the scope's home.
7574
+ */
7575
+ async postedIn(scope, channel, threadId) {
7576
+ if (threadId !== void 0) {
7577
+ return { thread: await this.conversationOf(threadId, scope) };
7578
+ }
7579
+ const name = channel === null ? void 0 : channelConversation(scope, channel);
7580
+ return name === void 0 ? void 0 : { channel: name };
7581
+ }
7047
7582
  async tryReadAgent(name) {
7048
7583
  try {
7049
7584
  const agent = await this.board.readAgent(name);
@@ -7059,8 +7594,8 @@ var Scheduler = class {
7059
7594
  scopeForProject(agent, project) {
7060
7595
  return wakeScope(agent, project);
7061
7596
  }
7062
- /** Queues a wake in one conversation: a thread's, or without one, the scope's home. */
7063
- enqueue(agent, project, input, now, thread) {
7597
+ /** Queues a wake in one conversation: a thread's, a channel's, or without either, the scope's home. */
7598
+ enqueue(agent, project, input, now, beside) {
7064
7599
  const trigger = TriggerSchema.parse(input);
7065
7600
  const decision = decideWake({
7066
7601
  trigger,
@@ -7073,7 +7608,7 @@ var Scheduler = class {
7073
7608
  return;
7074
7609
  }
7075
7610
  const debounce = this.debounceFor(trigger);
7076
- const key = sessionKey(agent, project, thread?.id);
7611
+ const key = conversationKey2(agent, project, beside);
7077
7612
  const readyAt = now + debounce;
7078
7613
  const existing = this.pending.get(key);
7079
7614
  const message = messageOf(trigger);
@@ -7082,7 +7617,7 @@ var Scheduler = class {
7082
7617
  dispatch: {
7083
7618
  agent,
7084
7619
  project,
7085
- ...thread === void 0 ? {} : { thread },
7620
+ ...besideFields(beside),
7086
7621
  trigger,
7087
7622
  priority: decision.priority,
7088
7623
  onboarding: trigger.kind === "onboarding"
@@ -7132,7 +7667,8 @@ var Scheduler = class {
7132
7667
  }
7133
7668
  /**
7134
7669
  * A heartbeat per member and scope on the cadence, which then looks at every conversation of the
7135
- * scope: the unread messages of its home and of each thread, a stage the member holds, and a stage
7670
+ * scope: the unread messages of its home, of each other channel, and of each thread, a stage the
7671
+ * member holds, and a stage
7136
7672
  * waiting that it may take, each in its task's thread. Each conversation with something wakes on
7137
7673
  * its own, so a heartbeat never brings one task's business into another's turn.
7138
7674
  */
@@ -7163,31 +7699,31 @@ var Scheduler = class {
7163
7699
  unread ??= await this.board.unreadByConversation(actor);
7164
7700
  held ??= await this.board.heldClaims(agent.name);
7165
7701
  const reasons = /* @__PURE__ */ new Map();
7166
- const reasonsIn = (thread) => {
7167
- const key = sessionKey(agent.name, project, thread?.id);
7702
+ const reasonsIn = (beside) => {
7703
+ const key = conversationKey2(agent.name, project, beside);
7168
7704
  const existing = reasons.get(key);
7169
7705
  if (existing !== void 0) {
7170
7706
  return existing;
7171
7707
  }
7172
- const fresh = { thread, digestSize: 0, claimsHeld: 0, waiting: 0 };
7708
+ const fresh = { beside, digestSize: 0, claimsHeld: 0, waiting: 0 };
7173
7709
  reasons.set(key, fresh);
7174
7710
  return fresh;
7175
7711
  };
7176
7712
  for (const entry of unread) {
7177
7713
  if (entry.scope === project) {
7178
- const thread = entry.thread === null ? void 0 : await this.conversationOf(entry.thread, project);
7179
- reasonsIn(thread).digestSize += entry.count;
7714
+ const beside = entry.thread !== null ? { thread: await this.conversationOf(entry.thread, project) } : entry.channel !== null ? { channel: entry.channel } : void 0;
7715
+ reasonsIn(beside).digestSize += entry.count;
7180
7716
  }
7181
7717
  }
7182
7718
  for (const task of held) {
7183
7719
  if (task.project === project) {
7184
- reasonsIn({ id: task.id, task: true }).claimsHeld += 1;
7720
+ reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
7185
7721
  }
7186
7722
  }
7187
7723
  if (project !== SOCIETY_SCOPE) {
7188
7724
  for (const task of await this.board.openTasks(project)) {
7189
7725
  if (mayHoldStage(agent, task)) {
7190
- reasonsIn({ id: task.id, task: true }).waiting += 1;
7726
+ reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
7191
7727
  }
7192
7728
  }
7193
7729
  }
@@ -7208,7 +7744,7 @@ var Scheduler = class {
7208
7744
  dispatch: {
7209
7745
  agent: agent.name,
7210
7746
  project,
7211
- ...reason.thread === void 0 ? {} : { thread: reason.thread },
7747
+ ...besideFields(reason.beside),
7212
7748
  trigger,
7213
7749
  priority: decision.priority,
7214
7750
  onboarding: false
@@ -7246,8 +7782,8 @@ var Scheduler = class {
7246
7782
  this.state.lastReflection[agent.name] = iso(now);
7247
7783
  continue;
7248
7784
  }
7249
- const key = sessionKey(agent.name, latest.scope);
7250
- if (this.pending.has(key) || this.running.has(key)) {
7785
+ const busy = [...this.pending.keys(), ...this.running.keys()].some((key) => parseSessionKey(key)?.agent === agent.name);
7786
+ if (busy) {
7251
7787
  continue;
7252
7788
  }
7253
7789
  this.state.lastReflection[agent.name] = iso(now);
@@ -7564,7 +8100,8 @@ var Scheduler = class {
7564
8100
  const attempt = steer({
7565
8101
  agent: dispatch.agent,
7566
8102
  project: dispatch.project,
7567
- ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id }
8103
+ ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
8104
+ ...dispatch.channel === void 0 ? {} : { channel: dispatch.channel }
7568
8105
  }).then((outcome) => {
7569
8106
  const current = this.pending.get(key);
7570
8107
  if (outcome !== "busy" && current !== void 0) {
@@ -7585,16 +8122,19 @@ var Scheduler = class {
7585
8122
  return false;
7586
8123
  }
7587
8124
  const { dispatch } = item;
7588
- const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, dispatch.thread?.id);
8125
+ const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, conversationPart({ thread: dispatch.thread?.id, channel: dispatch.channel }));
7589
8126
  return cursor !== null && cursor >= item.newestMessage;
7590
8127
  }
7591
8128
  async dispatchReady(now) {
7592
8129
  this.steerRunning(now);
7593
- const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
8130
+ const ready = [...this.pending.entries()].filter(([key, item]) => item.readyAt <= now && !this.running.has(key) && !this.landingIn(item.dispatch)).toSorted(([, a], [, b]) => b.dispatch.priority - a.dispatch.priority || a.readyAt - b.readyAt);
7594
8131
  for (const [key, item] of ready) {
7595
8132
  if (this.running.size >= this.concurrency) {
7596
8133
  break;
7597
8134
  }
8135
+ if (this.heldBack(key, item.dispatch)) {
8136
+ continue;
8137
+ }
7598
8138
  this.pending.delete(key);
7599
8139
  if (await this.alreadyRead(item)) {
7600
8140
  this.log.info({ agent: item.dispatch.agent, project: item.dispatch.project }, "dropping message wake, its conversation has read the message");
@@ -7618,6 +8158,7 @@ var Scheduler = class {
7618
8158
  agent: dispatch.agent,
7619
8159
  project: dispatch.project,
7620
8160
  thread: dispatch.thread?.id,
8161
+ channel: dispatch.channel,
7621
8162
  trigger: dispatch.trigger.kind
7622
8163
  }, "dispatching turn");
7623
8164
  const promise = (async () => {
@@ -7633,19 +8174,45 @@ var Scheduler = class {
7633
8174
  this.log.error({ agent: dispatch.agent, project: dispatch.project, error: String(error) }, "turn crashed");
7634
8175
  } finally {
7635
8176
  this.running.delete(key);
8177
+ this.exclusive.delete(key);
7636
8178
  }
7637
8179
  })();
7638
8180
  this.running.set(key, promise);
8181
+ if (dispatch.onboarding) {
8182
+ this.exclusive.set(key, "onboarding");
8183
+ } else if (dispatch.trigger.kind === "reflection") {
8184
+ this.exclusive.set(key, "reflection");
8185
+ }
8186
+ }
8187
+ }
8188
+ /**
8189
+ * Whether a ready turn waits for another of its citizen's turns that must run alone: its
8190
+ * onboarding in the project, queued or running, which comes before any other work of its there;
8191
+ * and a reflection, which consolidates the memory the citizen's other turns would be writing, so
8192
+ * it starts only once none of them runs and they wait while it does.
8193
+ */
8194
+ heldBack(key, dispatch) {
8195
+ const { agent, project } = dispatch;
8196
+ const home = sessionKey(agent, project);
8197
+ if (key !== home && (this.pending.get(home)?.dispatch.onboarding === true || this.exclusive.get(home) === "onboarding")) {
8198
+ return true;
8199
+ }
8200
+ const others = [...this.running.keys()].filter((other) => other !== key && parseSessionKey(other)?.agent === agent);
8201
+ if (dispatch.trigger.kind === "reflection") {
8202
+ return others.length > 0;
7639
8203
  }
8204
+ return others.some((other) => this.exclusive.get(other) === "reflection");
7640
8205
  }
7641
8206
  /** Starts each queued completion once the turn that finished the task's last stage has ended. */
7642
8207
  runCompletions() {
7643
8208
  for (const [taskId, item] of this.completions) {
7644
8209
  const home = sessionKey(item.actor, item.project);
7645
- if ([...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`))) {
8210
+ const busy = [...this.running.keys()].some((key) => key === home || key.startsWith(`${home}/`) || parseSessionKey(key)?.thread === taskId);
8211
+ if (busy) {
7646
8212
  continue;
7647
8213
  }
7648
8214
  this.completions.delete(taskId);
8215
+ this.landing.add(taskId);
7649
8216
  const promise = this.runner.completeTask(item.project, taskId).then((outcome) => {
7650
8217
  if (outcome === "deferred" && !this.completions.has(taskId)) {
7651
8218
  this.completions.set(taskId, item);
@@ -7655,6 +8222,7 @@ var Scheduler = class {
7655
8222
  this.log.error({ project: item.project, taskId, error: String(error) }, "completion failed");
7656
8223
  }).finally(() => {
7657
8224
  this.completing.delete(promise);
8225
+ this.landing.delete(taskId);
7658
8226
  });
7659
8227
  this.completing.add(promise);
7660
8228
  }
@@ -7666,16 +8234,17 @@ import { randomUUID } from "node:crypto";
7666
8234
 
7667
8235
  // packages/turn-host/dist/render.js
7668
8236
  var TURN_CONTRACT = [
7669
- "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its channels and for work tied to no thread, or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; at home, the channel messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other threads' and other projects' work to their own turns.",
8237
+ "- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's or at home, that channel's or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
7670
8238
  "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
7671
- "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task. In a proposal's or a topic's conversation it is a worktree of its own, detached at your own branch's tip, which goes when the thread closes, so work that should last goes on a task's branch.",
8239
+ "- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
7672
8240
  "- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
7673
8241
  "- Silence is allowed. If the digest needs no reply, post nothing.",
7674
8242
  "- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
7675
- "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on its project's general channel: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
8243
+ "- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
7676
8244
  "- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; a durable fact about a project's codebase or process goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
7677
8245
  '- A procedure you have followed twice is a skill: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Propose it with kind "skill" when the whole society would use it.',
7678
8246
  "- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill, so a warm session restarts with the new instructions.",
8247
+ "- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
7679
8248
  "- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
7680
8249
  "- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
7681
8250
  ].join("\n");
@@ -7697,7 +8266,7 @@ var GOVERNANCE = [
7697
8266
  ' - archive: {"project", "reason"}: approval archives the project: its members leave, its open threads close, and nothing more is posted, filed, or joined there, while its files and history stay; refused while a task there is in play',
7698
8267
  ' - reallocation: {"description"}',
7699
8268
  ` - skill: {"name", "summary", "body"}: the SKILL.md text; approval publishes it under society/skills, where every citizen's skills index lists it`,
7700
- "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
8269
+ "- Projects and membership: a project is a lasting area of work, not one request; `create_project` opens one with its general channel (front desk and user), and `configure_project` sets its completion effect, its display name, or its default branch, never its slug (user, steward, concierge). A workstream within a project or the society, such as a release, gets a channel of its own: `create_channel` opens it, which its opener follows, and announces it in the place's general, where members subscribe when it concerns them, `create_task` with `channel` files the workstream's tasks there, and `archive_channel` ends it once none of its tasks is in play, leaving its history readable (user, steward, concierge; others propose a channel). A project whose work is finished or has moved to another project is archived by proposal, once no task there is in play. `join_project` and `leave_project` move yourself, or another citizen when you are the concierge, the steward, or the user; joining gives the pair a worktree and an onboarding turn. A society role, one that keeps watch over the whole society as the concierge and the steward do, is never in a project: it moves others, never itself.",
7701
8270
  "- Prefer scaling an existing role over inventing one; a new role is justified by work a project needs that no existing role covers. A role that needs a tool the board lacks is an engineering task, not a hiring request.",
7702
8271
  "- Operations signals are counters and timers the board logs, listed in the prompt of a role that reads them: stages waiting for a holder, backlog per role, stages waiting on a role nobody fills, churn, stale threads, idle members, missing capabilities, replicas added, spend. They are not posts in any channel; interpreting them is your judgment."
7703
8272
  ].join("\n");
@@ -7708,6 +8277,9 @@ function renderOnboardingPreamble(context) {
7708
8277
  `Your role in one paragraph: ${context.roleSummary}`,
7709
8278
  "",
7710
8279
  `You are working on project "${context.project}" in the worktree at ${context.worktree}.`,
8280
+ ...(context.otherChannels ?? []).length === 0 ? [] : [
8281
+ `You follow its general channel. It also has ${(context.otherChannels ?? []).map((ref) => `#${ref}`).join(", ")}, each a workstream such as a release: subscribe to one when your work there needs its posts. Its tasks and mentions reach you either way.`
8282
+ ],
7711
8283
  "Your memory is empty. Write durable lessons about yourself, your craft, or the user to memory/core.md in your home directory.",
7712
8284
  "Write profile.md in your home directory: one paragraph on what you do well and what to send your way. The roster the front desk routes with is built from it.",
7713
8285
  "Read the project's instructions file, if it has one, before doing anything else.",
@@ -7733,7 +8305,7 @@ function renderInstructions(input) {
7733
8305
  if (norms.length > 0) {
7734
8306
  sections.push("", "## Society norms", "", norms);
7735
8307
  }
7736
- sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's conversation there works in a folder of its own inside it, which goes when the thread closes: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
8308
+ sections.push("", "## Core memory", "", memory.length === 0 ? "(empty)" : memory, "", "## Skills", "", ...renderSkillsIndex(input.skills ?? []), "", "## Where things are", "", `- Your home directory: ${input.homeDir}. memory/core.md is loaded every turn; memory/<topic>.md is your archive; skills/<name>/SKILL.md are your skills; projects/<slug>/notes.md are your notes. You may read and write all of it, and the board's search covers your archive and skills for you alone. The runner keeps it in git and shares it with the board after every turn, so whatever machine your next turn runs on has it; leave git there to the runner. Files over ${HOME_FILE_LIMIT_BYTES / (1024 * 1024)} MB are not kept.`, `- ${HOME_SCRATCH}/ in your home is your working folder in turns outside any project, and a thread's or a channel's conversation there works in a folder of its own inside it, which goes when the thread closes or the channel is archived: none of it is kept or shared, so put downloads, environments, and other working files there, and keep the rest of your home for what you mean to remember.`, `- The board projection: ${input.boardDir} (read-only markdown: society and project channels, tasks, threads; shared knowledge under projects/<slug>/knowledge/ and society/knowledge/; the society's skills under society/skills/). Search it with your file tools; act through the board tools.`, "", "## Turn contract", "", TURN_CONTRACT, "", "## Planning", "", PLANNING, "", "## Governance", "", GOVERNANCE);
7737
8309
  if (input.onboarding !== void 0) {
7738
8310
  sections.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
7739
8311
  }
@@ -7870,8 +8442,11 @@ function buildTurnPrompt(input) {
7870
8442
  }
7871
8443
  const conversation = input.conversation ?? null;
7872
8444
  lines.push("", "## This conversation", "");
7873
- if (conversation === null) {
7874
- lines.push(`This turn is in your home conversation for ${dispatch.project}: its channels, and work tied to no thread. Each thread you take part in, a task's, a proposal's, or a topic's, is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
8445
+ if (conversation === null && dispatch.channel !== void 0) {
8446
+ const ref = channelRef(dispatch.project === SOCIETY_SCOPE ? null : dispatch.project, dispatch.channel);
8447
+ lines.push(`This turn is in your conversation of ${ref}, a workstream with turns of its own: its posts, and the work it asks for. Your home conversation for ${dispatch.project} carries its general channel, and each other channel and each thread you take part in is a conversation of its own; read one when you need it, and leave its work to its turn. Post here with post_message and channel ${ref}.`);
8448
+ } else if (conversation === null) {
8449
+ lines.push(`This turn is in your home conversation for ${dispatch.project}: its general channel, and work tied to no other channel and no thread. Each other channel, a workstream, and each thread you take part in, a task's, a proposal's, or a topic's, is a conversation of its own with turns of its own; read one when you need it, and leave its work to its turn.`);
7875
8450
  } else {
7876
8451
  const { thread, task } = conversation;
7877
8452
  const about = thread.subject === void 0 ? "a topic" : `${thread.subject.kind} ${thread.subject.id}`;
@@ -7883,6 +8458,11 @@ function buildTurnPrompt(input) {
7883
8458
  }
7884
8459
  }
7885
8460
  }
8461
+ const ending = input.ending ?? null;
8462
+ if (ending !== null) {
8463
+ lines.push("", "## This conversation has ended", "", `${capitalized(ending.how)}. You are woken once more because your workspace here still holds work on no branch, listed under Your workspace, and it is removed when this turn ends, whatever it still holds. You may not be the one who ended the conversation, and the work may not be yours to finish, so decide for each piece:`, "- It is yours and it matters: land it through a task. File one, or use one you hold, switch this worktree to its branch task/<id> (git switch -c task/<id> for a task filed in this turn), and commit there.", `- It matters but is someone else's to finish: put it on a task's branch the same way, then hand that task to whoever should own it and ask them in ${ending.askIn} with a mention, since this conversation takes no more posts.`, "- It is disposable: leave it, and it goes with the workspace.");
8464
+ }
8465
+ lines.push("", WORKSPACE_TOKEN);
7886
8466
  const conflicts = input.conflicts ?? [];
7887
8467
  if (dispatch.trigger.kind === "reflection") {
7888
8468
  lines.push("", "## Reflection", "", REFLECTION_INTRO, ...conflicts.length > 0 ? [RECONCILE] : [], ...REFLECTION_STEPS);
@@ -7899,7 +8479,7 @@ function buildTurnPrompt(input) {
7899
8479
  }
7900
8480
  const society = input.societyView;
7901
8481
  if (society !== void 0 && society !== null) {
7902
- lines.push("", "## The society", "", "### Projects", "");
8482
+ lines.push("", "## The society", "", `Each ask is a conversation of its own, and others may be in a turn at the same time. Before proposing a citizen, a role, or a project, look at the proposals still waiting under ${PATH_TOKENS.board}/society/proposals/: another ask may have proposed it already.`, "", "### Projects", "");
7903
8483
  if (society.projects.length === 0) {
7904
8484
  lines.push("None yet.");
7905
8485
  }
@@ -7981,6 +8561,9 @@ function buildTurnPrompt(input) {
7981
8561
  return `${lines.join("\n")}
7982
8562
  `;
7983
8563
  }
8564
+ function capitalized(text) {
8565
+ return text.charAt(0).toUpperCase() + text.slice(1);
8566
+ }
7984
8567
 
7985
8568
  // packages/turn-host/dist/host.js
7986
8569
  var SILENT = { info() {
@@ -8036,11 +8619,14 @@ var TurnHost = class {
8036
8619
  const agent = await this.board.readAgent(dispatch.agent);
8037
8620
  const actor = { name: agent.name, role: agent.role };
8038
8621
  const thread = dispatch.thread;
8622
+ const channel = thread === void 0 ? dispatch.channel : void 0;
8623
+ const part = conversationPart({ thread: thread?.id, channel });
8039
8624
  const startedAt = this.now().toISOString();
8040
8625
  const base = {
8041
8626
  agent: agent.name,
8042
8627
  project: dispatch.project,
8043
8628
  ...thread === void 0 ? {} : { thread: thread.id },
8629
+ ...channel === void 0 ? {} : { channel },
8044
8630
  runner: runner.name,
8045
8631
  cli: agent.cli,
8046
8632
  session: null,
@@ -8065,23 +8651,29 @@ var TurnHost = class {
8065
8651
  const societyScope = dispatch.project === SOCIETY_SCOPE;
8066
8652
  const taskId = thread?.task === true ? thread.id : void 0;
8067
8653
  const project = societyScope ? null : await this.board.readProject(dispatch.project);
8068
- const workspace = await this.workspaceOf(project, thread);
8069
- const sessions = await this.board.readSessions(agent.name, dispatch.project, thread?.id);
8654
+ const workspace = await this.workspaceOf(project, thread, channel);
8655
+ const sessions = await this.board.readSessions(agent.name, dispatch.project, part);
8070
8656
  const recorded = sessions.runner === runner.name ? sessions[agent.cli] : void 0;
8071
8657
  const newSession = recorded === void 0;
8072
- const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, thread?.id);
8658
+ const lastTurn = await this.board.readLastTurn(agent.name, dispatch.project, part);
8073
8659
  const costSoFarUsd = !newSession && lastTurn?.session === recorded ? lastTurn.sessionCostUsd ?? lastTurn.costUsd : 0;
8074
- const digest = await this.board.readDigest({ ...actor, scope: dispatch.project, ...thread === void 0 ? {} : { thread: thread.id } }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8660
+ const digest = await this.board.readDigest({
8661
+ ...actor,
8662
+ scope: dispatch.project,
8663
+ ...thread === void 0 ? {} : { thread: thread.id },
8664
+ ...channel === void 0 ? {} : { channel }
8665
+ }, { advance: false, limit: thread === void 0 ? 50 : 100 });
8075
8666
  const threadSoFar = thread !== void 0 && newSession ? (await this.board.listThread(thread.id).catch(() => [])).slice(-100) : null;
8076
8667
  const messages = threadSoFar ?? digest.messages;
8077
8668
  const cursorAfter = threadSoFar?.at(-1)?.id ?? digest.cursor;
8078
8669
  const task = taskId === void 0 ? null : await this.board.findTask(taskId).then((found) => found.task).catch(() => null);
8079
8670
  const held = task === null ? [] : (await this.board.heldClaims(agent.name)).filter((each) => each.id === task.id);
8080
- const onboarding = dispatch.onboarding || newSession && thread === void 0 ? {
8671
+ const onboarding = dispatch.onboarding || newSession && part === void 0 ? {
8081
8672
  agentName: agent.name,
8082
8673
  roleSummary: charter.purpose,
8083
8674
  project: dispatch.project,
8084
- worktree: PATH_TOKENS.worktree
8675
+ worktree: PATH_TOKENS.worktree,
8676
+ otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
8085
8677
  } : null;
8086
8678
  const instructions = renderInstructions({
8087
8679
  agentName: agent.name,
@@ -8117,7 +8709,7 @@ var TurnHost = class {
8117
8709
  dir: `${PATH_TOKENS.board}/projects/${dispatch.project}/knowledge`,
8118
8710
  topics: await this.board.listKnowledge(dispatch.project)
8119
8711
  };
8120
- const signals = thread === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? (await this.board.listSignals(500)).filter((record2) => wakeScope(agent, record2.signal.project ?? null) === dispatch.project && (lastTurn === null || record2.ts > lastTurn.startedAt)).slice(-MAX_SIGNALS) : null;
8712
+ const signals = part === void 0 && charter.wakeTriggers.includes(OPS_TRIGGER2) ? (await this.board.listSignals(500)).filter((record2) => wakeScope(agent, record2.signal.project ?? null) === dispatch.project && (lastTurn === null || record2.ts > lastTurn.startedAt)).slice(-MAX_SIGNALS) : null;
8121
8713
  const waiting = task !== null && task.status === "open" && !task.completing && mayHoldStage(actor, task) ? [task] : [];
8122
8714
  const threads = /* @__PURE__ */ new Map();
8123
8715
  for (const id of new Set(messages.flatMap((message) => message.thread ?? []))) {
@@ -8128,11 +8720,13 @@ var TurnHost = class {
8128
8720
  }
8129
8721
  const threadRecord = thread === void 0 ? void 0 : threads.get(thread.id) ?? await this.board.readThread(thread.id).catch(() => void 0);
8130
8722
  const conversation = threadRecord === void 0 ? null : { thread: threadRecord, task, fresh: threadSoFar !== null };
8723
+ const ending = dispatch.trigger.kind === "closing" ? await this.endingOf(dispatch.project, threadRecord, task, channel) : null;
8131
8724
  const prompt = buildTurnPrompt({
8132
8725
  dispatch,
8133
8726
  messages,
8134
8727
  threads,
8135
8728
  conversation,
8729
+ ending,
8136
8730
  heldClaims: held,
8137
8731
  waitingStages: waiting,
8138
8732
  project,
@@ -8144,9 +8738,9 @@ var TurnHost = class {
8144
8738
  signals,
8145
8739
  conflicts: await this.board.listHomeConflicts(agent.name)
8146
8740
  });
8147
- const resident = charter.resident && thread === void 0 && runner.residentClis.includes(agent.cli);
8741
+ const resident = charter.resident && part === void 0 && runner.residentClis.includes(agent.cli);
8148
8742
  const residentKey = resident ? sessionKey(agent.name, dispatch.project) : null;
8149
- const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project, thread?.id) : this.residentToken(runner, residentKey, actor, dispatch.project);
8743
+ const token = residentKey === null ? this.board.issueTurnToken(agent.name, agent.role, this.tokenLifetime(5 * 6e4), dispatch.project, part) : this.residentToken(runner, residentKey, actor, dispatch.project);
8150
8744
  const record = await this.board.beginTurn({
8151
8745
  ...base,
8152
8746
  session: recorded ?? null,
@@ -8164,6 +8758,7 @@ var TurnHost = class {
8164
8758
  ...agent.effort === void 0 ? {} : { effort: agent.effort },
8165
8759
  scope: dispatch.project,
8166
8760
  ...thread === void 0 ? {} : { thread: thread.id },
8761
+ ...channel === void 0 ? {} : { channel },
8167
8762
  session: recorded ?? null,
8168
8763
  costSoFarUsd,
8169
8764
  instructions,
@@ -8202,6 +8797,7 @@ var TurnHost = class {
8202
8797
  agent: agent.name,
8203
8798
  project: dispatch.project,
8204
8799
  thread: thread?.id,
8800
+ channel,
8205
8801
  runner: runner.name,
8206
8802
  session: recorded,
8207
8803
  newSession,
@@ -8239,6 +8835,7 @@ var TurnHost = class {
8239
8835
  agent: turn.job.agent,
8240
8836
  project: turn.job.scope,
8241
8837
  thread: turn.job.thread,
8838
+ channel: turn.job.channel,
8242
8839
  messages: sent.messages
8243
8840
  }).catch((error) => {
8244
8841
  this.log.warn({ turnId, error: String(error) }, "could not record a steer");
@@ -8246,19 +8843,31 @@ var TurnHost = class {
8246
8843
  }
8247
8844
  }
8248
8845
  turn.transcript.push(entry);
8249
- this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, turn.job.thread);
8846
+ this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, {
8847
+ thread: turn.job.thread,
8848
+ channel: turn.job.channel
8849
+ });
8250
8850
  }
8251
8851
  }
8252
8852
  /** The turn in flight in a conversation, if any. */
8253
- turnIn(agent, scope, thread) {
8853
+ turnIn(agent, scope, conversation = {}) {
8254
8854
  for (const turn of this.open.values()) {
8255
8855
  const { job } = turn;
8256
- if (job.agent === agent && job.scope === scope && job.thread === thread) {
8856
+ if (job.agent === agent && job.scope === scope && job.thread === conversation.thread && job.channel === conversation.channel) {
8257
8857
  return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
8258
8858
  }
8259
8859
  }
8260
8860
  return null;
8261
8861
  }
8862
+ /** The turns in flight and their conversations, which the board checks before ending one. */
8863
+ inFlight() {
8864
+ return [...this.open.values()].map(({ job }) => ({
8865
+ agent: job.agent,
8866
+ scope: job.scope,
8867
+ ...job.thread === void 0 ? {} : { thread: job.thread },
8868
+ ...job.channel === void 0 ? {} : { channel: job.channel }
8869
+ }));
8870
+ }
8262
8871
  /** The turns in flight, for the interface: their conversations and where a mention reaches them. */
8263
8872
  runningTurns() {
8264
8873
  return [...this.open.values()].map((turn) => ({
@@ -8266,8 +8875,9 @@ var TurnHost = class {
8266
8875
  agent: turn.job.agent,
8267
8876
  scope: turn.job.scope,
8268
8877
  ...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
8878
+ ...turn.job.channel === void 0 ? {} : { channel: turn.job.channel },
8269
8879
  cli: turn.job.cli,
8270
- ...turn.record.trigger.channel === void 0 ? {} : { channel: turn.record.trigger.channel },
8880
+ ...turn.record.trigger.channel === void 0 ? {} : { askedIn: turn.record.trigger.channel },
8271
8881
  runner: turn.runner
8272
8882
  }));
8273
8883
  }
@@ -8288,7 +8898,8 @@ var TurnHost = class {
8288
8898
  const digest = await this.board.readDigest({
8289
8899
  ...turn.actor,
8290
8900
  scope: job.scope,
8291
- ...job.thread === void 0 ? {} : { thread: job.thread }
8901
+ ...job.thread === void 0 ? {} : { thread: job.thread },
8902
+ ...job.channel === void 0 ? {} : { channel: job.channel }
8292
8903
  }, {
8293
8904
  ...turn.delivered === null ? {} : { since_cursor: turn.delivered },
8294
8905
  advance: false,
@@ -8348,6 +8959,7 @@ var TurnHost = class {
8348
8959
  this.board.revokeTurnToken(turn.coldToken);
8349
8960
  }
8350
8961
  const { job } = turn;
8962
+ const part = conversationPart(job);
8351
8963
  const finished = {
8352
8964
  ...turn.record,
8353
8965
  session: outcome.session,
@@ -8363,29 +8975,52 @@ var TurnHost = class {
8363
8975
  ...outcome.work === null ? {} : { work: outcome.work },
8364
8976
  ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
8365
8977
  };
8978
+ const ended = await this.conversationEnded(job);
8979
+ const closing = turn.record.trigger.kind === "closing";
8980
+ const decided = closing && outcome.exitReason !== "error" && outcome.exitReason !== "timeout";
8366
8981
  const recorded = await this.recorded(turn, async () => {
8367
8982
  if (outcome.session !== job.session) {
8368
- await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, job.thread);
8983
+ await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
8369
8984
  }
8370
8985
  if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
8371
- await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, job.thread);
8986
+ await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
8372
8987
  await this.renewLeases(turn.actor, turn.held);
8373
8988
  if (outcome.status?.needsUserDecision === true) {
8374
- await this.askUser(turn.actor, job.scope, job.thread, turn.record.startedAt, outcome.status.summary);
8989
+ await this.askUser(turn.actor, job, turn.record.startedAt, outcome.status.summary);
8375
8990
  }
8376
8991
  }
8377
8992
  await this.board.finishTurn(finished, turn.transcript);
8993
+ const conversation = ended ? workspaceConversationOf(job) : null;
8994
+ if (conversation !== null && (closing ? !decided : outcome.leftovers)) {
8995
+ const again = await this.board.recordLeftovers({
8996
+ agent: job.agent,
8997
+ scope: job.scope,
8998
+ conversation
8999
+ });
9000
+ if (!again) {
9001
+ const where = conversation.kind === "thread" ? `thread ${conversation.id}` : `channel ${conversation.name}`;
9002
+ await this.board.publishSignal({
9003
+ kind: "stuck_workspace",
9004
+ key: `stuck_workspace:${job.agent}:${job.scope}:${conversationPart(job) ?? ""}`,
9005
+ summary: `${job.agent}'s workspace in ${where} still holds work on no branch after ${CLOSING_ATTEMPTS} closing turns failed, so it stays on runner ${turn.runner}`,
9006
+ value: CLOSING_ATTEMPTS,
9007
+ agent: job.agent,
9008
+ ...job.scope === SOCIETY_SCOPE ? {} : { project: job.scope }
9009
+ });
9010
+ }
9011
+ }
8378
9012
  });
8379
9013
  if (recorded) {
8380
9014
  this.log.info({
8381
9015
  agent: job.agent,
8382
9016
  project: job.scope,
8383
9017
  thread: job.thread,
9018
+ channel: job.channel,
8384
9019
  exitReason: finished.exitReason
8385
9020
  }, "turn finished");
8386
9021
  turn.done(finished);
8387
9022
  }
8388
- return { dropWorkspace: await this.conversationEnded(job) };
9023
+ return { dropWorkspace: ended && (closing ? decided : !outcome.leftovers) };
8389
9024
  }
8390
9025
  /** Ends a turn whose outcome will never come, as when its runner went away, as failed. */
8391
9026
  async abortTurn(turnId, reason) {
@@ -8433,6 +9068,7 @@ var TurnHost = class {
8433
9068
  agent: dispatch.agent,
8434
9069
  project: dispatch.project,
8435
9070
  ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id },
9071
+ ...dispatch.thread !== void 0 || dispatch.channel === void 0 ? {} : { channel: dispatch.channel },
8436
9072
  runner,
8437
9073
  cli: agent?.cli ?? null,
8438
9074
  session: null,
@@ -8477,9 +9113,12 @@ var TurnHost = class {
8477
9113
  this.residentTokens.set(slot, token);
8478
9114
  return token;
8479
9115
  }
8480
- /** Where a conversation works: a task's on its branch, any other thread's in a place of its own. */
8481
- async workspaceOf(project, thread) {
8482
- const own = thread === void 0 || thread.task ? {} : { thread: thread.id };
9116
+ /**
9117
+ * Where a conversation works: a task's on its branch, any other thread's and a channel's in a
9118
+ * place of its own, and the home in the citizen's own.
9119
+ */
9120
+ async workspaceOf(project, thread, channel) {
9121
+ const own = thread !== void 0 ? thread.task ? {} : { thread: thread.id } : channel === void 0 ? {} : { channel };
8483
9122
  if (project === null) {
8484
9123
  return { kind: "home", ...own };
8485
9124
  }
@@ -8494,31 +9133,50 @@ var TurnHost = class {
8494
9133
  const branches = (await this.board.listTasks(project.slug)).filter((task) => task.status === "open" || task.status === "claimed").map((task) => `task/${task.id}`);
8495
9134
  return { kind: "project", repo, branches, ...own };
8496
9135
  }
8497
- /** Whether a thread conversation's task or thread has ended, which frees its workspace. */
9136
+ /**
9137
+ * Whether a thread conversation's task or thread has ended, or a channel conversation's channel
9138
+ * was archived, which frees its workspace.
9139
+ */
8498
9140
  async conversationEnded(job) {
8499
- if (job.thread === void 0) {
8500
- return false;
9141
+ const conversation = workspaceConversationOf(job);
9142
+ return conversation !== null && await this.board.conversationEnded(conversation);
9143
+ }
9144
+ /**
9145
+ * How a closing turn's conversation ended, and where its citizen asks someone else to take over
9146
+ * work that is not its own: the thread's channel while it is open, else the place's general.
9147
+ */
9148
+ async endingOf(scope, thread, task, channel) {
9149
+ const place = scope === SOCIETY_SCOPE ? null : scope;
9150
+ if (thread !== void 0) {
9151
+ const closed = `the thread "${thread.title}" on ${thread.channel} was closed${thread.closedBy === void 0 ? "" : ` by ${thread.closedBy}`}${task === null ? "" : `, its task ${task.status}`}`;
9152
+ const open = await this.board.channelOpen(thread.channel);
9153
+ return {
9154
+ how: closed,
9155
+ askIn: open ? thread.channel : channelRef(parseChannelRef(thread.channel).project, "general")
9156
+ };
8501
9157
  }
8502
- try {
8503
- if (job.workspace.kind === "task") {
8504
- const { task } = await this.board.findTask(job.thread);
8505
- return task.status === "done" || task.status === "abandoned";
8506
- }
8507
- return (await this.board.readThread(job.thread)).state === "closed";
8508
- } catch {
8509
- return false;
9158
+ if (channel === void 0) {
9159
+ return null;
8510
9160
  }
9161
+ const ref = channelRef(place, channel);
9162
+ const archived = (await this.board.listChannels()).find((each) => each.ref === ref)?.archived;
9163
+ return {
9164
+ how: archived === void 0 ? `${ref} was archived` : `${ref} was archived by ${archived.by}: ${archived.reason}`,
9165
+ askIn: channelRef(place, "general")
9166
+ };
8511
9167
  }
8512
9168
  /**
8513
9169
  * A turn that reported the user must decide asks where the question belongs, with a mention of
8514
9170
  * the user, which is what puts it before them. One that mentioned the user nowhere asks in its
8515
9171
  * own thread when it was in one that is still open, so the answer comes back to the same
8516
- * conversation, and otherwise in a thread of its own on its scope's general channel, opened as the
8517
- * citizen with its summary. One that proposed something the user decides has put its decision
8518
- * before the user already, where deciding it clears it; a mention would stay until the user
8519
- * posted beside it.
9172
+ * conversation, and otherwise in a thread of its own on its conversation's channel, or its scope's
9173
+ * general for the home, opened as the citizen with its summary. One that proposed something the
9174
+ * user decides has put its decision before the user already, where deciding it clears it; a
9175
+ * mention would stay until the user posted beside it.
8520
9176
  */
8521
- async askUser(actor, scope, threadId, since, summary) {
9177
+ async askUser(actor, job, since, summary) {
9178
+ const threadId = job.thread;
9179
+ const place = job.scope === SOCIETY_SCOPE ? null : job.scope;
8522
9180
  try {
8523
9181
  if (await this.board.hasMentioned(actor.name, USER_NAME, since)) {
8524
9182
  return;
@@ -8536,7 +9194,7 @@ var TurnHost = class {
8536
9194
  return;
8537
9195
  }
8538
9196
  const thread = await this.board.openThread(actor, {
8539
- channel: scope === SOCIETY_SCOPE ? "general" : `${scope}/general`,
9197
+ channel: channelRef(place, job.channel ?? "general"),
8540
9198
  title: `${actor.name} asks for a decision: ${summary}`.slice(0, 200)
8541
9199
  });
8542
9200
  await this.board.postMessage(actor, {
@@ -8587,6 +9245,7 @@ var DEFAULT_GRACE_MS = 2 * 6e4;
8587
9245
  var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
8588
9246
  var CONTROL_TIMEOUT_MS = 15e3;
8589
9247
  var FILE_TIMEOUT_MS = 6e4;
9248
+ var SWEEP_TIMEOUT_MS = 2 * 6e4;
8590
9249
  var RunnerHub = class {
8591
9250
  board;
8592
9251
  host;
@@ -8722,6 +9381,25 @@ var RunnerHub = class {
8722
9381
  pending.reject(new Error(answer.error));
8723
9382
  }
8724
9383
  }
9384
+ /**
9385
+ * The workspaces a runner holds, reported when its stream opens: those of conversations that
9386
+ * ended while it was away are swept there now, as a sweep at their end would have.
9387
+ */
9388
+ heldWorkspaces(runner, workspaces) {
9389
+ const seat = this.seats.get(runner);
9390
+ if (seat === void 0) {
9391
+ return;
9392
+ }
9393
+ const conversations = /* @__PURE__ */ new Map();
9394
+ for (const { conversation } of workspaces) {
9395
+ conversations.set(JSON.stringify(conversation), conversation);
9396
+ }
9397
+ for (const conversation of conversations.values()) {
9398
+ void this.board.conversationEnded(conversation).then((ended) => ended ? this.sweepOn(seat, conversation) : void 0).catch((error) => {
9399
+ this.log.warn({ runner, conversation, error: String(error) }, "could not sweep");
9400
+ });
9401
+ }
9402
+ }
8725
9403
  warmChanged(runner, warm) {
8726
9404
  const seat = this.seats.get(runner);
8727
9405
  if (seat !== void 0) {
@@ -8771,12 +9449,25 @@ var RunnerHub = class {
8771
9449
  // -------------------------------------------------------------------------------------------
8772
9450
  // The scheduler's runner
8773
9451
  // -------------------------------------------------------------------------------------------
9452
+ /**
9453
+ * A conversation ended: every connected runner removes its citizens' workspaces for it, and
9454
+ * those kept for holding work on no branch are recorded, which gives their citizens a closing
9455
+ * turn. A runner away now sweeps when it reports its workspaces on reconnecting.
9456
+ */
9457
+ async sweep(conversation) {
9458
+ await Promise.all([...this.seats.values()].filter((seat) => seat.send !== null).map((seat) => this.sweepOn(seat, conversation).catch((error) => {
9459
+ this.log.warn({ runner: seat.name, conversation, error: String(error) }, "could not sweep");
9460
+ })));
9461
+ }
8774
9462
  /**
8775
9463
  * Delivers what arrived in a conversation into its turn in flight, when that turn's runner can
8776
9464
  * steer its CLI. `refused` leaves the wake to a turn of its own once this one ends.
8777
9465
  */
8778
9466
  async steer(conversation) {
8779
- const turn = this.host.turnIn(conversation.agent, conversation.project, conversation.thread);
9467
+ const turn = this.host.turnIn(conversation.agent, conversation.project, {
9468
+ thread: conversation.thread,
9469
+ channel: conversation.channel
9470
+ });
8780
9471
  const seat = turn === null ? void 0 : this.seats.get(turn.runner);
8781
9472
  if (turn === null || seat === void 0 || seat.send === null) {
8782
9473
  return "refused";
@@ -9081,6 +9772,12 @@ var RunnerHub = class {
9081
9772
  throw new RunnerProtocolError(`turn ${turnId} is not ${runner}'s`);
9082
9773
  }
9083
9774
  }
9775
+ async sweepOn(seat, conversation) {
9776
+ const { kept } = SweepResultSchema.parse(await this.request(seat, (request) => ({ type: "sweep", request, conversation }), SWEEP_TIMEOUT_MS));
9777
+ for (const workspace of kept) {
9778
+ await this.board.recordLeftovers(workspace);
9779
+ }
9780
+ }
9084
9781
  request(seat, message, timeoutMs = this.requestTimeoutMs) {
9085
9782
  const request = randomUUID2();
9086
9783
  return new Promise((resolve, reject) => {
@@ -9536,6 +10233,10 @@ function runnerRoutes(board2, hub, enrollments, enrollLimit) {
9536
10233
  hub.answer(c.get("runner"), c.req.param("id"), RunnerAnswerSchema.parse(await c.req.json()));
9537
10234
  return c.json({ ok: true });
9538
10235
  });
10236
+ routes.post("/workspaces", async (c) => {
10237
+ hub.heldWorkspaces(c.get("runner"), HeldWorkspacesSchema.parse(await c.req.json()).workspaces);
10238
+ return c.json({ ok: true });
10239
+ });
9539
10240
  routes.post("/warm", async (c) => {
9540
10241
  hub.warmChanged(c.get("runner"), WarmSessionsSchema.parse(await c.req.json()).warm);
9541
10242
  return c.json({ ok: true });
@@ -10132,15 +10833,16 @@ var TurnHub = class {
10132
10833
  get lastSeq() {
10133
10834
  return this.seq;
10134
10835
  }
10135
- /** Records one event of a turn, in its scope and, for a thread's conversation, its thread. */
10136
- push(agent, project, event, thread) {
10836
+ /** Records one event of a turn, in its scope and its conversation there: a thread's, a channel's, or home. */
10837
+ push(agent, project, event, conversation = {}) {
10137
10838
  this.seq += 1;
10138
10839
  const item = {
10139
10840
  seq: this.seq,
10140
10841
  ts: this.now().toISOString(),
10141
10842
  agent,
10142
10843
  project,
10143
- ...thread === void 0 ? {} : { thread },
10844
+ ...conversation.thread === void 0 ? {} : { thread: conversation.thread },
10845
+ ...conversation.channel === void 0 ? {} : { channel: conversation.channel },
10144
10846
  event
10145
10847
  };
10146
10848
  this.buffer.push(item);
@@ -10192,12 +10894,13 @@ var host = new TurnHost({
10192
10894
  maxTurns: config.toolRounds,
10193
10895
  residentIdleMs: config.residentIdleMs,
10194
10896
  log,
10195
- onEvent: (agent, scope, event, thread) => {
10196
- turns.push(agent, scope, event, thread);
10197
- log.debug({ agent, scope, thread, event }, "agent event");
10897
+ onEvent: (agent, scope, event, conversation) => {
10898
+ turns.push(agent, scope, event, conversation);
10899
+ log.debug({ agent, scope, ...conversation, event }, "agent event");
10198
10900
  }
10199
10901
  });
10200
10902
  var runners = new RunnerHub({ board, host, version: VERSION, log });
10903
+ board.watchTurns(() => host.inFlight());
10201
10904
  var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
10202
10905
  var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
10203
10906
  var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });