@kubitnodes/stellaris 0.3.0 → 0.3.2

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 (47) hide show
  1. package/dist/cli/index.js +122 -4
  2. package/dist/server/index.js +488 -30
  3. package/package.json +1 -1
  4. package/web/dist/assets/ChannelView-CX4cPMWM.js +1 -0
  5. package/web/dist/assets/CitizenView-8ZU2hK4D.js +2 -0
  6. package/web/dist/assets/{CitizensView-DB2TlGxn.js → CitizensView-BDenU7WD.js} +1 -1
  7. package/web/dist/assets/KnowledgeView-IUzeK83J.js +1 -0
  8. package/web/dist/assets/Markdown-DH59NuID.js +31 -0
  9. package/web/dist/assets/{MessageItem-BR30twQh.js → MessageItem-BWuFJqdk.js} +1 -1
  10. package/web/dist/assets/MetricsView-RzP1J0EG.js +1 -0
  11. package/web/dist/assets/NeedsYouView-DvnPW0tZ.js +1 -0
  12. package/web/dist/assets/Overview-BYcIErNt.js +1 -0
  13. package/web/dist/assets/{Pane-BXpuXst6.js → Pane-KNrdAxkc.js} +1 -1
  14. package/web/dist/assets/{ProjectView-CxUA5nFw.js → ProjectView-CmoGtfJ4.js} +1 -1
  15. package/web/dist/assets/ProposalView-FjvrD4Fz.js +1 -0
  16. package/web/dist/assets/{ProposalsView-BscIGUTT.js → ProposalsView-DSiFdLGD.js} +1 -1
  17. package/web/dist/assets/{RunnersView-BypVHvzI.js → RunnersView-DfduymeY.js} +1 -1
  18. package/web/dist/assets/{SocietyView-C403e1uR.js → SocietyView-BwfrqQiF.js} +1 -1
  19. package/web/dist/assets/SubjectThread-B-1_FwBN.js +1 -0
  20. package/web/dist/assets/TaskFileView-BNR8mQRk.js +1 -0
  21. package/web/dist/assets/TaskView-qdFvjqvJ.js +1 -0
  22. package/web/dist/assets/{TasksView-CA02-WIW.js → TasksView-QpcbsOzR.js} +1 -1
  23. package/web/dist/assets/ThreadCard-ClSg5-Lj.js +1 -0
  24. package/web/dist/assets/{ThreadForms-Dh9ky_aU.js → ThreadForms-B6Lv_t5p.js} +1 -1
  25. package/web/dist/assets/{ThreadView-CAdCu-y_.js → ThreadView-5PMRCHd4.js} +1 -1
  26. package/web/dist/assets/citizen-B9DadtKn.js +1 -0
  27. package/web/dist/assets/format-DbfHOiKW.js +55 -0
  28. package/web/dist/assets/index-2BnqwsfR.js +23 -0
  29. package/web/dist/assets/index-C3ujZpqY.css +2 -0
  30. package/web/dist/assets/{tasks-DZgkXWwu.js → tasks-Bw07dkQC.js} +1 -1
  31. package/web/dist/assets/{useStickyScroll-CVTdIoKj.js → useStickyScroll-CRgyaBkv.js} +1 -1
  32. package/web/dist/index.html +4 -4
  33. package/web/dist/assets/ChannelView-B4rv0TMD.js +0 -1
  34. package/web/dist/assets/CitizenView-DfBiP1Jw.js +0 -2
  35. package/web/dist/assets/KnowledgeView-l0I_R5Zd.js +0 -1
  36. package/web/dist/assets/Markdown-C6mhAmbQ.js +0 -29
  37. package/web/dist/assets/MetricsView-BZ0WW2Do.js +0 -1
  38. package/web/dist/assets/NeedsYouView-C6CZNnJn.js +0 -1
  39. package/web/dist/assets/Overview-D47FfDDO.js +0 -1
  40. package/web/dist/assets/ProposalView-C0oLBLQD.js +0 -1
  41. package/web/dist/assets/SubjectThread-irSHRJpB.js +0 -1
  42. package/web/dist/assets/TaskView-B-4WGLWK.js +0 -1
  43. package/web/dist/assets/ThreadCard-C4rb-53v.js +0 -1
  44. package/web/dist/assets/citizen-BWGATcew.js +0 -1
  45. package/web/dist/assets/format-DZLzLk7S.js +0 -55
  46. package/web/dist/assets/index-D-wuKd9C.js +0 -21
  47. package/web/dist/assets/index-HwY3AK-1.css +0 -2
