switchroom 0.13.26 → 0.13.27

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 (30) hide show
  1. package/dist/cli/switchroom.js +2 -2
  2. package/package.json +1 -1
  3. package/telegram-plugin/active-reactions-sweep.ts +4 -4
  4. package/telegram-plugin/dist/gateway/gateway.js +239 -64
  5. package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
  6. package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
  7. package/telegram-plugin/gateway/gateway.ts +166 -51
  8. package/telegram-plugin/gateway/inbound-spool.ts +69 -2
  9. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
  10. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
  11. package/telegram-plugin/pending-work-progress.ts +5 -1
  12. package/telegram-plugin/status-reactions.ts +70 -58
  13. package/telegram-plugin/stream-reply-handler.ts +7 -36
  14. package/telegram-plugin/subagent-watcher.ts +64 -3
  15. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
  16. package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
  17. package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
  18. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  19. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  20. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  21. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +78 -135
  22. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  23. package/telegram-plugin/tests/status-reactions.test.ts +56 -27
  24. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  25. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
  26. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  27. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  28. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
  29. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
  30. package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +204 -0
@@ -47436,8 +47436,8 @@ var {
47436
47436
  } = import__.default;
47437
47437
 
47438
47438
  // src/build-info.ts
47439
- var VERSION = "0.13.26";
47440
- var COMMIT_SHA = "b2767bb7";
47439
+ var VERSION = "0.13.27";
47440
+ var COMMIT_SHA = "a158e029";
47441
47441
 
47442
47442
  // src/cli/agent.ts
47443
47443
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.26",
3
+ "version": "0.13.27",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  * any still-active reactions to 👍 before it gets SIGTERM'd.
17
17
  *
18
18
  * Both consumers call `sweepActiveReactions`, which is shaped as a
19
- * pure function that takes the setDone callback as an argument. That
19
+ * pure function that takes the finalize callback as an argument. That
20
20
  * keeps it testable in isolation — the tests pass a fake callback and
21
21
  * assert which reactions were visited and whether the sidecar was
22
22
  * cleared.
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { readActiveReactions, clearActiveReactions, type ActiveReaction } from "./active-reactions.js";
26
26
 
27
- export type SetDoneReactionFn = (chatId: string, messageId: number) => Promise<unknown>;
27
+ export type FinalizeReactionFn = (chatId: string, messageId: number) => Promise<unknown>;
28
28
 
