@kevin5251984/guild 0.2.20 → 0.2.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevin5251984/guild",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
package/src/handlers.ts CHANGED
@@ -522,6 +522,7 @@ export function workspace(store: GuildStore) {
522
522
  startedAt: turn.startedAt || "",
523
523
  thinking: turn.thinking,
524
524
  steps: turn.steps,
525
+ ...(turn.paused ? { paused: true } : {}),
525
526
  },
526
527
  ];
527
528
  });
@@ -996,6 +997,7 @@ function publicLiveTurn(live: LiveTurn): LiveTurn {
996
997
  thinking: live.thinking,
997
998
  steps: live.steps,
998
999
  startedAt: live.startedAt,
1000
+ ...(live.paused ? { paused: true } : {}),
999
1001
  };
1000
1002
  }
1001
1003
 
@@ -1085,6 +1087,84 @@ export function abortLiveTurn(
1085
1087
  return { ok: true };
1086
1088
  }
1087
1089
 
1090
+ export function pauseLiveTurn(
1091
+ store: GuildStore,
1092
+ roomId: string,
1093
+ botId?: string,
1094
+ ) {
1095
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1096
+ const live = botId
1097
+ ? store.getLiveBotTurn(roomId, botId)
1098
+ : store.getLiveTurn(roomId);
1099
+ if (live?.paused) return { ok: true, paused: true as const };
1100
+ const had = store.pauseTurn(roomId, botId);
1101
+ if (!live && !had) throw new StoreError(409, "no live turn");
1102
+ return { ok: true, paused: true as const };
1103
+ }
1104
+
1105
+ function resumeSteer(live: LiveTurn): string {
1106
+ const lines = [
1107
+ "Paused mid-turn so the user could switch models. Continue from here. Do not redo finished tools unless you need a different result.",
1108
+ ];
1109
+ const thinking = (live.thinking || "").trim();
1110
+ if (thinking) lines.push(`Thinking so far:\n${thinking.slice(0, 6000)}`);
1111
+ const tools = (live.traces || []).filter((tr) => tr.name && tr.name !== "think");
1112
+ if (tools.length) {
1113
+ lines.push(
1114
+ "Tools already run:\n" +
1115
+ tools
1116
+ .slice(-20)
1117
+ .map((tr) => {
1118
+ const bit = String(tr.text || "")
1119
+ .replace(/\s+/g, " ")
1120
+ .trim()
1121
+ .slice(0, 400);
1122
+ return `- ${tr.name}${bit ? `: ${bit}` : ""}`;
1123
+ })
1124
+ .join("\n"),
1125
+ );
1126
+ }
1127
+ return lines.join("\n\n");
1128
+ }
1129
+
1130
+ export async function continueLiveTurn(
1131
+ store: GuildStore,
1132
+ roomId: string,
1133
+ botId: string,
1134
+ env: NodeJS.ProcessEnv = process.env,
1135
+ extras: HandlerExtras = {},
1136
+ ) {
1137
+ if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
1138
+ const id = botId.trim();
1139
+ if (!id) throw new StoreError(400, "botId is required");
1140
+ const live = store.getLiveBotTurn(roomId, id);
1141
+ if (!live?.paused) throw new StoreError(409, "no paused turn");
1142
+ const messages = store.listMessages(roomId);
1143
+ let userIndex = messages.length - 1;
1144
+ while (userIndex >= 0 && messages[userIndex].author !== "you") userIndex -= 1;
1145
+ if (userIndex < 0) throw new StoreError(400, "no user message to continue");
1146
+ const userMessage = messages[userIndex];
1147
+ const asked = live.asked?.trim() || userMessage.body.trim();
1148
+ if (!asked) throw new StoreError(409, "nothing to continue");
1149
+ const history = messages.slice(0, userIndex).map(toHistoryItem);
1150
+ const parent = parentMessage(messages.slice(0, userIndex), userMessage.replyTo);
1151
+ const room = store.getRoom(roomId);
1152
+ store.setLiveTurn(roomId, { ...live, paused: false });
1153
+ store.pushSteer(roomId, resumeSteer(live), id);
1154
+ const replies = await generateReplies(
1155
+ store,
1156
+ roomId,
1157
+ room?.memberIds ?? [id],
1158
+ { ...userMessage, body: asked },
1159
+ history,
1160
+ id,
1161
+ env,
1162
+ parent,
1163
+ extras,
1164
+ );
1165
+ return { replies };
1166
+ }
1167
+
1088
1168
  function isAbortError(err: unknown): boolean {
1089
1169
  return Boolean(
1090
1170
  err &&
@@ -1169,14 +1249,19 @@ async function generateReplies(
1169
1249
  if (!memberIds.includes(botId)) return;
1170
1250
  if (signal.aborted) return;
1171
1251
  const prev = store.getLiveBotTurn(roomId, botId);
1252
+ if (!prev || prev.paused) return;
1172
1253
  const startedAt = prev?.startedAt || new Date().toISOString();
1173
1254
  store.dropLastFailedReply(roomId, botId);
1174
1255
  store.setLiveTurn(roomId, {
1175
1256
  botId,
1176
1257
  thinking: prev?.thinking || "",
1177
1258
  steps: prev?.steps || [],
1259
+ traces: prev?.traces,
1260
+ asked: turnAsked,
1178
1261
  startedAt,
1262
+ paused: false,
1179
1263
  });
1264
+ const botSignal = store.armBotTurn(roomId, botId, signal);
1180
1265
  let generated;
1181
1266
  try {
1182
1267
  generated = await (extras.turn ?? chatReply)({
@@ -1189,10 +1274,11 @@ async function generateReplies(
1189
1274
  userMessage.body,
1190
1275
  ),
1191
1276
  env,
1192
- signal,
1277
+ signal: botSignal,
1193
1278
  mcpTools,
1194
1279
  onProgress: (update) => {
1195
1280
  const prev = store.getLiveBotTurn(roomId, botId);
1281
+ if (prev?.paused) return;
1196
1282
  const next = toLiveTurn(botId, update);
1197
1283
  const handoff = (prev?.steps || []).find((step) => step.name === "handoff");
1198
1284
  const pendingSteers = store.peekSteers(roomId, botId).map((text) => ({
@@ -1209,14 +1295,16 @@ async function generateReplies(
1209
1295
  );
1210
1296
  store.setLiveTurn(roomId, {
1211
1297
  ...next,
1298
+ asked: prev?.asked || turnAsked,
1212
1299
  startedAt: prev?.startedAt || startedAt,
1300
+ paused: false,
1213
1301
  steps: [...(handoff ? [handoff] : []), ...keptSteer, ...rest].slice(0, 5),
1214
1302
  });
1215
1303
  },
1216
1304
  pullSteers: () => store.drainSteers(roomId, botId),
1217
1305
  });
1218
1306
  } catch (err) {
1219
- if (isAbortError(err) || signal.aborted) return;
1307
+ if (isAbortError(err) || botSignal.aborted || signal.aborted) return;
1220
1308
  throw err;
1221
1309
  }
1222
1310
  const usage = { ...(generated.usage || {}), startedAt };
@@ -1406,11 +1494,15 @@ function plantLiveTurns(
1406
1494
  if (!botId) continue;
1407
1495
  if (!memberIds.includes(botId)) continue;
1408
1496
  store.dropLastFailedReply(roomId, botId);
1497
+ const prev = store.getLiveBotTurn(roomId, botId);
1409
1498
  store.setLiveTurn(roomId, {
1410
1499
  botId,
1411
- thinking: "",
1412
- steps: [],
1413
- startedAt,
1500
+ thinking: prev?.thinking || "",
1501
+ steps: prev?.steps || [],
1502
+ traces: prev?.traces,
1503
+ asked: prev?.asked,
1504
+ startedAt: prev?.startedAt || startedAt,
1505
+ paused: false,
1414
1506
  });
1415
1507
  }
1416
1508
  return startedAt;
package/src/llm.ts CHANGED
@@ -496,7 +496,7 @@ export async function llmComplete(input: {
496
496
  };
497
497
  const file = readModelsFile(input.dataDir);
498
498
  const effort = clampEffort(
499
- file.fast ? "low" : file.reasoning,
499
+ file.fast ? "low" : input.prefer ? input.prefer.reasoning : file.reasoning,
500
500
  reasoningFor(target.providerId, target.model),
501
501
  Boolean(file.fast),
502
502
  );
@@ -1894,6 +1894,10 @@ a.invite-btn:hover { text-decoration: none; }
1894
1894
  .turn-actions .turn-stop {
1895
1895
  color: var(--danger);
1896
1896
  }
1897
+ .turn-actions .turn-pause,
1898
+ .turn-actions .turn-continue {
1899
+ color: var(--steel);
1900
+ }
1897
1901
  .turn-actions .turn-steer {
1898
1902
  color: #b4c8e8;
1899
1903
  }
@@ -1907,6 +1911,8 @@ body.grok .turn-actions button:hover {
1907
1911
  color: #f2eadf;
1908
1912
  }
1909
1913
  body.grok .turn-actions .turn-stop { color: #d4a090; }
1914
+ body.grok .turn-actions .turn-pause,
1915
+ body.grok .turn-actions .turn-continue { color: #c4b090; }
1910
1916
  body.grok .turn-actions .turn-steer { color: #c4b090; }
1911
1917
  .turn-status .clock {
1912
1918
  margin-left: 8px;
@@ -1292,8 +1292,8 @@
1292
1292
  }
1293
1293
  function botModelMeta(bot) {
1294
1294
  const model = botModelLabel(bot) || "—";
1295
+ const ref = botModelRef(bot);
1295
1296
  const spec = (() => {
1296
- const ref = botModelRef(bot);
1297
1297
  if (!ref) return null;
1298
1298
  const hit = allModels().find(
1299
1299
  (row) => row.provider === ref.provider && row.model === ref.model,
@@ -1301,7 +1301,7 @@
1301
1301
  return (hit && hit.reasoning) || null;
1302
1302
  })();
1303
1303
  const reasonVal = clampEffort(
1304
- modelCfg && modelCfg.reasoning,
1304
+ ref && ref.reasoning,
1305
1305
  spec,
1306
1306
  Boolean(modelCfg && modelCfg.fast),
1307
1307
  );
@@ -1713,6 +1713,7 @@
1713
1713
  thinking: row.thinking || "",
1714
1714
  steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
1715
1715
  startedAt: row.startedAt || "",
1716
+ paused: Boolean(row.paused),
1716
1717
  }));
1717
1718
  }
1718
1719
  function rebuildBusyBots() {
@@ -1741,6 +1742,7 @@
1741
1742
  thinking: row.thinking || "",
1742
1743
  steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
1743
1744
  startedAt: row.startedAt || "",
1745
+ paused: Boolean(row.paused),
1744
1746
  room: { kind: r.kind, id: r.id },
1745
1747
  }));
1746
1748
  state.lives = others.concat(tagged);
@@ -1791,24 +1793,38 @@
1791
1793
  ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
1792
1794
  : "";
1793
1795
  const botId = live && live.botId ? live.botId : "";
1796
+ const paused = Boolean(live && live.paused);
1794
1797
  const actions = botId
1795
1798
  ? '<div class="turn-actions">' +
1799
+ (paused
1800
+ ? '<button type="button" class="turn-continue" data-live-continue="' +
1801
+ escapeHtml(botId) +
1802
+ '">' +
1803
+ t("live.continue") +
1804
+ "</button>"
1805
+ : '<button type="button" class="turn-pause" data-live-pause="' +
1806
+ escapeHtml(botId) +
1807
+ '">' +
1808
+ t("live.pause") +
1809
+ "</button>") +
1796
1810
  '<button type="button" class="turn-stop" data-live-stop="' +
1797
1811
  escapeHtml(botId) +
1798
1812
  '">' +
1799
1813
  t("live.stop") +
1800
1814
  "</button>" +
1801
- '<button type="button" class="turn-steer" data-live-steer="' +
1802
- escapeHtml(botId) +
1803
- '">' +
1804
- t("live.steer") +
1805
- "</button>" +
1815
+ (paused
1816
+ ? ""
1817
+ : '<button type="button" class="turn-steer" data-live-steer="' +
1818
+ escapeHtml(botId) +
1819
+ '">' +
1820
+ t("live.steer") +
1821
+ "</button>") +
1806
1822
  "</div>"
1807
1823
  : "";
1808
1824
  return (
1809
1825
  '<div class="turn-live">' +
1810
1826
  '<div class="turn-status">' +
1811
- t("deepDiving") +
1827
+ (paused ? t("live.paused") : t("deepDiving")) +
1812
1828
  clock +
1813
1829
  "</div>" +
1814
1830
  liveStepsHtml(live && live.steps) +
@@ -1829,7 +1845,9 @@
1829
1845
  return (
1830
1846
  '<article class="msg bot live" data-bot-id="' +
1831
1847
  escapeHtml(bot.id) +
1832
- '" role="status">' +
1848
+ '"' +
1849
+ (live.paused ? ' data-paused="1"' : "") +
1850
+ ' role="status">' +
1833
1851
  actor.av +
1834
1852
  '<div class="msg-main">' +
1835
1853
  actor.head +
@@ -1866,6 +1884,11 @@
1866
1884
  const block = article.querySelector(".turn-live");
1867
1885
  if (!block) return;
1868
1886
  const status = block.querySelector(".turn-status");
1887
+ const paused = Boolean(live.paused);
1888
+ if ((article.getAttribute("data-paused") === "1") !== paused) {
1889
+ renderThread();
1890
+ return;
1891
+ }
1869
1892
  if (status) {
1870
1893
  const started = live.startedAt ? Date.parse(live.startedAt) : NaN;
1871
1894
  const elapsed = Number.isFinite(started)
@@ -1875,7 +1898,7 @@
1875
1898
  elapsed >= 15000
1876
1899
  ? '<span class="clock">' + Math.floor(elapsed / 1000) + "s</span>"
1877
1900
  : "";
1878
- status.innerHTML = t("deepDiving") + clock;
1901
+ status.innerHTML = (paused ? t("live.paused") : t("deepDiving")) + clock;
1879
1902
  }
1880
1903
  const html = liveStepsHtml(live.steps);
1881
1904
  const existing = block.querySelector(".live-steps");
@@ -2328,6 +2351,18 @@
2328
2351
  ? "/dms/" + encodeURIComponent(r.id) + "/abort"
2329
2352
  : "/channels/" + encodeURIComponent(r.id) + "/abort";
2330
2353
  }
2354
+ function pauseUrl(room) {
2355
+ const r = room || state.busyRoom || { kind: state.kind, id: state.id };
2356
+ return r.kind === "dm"
2357
+ ? "/dms/" + encodeURIComponent(r.id) + "/pause"
2358
+ : "/channels/" + encodeURIComponent(r.id) + "/pause";
2359
+ }
2360
+ function continueUrl(room) {
2361
+ const r = room || state.busyRoom || { kind: state.kind, id: state.id };
2362
+ return r.kind === "dm"
2363
+ ? "/dms/" + encodeURIComponent(r.id) + "/continue"
2364
+ : "/channels/" + encodeURIComponent(r.id) + "/continue";
2365
+ }
2331
2366
  function stopTurn(botId) {
2332
2367
  const room = currentRoomRef();
2333
2368
  (state.aborts || []).forEach((item) => {
@@ -2353,6 +2388,30 @@
2353
2388
  }).catch(() => {});
2354
2389
  setBusy(false, botId ? [botId] : [], { room: room });
2355
2390
  }
2391
+ function pauseTurn(botId) {
2392
+ const room = currentRoomRef();
2393
+ fetch(pauseUrl(room), {
2394
+ method: "POST",
2395
+ headers: { "content-type": "application/json" },
2396
+ body: JSON.stringify(botId ? { botId: botId } : {}),
2397
+ }).catch(() => {});
2398
+ (state.lives || []).forEach((row) => {
2399
+ if (!botId || row.botId === botId) row.paused = true;
2400
+ });
2401
+ if (state.live && (!botId || state.live.botId === botId)) {
2402
+ state.live.paused = true;
2403
+ }
2404
+ renderThread();
2405
+ }
2406
+ async function continueTurn(botId) {
2407
+ const room = currentRoomRef();
2408
+ (state.lives || []).forEach((row) => {
2409
+ if (row.botId === botId) row.paused = false;
2410
+ });
2411
+ if (state.live && state.live.botId === botId) state.live.paused = false;
2412
+ renderThread();
2413
+ await postChat(continueUrl(room), { botId: botId }, room, [botId]);
2414
+ }
2356
2415
  async function insertIntoBotTurn(botId) {
2357
2416
  if (!composerHasPayload()) {
2358
2417
  const draft = document.getElementById("draft");
@@ -2817,6 +2876,22 @@
2817
2876
  stopTurn(liveStop.getAttribute("data-live-stop"));
2818
2877
  return;
2819
2878
  }
2879
+ const livePause = event.target.closest("[data-live-pause]");
2880
+ if (livePause) {
2881
+ event.preventDefault();
2882
+ event.stopImmediatePropagation();
2883
+ pauseTurn(livePause.getAttribute("data-live-pause"));
2884
+ return;
2885
+ }
2886
+ const liveContinue = event.target.closest("[data-live-continue]");
2887
+ if (liveContinue) {
2888
+ event.preventDefault();
2889
+ event.stopImmediatePropagation();
2890
+ continueTurn(liveContinue.getAttribute("data-live-continue")).catch(
2891
+ (err) => alert(err.message),
2892
+ );
2893
+ return;
2894
+ }
2820
2895
  const liveSteer = event.target.closest("[data-live-steer]");
2821
2896
  if (liveSteer) {
2822
2897
  event.preventDefault();
@@ -3084,7 +3159,12 @@
3084
3159
  if (bot) showBotCard(bot, event.currentTarget);
3085
3160
  });
3086
3161
  document.getElementById("thread").addEventListener("click", (event) => {
3087
- if (event.target.closest("[data-live-stop], [data-live-steer]")) return;
3162
+ if (
3163
+ event.target.closest(
3164
+ "[data-live-stop], [data-live-pause], [data-live-continue], [data-live-steer]",
3165
+ )
3166
+ )
3167
+ return;
3088
3168
  const av = event.target.closest(
3089
3169
  ".avatar[data-bot-id], button.name[data-bot-id]",
3090
3170
  );
@@ -4452,6 +4532,9 @@
4452
4532
  }
4453
4533
  setBusy(false, botIds, { room: sent });
4454
4534
  }
4535
+ if (state.kind === sent.kind && state.id === sent.id) {
4536
+ await resumeCurrentLive().catch(() => {});
4537
+ }
4455
4538
  if (ok) await flushQueued(sent);
4456
4539
  }
4457
4540
  async function flushQueued(sent) {
@@ -5550,8 +5633,9 @@
5550
5633
  document.getElementById("model-fast").checked = Boolean(modelCfg.fast);
5551
5634
  const spec = activeReasoningSpec();
5552
5635
  const choices = effortChoices(spec);
5636
+ const currentRef = activeModelRef();
5553
5637
  const reasonVal = clampEffort(
5554
- modelCfg.reasoning,
5638
+ currentRef && currentRef.reasoning,
5555
5639
  spec,
5556
5640
  Boolean(modelCfg.fast),
5557
5641
  );
@@ -5621,11 +5705,24 @@
5621
5705
  async function applyChatModel(ref) {
5622
5706
  if (state.kind !== "dm") return;
5623
5707
  const bot = botById(state.id);
5624
- if (!bot) return;
5708
+ if (!bot || !ref || !ref.provider || !ref.model) return;
5709
+ const spec = (() => {
5710
+ const hit = allModels().find(
5711
+ (row) => row.provider === ref.provider && row.model === ref.model,
5712
+ );
5713
+ return (hit && hit.reasoning) || null;
5714
+ })();
5715
+ const reason = clampEffort(
5716
+ document.getElementById("model-reasoning").value || ref.reasoning,
5717
+ spec,
5718
+ Boolean(document.getElementById("model-fast").checked),
5719
+ );
5720
+ const next = { provider: ref.provider, model: ref.model };
5721
+ if (reason) next.reasoning = reason;
5625
5722
  const res = await fetch("/bots/" + encodeURIComponent(bot.id), {
5626
5723
  method: "PATCH",
5627
5724
  headers: { "content-type": "application/json" },
5628
- body: JSON.stringify({ model: ref }),
5725
+ body: JSON.stringify({ model: next }),
5629
5726
  });
5630
5727
  if (!res.ok) {
5631
5728
  const body = await res.json().catch(() => ({}));
@@ -5635,16 +5732,16 @@
5635
5732
  const updated = await res.json();
5636
5733
  const i = state.bots.findIndex((item) => item.id === bot.id);
5637
5734
  if (i >= 0) state.bots[i] = { ...state.bots[i], model: updated.model };
5638
- await fetch("/settings/models", {
5639
- method: "PUT",
5640
- headers: { "content-type": "application/json" },
5641
- body: JSON.stringify({
5642
- reasoning: document.getElementById("model-reasoning").value,
5643
- fast: document.getElementById("model-fast").checked,
5644
- }),
5645
- }).then(async (r) => {
5646
- if (r.ok) modelCfg = await r.json();
5647
- });
5735
+ const fast = document.getElementById("model-fast").checked;
5736
+ if (Boolean(modelCfg.fast) !== Boolean(fast)) {
5737
+ await fetch("/settings/models", {
5738
+ method: "PUT",
5739
+ headers: { "content-type": "application/json" },
5740
+ body: JSON.stringify({ fast: fast }),
5741
+ }).then(async (r) => {
5742
+ if (r.ok) modelCfg = await r.json();
5743
+ });
5744
+ }
5648
5745
  renderModelList();
5649
5746
  renderNav();
5650
5747
  }
@@ -90,6 +90,9 @@ var I18N_ROWS = [
90
90
  ["stop", "停止", "Stop"],
91
91
  ["steer.hint", "Enter 排隊 · {mod}↩ 插入這輪", "Enter to queue · {mod}↩ to steer this turn"],
92
92
  ["live.stop", "停止", "Stop"],
93
+ ["live.pause", "暫停", "Pause"],
94
+ ["live.continue", "繼續", "Continue"],
95
+ ["live.paused", "已暫停", "Paused"],
93
96
  ["live.steer", "插入這輪", "Insert into turn"],
94
97
  ["steer.queue", "排隊", "Queue"],
95
98
  ["steer.insert", "插入引導", "Steer"],
package/src/public/md.js CHANGED
@@ -327,6 +327,23 @@ function renderMarkdown(raw) {
327
327
  return html.join("");
328
328
  }
329
329
 
330
+ function hydrateHtmlPreviews(root) {
331
+ if (!root || typeof root.querySelectorAll !== "function") return;
332
+ root.querySelectorAll(".md-html-preview").forEach((box) => {
333
+ const src = box.querySelector(".md-html-src");
334
+ const frame = box.querySelector(".md-html-frame");
335
+ if (!src || !frame || frame.dataset.ready) return;
336
+ frame.dataset.ready = "1";
337
+ const raw = src.value;
338
+ const lang = (box.querySelector(".md-fence-lang") || {}).textContent || "";
339
+ frame.srcdoc = /svg/i.test(lang)
340
+ ? '<!doctype html><html><body style="margin:0;background:#fff">' +
341
+ raw +
342
+ "</body></html>"
343
+ : raw;
344
+ });
345
+ }
346
+
330
347
  if (typeof module !== "undefined" && module.exports) {
331
- module.exports = { renderMarkdown, inlineMd };
348
+ module.exports = { renderMarkdown, inlineMd, hydrateHtmlPreviews };
332
349
  }
@@ -57,6 +57,8 @@ textarea:focus-visible {
57
57
  .navlist a,
58
58
  .back,
59
59
  .turn-stop,
60
+ .turn-pause,
61
+ .turn-continue,
60
62
  .turn-steer,
61
63
  .steer,
62
64
  .send,
@@ -72,6 +74,8 @@ textarea:focus-visible {
72
74
  .navlist a:active,
73
75
  .back:active,
74
76
  .turn-stop:active:not(:disabled),
77
+ .turn-pause:active:not(:disabled),
78
+ .turn-continue:active:not(:disabled),
75
79
  .turn-steer:active:not(:disabled),
76
80
  .steer:active:not(:disabled),
77
81
  .send:active:not(:disabled),
@@ -83,6 +87,8 @@ textarea:focus-visible {
83
87
  .navlist a,
84
88
  .back,
85
89
  .turn-stop,
90
+ .turn-pause,
91
+ .turn-continue,
86
92
  .turn-steer,
87
93
  .steer,
88
94
  .send,
@@ -97,6 +103,8 @@ textarea:focus-visible {
97
103
  .navlist a:active,
98
104
  .back:active,
99
105
  .turn-stop:active,
106
+ .turn-pause:active,
107
+ .turn-continue:active,
100
108
  .turn-steer:active,
101
109
  .steer:active,
102
110
  .send:active,
@@ -326,6 +334,7 @@ textarea:focus-visible {
326
334
  background: var(--fill);
327
335
  overflow: auto;
328
336
  }
337
+ .assistant-text .md-html-preview { overflow: hidden; }
329
338
  .assistant-text .md-fence-bar {
330
339
  display: flex;
331
340
  align-items: center;
@@ -336,11 +345,101 @@ textarea:focus-visible {
336
345
  color: var(--muted);
337
346
  font-size: 0.75rem;
338
347
  }
348
+ .assistant-text .md-fence-tabs {
349
+ display: inline-flex;
350
+ align-items: center;
351
+ gap: 2px;
352
+ margin-left: auto;
353
+ margin-right: 4px;
354
+ }
355
+ .assistant-text .md-fence-tab {
356
+ margin: 0;
357
+ min-height: var(--tap);
358
+ padding: 0 10px;
359
+ border: 0;
360
+ border-radius: 8px;
361
+ background: transparent;
362
+ color: var(--muted);
363
+ font: inherit;
364
+ font-size: 0.78rem;
365
+ cursor: pointer;
366
+ }
367
+ .assistant-text .md-fence-tab.on,
368
+ .assistant-text .md-fence-tab:hover { background: var(--lift); color: var(--text); }
369
+ .assistant-text .md-fence-acts {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 2px;
373
+ }
374
+ .assistant-text .md-fence-btn {
375
+ margin: 0;
376
+ width: var(--tap);
377
+ height: var(--tap);
378
+ padding: 0;
379
+ border: 0;
380
+ border-radius: 8px;
381
+ background: transparent;
382
+ color: var(--muted);
383
+ cursor: pointer;
384
+ display: inline-flex;
385
+ align-items: center;
386
+ justify-content: center;
387
+ }
388
+ .assistant-text .md-html-frame {
389
+ display: block;
390
+ width: 100%;
391
+ min-height: 220px;
392
+ height: 52vh;
393
+ border: 0;
394
+ background: #fff;
395
+ border-radius: 0 0 12px 12px;
396
+ }
397
+ .assistant-text .md-html-preview[data-view="code"] .md-html-frame { display: none; }
398
+ .assistant-text .md-html-preview[data-view="preview"] .md-pre { display: none; }
339
399
  .assistant-text .md-pre {
340
400
  margin: 0;
341
401
  padding: 0.6rem 0.8rem 0.8rem;
342
402
  overflow: auto;
343
403
  font-size: 0.82rem;
404
+ white-space: pre;
405
+ overflow-wrap: normal;
406
+ word-break: normal;
407
+ }
408
+ .html-zoom {
409
+ position: fixed;
410
+ inset: 0;
411
+ z-index: 80;
412
+ display: grid;
413
+ grid-template-rows: auto 1fr;
414
+ padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom));
415
+ background: rgba(0, 0, 0, 0.72);
416
+ }
417
+ .html-zoom[hidden] { display: none !important; }
418
+ .html-zoom-bar {
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: space-between;
422
+ gap: 12px;
423
+ margin: 0 0 12px;
424
+ color: var(--text);
425
+ }
426
+ .html-zoom-close {
427
+ margin: 0;
428
+ width: var(--tap);
429
+ height: var(--tap);
430
+ border: 0;
431
+ border-radius: 50%;
432
+ background: var(--fill);
433
+ color: var(--text);
434
+ font-size: 1.35rem;
435
+ cursor: pointer;
436
+ }
437
+ .html-zoom-frame {
438
+ width: 100%;
439
+ height: 100%;
440
+ border: 0;
441
+ border-radius: 16px;
442
+ background: #fff;
344
443
  }
345
444
  .assistant-text img.md-img {
346
445
  max-width: 100%;
@@ -416,6 +515,8 @@ textarea:focus-visible {
416
515
  margin-top: 4px;
417
516
  }
418
517
  .turn-stop,
518
+ .turn-pause,
519
+ .turn-continue,
419
520
  .turn-steer,
420
521
  .steer,
421
522
  .send,
@@ -447,13 +548,16 @@ textarea:focus-visible {
447
548
  background: var(--lift);
448
549
  border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
449
550
  }
450
- /* Steer rides the steel rail, never a filled danger slab. */
551
+ .turn-pause,
552
+ .turn-continue,
451
553
  .turn-steer,
452
554
  .steer {
453
555
  color: var(--steel);
454
556
  background: var(--fill);
455
557
  border-color: color-mix(in srgb, var(--steel) 38%, var(--line));
456
558
  }
559
+ .turn-pause:hover,
560
+ .turn-continue:hover,
457
561
  .turn-steer:hover,
458
562
  .steer:hover {
459
563
  background: var(--lift);
@@ -9,7 +9,7 @@
9
9
  <link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
10
10
  <link rel="icon" href="/favicon-16.png" type="image/png" sizes="16x16" />
11
11
  <title>Guild — 外出</title>
12
- <link rel="stylesheet" href="/mobile.css?v=enamel" />
12
+ <link rel="stylesheet" href="/mobile.css?v=html-preview" />
13
13
  <script src="/i18n.js"></script>
14
14
  <script src="/md.js"></script>
15
15
  </head>
@@ -49,6 +49,13 @@
49
49
  </form>
50
50
  </section>
51
51
  <div class="flash" id="flash" role="status" aria-live="polite" hidden></div>
52
+ <div class="html-zoom" id="html-zoom" hidden>
53
+ <div class="html-zoom-bar">
54
+ <strong data-i18n="html.zoom">HTML 預覽</strong>
55
+ <button type="button" class="html-zoom-close" id="html-zoom-close" title="關閉" aria-label="關閉" data-i18n-title="close" data-i18n-aria="close">×</button>
56
+ </div>
57
+ <iframe class="html-zoom-frame" id="html-zoom-frame" sandbox="allow-scripts" title="HTML preview large"></iframe>
58
+ </div>
52
59
  <script>
53
60
  (function () {
54
61
  const COLORS = ["#7c6af7", "#5b8def", "#2ea887", "#e07a3d", "#d4537e"];
@@ -141,6 +148,20 @@
141
148
  ? "/dms/" + encodeURIComponent(r.id) + "/abort"
142
149
  : "/channels/" + encodeURIComponent(r.id) + "/abort";
143
150
  }
151
+ function pauseUrl() {
152
+ const r = currentRoomRef();
153
+ if (!r) return "";
154
+ return r.kind === "dm"
155
+ ? "/dms/" + encodeURIComponent(r.id) + "/pause"
156
+ : "/channels/" + encodeURIComponent(r.id) + "/pause";
157
+ }
158
+ function continueUrl() {
159
+ const r = currentRoomRef();
160
+ if (!r) return "";
161
+ return r.kind === "dm"
162
+ ? "/dms/" + encodeURIComponent(r.id) + "/continue"
163
+ : "/channels/" + encodeURIComponent(r.id) + "/continue";
164
+ }
144
165
  function steerUrl() {
145
166
  const r = currentRoomRef();
146
167
  if (!r) return "";
@@ -225,6 +246,7 @@
225
246
  thinking: row.thinking || "",
226
247
  steps: Array.isArray(row.steps) ? row.steps.slice(-5) : [],
227
248
  startedAt: row.startedAt || "",
249
+ paused: Boolean(row.paused),
228
250
  }));
229
251
  }
230
252
  function isBusy() {
@@ -342,10 +364,11 @@
342
364
  }
343
365
  function formatBody(text) {
344
366
  const cleaned = visibleAssistantText(text);
345
- if (cleaned.length > 8000) return escapeHtml(cleaned.slice(-8000));
367
+ const clipped =
368
+ cleaned.length > 48000 ? cleaned.slice(0, 48000) + "\n…" : cleaned;
346
369
  return typeof renderMarkdown === "function"
347
- ? renderMarkdown(cleaned)
348
- : escapeHtml(cleaned);
370
+ ? renderMarkdown(clipped)
371
+ : escapeHtml(clipped);
349
372
  }
350
373
  function liveStepLabel(name) {
351
374
  if (name === "think") return t("think");
@@ -409,19 +432,35 @@
409
432
  '</span><span class="handle">@' +
410
433
  escapeHtml(bot.handle) +
411
434
  '</span></div><div class="turn-live"><div class="turn-status">' +
412
- t("deepDiving") +
435
+ (live.paused ? t("live.paused") : t("deepDiving")) +
413
436
  clock +
414
437
  "</div>" +
415
438
  liveStepsHtml(live.steps) +
416
- '<div class="turn-actions"><button type="button" class="turn-stop" data-live-stop="' +
439
+ '<div class="turn-actions">' +
440
+ (live.paused
441
+ ? '<button type="button" class="turn-continue" data-live-continue="' +
442
+ escapeHtml(bot.id) +
443
+ '">' +
444
+ t("live.continue") +
445
+ "</button>"
446
+ : '<button type="button" class="turn-pause" data-live-pause="' +
447
+ escapeHtml(bot.id) +
448
+ '">' +
449
+ t("live.pause") +
450
+ "</button>") +
451
+ '<button type="button" class="turn-stop" data-live-stop="' +
417
452
  escapeHtml(bot.id) +
418
453
  '">' +
419
454
  t("live.stop") +
420
- '</button><button type="button" class="turn-steer" data-live-steer="' +
421
- escapeHtml(bot.id) +
422
- '">' +
423
- t("live.steer") +
424
- "</button></div></div></div></article>"
455
+ "</button>" +
456
+ (live.paused
457
+ ? ""
458
+ : '<button type="button" class="turn-steer" data-live-steer="' +
459
+ escapeHtml(bot.id) +
460
+ '">' +
461
+ t("live.steer") +
462
+ "</button>") +
463
+ "</div></div></div></article>"
425
464
  );
426
465
  })
427
466
  .join("");
@@ -482,8 +521,24 @@
482
521
  "</div>"
483
522
  : "";
484
523
  root.innerHTML = notice + rows.map(msgHtml).join("") + turnStatusHtml();
524
+ if (typeof hydrateHtmlPreviews === "function") {
525
+ hydrateHtmlPreviews(root);
526
+ }
485
527
  if (pin) root.scrollTop = root.scrollHeight;
486
528
  }
529
+ function openHtmlZoom(raw) {
530
+ const wrap = document.getElementById("html-zoom");
531
+ const frame = document.getElementById("html-zoom-frame");
532
+ if (!wrap || !frame) return;
533
+ frame.srcdoc = raw || "";
534
+ wrap.hidden = false;
535
+ }
536
+ function closeHtmlZoom() {
537
+ const wrap = document.getElementById("html-zoom");
538
+ const frame = document.getElementById("html-zoom-frame");
539
+ if (frame) frame.srcdoc = "";
540
+ if (wrap) wrap.hidden = true;
541
+ }
487
542
  function steerModLabel() {
488
543
  return /Mac|iPhone|iPad/.test(navigator.platform || "") ? "\u2318" : "Ctrl+";
489
544
  }
@@ -771,6 +826,48 @@
771
826
  renderThread();
772
827
  await loadMessages().catch(() => {});
773
828
  }
829
+ async function pauseTurn(botId) {
830
+ try {
831
+ await fetch(pauseUrl(), {
832
+ method: "POST",
833
+ headers: { "content-type": "application/json" },
834
+ body: JSON.stringify(botId ? { botId: botId } : {}),
835
+ });
836
+ } catch {
837
+ /* ignore */
838
+ }
839
+ (state.lives || []).forEach((row) => {
840
+ if (!botId || row.botId === botId) row.paused = true;
841
+ });
842
+ renderThread();
843
+ await pollLive().catch(() => {});
844
+ }
845
+ async function continueTurn(botId) {
846
+ (state.lives || []).forEach((row) => {
847
+ if (row.botId === botId) row.paused = false;
848
+ });
849
+ state.posting = true;
850
+ syncComposer();
851
+ renderThread();
852
+ try {
853
+ const res = await fetch(continueUrl(), {
854
+ method: "POST",
855
+ headers: { "content-type": "application/json" },
856
+ body: JSON.stringify({ botId: botId }),
857
+ });
858
+ const body = await res.json().catch(() => ({}));
859
+ if (!res.ok) throw new Error(body.error || "continue failed");
860
+ await loadMessages();
861
+ await pollLive();
862
+ } catch (err) {
863
+ toast(err.message || String(err));
864
+ await pollLive().catch(() => {});
865
+ } finally {
866
+ state.posting = false;
867
+ syncComposer();
868
+ schedule();
869
+ }
870
+ }
774
871
  async function sendSteer(raw, botId) {
775
872
  const text = String(raw || "").trim();
776
873
  if (!text || !state.kind) return;
@@ -886,13 +983,68 @@
886
983
  document.getElementById("steer").addEventListener("click", () => {
887
984
  steerDraft().catch((err) => toast(err.message || String(err)));
888
985
  });
986
+ document.getElementById("html-zoom-close").addEventListener("click", () => {
987
+ closeHtmlZoom();
988
+ });
989
+ document.getElementById("html-zoom").addEventListener("click", (event) => {
990
+ if (event.target.id === "html-zoom") closeHtmlZoom();
991
+ });
889
992
  document.getElementById("thread").addEventListener("click", (event) => {
993
+ const htmlExpand = event.target.closest("[data-html-expand]");
994
+ if (htmlExpand) {
995
+ event.preventDefault();
996
+ const box = htmlExpand.closest(".md-html-preview");
997
+ const src = box && box.querySelector(".md-html-src");
998
+ if (src) openHtmlZoom(src.value);
999
+ return;
1000
+ }
1001
+ const htmlView = event.target.closest("[data-html-view]");
1002
+ if (htmlView) {
1003
+ event.preventDefault();
1004
+ const box = htmlView.closest(".md-html-preview");
1005
+ if (!box) return;
1006
+ const view = htmlView.getAttribute("data-html-view");
1007
+ box.setAttribute("data-view", view);
1008
+ const frame = box.querySelector(".md-html-frame");
1009
+ const pre = box.querySelector(".md-pre");
1010
+ if (frame) frame.hidden = view !== "preview";
1011
+ if (pre) pre.hidden = view !== "code";
1012
+ box.querySelectorAll("[data-html-view]").forEach((tab) => {
1013
+ tab.classList.toggle("on", tab.getAttribute("data-html-view") === view);
1014
+ });
1015
+ return;
1016
+ }
1017
+ const fenceCopy = event.target.closest("[data-fence-copy]");
1018
+ if (fenceCopy) {
1019
+ event.preventDefault();
1020
+ const box = fenceCopy.closest(".md-fence");
1021
+ const src = box && (box.querySelector(".md-html-src") || box.querySelector("code"));
1022
+ const text = src && "value" in src ? src.value : src ? src.textContent : "";
1023
+ if (text && navigator.clipboard) {
1024
+ navigator.clipboard.writeText(text).catch(() => {});
1025
+ }
1026
+ return;
1027
+ }
890
1028
  const stop = event.target.closest("[data-live-stop]");
891
1029
  if (stop) {
892
1030
  event.preventDefault();
893
1031
  stopTurn(stop.getAttribute("data-live-stop"));
894
1032
  return;
895
1033
  }
1034
+ const pause = event.target.closest("[data-live-pause]");
1035
+ if (pause) {
1036
+ event.preventDefault();
1037
+ pauseTurn(pause.getAttribute("data-live-pause"));
1038
+ return;
1039
+ }
1040
+ const cont = event.target.closest("[data-live-continue]");
1041
+ if (cont) {
1042
+ event.preventDefault();
1043
+ continueTurn(cont.getAttribute("data-live-continue")).catch((err) =>
1044
+ toast(err.message || String(err)),
1045
+ );
1046
+ return;
1047
+ }
896
1048
  const steer = event.target.closest("[data-live-steer]");
897
1049
  if (!steer) return;
898
1050
  event.preventDefault();
package/src/router.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  getBotDetail,
25
25
  getLiveTurn,
26
26
  abortLiveTurn,
27
+ pauseLiveTurn,
28
+ continueLiveTurn,
27
29
  healthPayload,
28
30
  importSkills,
29
31
  mergeModelsFile,
@@ -271,7 +273,8 @@ function modelRefFrom(value: unknown): ModelRef | null {
271
273
  const provider = str(rec, "provider").trim();
272
274
  const model = str(rec, "model").trim();
273
275
  if (!provider || !model) return null;
274
- return { provider, model };
276
+ const reasoning = str(rec, "reasoning").trim();
277
+ return reasoning ? { provider, model, reasoning } : { provider, model };
275
278
  }
276
279
 
277
280
  function strList(record: Record<string, unknown>, key: string): string[] {
@@ -709,6 +712,66 @@ export async function handleRequest(
709
712
  return;
710
713
  }
711
714
 
715
+ const channelPause = path.match(/^\/channels\/([^/]+)\/pause$/);
716
+ if (channelPause && method === "POST") {
717
+ const body = asRecord(await readJson(req));
718
+ json(
719
+ res,
720
+ 200,
721
+ pauseLiveTurn(
722
+ store,
723
+ decodeURIComponent(channelPause[1]),
724
+ str(body, "botId") || undefined,
725
+ ),
726
+ );
727
+ return;
728
+ }
729
+ const dmPause = path.match(/^\/dms\/([^/]+)\/pause$/);
730
+ if (dmPause && method === "POST") {
731
+ const room = openDm(store, decodeURIComponent(dmPause[1]));
732
+ const body = asRecord(await readJson(req));
733
+ json(
734
+ res,
735
+ 200,
736
+ pauseLiveTurn(store, room.id, str(body, "botId") || undefined),
737
+ );
738
+ return;
739
+ }
740
+
741
+ const channelContinue = path.match(/^\/channels\/([^/]+)\/continue$/);
742
+ if (channelContinue && method === "POST") {
743
+ const body = asRecord(await readJson(req));
744
+ json(
745
+ res,
746
+ 200,
747
+ await continueLiveTurn(
748
+ store,
749
+ decodeURIComponent(channelContinue[1]),
750
+ str(body, "botId"),
751
+ env,
752
+ extras,
753
+ ),
754
+ );
755
+ return;
756
+ }
757
+ const dmContinue = path.match(/^\/dms\/([^/]+)\/continue$/);
758
+ if (dmContinue && method === "POST") {
759
+ const room = openDm(store, decodeURIComponent(dmContinue[1]));
760
+ const body = asRecord(await readJson(req));
761
+ json(
762
+ res,
763
+ 200,
764
+ await continueLiveTurn(
765
+ store,
766
+ room.id,
767
+ str(body, "botId"),
768
+ env,
769
+ extras,
770
+ ),
771
+ );
772
+ return;
773
+ }
774
+
712
775
  const channelSteer = path.match(/^\/channels\/([^/]+)\/steer$/);
713
776
  if (channelSteer && method === "POST") {
714
777
  const body = asRecord(await readJson(req));
package/src/store.ts CHANGED
@@ -101,6 +101,9 @@ export type LiveTurn = {
101
101
  startedAt?: string;
102
102
  /** Full-ish tool history for Trajectory. Stripped from GET /live. */
103
103
  traces?: LiveTrace[];
104
+ /** Seat assignment text, kept so Continue can resume after Pause. */
105
+ asked?: string;
106
+ paused?: boolean;
104
107
  };
105
108
 
106
109
  export class GuildStore {
@@ -366,7 +369,6 @@ export class GuildStore {
366
369
  beginTurn(roomId: string, botIds: string[] = [""]): AbortSignal {
367
370
  const controller = new AbortController();
368
371
  const ids = botIds.length ? botIds : [""];
369
- for (const botId of ids) this.bindBotAbort(roomId, botId, controller);
370
372
  this.turnGroups.set(controller.signal, {
371
373
  roomId,
372
374
  botIds: new Set(ids),
@@ -375,43 +377,54 @@ export class GuildStore {
375
377
  return controller.signal;
376
378
  }
377
379
 
380
+ /**
381
+ * Per-seat AbortController, child of the turn group. Pause/Stop one bot
382
+ * without taking the rest of the wave down.
383
+ */
384
+ armBotTurn(roomId: string, botId: string, parent: AbortSignal): AbortSignal {
385
+ const controller = new AbortController();
386
+ const onParent = () => {
387
+ if (!controller.signal.aborted) controller.abort();
388
+ };
389
+ if (parent.aborted) onParent();
390
+ else parent.addEventListener("abort", onParent, { once: true });
391
+ this.bindBotAbort(roomId, botId, controller);
392
+ const group = this.turnGroups.get(parent);
393
+ if (group && group.roomId === roomId) group.botIds.add(botId);
394
+ return controller.signal;
395
+ }
396
+
378
397
  adoptTurn(roomId: string, botId: string, signal: AbortSignal): void {
379
398
  const group = this.turnGroups.get(signal);
380
399
  if (!group || group.roomId !== roomId) return;
381
- this.bindBotAbort(roomId, botId, group.controller);
382
400
  group.botIds.add(botId);
383
401
  }
384
402
 
403
+ private dropBotLive(roomId: string, botId: string): boolean {
404
+ const live = this.liveTurns.get(roomId);
405
+ const steers = this.pendingSteers.get(roomId);
406
+ const room = this.botAborts.get(roomId);
407
+ const hadLive = Boolean(live?.delete(botId));
408
+ steers?.delete(botId);
409
+ room?.delete(botId);
410
+ if (live && live.size === 0) this.liveTurns.delete(roomId);
411
+ if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
412
+ if (room && room.size === 0) this.botAborts.delete(roomId);
413
+ for (const [sig, group] of this.turnGroups) {
414
+ if (group.roomId !== roomId || !group.botIds.has(botId)) continue;
415
+ group.botIds.delete(botId);
416
+ if (group.botIds.size === 0) this.turnGroups.delete(sig);
417
+ }
418
+ return hadLive;
419
+ }
420
+
385
421
  abortTurn(roomId: string, botId?: string): boolean {
386
422
  if (botId) {
387
- const room = this.botAborts.get(roomId);
388
- const controller = room?.get(botId);
389
- if (!controller) {
390
- const live = this.liveTurns.get(roomId);
391
- const steers = this.pendingSteers.get(roomId);
392
- const hadLive = Boolean(live?.delete(botId));
393
- steers?.delete(botId);
394
- if (live && live.size === 0) this.liveTurns.delete(roomId);
395
- if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
396
- this.spillTrajectoryIfIdle(roomId);
397
- return hadLive;
398
- }
399
- const group = this.turnGroups.get(controller.signal);
400
- const ids = group ? [...group.botIds] : [botId];
401
- this.turnGroups.delete(controller.signal);
402
- const live = this.liveTurns.get(roomId);
403
- const steers = this.pendingSteers.get(roomId);
404
- for (const id of ids) {
405
- room?.delete(id);
406
- live?.delete(id);
407
- steers?.delete(id);
408
- }
409
- if (room && room.size === 0) this.botAborts.delete(roomId);
410
- if (live && live.size === 0) this.liveTurns.delete(roomId);
411
- if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
412
- if (!controller.signal.aborted) controller.abort();
423
+ const controller = this.botAborts.get(roomId)?.get(botId);
424
+ const hadLive = this.dropBotLive(roomId, botId);
425
+ if (controller && !controller.signal.aborted) controller.abort();
413
426
  this.spillTrajectoryIfIdle(roomId);
414
- return true;
427
+ return hadLive || Boolean(controller);
415
428
  }
416
429
  const room = this.botAborts.get(roomId);
417
430
  this.botAborts.delete(roomId);
@@ -419,10 +432,18 @@ export class GuildStore {
419
432
  this.pendingSteers.delete(roomId);
420
433
  let aborted = false;
421
434
  const seen = new Set<AbortController>();
435
+ for (const [sig, group] of [...this.turnGroups]) {
436
+ if (group.roomId !== roomId) continue;
437
+ this.turnGroups.delete(sig);
438
+ if (!group.controller.signal.aborted) {
439
+ group.controller.abort();
440
+ aborted = true;
441
+ }
442
+ seen.add(group.controller);
443
+ }
422
444
  for (const controller of room?.values() ?? []) {
423
445
  if (seen.has(controller)) continue;
424
446
  seen.add(controller);
425
- this.turnGroups.delete(controller.signal);
426
447
  if (!controller.signal.aborted) {
427
448
  controller.abort();
428
449
  aborted = true;
@@ -432,6 +453,37 @@ export class GuildStore {
432
453
  return aborted || Boolean(room);
433
454
  }
434
455
 
456
+ pauseTurn(roomId: string, botId?: string): boolean {
457
+ const ids = botId
458
+ ? [botId]
459
+ : [...(this.liveTurns.get(roomId)?.keys() ?? [])];
460
+ let any = false;
461
+ for (const id of ids) {
462
+ if (!id) continue;
463
+ const live = this.getLiveBotTurn(roomId, id);
464
+ if (!live) continue;
465
+ const traces = (live.traces || []).map((tr) =>
466
+ tr.running ? { ...tr, running: false, text: tr.text || "paused" } : tr,
467
+ );
468
+ const steps = (live.steps || []).map((step) =>
469
+ step.running ? { ...step, running: false } : step,
470
+ );
471
+ this.setLiveTurn(roomId, {
472
+ ...live,
473
+ traces,
474
+ steps,
475
+ paused: true,
476
+ });
477
+ const controller = this.botAborts.get(roomId)?.get(id);
478
+ this.botAborts.get(roomId)?.delete(id);
479
+ const room = this.botAborts.get(roomId);
480
+ if (room && room.size === 0) this.botAborts.delete(roomId);
481
+ if (controller && !controller.signal.aborted) controller.abort();
482
+ any = true;
483
+ }
484
+ return any;
485
+ }
486
+
435
487
  endTurn(roomId: string, signal?: AbortSignal): void {
436
488
  const group = signal ? this.turnGroups.get(signal) : undefined;
437
489
  if (group) {
@@ -440,7 +492,11 @@ export class GuildStore {
440
492
  const live = this.liveTurns.get(roomId);
441
493
  const steers = this.pendingSteers.get(roomId);
442
494
  for (const botId of group.botIds) {
443
- if (room?.get(botId) === group.controller) room.delete(botId);
495
+ if (live?.get(botId)?.paused) {
496
+ room?.delete(botId);
497
+ continue;
498
+ }
499
+ room?.delete(botId);
444
500
  live?.delete(botId);
445
501
  steers?.delete(botId);
446
502
  }
@@ -449,8 +505,14 @@ export class GuildStore {
449
505
  if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
450
506
  if (!group.controller.signal.aborted) group.controller.abort();
451
507
  } else {
508
+ const live = this.liveTurns.get(roomId);
509
+ const kept = new Map<string, LiveTurn>();
510
+ for (const [id, turn] of live ?? []) {
511
+ if (turn.paused) kept.set(id, turn);
512
+ }
452
513
  this.botAborts.delete(roomId);
453
- this.clearLiveTurn(roomId);
514
+ if (kept.size) this.liveTurns.set(roomId, kept);
515
+ else this.clearLiveTurn(roomId);
454
516
  }
455
517
  this.spillTrajectoryIfIdle(roomId);
456
518
  }
@@ -6,7 +6,12 @@ export type HealthResponse = {
6
6
 
7
7
  export type BotStatus = "bench" | "staffed" | "running" | "retired";
8
8
 
9
- export type ModelRef = { provider: string; model: string };
9
+ export type ModelRef = {
10
+ provider: string;
11
+ model: string;
12
+ /** Seat-specific effort. Missing → that model's catalog default. */
13
+ reasoning?: string;
14
+ };
10
15
 
11
16
  export type LibraryKind =
12
17
  | "souls"
@@ -169,7 +174,7 @@ export type AuxRole =
169
174
 
170
175
  export type ModelsFile = {
171
176
  default?: ModelRef | null;
172
- /** Last chosen effort string (catalog-defined: low, high, xhigh, …). */
177
+ /** Guild-default effort when a seat has no `model.reasoning`. */
173
178
  reasoning?: string;
174
179
  fast?: boolean;
175
180
  aux?: Partial<Record<AuxRole, ModelRef | null>>;