@@ -703,6 +703,8 @@ var BOARD_EVENT_TYPES = [
703
703
  "paused.changed",
704
704
  "wake.requested",
705
705
  "turn.started",
706
+ "turn.steered",
707
+ "turn.stopped",
706
708
  "turn.completed",
707
709
  "turn.failed",
708
710
  "merge.completed",
@@ -734,7 +736,8 @@ var TurnExitReasonSchema = z5.enum([
734
736
  "timeout",
735
737
  "error",
736
738
  "interrupted",
737
- "blocked"
739
+ "blocked",
740
+ "stopped"
738
741
  ]);
739
742
  var AgentEventSchema = z5.discriminatedUnion("type", [
740
743
  z5.object({
@@ -745,6 +748,16 @@ var AgentEventSchema = z5.discriminatedUnion("type", [
745
748
  /** The model the CLI reports running, when it reports one. */
746
749
  model: z5.string().optional()
747
750
  }),
751
+ /**
752
+ * The CLI took a steer into the running turn. Adapters name the steer; the server adds the
753
+ * messages it delivered and the text the CLI was given.
754
+ */
755
+ z5.object({
756
+ type: z5.literal("steered"),
757
+ steer: z5.string().min(1),
758
+ messages: z5.array(UlidSchema).optional(),
759
+ text: z5.string().optional()
760
+ }),
748
761
  z5.object({ type: z5.literal("text"), delta: z5.string() }),
749
762
  z5.object({ type: z5.literal("tool_call"), name: z5.string(), input: z5.unknown() }),
750
763
  z5.object({
@@ -799,6 +812,12 @@ var RunnerHelloSchema = z6.object({
799
812
  clis: z6.array(CliKindSchema),
800
813
  /** The CLIs whose adapter can keep a session warm between turns. */
801
814
  residentClis: z6.array(CliKindSchema).default([]),
815
+ /** The CLIs whose adapter can take input into a running turn; the server steers only these. */
816
+ steerableClis: z6.array(CliKindSchema).default([]),
817
+ /** The CLIs whose adapter can stop a running turn; the server stops only these. */
818
+ stoppableClis: z6.array(CliKindSchema).default([]),
819
+ /** Whether the runner answers file reads on its projects' branches; one from before never does. */
820
+ branchReads: z6.boolean().default(false),
802
821
  capabilities: z6.array(z6.string()).default([]),
803
822
  /** Turns the runner runs at once, or null for no limit. */
804
823
  slots: z6.number().int().positive().nullable(),
@@ -902,10 +921,54 @@ var LandRequestSchema = z6.object({
902
921
  });
903
922
  var MergeOutcomeSchema = z6.object({ ok: z6.boolean(), detail: z6.string() });
904
923
  var ModelListSchema = z6.array(ModelOptionSchema);
924
+ var BRANCH_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
925
+ var BranchPathSchema = z6.string().max(4096).refine((value) => value === "" || value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".." && !segment.includes("\0")), { message: "a path inside the branch, with no empty, `.`, or `..` segment" });
926
+ var BranchNameSchema = z6.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/);
927
+ var BranchReadSchema = z6.object({
928
+ repo: ProjectRepoSchema,
929
+ branch: BranchNameSchema,
930
+ path: BranchPathSchema
931
+ });
932
+ var BranchCommitSchema = z6.object({
933
+ id: z6.string().min(1),
934
+ at: IsoDateTimeSchema,
935
+ author: z6.string()
936
+ });
937
+ var BranchFileSchema = z6.discriminatedUnion("kind", [
938
+ z6.object({
939
+ kind: z6.literal("file"),
940
+ path: BranchPathSchema,
941
+ commit: BranchCommitSchema,
942
+ size: z6.number().int().nonnegative(),
943
+ content: z6.string().nullable()
944
+ }),
945
+ z6.object({
946
+ kind: z6.literal("dir"),
947
+ path: BranchPathSchema,
948
+ commit: BranchCommitSchema,
949
+ entries: z6.array(z6.object({
950
+ name: z6.string().min(1),
951
+ kind: z6.enum(["file", "dir"]),
952
+ size: z6.number().int().nonnegative().nullable()
953
+ }))
954
+ })
955
+ ]);
956
+ var TurnSteerSchema = z6.object({ id: z6.uuid(), text: z6.string().min(1) });
905
957
  var RunnerMessageSchema = z6.discriminatedUnion("type", [
906
958
  z6.object({ type: z6.literal("turn"), job: TurnJobSchema }),
907
959
  z6.object({ type: z6.literal("land"), request: z6.string().min(1), land: LandRequestSchema }),
908
- z6.object({ type: z6.literal("models"), request: z6.string().min(1), cli: CliKindSchema })
960
+ z6.object({ type: z6.literal("models"), request: z6.string().min(1), cli: CliKindSchema }),
961
+ /** Answered with a `BranchFile`, or null when the branch or the path does not exist. */
962
+ z6.object({ type: z6.literal("file"), request: z6.string().min(1), read: BranchReadSchema }),
963
+ /** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
964
+ z6.object({
965
+ type: z6.literal("steer"),
966
+ request: z6.string().min(1),
967
+ turnId: UlidSchema,
968
+ steer: TurnSteerSchema
969
+ }),
970
+ /** Answered with whether the turn was there to stop; it ends `stopped`. */
971
+ z6.object({ type: z6.literal("stop"), request: z6.string().min(1), turnId: UlidSchema })
909
972
  ]);
910
973
  var RunnerAnswerSchema = z6.discriminatedUnion("ok", [
911
974
  z6.object({ ok: z6.literal(true), value: z6.unknown() }),
@@ -984,7 +1047,19 @@ var TurnRecordSchema = z7.object({
984
1047
  /** The model the CLI reported for this turn, else the model configured for the agent, else null. */
985
1048
  model: z7.string().nullable().default(null),
986
1049
  /** The task branch the turn left work on, with its commit, as the runner reported it. */
987
- work: TurnWorkSchema.optional()
1050
+ work: TurnWorkSchema.optional(),
1051
+ /** Who stopped the turn, for one that ended `stopped`. */
1052
+ stoppedBy: NameSchema.optional()
1053
+ });
1054
+ var RunningTurnSchema = z7.object({
1055
+ turnId: UlidSchema,
1056
+ agent: NameSchema,
1057
+ scope: NameSchema,
1058
+ thread: UlidSchema.optional(),
1059
+ cli: CliKindSchema,
1060
+ channel: ChannelRefSchema.optional(),
1061
+ steerable: z7.boolean(),
1062
+ stoppable: z7.boolean()
988
1063
  });
989
1064
  var SessionsFileSchema = z7.object({
990
1065
  claude: z7.string().optional(),
@@ -1240,7 +1315,7 @@ var RunnerConfigSchema = z10.object({
1240
1315
  });
1241
1316
 
1242
1317
  // packages/shared/dist/version.js
1243
- var STELLARIS_VERSION = "0.3.0";
1318
+ var STELLARIS_VERSION = "0.3.2";
1244
1319
 
1245
1320
  // packages/board-core/dist/errors.js
1246
1321
  var BoardError = class extends Error {
@@ -1787,10 +1862,12 @@ var TaskPayload = z11.object({
1787
1862
  from: z11.string().optional()
1788
1863
  });
1789
1864
  var MessagePayload = z11.object({
1865
+ id: z11.string().optional(),
1790
1866
  channel: z11.string(),
1791
1867
  thread: z11.string().nullable().optional(),
1792
1868
  mentions: z11.array(z11.string()).default([])
1793
1869
  });
1870
+ var SteeredPayload = z11.object({ messages: z11.array(z11.string()) });
1794
1871
  var SignalPayload = z11.object({
1795
1872
  kind: z11.string(),
1796
1873
  key: z11.string(),
@@ -1897,6 +1974,23 @@ function computeMetrics(input) {
1897
1974
  }
1898
1975
  break;
1899
1976
  }
1977
+ case "turn.steered": {
1978
+ const steered = SteeredPayload.safeParse(event.payload);
1979
+ if (!steered.success) {
1980
+ break;
1981
+ }
1982
+ const delivered = new Set(steered.data.messages);
1983
+ const at = Date.parse(event.ts);
1984
+ const answered = waiting.filter((mention) => mention.agent === event.actor && mention.message !== null && delivered.has(mention.message));
1985
+ if (answered.length > 0) {
1986
+ latencies.set(event.actor, [
1987
+ ...latencies.get(event.actor) ?? [],
1988
+ ...answered.map((mention) => at - mention.at)
1989
+ ]);
1990
+ waiting = waiting.filter((mention) => !answered.includes(mention));
1991
+ }
1992
+ break;
1993
+ }
1900
1994
  case "task.completed": {
1901
1995
  const task = TaskPayload.safeParse(event.payload);
1902
1996
  if (counted && task.success) {
@@ -1961,6 +2055,7 @@ function computeMetrics(input) {
1961
2055
  waiting.push({
1962
2056
  agent: name,
1963
2057
  at: Date.parse(event.ts),
2058
+ message: message.data.id ?? null,
1964
2059
  thread,
1965
2060
  scopes: [project, wakeScope(citizen, project)].filter((scope) => scope !== null)
1966
2061
  });
@@ -4773,6 +4868,21 @@ ${rationale}`}`);
4773
4868
  });
4774
4869
  });
4775
4870
  }
4871
+ /** Where a conversation's digest resumes: the newest message a turn there was shown. */
4872
+ async digestCursor(agent, scope, thread) {
4873
+ return this.mutex.run(async () => cursorOf(await this.readCursors(agent), scope, thread ?? null));
4874
+ }
4875
+ /** Records that a running turn took posts delivered into it, which wake latency counts to. */
4876
+ async recordSteered(input) {
4877
+ await this.mutex.run(async () => {
4878
+ await this.events.append("turn.steered", input.agent, {
4879
+ turnId: input.turnId,
4880
+ project: input.project,
4881
+ ...input.thread === void 0 ? {} : { thread: input.thread },
4882
+ messages: [...input.messages]
4883
+ });
4884
+ });
4885
+ }
4776
4886
  /** Where a conversation's session ids and last turn are kept: the scope's home, or a thread's. */
4777
4887
  sessionDir(agent, scope, thread) {
4778
4888
  return thread === void 0 ? this.paths.agentProject(agent, scope) : this.paths.agentThread(agent, scope, thread);
@@ -4842,6 +4952,14 @@ ${rationale}`}`);
4842
4952
  }
4843
4953
  await this.writeTurnRecord(parsed);
4844
4954
  await this.refreshMember(parsed.agent);
4955
+ if (parsed.exitReason === "stopped") {
4956
+ await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
4957
+ ...parsed.id === void 0 ? {} : { turnId: parsed.id },
4958
+ agent: parsed.agent,
4959
+ project: parsed.project,
4960
+ ...parsed.thread === void 0 ? {} : { thread: parsed.thread }
4961
+ });
4962
+ }
4845
4963
  await this.events.append(failed ? "turn.failed" : "turn.completed", parsed.agent, {
4846
4964
  ...parsed.id === void 0 ? {} : { turnId: parsed.id },
4847
4965
  project: parsed.project,
@@ -6133,7 +6251,10 @@ var PendingSchema = z13.object({
6133
6251
  dispatch: TurnDispatchSchema,
6134
6252
  readyAt: z13.number(),
6135
6253
  stageWakes: z13.array(TriggerSchema).optional(),
6136
- holder: z13.boolean().optional()
6254
+ holder: z13.boolean().optional(),
6255
+ newestMessage: UlidSchema.optional(),
6256
+ onlyMessages: z13.boolean().optional(),
6257
+ steeredThrough: UlidSchema.optional()
6137
6258
  });
6138
6259
  var StateSchema = z13.object({
6139
6260
  cursor: z13.string().nullable(),
@@ -6152,6 +6273,16 @@ var StateSchema = z13.object({
6152
6273
  /** When each member last reflected, or was first seen; the cadence counts from here. */
6153
6274
  lastReflection: z13.record(z13.string(), z13.string()).default({})
6154
6275
  });
6276
+ function messageOf(trigger) {
6277
+ return trigger.kind === "mention" || trigger.kind === "user_post" ? trigger.messageId : void 0;
6278
+ }
6279
+ function later(a, b) {
6280
+ if (a === void 0)
6281
+ return b;
6282
+ if (b === void 0)
6283
+ return a;
6284
+ return a > b ? a : b;
6285
+ }
6155
6286
  var SILENT_LOG = { info() {
6156
6287
  }, warn() {
6157
6288
  }, error() {
@@ -6191,6 +6322,8 @@ var Scheduler = class {
6191
6322
  pending = /* @__PURE__ */ new Map();
6192
6323
  /** Turns in flight by session key, each with the lane it holds (see `laneOf`). */
6193
6324
  running = /* @__PURE__ */ new Map();
6325
+ /** Steers under way, by session key; a pass does not wait for a runner to answer one. */
6326
+ steering = /* @__PURE__ */ new Map();
6194
6327
  /** Tasks whose completion effect waits for the turn that finished their last stage to end. */
6195
6328
  completions = /* @__PURE__ */ new Map();
6196
6329
  completing = /* @__PURE__ */ new Set();
@@ -6245,7 +6378,8 @@ var Scheduler = class {
6245
6378
  await this.passing?.catch(() => void 0);
6246
6379
  await Promise.allSettled([
6247
6380
  ...[...this.running.values()].map((turn) => turn.promise),
6248
- ...this.completing
6381
+ ...this.completing,
6382
+ ...this.steering.values()
6249
6383
  ]);
6250
6384
  }
6251
6385
  /** One pass, unless one is under way already. */
@@ -6292,6 +6426,7 @@ var Scheduler = class {
6292
6426
  /** Waits for every running turn and in-flight completion to settle. Used by tests and by stop(). */
6293
6427
  async drain() {
6294
6428
  await Promise.allSettled([
6429
+ ...this.steering.values(),
6295
6430
  ...[...this.running.values()].map((turn) => turn.promise),
6296
6431
  ...this.completing
6297
6432
  ]);
@@ -6708,6 +6843,7 @@ var Scheduler = class {
6708
6843
  const key = sessionKey(agent, project, thread?.id);
6709
6844
  const readyAt = now + debounce;
6710
6845
  const existing = this.pending.get(key);
6846
+ const message = messageOf(trigger);
6711
6847
  if (existing === void 0) {
6712
6848
  this.pending.set(key, {
6713
6849
  dispatch: {
@@ -6719,10 +6855,13 @@ var Scheduler = class {
6719
6855
  onboarding: trigger.kind === "onboarding"
6720
6856
  },
6721
6857
  readyAt,
6722
- ...trigger.kind === "stage" ? { stageWakes: [trigger] } : {}
6858
+ ...trigger.kind === "stage" ? { stageWakes: [trigger] } : {},
6859
+ ...message === void 0 ? {} : { newestMessage: message },
6860
+ onlyMessages: message !== void 0
6723
6861
  });
6724
6862
  return;
6725
6863
  }
6864
+ const newestMessage = later(existing.newestMessage, message);
6726
6865
  const keepNew = decision.priority > existing.dispatch.priority;
6727
6866
  const priority = keepNew ? decision.priority : existing.dispatch.priority;
6728
6867
  this.pending.set(key, {
@@ -6736,7 +6875,10 @@ var Scheduler = class {
6736
6875
  ...existing.stageWakes !== void 0 && trigger.kind === "stage" ? {
6737
6876
  stageWakes: [...existing.stageWakes, trigger],
6738
6877
  ...existing.holder === true ? { holder: true } : {}
6739
- } : {}
6878
+ } : {},
6879
+ ...newestMessage === void 0 ? {} : { newestMessage },
6880
+ onlyMessages: existing.onlyMessages === true && message !== void 0,
6881
+ ...existing.steeredThrough === void 0 ? {} : { steeredThrough: existing.steeredThrough }
6740
6882
  });
6741
6883
  }
6742
6884
  /** Debounce coalesces chatter: mentions, stage handoffs, and bursts of signals. Everything else runs at once. */
@@ -7186,7 +7328,51 @@ var Scheduler = class {
7186
7328
  }
7187
7329
  return false;
7188
7330
  }
7331
+ /**
7332
+ * Delivers ready message wakes into the turn already running in their conversation, rather than
7333
+ * keeping them for a turn of their own. Each stays queued until that turn ends and is checked
7334
+ * again then, so a steer refused, or left untaken, loses nothing. A pass never waits on a runner.
7335
+ */
7336
+ steerRunning(now) {
7337
+ const steer = this.runner.steer?.bind(this.runner);
7338
+ if (steer === void 0) {
7339
+ return;
7340
+ }
7341
+ for (const [key, item] of this.pending) {
7342
+ const through = item.newestMessage;
7343
+ if (item.readyAt > now || through === void 0 || !this.running.has(key) || this.steering.has(key) || item.steeredThrough !== void 0 && through <= item.steeredThrough) {
7344
+ continue;
7345
+ }
7346
+ const { dispatch } = item;
7347
+ const attempt = steer({
7348
+ agent: dispatch.agent,
7349
+ project: dispatch.project,
7350
+ ...dispatch.thread === void 0 ? {} : { thread: dispatch.thread.id }
7351
+ }).then((outcome) => {
7352
+ const current = this.pending.get(key);
7353
+ if (outcome !== "busy" && current !== void 0) {
7354
+ current.steeredThrough = later(current.steeredThrough, through);
7355
+ }
7356
+ return void 0;
7357
+ }).catch((error) => {
7358
+ this.log.warn({ key, error: String(error) }, "steering failed");
7359
+ }).finally(() => {
7360
+ this.steering.delete(key);
7361
+ });
7362
+ this.steering.set(key, attempt);
7363
+ }
7364
+ }
7365
+ /** Whether every message that woke a queued turn was read in its conversation, by a turn already. */
7366
+ async alreadyRead(item) {
7367
+ if (item.onlyMessages !== true || item.newestMessage === void 0) {
7368
+ return false;
7369
+ }
7370
+ const { dispatch } = item;
7371
+ const cursor = await this.board.digestCursor(dispatch.agent, dispatch.project, dispatch.thread?.id);
7372
+ return cursor !== null && cursor >= item.newestMessage;
7373
+ }
7189
7374
  async dispatchReady(now) {
7375
+ this.steerRunning(now);
7190
7376
  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);
7191
7377
  for (const [key, item] of ready) {
7192
7378
  if (this.running.size >= this.concurrency) {
@@ -7197,6 +7383,10 @@ var Scheduler = class {
7197
7383
  continue;
7198
7384
  }
7199
7385
  this.pending.delete(key);
7386
+ if (await this.alreadyRead(item)) {
7387
+ this.log.info({ agent: item.dispatch.agent, project: item.dispatch.project }, "dropping message wake, its conversation has read the message");
7388
+ continue;
7389
+ }
7200
7390
  let { dispatch } = item;
7201
7391
  if (item.stageWakes !== void 0) {
7202
7392
  const current = await this.stillWaiting(dispatch.agent, item.stageWakes, item.holder === true);
@@ -7258,11 +7448,15 @@ var Scheduler = class {
7258
7448
  }
7259
7449
  };
7260
7450
 
7451
+ // packages/turn-host/dist/host.js
7452
+ import { randomUUID } from "node:crypto";
7453
+
7261
7454
  // packages/turn-host/dist/render.js
7262
7455
  var TURN_CONTRACT = [
7263
7456
  "- 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.",
7264
7457
  "- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
7265
7458
  "- 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.",
7459
+ "- 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.",
7266
7460
  "- Silence is allowed. If the digest needs no reply, post nothing.",
7267
7461
  "- 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.",
7268
7462
  "- 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.",
@@ -7412,6 +7606,26 @@ function rosterLine(member) {
7412
7606
  ].filter((part) => part.length > 0);
7413
7607
  return `- ${member.name}: ${parts.join("; ")}. Profile: ${profileLine(member.profile)}`;
7414
7608
  }
7609
+ function messageLines(messages, threads) {
7610
+ const lines = [];
7611
+ for (const message of messages) {
7612
+ const thread = message.thread === void 0 ? void 0 : threads?.get(message.thread);
7613
+ const where = message.thread === void 0 ? message.channel : thread === void 0 ? `${message.channel} thread ${message.thread}` : `${message.channel} thread "${thread.title}" (${message.thread})`;
7614
+ const step = message.step === void 0 ? "" : `, who ${stepPhrase(message.step)}`;
7615
+ lines.push(`### [${message.ts}] ${where} from @${message.author}${step} (message ${message.id})`, "", clip(message.body), "");
7616
+ }
7617
+ return lines;
7618
+ }
7619
+ function buildSteerText(messages, threads) {
7620
+ return `${[
7621
+ `## New in this conversation while you work (${messages.length})`,
7622
+ "",
7623
+ "The board delivered these into your turn as they arrived, as it delivers the digest. Act on them now, after the step in hand, or not at all; the turn still ends with one status object covering everything it did.",
7624
+ "",
7625
+ ...messageLines(messages, threads)
7626
+ ].join("\n").trimEnd()}
7627
+ `;
7628
+ }
7415
7629
  function buildTurnPrompt(input) {
7416
7630
  const { dispatch } = input;
7417
7631
  const lines = [];
@@ -7464,6 +7678,9 @@ function buildTurnPrompt(input) {
7464
7678
  lines.push("", "## First turn", "", renderOnboardingPreamble(input.onboarding));
7465
7679
  }
7466
7680
  const last = input.lastTurn;
7681
+ if (last?.exitReason === "stopped") {
7682
+ lines.push("", "## The user stopped your previous turn", "", `It was ended on purpose at ${last.endedAt ?? "its end"}, not by a failure. Do not pick its work up again unless this turn asks for it. The worktree may hold uncommitted changes; run git status before doing anything else.`);
7683
+ }
7467
7684
  if (last !== null && (last.exitReason === "error" || last.exitReason === "timeout")) {
7468
7685
  lines.push("", "## Your previous turn did not finish", "", `It ended with ${last.exitReason}${last.error === null ? "" : `: ${last.error}`}.`, "The worktree may hold uncommitted changes. Run git status before doing anything else.");
7469
7686
  }
@@ -7545,12 +7762,7 @@ function buildTurnPrompt(input) {
7545
7762
  if (input.messages.length === 0) {
7546
7763
  lines.push("Nothing new.");
7547
7764
  } else {
7548
- for (const message of input.messages) {
7549
- const thread = message.thread === void 0 ? void 0 : input.threads?.get(message.thread);
7550
- const where = message.thread === void 0 ? message.channel : thread === void 0 ? `${message.channel} thread ${message.thread}` : `${message.channel} thread "${thread.title}" (${message.thread})`;
7551
- const step = message.step === void 0 ? "" : `, who ${stepPhrase(message.step)}`;
7552
- lines.push(`### [${message.ts}] ${where} from @${message.author}${step} (message ${message.id})`, "", clip(message.body), "");
7553
- }
7765
+ lines.push(...messageLines(input.messages, input.threads));
7554
7766
  }
7555
7767
  lines.push("## What to do", "", "Act on the unread messages and your stages through the board tools: claim_task to hold a stage, advance_task when your part is done, plan_task to reshape what comes next, post_message to talk, and propose for what the society lacks. Silence is allowed when nothing needs a reply. End with the status object.");
7556
7768
  return `${lines.join("\n")}
@@ -7757,7 +7969,10 @@ var TurnHost = class {
7757
7969
  runner: runner.name,
7758
7970
  actor,
7759
7971
  record,
7760
- cursorAfter,
7972
+ delivered: cursorAfter,
7973
+ steers: /* @__PURE__ */ new Map(),
7974
+ outstanding: null,
7975
+ stopRequestedBy: null,
7761
7976
  held: held.map((each) => each.id),
7762
7977
  taskId,
7763
7978
  costSoFarUsd,
@@ -7779,17 +7994,130 @@ var TurnHost = class {
7779
7994
  }, "turn starting");
7780
7995
  return { job, ended };
7781
7996
  }
7782
- /** Steps of a turn in flight, as the runner received them. */
7783
- addEvents(turnId, entries) {
7997
+ /**
7998
+ * Steps of a turn in flight, as the runner received them. A steer the CLI took moves what the
7999
+ * turn has been shown past its messages, and its step carries them and the text it delivered.
8000
+ */
8001
+ async addEvents(turnId, entries) {
7784
8002
  const turn = this.open.get(turnId);
7785
8003
  if (turn === void 0) {
7786
8004
  return;
7787
8005
  }
7788
- for (const entry of entries) {
8006
+ for (const received of entries) {
8007
+ let entry = received;
8008
+ const { event } = received;
8009
+ if (event.type === "steered") {
8010
+ const sent = turn.steers.get(event.steer);
8011
+ if (sent !== void 0) {
8012
+ entry = {
8013
+ ...received,
8014
+ event: { ...event, messages: [...sent.messages], text: sent.text }
8015
+ };
8016
+ if (turn.delivered === null || sent.through > turn.delivered) {
8017
+ turn.delivered = sent.through;
8018
+ }
8019
+ if (turn.outstanding === event.steer) {
8020
+ turn.outstanding = null;
8021
+ }
8022
+ await this.board.recordSteered({
8023
+ turnId,
8024
+ agent: turn.job.agent,
8025
+ project: turn.job.scope,
8026
+ thread: turn.job.thread,
8027
+ messages: sent.messages
8028
+ }).catch((error) => {
8029
+ this.log.warn({ turnId, error: String(error) }, "could not record a steer");
8030
+ });
8031
+ }
8032
+ }
7789
8033
  turn.transcript.push(entry);
7790
8034
  this.onEvent?.(turn.job.agent, turn.job.scope, entry.event, turn.job.thread);
7791
8035
  }
7792
8036
  }
8037
+ /** The turn in flight in a conversation, if any. */
8038
+ turnIn(agent, scope, thread) {
8039
+ for (const turn of this.open.values()) {
8040
+ const { job } = turn;
8041
+ if (job.agent === agent && job.scope === scope && job.thread === thread) {
8042
+ return { turnId: job.turnId, runner: turn.runner, cli: job.cli };
8043
+ }
8044
+ }
8045
+ return null;
8046
+ }
8047
+ /** The turns in flight, for the interface: their conversations and where a mention reaches them. */
8048
+ runningTurns() {
8049
+ return [...this.open.values()].map((turn) => ({
8050
+ turnId: turn.job.turnId,
8051
+ agent: turn.job.agent,
8052
+ scope: turn.job.scope,
8053
+ ...turn.job.thread === void 0 ? {} : { thread: turn.job.thread },
8054
+ cli: turn.job.cli,
8055
+ ...turn.record.trigger.channel === void 0 ? {} : { channel: turn.record.trigger.channel },
8056
+ runner: turn.runner
8057
+ }));
8058
+ }
8059
+ /**
8060
+ * What a steer into a turn would deliver: the conversation's digest from what the turn has been
8061
+ * shown, rendered as the prompt renders its digest. `busy` while a steer it was sent is not yet
8062
+ * taken or refused, and `nothing` when everything new was in what it was shown.
8063
+ */
8064
+ async prepareSteer(turnId) {
8065
+ const turn = this.open.get(turnId);
8066
+ if (turn === void 0 || turn.stopRequestedBy !== null) {
8067
+ return "gone";
8068
+ }
8069
+ if (turn.outstanding !== null) {
8070
+ return "busy";
8071
+ }
8072
+ const { job } = turn;
8073
+ const digest = await this.board.readDigest({
8074
+ ...turn.actor,
8075
+ scope: job.scope,
8076
+ ...job.thread === void 0 ? {} : { thread: job.thread }
8077
+ }, {
8078
+ ...turn.delivered === null ? {} : { since_cursor: turn.delivered },
8079
+ advance: false,
8080
+ limit: 50
8081
+ });
8082
+ const last = digest.messages.at(-1);
8083
+ if (!this.open.has(turnId) || turn.outstanding !== null) {
8084
+ return "gone";
8085
+ }
8086
+ if (last === void 0) {
8087
+ return "nothing";
8088
+ }
8089
+ const threads = /* @__PURE__ */ new Map();
8090
+ for (const id of new Set(digest.messages.flatMap((message) => message.thread ?? []))) {
8091
+ const record = await this.board.readThread(id).catch(() => void 0);
8092
+ if (record !== void 0) {
8093
+ threads.set(id, record);
8094
+ }
8095
+ }
8096
+ const steer = { id: randomUUID(), text: buildSteerText(digest.messages, threads) };
8097
+ turn.steers.set(steer.id, {
8098
+ through: last.id,
8099
+ messages: digest.messages.map((message) => message.id),
8100
+ text: steer.text
8101
+ });
8102
+ turn.outstanding = steer.id;
8103
+ return steer;
8104
+ }
8105
+ /** The runner's answer to a steer: one it did not take frees the turn for the next. */
8106
+ steerAnswered(turnId, steerId, accepted) {
8107
+ const turn = this.open.get(turnId);
8108
+ if (turn !== void 0 && !accepted && turn.outstanding === steerId) {
8109
+ turn.outstanding = null;
8110
+ }
8111
+ }
8112
+ /** Marks a turn stopped by `by`; returns false for a turn not in flight. */
8113
+ requestStop(turnId, by) {
8114
+ const turn = this.open.get(turnId);
8115
+ if (turn === void 0) {
8116
+ return false;
8117
+ }
8118
+ turn.stopRequestedBy ??= by;
8119
+ return true;
8120
+ }
7793
8121
  /**
7794
8122
  * Records a turn's end from its outcome. Returns null for a turn the host does not know, else
7795
8123
  * whether the task the turn worked on has ended, so the runner may drop its worktree.
@@ -7820,10 +8148,11 @@ var TurnHost = class {
7820
8148
  sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
7821
8149
  toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
7822
8150
  model: outcome.model ?? turn.configuredModel,
7823
- ...outcome.work === null ? {} : { work: outcome.work }
8151
+ ...outcome.work === null ? {} : { work: outcome.work },
8152
+ ...outcome.exitReason === "stopped" ? { stoppedBy: turn.stopRequestedBy ?? USER_NAME } : {}
7824
8153
  };
7825
- if (outcome.exitReason === "completed" || outcome.exitReason === "blocked") {
7826
- await this.board.setDigestCursor(job.agent, job.scope, turn.cursorAfter, job.thread);
8154
+ if (outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped") {
8155
+ await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, job.thread);
7827
8156
  await this.renewLeases(turn.actor, turn.held);
7828
8157
  if (outcome.status?.needsUserDecision === true) {
7829
8158
  await this.askUser(turn.actor, job.scope, job.thread, turn.record.startedAt, outcome.status.summary);
@@ -7999,15 +8328,19 @@ var TurnHost = class {
7999
8328
  };
8000
8329
 
8001
8330
  // packages/turn-host/dist/hub.js
8002
- import { randomUUID } from "node:crypto";
8331
+ import { randomUUID as randomUUID2 } from "node:crypto";
8003
8332
  var RunnerProtocolError = class extends Error {
8004
8333
  };
8334
+ var RunnerAwayError = class extends Error {
8335
+ };
8005
8336
  var SILENT2 = { info() {
8006
8337
  }, warn() {
8007
8338
  }, error() {
8008
8339
  } };
8009
8340
  var DEFAULT_GRACE_MS = 2 * 6e4;
8010
8341
  var DEFAULT_REQUEST_TIMEOUT_MS = 10 * 6e4;
8342
+ var CONTROL_TIMEOUT_MS = 15e3;
8343
+ var FILE_TIMEOUT_MS = 6e4;
8011
8344
  var RunnerHub = class {
8012
8345
  board;
8013
8346
  host;
@@ -8120,7 +8453,7 @@ var RunnerHub = class {
8120
8453
  }
8121
8454
  async turnEvents(runner, turnId, entries) {
8122
8455
  this.assertOwns(runner, turnId);
8123
- this.host.addEvents(turnId, entries);
8456
+ await this.host.addEvents(turnId, entries);
8124
8457
  }
8125
8458
  async turnOutcome(runner, turnId, outcome) {
8126
8459
  this.assertOwns(runner, turnId);
@@ -8151,9 +8484,76 @@ var RunnerHub = class {
8151
8484
  mayTouchHome(runner, agent) {
8152
8485
  return this.host.hasTurnFor(runner, agent);
8153
8486
  }
8487
+ /** The turns in flight, and whether each one's runner can steer and stop it. */
8488
+ liveTurns() {
8489
+ return this.host.runningTurns().map(({ runner, ...turn }) => {
8490
+ const seat = this.seats.get(runner);
8491
+ const connected = seat !== void 0 && seat.send !== null;
8492
+ return {
8493
+ ...turn,
8494
+ steerable: connected && seat.hello.steerableClis.includes(turn.cli),
8495
+ stoppable: connected && seat.hello.stoppableClis.includes(turn.cli)
8496
+ };
8497
+ });
8498
+ }
8499
+ /**
8500
+ * Stops a turn in flight, as the user asked: its runner interrupts the CLI and the turn ends
8501
+ * `stopped`. False for a turn not in flight or on a runner that cannot stop it.
8502
+ */
8503
+ async stop(turnId, by) {
8504
+ const runner = this.host.runnerOf(turnId);
8505
+ const seat = runner === void 0 ? void 0 : this.seats.get(runner);
8506
+ const cli = this.host.runningTurns().find((turn) => turn.turnId === turnId)?.cli;
8507
+ if (seat === void 0 || seat.send === null || cli === void 0) {
8508
+ return false;
8509
+ }
8510
+ if (!seat.hello.stoppableClis.includes(cli) || !this.host.requestStop(turnId, by)) {
8511
+ return false;
8512
+ }
8513
+ try {
8514
+ const answered = await this.request(seat, (request) => ({ type: "stop", request, turnId }), CONTROL_TIMEOUT_MS);
8515
+ this.log.info({ turnId, runner: seat.name, by }, "turn stop requested");
8516
+ return answered === true;
8517
+ } catch (error) {
8518
+ this.log.warn({ turnId, error: String(error) }, "could not stop a turn");
8519
+ return false;
8520
+ }
8521
+ }
8154
8522
  // -------------------------------------------------------------------------------------------
8155
8523
  // The scheduler's runner
8156
8524
  // -------------------------------------------------------------------------------------------
8525
+ /**
8526
+ * Delivers what arrived in a conversation into its turn in flight, when that turn's runner can
8527
+ * steer its CLI. `refused` leaves the wake to a turn of its own once this one ends.
8528
+ */
8529
+ async steer(conversation) {
8530
+ const turn = this.host.turnIn(conversation.agent, conversation.project, conversation.thread);
8531
+ const seat = turn === null ? void 0 : this.seats.get(turn.runner);
8532
+ if (turn === null || seat === void 0 || seat.send === null) {
8533
+ return "refused";
8534
+ }
8535
+ if (!seat.hello.steerableClis.includes(turn.cli)) {
8536
+ return "refused";
8537
+ }
8538
+ const steer = await this.host.prepareSteer(turn.turnId);
8539
+ if (steer === "busy" || steer === "nothing") {
8540
+ return steer;
8541
+ }
8542
+ if (steer === "gone") {
8543
+ return "refused";
8544
+ }
8545
+ let accepted = false;
8546
+ try {
8547
+ accepted = await this.request(seat, (request) => ({ type: "steer", request, turnId: turn.turnId, steer }), CONTROL_TIMEOUT_MS) === true;
8548
+ } catch (error) {
8549
+ this.log.warn({ turnId: turn.turnId, error: String(error) }, "could not steer a turn");
8550
+ }
8551
+ this.host.steerAnswered(turn.turnId, steer.id, accepted);
8552
+ if (accepted) {
8553
+ this.log.info({ ...conversation, turnId: turn.turnId }, "steered a turn in flight");
8554
+ }
8555
+ return accepted ? "sent" : "refused";
8556
+ }
8157
8557
  async assign(dispatch) {
8158
8558
  const agent = await this.board.readAgent(dispatch.agent).catch(() => null);
8159
8559
  if (agent === null) {
@@ -8272,6 +8672,37 @@ var RunnerHub = class {
8272
8672
  });
8273
8673
  return "done";
8274
8674
  }
8675
+ /**
8676
+ * One path on a branch of a project's repository, read on the runner the project lives on.
8677
+ * NOT_FOUND when the project has no runner yet or the branch or the path does not exist, and
8678
+ * `RunnerAwayError` while its runner is away.
8679
+ */
8680
+ async readBranch(project, branch, path10) {
8681
+ const record = await this.board.readProject(project);
8682
+ if (record.runner === void 0) {
8683
+ throw new BoardError("NOT_FOUND", `${project} has no repository yet, since no turn has run there`);
8684
+ }
8685
+ const seat = this.seats.get(record.runner);
8686
+ if (seat === void 0 || seat.send === null) {
8687
+ throw new RunnerAwayError(`runner ${record.runner}, where ${project} lives, is not connected`);
8688
+ }
8689
+ if (!seat.hello.branchReads) {
8690
+ throw new BoardError("INVALID_STATE", `runner ${seat.name}, where ${project} lives, cannot read files yet; upgrade it`);
8691
+ }
8692
+ const file = BranchFileSchema.nullable().parse(await this.request(seat, (request) => ({
8693
+ type: "file",
8694
+ request,
8695
+ read: {
8696
+ repo: { slug: record.slug, origin: record.repo, defaultBranch: record.defaultBranch },
8697
+ branch,
8698
+ path: path10
8699
+ }
8700
+ }), FILE_TIMEOUT_MS));
8701
+ if (file === null) {
8702
+ throw new BoardError("NOT_FOUND", `${branch} has no ${path10 === "" ? "commit" : path10}`);
8703
+ }
8704
+ return file;
8705
+ }
8275
8706
  /** The models a CLI offers, asked of a connected runner that has it; none when no runner has it. */
8276
8707
  async models(cli) {
8277
8708
  const seat = this.byLoad().find((each) => each.send !== null && each.hello.clis.includes(cli));
@@ -8288,7 +8719,7 @@ var RunnerHub = class {
8288
8719
  if (pending.runner === seat.name) {
8289
8720
  this.pending.delete(id);
8290
8721
  clearTimeout(pending.timer);
8291
- pending.reject(new Error(`runner ${seat.name} disconnected`));
8722
+ pending.reject(new RunnerAwayError(`runner ${seat.name} disconnected`));
8292
8723
  }
8293
8724
  }
8294
8725
  await this.board.markRunner(seat.name, { status: "disconnected" }).catch((error) => {
@@ -8354,6 +8785,8 @@ var RunnerHub = class {
8354
8785
  return {
8355
8786
  name: seat.name,
8356
8787
  residentClis: seat.hello.residentClis,
8788
+ steerableClis: seat.hello.steerableClis,
8789
+ stoppableClis: seat.hello.stoppableClis,
8357
8790
  isWarm: (key) => seat.warm.has(key)
8358
8791
  };
8359
8792
  }
@@ -8363,19 +8796,19 @@ var RunnerHub = class {
8363
8796
  throw new RunnerProtocolError(`turn ${turnId} is not ${runner}'s`);
8364
8797
  }
8365
8798
  }
8366
- request(seat, message) {
8367
- const request = randomUUID();
8799
+ request(seat, message, timeoutMs = this.requestTimeoutMs) {
8800
+ const request = randomUUID2();
8368
8801
  return new Promise((resolve, reject) => {
8369
8802
  const timer = setTimeout(() => {
8370
8803
  this.pending.delete(request);
8371
- reject(new Error(`runner ${seat.name} did not answer in time`));
8372
- }, this.requestTimeoutMs);
8804
+ reject(new RunnerAwayError(`runner ${seat.name} did not answer in time`));
8805
+ }, timeoutMs);
8373
8806
  timer.unref?.();
8374
8807
  this.pending.set(request, { runner: seat.name, resolve, reject, timer });
8375
8808
  if (seat.send === null || !seat.send(message(request))) {
8376
8809
  this.pending.delete(request);
8377
8810
  clearTimeout(timer);
8378
- reject(new Error(`runner ${seat.name} is not connected`));
8811
+ reject(new RunnerAwayError(`runner ${seat.name} is not connected`));
8379
8812
  }
8380
8813
  });
8381
8814
  }
@@ -8939,6 +9372,9 @@ function createApp(deps) {
8939
9372
  if (error instanceof RunnerProtocolError) {
8940
9373
  return c.json({ error: "RUNNER_PROTOCOL", message: error.message }, 409);
8941
9374
  }
9375
+ if (error instanceof RunnerAwayError) {
9376
+ return c.json({ error: "RUNNER_AWAY", message: error.message }, 503);
9377
+ }
8942
9378
  return c.json({ error: "INTERNAL", message: error.message }, 500);
8943
9379
  });
8944
9380
  const authenticate = async (c, next) => {
@@ -9053,6 +9489,15 @@ function createApp(deps) {
9053
9489
  api.get("/projects/:slug/knowledge", async (c) => c.json(await board2.listKnowledge(c.req.param("slug"))));
9054
9490
  api.get("/tasks/:id", async (c) => c.json((await board2.findTask(c.req.param("id"))).task));
9055
9491
  api.get("/tasks/:id/thread", async (c) => c.json(await board2.listThread(c.req.param("id"))));
9492
+ const taskFile = async (c, file) => {
9493
+ const { task } = await board2.findTask(UlidSchema.parse(c.req.param("id")));
9494
+ if (deps.runners === void 0) {
9495
+ throw new BoardError("NOT_FOUND", "this board has no runners to read a task's files from");
9496
+ }
9497
+ return c.json(await deps.runners.readBranch(task.project, `task/${task.id}`, BranchPathSchema.parse(file)));
9498
+ };
9499
+ api.get("/tasks/:id/files", (c) => taskFile(c, ""));
9500
+ api.get("/tasks/:id/files/:path{.+}", (c) => taskFile(c, c.req.param("path")));
9056
9501
  api.get("/threads", async (c) => c.json(await board2.listThreads()));
9057
9502
  api.get("/requests", async (c) => c.json(await board2.listRequests()));
9058
9503
  api.get("/threads/:id", async (c) => {
@@ -9087,8 +9532,21 @@ function createApp(deps) {
9087
9532
  running: scheduler2?.runningPairs ?? [],
9088
9533
  pending: scheduler2?.pendingPairs ?? [],
9089
9534
  resident: scheduler2?.residentPairs ?? [],
9090
- signals: scheduler2?.activeSignals ?? []
9535
+ signals: scheduler2?.activeSignals ?? [],
9536
+ turns: deps.runners?.liveTurns() ?? []
9091
9537
  }));
9538
+ api.post("/turns/:turnId/stop", async (c) => {
9539
+ const actor = c.get("actor");
9540
+ if (actor.role !== USER_ROLE) {
9541
+ throw new BoardError("FORBIDDEN", "only the user stops a turn");
9542
+ }
9543
+ const turnId = UlidSchema.parse(c.req.param("turnId"));
9544
+ const stopped = await deps.runners?.stop(turnId, actor.name) ?? false;
9545
+ if (!stopped) {
9546
+ throw new BoardError("NOT_FOUND", `turn ${turnId} is not in flight, or its runner cannot stop it`);
9547
+ }
9548
+ return c.json({ stopped });
9549
+ });
9092
9550
  api.post("/pause", async (c) => {
9093
9551
  await board2.setPaused(c.get("actor"), true);
9094
9552
  return c.json({ paused: true });