29
29
  export interface SweepOptions {
30
30
  timeoutMs?: number;
@@ -45,7 +45,7 @@ export interface SweepResult {
45
45
  */
46
46
  export async function sweepActiveReactions(
47
47
  agentDir: string,
48
- setDone: SetDoneReactionFn,
48
+ finalize: FinalizeReactionFn,
49
49
  options: SweepOptions = {},
50
50
  ): Promise<SweepResult> {
51
51
  const log = options.log ?? (() => {});
@@ -56,7 +56,7 @@ export async function sweepActiveReactions(
56
56
  log(`sweeping ${reactions.length} stale reaction(s)`);
57
57
  const attempts = reactions.map((r) =>
58
58
  Promise.resolve()
59
- .then(() => setDone(r.chatId, r.messageId))
59
+ .then(() => finalize(r.chatId, r.messageId))
60
60
  .catch((err: unknown) => {
61
61
  const msg = err instanceof Error ? err.message : String(err);
62
62
  log(`reaction sweep failed for ${r.chatId}/${r.messageId}: ${msg}`);
@@ -27230,7 +27230,7 @@ var init_secretlint_source = __esm(() => {
27230
27230
  function escapeHtml8(s) {
27231
27231
  return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
27232
27232
  }
27233
- function truncate3(s, n) {
27233
+ function truncate4(s, n) {
27234
27234
  return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
27235
27235
  }
27236
27236
 
@@ -30763,7 +30763,6 @@ var REACTION_VARIANTS = {
30763
30763
  web: ["\u26a1", "\uD83E\uDD14", "\uD83D\uDC4C"],
30764
30764
  compacting: ["\u270d", "\uD83E\uDD14", "\uD83D\uDC40"],
30765
30765
  done: ["\uD83D\uDC4D", "\uD83D\uDCAF", "\uD83C\uDF89"],
30766
- silent: ["\uD83D\uDE4A", "\uD83E\uDD14", "\uD83D\uDE10"],
30767
30766
  error: ["\uD83D\uDE31", "\uD83D\uDE28", "\uD83E\uDD2F"],
30768
30767
  stallSoft: ["\uD83E\uDD71", "\uD83D\uDE34", "\uD83E\uDD14"],
30769
30768
  stallHard: ["\uD83D\uDE28", "\uD83E\uDD2F", "\uD83D\uDE31"]
@@ -30796,7 +30795,7 @@ class StatusReactionController {
30796
30795
  constructor(emit, allowedReactions = null, config = {}) {
30797
30796
  this.emit = emit;
30798
30797
  this.allowedReactions = allowedReactions;
30799
- this.debounceMs = config.debounceMs ?? 700;
30798
+ this.debounceMs = config.debounceMs ?? 3500;
30800
30799
  this.stallSoftMs = config.stallSoftMs ?? 30000;
30801
30800
  this.stallHardMs = config.stallHardMs ?? 90000;
30802
30801
  this.log = config.log;
@@ -30814,14 +30813,15 @@ class StatusReactionController {
30814
30813
  setCompacting() {
30815
30814
  this.scheduleState("compacting");
30816
30815
  }
30817
- setDone() {
30818
- this.finishWithState("done");
30816
+ setError() {
30817
+ this.scheduleState("error");
30819
30818
  }
30820
- setSilent() {
30821
- this.finishWithState("silent");
30819
+ finalize(reason = "done") {
30820
+ const state = reason === "error" ? "error" : "done";
30821
+ this.finishWithState(state);
30822
30822
  }
30823
- setError() {
30824
- this.finishWithState("error");
30823
+ setDone() {
30824
+ this.finalize("done");
30825
30825
  }
30826
30826
  cancel() {
30827
30827
  if (this.finished)
@@ -32025,15 +32025,6 @@ async function handleStreamReply(args, state, deps) {
32025
32025
  await stream.finalize();
32026
32026
  state.activeDraftStreams.delete(sKey);
32027
32027
  state.activeDraftParseModes?.delete(sKey);
32028
- const isDefaultLaneForCompletion = args.lane == null || args.lane.length === 0;
32029
- if (isDefaultLaneForCompletion && stream.getMessageId() != null) {
32030
- try {
32031
- deps.endStatusReaction(chat_id, threadId, "done");
32032
- } catch (err) {
32033
- deps.writeError(`telegram channel: stream_reply: endStatusReaction hook threw: ${err}
32034
- `);
32035
- }
32036
- }
32037
32028
  if (stream.getMessageId() == null) {
32038
32029
  throw new Error(`stream_reply finalized without sending any message (length=${rawText.length}, ` + `max=4096). Telegram's per-message limit is 4096 chars and stream_reply does not ` + `auto-chunk. Split the text or use \`reply\` (which chunks).`);
32039
32030
  }
@@ -41139,14 +41130,14 @@ function clearActiveReactions2(agentDir) {
41139
41130
  }
41140
41131
 
41141
41132
  // active-reactions-sweep.ts
41142
- async function sweepActiveReactions(agentDir, setDone, options = {}) {
41133
+ async function sweepActiveReactions(agentDir, finalize, options = {}) {
41143
41134
  const log = options.log ?? (() => {});
41144
41135
  const timeoutMs = options.timeoutMs ?? 2000;
41145
41136
  const reactions = readActiveReactions2(agentDir);
41146
41137
  if (reactions.length === 0)
41147
41138
  return { swept: [], timedOut: false };
41148
41139
  log(`sweeping ${reactions.length} stale reaction(s)`);
41149
- const attempts = reactions.map((r) => Promise.resolve().then(() => setDone(r.chatId, r.messageId)).catch((err) => {
41140
+ const attempts = reactions.map((r) => Promise.resolve().then(() => finalize(r.chatId, r.messageId)).catch((err) => {
41150
41141
  const msg = err instanceof Error ? err.message : String(err);
41151
41142
  log(`reaction sweep failed for ${r.chatId}/${r.messageId}: ${msg}`);
41152
41143
  }));
@@ -41181,7 +41172,7 @@ function flushOnAgentDisconnect(deps) {
41181
41172
  return false;
41182
41173
  }
41183
41174
  for (const [key, ctrl] of activeStatusReactions.entries()) {
41184
- ctrl.setDone();
41175
+ ctrl.finalize("done");
41185
41176
  activeStatusReactions.delete(key);
41186
41177
  activeReactionMsgIds.delete(key);
41187
41178
  activeTurnStartedAt.delete(key);
@@ -41193,7 +41184,7 @@ function flushOnAgentDisconnect(deps) {
41193
41184
  activeTurnStartedAt.delete(k);
41194
41185
  activeReactionMsgIds.delete(k);
41195
41186
  }
41196
- log(`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` + `post-bridge-death (controller loop missed \u2014 setDone raced disconnect)`);
41187
+ log(`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` + `post-bridge-death (controller loop missed \u2014 finalize raced disconnect)`);
41197
41188
  onDanglingTurnsSwept?.(danglingKeys);
41198
41189
  }
41199
41190
  disposeProgressDriver();
@@ -44315,6 +44306,12 @@ function createPendingInboundBuffer(opts = {}) {
44315
44306
 
44316
44307
  // gateway/inbound-spool.ts
44317
44308
  function spoolId(msg) {
44309
+ if (msg.meta?.source === "subagent_handback" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0) {
44310
+ return `s:handback:${msg.meta.subagent_jsonl_id}`;
44311
+ }
44312
+ if (msg.meta?.source === "subagent_progress" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0 && typeof msg.meta?.bucket_idx === "string" && msg.meta.bucket_idx.length > 0) {
44313
+ return `s:progress:${msg.meta.subagent_jsonl_id}:${msg.meta.bucket_idx}`;
44314
+ }
44318
44315
  if (typeof msg.messageId === "number" && msg.messageId > 0) {
44319
44316
  return `m:${msg.chatId}:${msg.messageId}`;
44320
44317
  }
@@ -44437,7 +44434,31 @@ function createInboundSpool(opts) {
44437
44434
  maybeCompact();
44438
44435
  },
44439
44436
  liveEntries() {
44440
- return [...live.values()].map((e) => ({ agent: e.agent, msg: e.msg }));
44437
+ const cutoff = now();
44438
+ const out = [];
44439
+ for (const e of live.values()) {
44440
+ const expRaw = e.msg.meta?.expiresAt;
44441
+ if (typeof expRaw === "string" && expRaw.length > 0) {
44442
+ const exp = Number(expRaw);
44443
+ if (Number.isFinite(exp) && exp <= cutoff)
44444
+ continue;
44445
+ }
44446
+ out.push({ agent: e.agent, msg: e.msg });
44447
+ }
44448
+ return out;
44449
+ },
44450
+ dropMatching(predicate) {
44451
+ let n = 0;
44452
+ for (const [id, _e] of [...live.entries()]) {
44453
+ if (!predicate(id))
44454
+ continue;
44455
+ live.delete(id);
44456
+ appendRecord({ t: "ack", id });
44457
+ n++;
44458
+ }
44459
+ if (n > 0)
44460
+ maybeCompact();
44461
+ return n;
44441
44462
  },
44442
44463
  sweepEscalations(onEscalate) {
44443
44464
  const cutoff = now() - escalateAfterMs;
@@ -45082,7 +45103,8 @@ ${result}
45082
45103
  text,
45083
45104
  meta: {
45084
45105
  source: "subagent_handback",
45085
- outcome: opts.ctx.outcome
45106
+ outcome: opts.ctx.outcome,
45107
+ ...opts.ctx.jsonlAgentId ? { subagent_jsonl_id: opts.ctx.jsonlAgentId } : {}
45086
45108
  }
45087
45109
  };
45088
45110
  }
@@ -45105,13 +45127,110 @@ function decideSubagentHandback(input) {
45105
45127
  chatId,
45106
45128
  taskDescription: input.taskDescription,
45107
45129
  resultText: input.resultText,
45108
- outcome: input.outcome
45130
+ outcome: input.outcome,
45131
+ ...input.jsonlAgentId ? { jsonlAgentId: input.jsonlAgentId } : {}
45109
45132
  },
45110
45133
  ...input.nowMs !== undefined ? { nowMs: input.nowMs } : {}
45111
45134
  });
45112
45135
  return { deliver: true, chatId, inbound };
45113
45136
  }
45114
45137
 
45138
+ // gateway/subagent-progress-inbound-builder.ts
45139
+ var PROGRESS_RESULT_MAX = 800;
45140
+ var PROGRESS_DESC_MAX = 200;
45141
+ var DEFAULT_PROGRESS_INTERVAL_MS = 5 * 60 * 1000;
45142
+ function truncate3(s, max) {
45143
+ const t = s.trim();
45144
+ return t.length > max ? t.slice(0, max) + "\u2026" : t;
45145
+ }
45146
+ function formatElapsed(ms) {
45147
+ const totalMin = Math.floor(ms / 60000);
45148
+ if (totalMin < 60)
45149
+ return `${Math.max(1, totalMin)}m`;
45150
+ const h = Math.floor(totalMin / 60);
45151
+ const m = totalMin % 60;
45152
+ return m === 0 ? `${h}h` : `${h}h${m}m`;
45153
+ }
45154
+ function buildSubagentProgressInbound(opts) {
45155
+ const ts = opts.nowMs ?? Date.now();
45156
+ const desc = truncate3(opts.ctx.taskDescription, PROGRESS_DESC_MAX) || "(no description)";
45157
+ const summary = truncate3(opts.ctx.latestSummary, PROGRESS_RESULT_MAX);
45158
+ const elapsed = formatElapsed(opts.ctx.elapsedMs);
45159
+ const expiresAt = ts + 2 * opts.ctx.progressIntervalMs;
45160
+ const text = `\uD83D\uDD04 A background worker you dispatched is still running.
45161
+
45162
+ ` + `Task: ${desc}
45163
+ ` + `Elapsed: ${elapsed}
45164
+
45165
+ ` + (summary ? `Latest activity:
45166
+ ${summary}
45167
+
45168
+ ` : `(no narrative line yet \u2014 worker has been tool-only)
45169
+
45170
+ `) + `This is beat 3 \u2014 mid-flight progress. Surface ONE short line to ` + `the user in your own voice about what the worker is up to. Do ` + `NOT paste this raw, do NOT repeat the elapsed time verbatim, do ` + `NOT promise completion. The handback (beat 4) will come ` + `separately when the worker finishes.`;
45171
+ return {
45172
+ type: "inbound",
45173
+ chatId: opts.ctx.chatId,
45174
+ messageId: ts,
45175
+ user: "subagent-watcher",
45176
+ userId: 0,
45177
+ ts,
45178
+ text,
45179
+ meta: {
45180
+ source: "subagent_progress",
45181
+ subagent_jsonl_id: opts.ctx.subagentJsonlId,
45182
+ bucket_idx: String(opts.ctx.bucketIdx),
45183
+ expiresAt: String(expiresAt),
45184
+ elapsed_ms: String(opts.ctx.elapsedMs)
45185
+ }
45186
+ };
45187
+ }
45188
+ function isEnvFlagOn(value) {
45189
+ if (value == null)
45190
+ return false;
45191
+ const v = value.trim().toLowerCase();
45192
+ if (v === "")
45193
+ return false;
45194
+ if (v === "0" || v === "false" || v === "no" || v === "off")
45195
+ return false;
45196
+ return true;
45197
+ }
45198
+ function decideSubagentProgress(input) {
45199
+ if (isEnvFlagOn(input.disableEnvValue)) {
45200
+ return { deliver: false, reason: "env-disabled" };
45201
+ }
45202
+ if (!input.isBackground) {
45203
+ return { deliver: false, reason: "foreground" };
45204
+ }
45205
+ const chatId = input.fleetChatId || input.ownerChatId;
45206
+ if (!chatId) {
45207
+ return { deliver: false, reason: "no-chat" };
45208
+ }
45209
+ if (!input.subagentJsonlId) {
45210
+ return { deliver: false, reason: "missing-jsonl-id" };
45211
+ }
45212
+ const bucketIdx = Math.floor(input.elapsedMs / input.progressIntervalMs);
45213
+ if (bucketIdx < 1) {
45214
+ return { deliver: false, reason: "first-bucket-suppressed" };
45215
+ }
45216
+ if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
45217
+ return { deliver: false, reason: "bucket-already-fired" };
45218
+ }
45219
+ const inbound = buildSubagentProgressInbound({
45220
+ ctx: {
45221
+ chatId,
45222
+ subagentJsonlId: input.subagentJsonlId,
45223
+ taskDescription: input.taskDescription,
45224
+ latestSummary: input.latestSummary,
45225
+ elapsedMs: input.elapsedMs,
45226
+ bucketIdx,
45227
+ progressIntervalMs: input.progressIntervalMs
45228
+ },
45229
+ ...input.nowMs !== undefined ? { nowMs: input.nowMs } : {}
45230
+ });
45231
+ return { deliver: true, chatId, bucketIdx, inbound };
45232
+ }
45233
+
45115
45234
  // gateway/poll-health.ts
45116
45235
  var DEFAULT_LOG = (msg) => {
45117
45236
  process.stderr.write(msg.endsWith(`
@@ -46733,7 +46852,7 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
46733
46852
  db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
46734
46853
  log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
46735
46854
  }
46736
- function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished) {
46855
+ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished, onProgress) {
46737
46856
  try {
46738
46857
  const stat = fs2.statSync(entry.filePath);
46739
46858
  if (stat.size < tail.cursor) {
@@ -46815,6 +46934,22 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
46815
46934
  entry.lastSummaryLine = ev.text.split(`
46816
46935
  `)[0].trim().slice(0, 120);
46817
46936
  entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX);
46937
+ if (onProgress != null && entry.state === "running" && !entry.historical) {
46938
+ try {
46939
+ onProgress({
46940
+ agentId: entry.agentId,
46941
+ description: entry.description,
46942
+ latestSummary: entry.lastResultText,
46943
+ elapsedMs: now - entry.dispatchedAt,
46944
+ prevBucketIdx: entry.lastProgressBucketIdx,
46945
+ setBucketIdx: (b) => {
46946
+ entry.lastProgressBucketIdx = b;
46947
+ }
46948
+ });
46949
+ } catch (cbErr) {
46950
+ log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${cbErr.message}`);
46951
+ }
46952
+ }
46818
46953
  } else if (ev.kind === "sub_agent_turn_end") {
46819
46954
  if (entry.state === "running") {
46820
46955
  entry.state = "done";
@@ -46913,6 +47048,7 @@ function startSubagentWatcher(config) {
46913
47048
  stallTerminalSynthesised: false,
46914
47049
  lastSummaryLine: "",
46915
47050
  lastResultText: "",
47051
+ lastProgressBucketIdx: null,
46916
47052
  lastTool: null,
46917
47053
  historical: isHistorical
46918
47054
  };
@@ -46933,7 +47069,7 @@ function startSubagentWatcher(config) {
46933
47069
  tails.set(agentId, tail);
46934
47070
  readSubTail(entry, tail, n, (desc) => {
46935
47071
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
46936
- }, fs2, log, db2, parentStateDir, config.onUnstall);
47072
+ }, fs2, log, db2, parentStateDir, config.onUnstall, undefined, config.onProgress);
46937
47073
  if (isHistorical && entry.state === "done") {
46938
47074
  entry.completionNotified = true;
46939
47075
  scheduleTerminalCleanup(agentId);
@@ -46950,7 +47086,7 @@ function startSubagentWatcher(config) {
46950
47086
  return;
46951
47087
  readSubTail(entry2, t, nowFn(), (desc) => {
46952
47088
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
46953
- }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
47089
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress);
46954
47090
  maybySendStateTransition(agentId);
46955
47091
  });
46956
47092
  } catch (err) {
@@ -47044,7 +47180,7 @@ function startSubagentWatcher(config) {
47044
47180
  if (idleMs >= threshold) {
47045
47181
  entry.stallNotified = true;
47046
47182
  entry.stalledAt = n;
47047
- const desc = escapeHtml8(truncate3(entry.description, 80));
47183
+ const desc = escapeHtml8(truncate4(entry.description, 80));
47048
47184
  const idleSec = Math.floor(idleMs / 1000);
47049
47185
  log?.(`subagent-watcher: stall detected for ${entry.agentId} (idle ${idleSec}s): ${desc}`);
47050
47186
  if (db2 != null) {
@@ -47196,7 +47332,7 @@ function startSubagentWatcher(config) {
47196
47332
  continue;
47197
47333
  readSubTail(entry, tail, n, (desc) => {
47198
47334
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
47199
- }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
47335
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress);
47200
47336
  maybySendStateTransition(agentId);
47201
47337
  }
47202
47338
  checkStalls();
@@ -48006,7 +48142,7 @@ function summarizeToolForTitle(toolName, inputPreview) {
48006
48142
  }
48007
48143
  case "Bash": {
48008
48144
  const command = readString(input, "command");
48009
- return command ? `${toolName}: ${truncate4(command, COMMAND_TITLE_MAX)}` : toolName;
48145
+ return command ? `${toolName}: ${truncate5(command, COMMAND_TITLE_MAX)}` : toolName;
48010
48146
  }
48011
48147
  case "Read":
48012
48148
  case "Edit":
@@ -48014,17 +48150,17 @@ function summarizeToolForTitle(toolName, inputPreview) {
48014
48150
  case "MultiEdit":
48015
48151
  case "NotebookEdit": {
48016
48152
  const filePath = readString(input, "file_path") ?? readString(input, "notebook_path");
48017
- return filePath ? `${toolName}: ${truncate4(basename5(filePath), PATH_TITLE_MAX)}` : toolName;
48153
+ return filePath ? `${toolName}: ${truncate5(basename5(filePath), PATH_TITLE_MAX)}` : toolName;
48018
48154
  }
48019
48155
  case "Glob":
48020
48156
  case "Grep": {
48021
48157
  const pattern = readString(input, "pattern");
48022
- return pattern ? `${toolName}: ${truncate4(pattern, COMMAND_TITLE_MAX)}` : toolName;
48158
+ return pattern ? `${toolName}: ${truncate5(pattern, COMMAND_TITLE_MAX)}` : toolName;
48023
48159
  }
48024
48160
  case "WebFetch":
48025
48161
  case "WebSearch": {
48026
48162
  const query2 = readString(input, "url") ?? readString(input, "query");
48027
- return query2 ? `${toolName}: ${truncate4(query2, COMMAND_TITLE_MAX)}` : toolName;
48163
+ return query2 ? `${toolName}: ${truncate5(query2, COMMAND_TITLE_MAX)}` : toolName;
48028
48164
  }
48029
48165
  default:
48030
48166
  return toolName;
@@ -48057,7 +48193,7 @@ function skillBasenameFromPath(input) {
48057
48193
  const basename6 = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
48058
48194
  return basename6.length > 0 ? basename6 : null;
48059
48195
  }
48060
- function truncate4(text, max) {
48196
+ function truncate5(text, max) {
48061
48197
  const collapsed = text.replace(/\s+/g, " ").trim();
48062
48198
  if (collapsed.length <= max)
48063
48199
  return collapsed;
@@ -48328,10 +48464,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48328
48464
  }
48329
48465
 
48330
48466
  // ../src/build-info.ts
48331
- var VERSION = "0.13.26";
48332
- var COMMIT_SHA = "b2767bb7";
48333
- var COMMIT_DATE = "2026-05-24T07:35:41Z";
48334
- var LATEST_PR = 1723;
48467
+ var VERSION = "0.13.27";
48468
+ var COMMIT_SHA = "a158e029";
48469
+ var COMMIT_DATE = "2026-05-24T09:47:16Z";
48470
+ var LATEST_PR = 1727;
48335
48471
  var COMMITS_AHEAD_OF_TAG = 0;
48336
48472
 
48337
48473
  // gateway/boot-version.ts
@@ -49362,6 +49498,9 @@ function maybeProactiveCompact() {
49362
49498
  resolveCompactCard("superseded", occupancy);
49363
49499
  }
49364
49500
  postCompactCard(occupancy, cap);
49501
+ for (const ctrl of activeStatusReactions.values()) {
49502
+ ctrl.setCompacting();
49503
+ }
49365
49504
  }
49366
49505
  if (!decision.fire)
49367
49506
  return;
@@ -49435,15 +49574,12 @@ async function resolveCompactCard(kind, occNow) {
49435
49574
  `);
49436
49575
  }
49437
49576
  }
49438
- function endStatusReaction(chatId, threadId, outcome) {
49577
+ function finalizeStatusReaction(chatId, threadId, reason = "done") {
49439
49578
  const key = statusKey(chatId, threadId);
49440
49579
  const ctrl = activeStatusReactions.get(key);
49441
49580
  if (!ctrl)
49442
49581
  return;
49443
- if (outcome === "done")
49444
- ctrl.setDone();
49445
- else
49446
- ctrl.setError();
49582
+ ctrl.finalize(reason);
49447
49583
  purgeReactionTracking(key);
49448
49584
  }
49449
49585
  function resolveThreadId(chat_id, explicit) {
@@ -51188,12 +51324,6 @@ ${url}`;
51188
51324
  progressDriver?.recordOutboundDelivered(chat_id, threadId != null ? String(threadId) : undefined);
51189
51325
  } catch {}
51190
51326
  noteSignal(statusKey(chat_id, threadId), Date.now());
51191
- try {
51192
- endStatusReaction(chat_id, threadId, "done");
51193
- } catch (err) {
51194
- process.stderr.write(`telegram gateway: reply: endStatusReaction hook threw: ${err}
51195
- `);
51196
- }
51197
51327
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
51198
51328
  turn.finalAnswerDelivered = true;
51199
51329
  }
@@ -51284,7 +51414,6 @@ async function executeStreamReply(args) {
51284
51414
  disableLinkPreview: access.disableLinkPreview !== false,
51285
51415
  defaultFormat: access.parseMode ?? "html",
51286
51416
  logStreamingEvent,
51287
- endStatusReaction,
51288
51417
  isPrivateChat: streamIsPrivate,
51289
51418
  isForumTopic: streamIsForumTopic,
51290
51419
  ...sendMessageDraftFn != null ? { sendMessageDraft: sendMessageDraftFn } : {},
@@ -52274,11 +52403,8 @@ function handleSessionEvent(ev) {
52274
52403
  verb: "context-exhaust-warning",
52275
52404
  ...threadId != null ? { threadId } : {}
52276
52405
  });
52406
+ finalizeStatusReaction(chatId, threadId, "error");
52277
52407
  const ceKey = statusKey(chatId, threadId);
52278
- const ctrl = activeStatusReactions.get(ceKey);
52279
- if (ctrl)
52280
- ctrl.setError();
52281
- purgeReactionTracking(ceKey);
52282
52408
  endTurn(ceKey);
52283
52409
  noteTurnEnd(ceKey);
52284
52410
  if (turn.answerStream != null) {
@@ -52380,9 +52506,7 @@ function handleSessionEvent(ev) {
52380
52506
  }
52381
52507
  }
52382
52508
  unpinProgressCardForChat?.(chatId, threadId);
52383
- if (ctrl)
52384
- ctrl.setDone();
52385
- purgeReactionTracking(statusKey(chatId, threadId));
52509
+ finalizeStatusReaction(chatId, threadId, "done");
52386
52510
  {
52387
52511
  const sKey = streamKey3(chatId, threadId);
52388
52512
  const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
@@ -52537,7 +52661,7 @@ function handleSessionEvent(ev) {
52537
52661
  }
52538
52662
  outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
52539
52663
  if (backstopCtrl)
52540
- backstopCtrl.setDone();
52664
+ backstopCtrl.finalize("done");
52541
52665
  if (backstopCardTurnKey != null) {
52542
52666
  completeProgressCardTurn?.({
52543
52667
  chatId: backstopChatId,
@@ -52551,16 +52675,14 @@ function handleSessionEvent(ev) {
52551
52675
  process.stderr.write(`telegram gateway: turn-flush send failed: ${err.message}
52552
52676
  `);
52553
52677
  if (backstopCtrl)
52554
- backstopCtrl.setError();
52678
+ backstopCtrl.finalize("error");
52555
52679
  } finally {
52556
52680
  purgeReactionTracking(statusKey(backstopChatId, backstopThreadId));
52557
52681
  }
52558
52682
  })();
52559
52683
  return;
52560
52684
  }
52561
- if (ctrl)
52562
- ctrl.setDone();
52563
- purgeReactionTracking(statusKey(chatId, threadId));
52685
+ finalizeStatusReaction(chatId, threadId, "done");
52564
52686
  {
52565
52687
  const sKey = streamKey3(chatId, threadId);
52566
52688
  const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
@@ -57918,7 +58040,8 @@ var didOneTimeSetup = false;
57918
58040
  fleetChatId,
57919
58041
  ownerChatId: loadAccess().allowFrom[0] ?? "",
57920
58042
  taskDescription: description,
57921
- resultText
58043
+ resultText,
58044
+ jsonlAgentId: agentId
57922
58045
  });
57923
58046
  if (!decision.deliver) {
57924
58047
  if (decision.reason === "no-chat") {
@@ -57927,8 +58050,60 @@ var didOneTimeSetup = false;
57927
58050
  }
57928
58051
  return;
57929
58052
  }
58053
+ try {
58054
+ const progressPrefix = `s:progress:${agentId}:`;
58055
+ const dropped = inboundSpool?.dropMatching((id) => id.startsWith(progressPrefix)) ?? 0;
58056
+ if (dropped > 0) {
58057
+ process.stderr.write(`telegram gateway: subagent-handback ${agentId} swept ${dropped} live progress envelope(s) from spool
58058
+ `);
58059
+ }
58060
+ } catch (err) {
58061
+ process.stderr.write(`telegram gateway: subagent-handback ${agentId} progress-sweep error: ${err.message}
58062
+ `);
58063
+ }
57930
58064
  pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
57931
58065
  process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
58066
+ `);
58067
+ },
58068
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx }) => {
58069
+ let fleetChatId = "";
58070
+ let isBackground = false;
58071
+ try {
58072
+ const fleets = progressDriver?.peekAllFleets() ?? [];
58073
+ for (const f of fleets) {
58074
+ if (f.fleet.has(agentId)) {
58075
+ fleetChatId = f.chatId ?? "";
58076
+ break;
58077
+ }
58078
+ }
58079
+ } catch {}
58080
+ if (turnsDb != null) {
58081
+ try {
58082
+ const row = turnsDb.prepare("SELECT background FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
58083
+ if (row != null)
58084
+ isBackground = row.background === 1;
58085
+ } catch {}
58086
+ }
58087
+ if (!isBackground)
58088
+ return;
58089
+ const decision = decideSubagentProgress({
58090
+ disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
58091
+ isBackground,
58092
+ fleetChatId,
58093
+ ownerChatId: loadAccess().allowFrom[0] ?? "",
58094
+ subagentJsonlId: agentId,
58095
+ taskDescription: description,
58096
+ latestSummary,
58097
+ elapsedMs,
58098
+ progressIntervalMs: DEFAULT_PROGRESS_INTERVAL_MS,
58099
+ lastBucketIdx: prevBucketIdx
58100
+ });
58101
+ if (!decision.deliver)
58102
+ return;
58103
+ setBucketIdx(decision.bucketIdx);
58104
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
58105
+ clearPending(statusKey(decision.chatId, undefined), "progress");
58106
+ process.stderr.write(`telegram gateway: subagent-progress queued agent=${agentId} bucket=${decision.bucketIdx} elapsed_ms=${elapsedMs} chat=${decision.chatId}
57932
58107
  `);
57933
58108
  }
57934
58109
  });
@@ -1,7 +1,23 @@
1
1
  # Waiting-for-reply UX — v2 spec (three-class contract)
2
2
 
3
3
  Tracks: [#545](https://github.com/mekenthompson/switchroom/issues/545),
4
- [#553](https://github.com/mekenthompson/switchroom/issues/553) (PR series)
4
+ [#553](https://github.com/mekenthompson/switchroom/issues/553) (PR series),
5
+ [#1713](https://github.com/switchroom/switchroom/issues/1713)
6
+ (reflective status-reaction restoration)
7
+
8
+ > **#1713 note — defect restoration, not feature change.** The Class B
9
+ > contract below ("Ladder progresses through 🤔 / tool-glyphs … **Must
10
+ > NOT collapse straight to 👍**") already documents the correct
11
+ > reflective behaviour. The implementation had regressed: plain `reply`
12
+ > and `stream_reply done=true` were firing the terminal 👍 mid-turn,
13
+ > collapsing the ladder. #1713 restores the documented contract — the
14
+ > **`turn_end` IPC event (Stop hook) is the sole terminal trigger**.
15
+ > Mid-turn replies (ack OR final-answer) are non-events for the
16
+ > reaction. Working states (🤔 / ✍ / 👨‍💻 / ⚡ / 🗜) are bidirectional
17
+ > and may re-enter any number of times within a turn. 🔥 (5xx) is also
18
+ > non-terminal: recovery to a working state is allowed; only
19
+ > `finalize()` ends the controller. Controller debounce is 3500ms by
20
+ > default (#1713 spec: 3-5s) to coalesce rapid state flips.
5
21
 
6
22
  This document codifies the user-perceived contract for what happens
7
23
  between "I sent a Telegram message" and "the agent's reply is locked