blun-king-cli 9.1.515 → 9.1.516

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.1.516 - 2026-08-31
4
+
5
+ - Caps the first tool-capable turn's wait for the initial MCP catalogue at 2.5 seconds, so a cold optional desktop server can finish in the background instead of blocking Read, Bash, Grep, TodoList, Telegram, and AgentSpine.
6
+ - Verifies and installs AgentSpine only for the active profile at startup; dormant profiles are verified when they are launched, preventing startup time from growing with abandoned proof profiles.
7
+ - Reuses the immutable packaged plugin source snapshot across managed-plugin checks while still reading and byte-verifying every active installed plugin tree, and logs each MCP server's startup status for reproducible timing evidence.
8
+ - Retires an active-steer queue entry exactly once as soon as the core has durably accepted its `turn.steer`, instead of restoring an already delivered message after a 15-second UI confirmation timeout; genuine rejections remain queued.
9
+ - Restarts an owned Telegram bridge after an unexpected exit or launch error with one deduplicated timer, while ownership loss and normal shutdown still prevent respawn.
10
+ - Reconstructs a missing assistant step when a persisted `content.part` or tool call follows full compaction without its `step.begin`, preserving the continuation instead of making `king -c` fail during replay.
11
+ - Allows only the first TodoList write in a model step, preventing parallel list replacements from racing while leaving read-only list queries untouched.
12
+ - Extends the package gates for Ctrl+S, Escape, durable queue acknowledgement, rejection replay, stable urgent/direct/addressed/bot/normal priority, Todo lifecycle, visible pending markers, valid initial Todo state, and single-bridge recovery.
13
+
3
14
  ## 9.1.515 - 2026-08-31
4
15
 
5
16
  - Accepts a truthful unchanged TodoList during mandatory maintenance instead of terminating the turn after five identical retries.
package/LIESMICH.txt CHANGED
@@ -9,11 +9,11 @@ Installation
9
9
  ------------
10
10
  Die geprüfte Version exakt global installieren:
11
11
 
12
- npm install -g blun-king-cli@9.1.515
12
+ npm install -g blun-king-cli@9.1.516
13
13
 
14
14
  AgentSpine 0.10.1
15
15
  -----------------
16
- Version 9.1.515 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
16
+ Version 9.1.516 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
17
17
 
18
18
  Start
19
19
  -----
package/README.md CHANGED
@@ -9,12 +9,12 @@ Voraussetzung ist Node.js 24.15 oder neuer. Die geprüfte Version wird exakt
9
9
  installiert:
10
10
 
11
11
  ```powershell
12
- npm install -g blun-king-cli@9.1.515
12
+ npm install -g blun-king-cli@9.1.516
13
13
  ```
14
14
 
15
15
  ## AgentSpine 0.10.1
16
16
 
17
- Version 9.1.515 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
17
+ Version 9.1.516 enthält weiterhin AgentSpine 0.10.1 als inhaltsadressierte Pluginfassung. Der Preflight prüft jede aktive Host-Anweisungsdatei weiterhin race-sicher und bindet SHA-256 sowie Dateiidentität an den Zug, dupliziert den bereits vom Host geladenen Volltext aber nicht im Laufzeitkontext. Die reale Probe mit einer 15.519 Byte großen `CLAUDE.md` blieb dadurch bei 5.667 injizierten Byte. Der Stand enthält außerdem den selbstheilenden Persona- und Beziehungsgraphen, die begrenzte Telegram-Mnemo-Abfrage und eine sichtbare Fünf-Sekunden-Grenze für lokale Beziehungsabfragen.
18
18
 
19
19
  ## Reproduzierbares Staging und Packen
20
20
 
@@ -282,36 +282,13 @@ function installAgentSpineForProfile(packageRoot, blunDir) {
282
282
  installManagedAgentSpinePlugin({ packageRoot, blunDir });
283
283
  }
284
284
 
285
- function installAgentSpineForExistingProfiles(packageRoot, sharedHome, currentProfileHome) {
286
- const profileRoot = path.join(sharedHome, 'profile');
287
- const profileHomes = new Map();
288
- const addProfileHome = (profileHome) => {
289
- const resolved = path.resolve(profileHome);
290
- const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
291
- profileHomes.set(key, resolved);
292
- };
293
-
294
- addProfileHome(currentProfileHome);
295
- if (fs.existsSync(profileRoot)) {
296
- for (const entry of fs.readdirSync(profileRoot, { withFileTypes: true })) {
297
- if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
298
- let profileHome;
299
- try {
300
- profileHome = resolveProfilePaths(sharedHome, entry.name).home;
301
- } catch {
302
- continue;
303
- }
304
- const stat = fs.lstatSync(profileHome);
305
- if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
306
- addProfileHome(profileHome);
307
- }
308
- }
309
-
310
- for (const profileHome of profileHomes.values()) {
311
- ensurePrivateDirectory(profileHome);
312
- installAgentSpineForProfile(packageRoot, profileHome);
313
- }
314
- return [...profileHomes.values()];
285
+ function installAgentSpineForExistingProfiles(packageRoot, _sharedHome, currentProfileHome) {
286
+ // A dormant profile is verified when that profile is launched. Rescanning all
287
+ // profiles here makes every startup slower as old profiles accumulate.
288
+ const profileHome = path.resolve(currentProfileHome);
289
+ ensurePrivateDirectory(profileHome);
290
+ installAgentSpineForProfile(packageRoot, profileHome);
291
+ return [profileHome];
315
292
  }
316
293
 
317
294
  function createManagedNodeEnvironment(binary, env = process.env, platform = process.platform) {
@@ -11,6 +11,8 @@ const {
11
11
  writePrivateFile,
12
12
  } = require('./private-paths');
13
13
 
14
+ const managedPluginSourceSnapshots = new Map();
15
+
14
16
  function isObject(value) {
15
17
  return typeof value === 'object' && value !== null && !Array.isArray(value);
16
18
  }
@@ -42,9 +44,18 @@ function snapshotPluginTree(root) {
42
44
  return visit(root) ? entries : null;
43
45
  }
44
46
 
47
+ function snapshotManagedPluginSource(root) {
48
+ const resolvedRoot = path.resolve(root);
49
+ const cacheKey = process.platform === 'win32' ? resolvedRoot.toLowerCase() : resolvedRoot;
50
+ if (!managedPluginSourceSnapshots.has(cacheKey)) {
51
+ managedPluginSourceSnapshots.set(cacheKey, snapshotPluginTree(resolvedRoot));
52
+ }
53
+ return managedPluginSourceSnapshots.get(cacheKey);
54
+ }
55
+
45
56
  function pluginTreesAreIdentical(sourceRoot, pluginRoot) {
46
57
  try {
47
- const source = snapshotPluginTree(sourceRoot);
58
+ const source = snapshotManagedPluginSource(sourceRoot);
48
59
  const installed = snapshotPluginTree(pluginRoot);
49
60
  if (source === null || installed === null || source.length !== installed.length) return false;
50
61
  return source.every((entry, index) => {
@@ -59,7 +70,7 @@ function pluginTreesAreIdentical(sourceRoot, pluginRoot) {
59
70
  }
60
71
 
61
72
  function pluginTreeDigest(root) {
62
- const entries = snapshotPluginTree(root);
73
+ const entries = snapshotManagedPluginSource(root);
63
74
  if (entries === null) throw new Error(`Invalid managed plugin tree: ${root}`);
64
75
  const hash = createHash('sha256');
65
76
  for (const entry of entries) {
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const TODO_LIST_TOOL_NAME = 'TodoList';
4
+
5
+ function enforceSingleTodoWritePerStep(context) {
6
+ if (context?.toolCall?.name !== TODO_LIST_TOOL_NAME) return undefined;
7
+ if (!Array.isArray(context.args?.todos)) return undefined;
8
+
9
+ const todoWrites = Array.isArray(context.toolCalls)
10
+ ? context.toolCalls.filter((call) => call?.name === TODO_LIST_TOOL_NAME)
11
+ : [];
12
+ if (todoWrites.length <= 1) return undefined;
13
+ if (context.toolCall.id === todoWrites[0]?.id) return undefined;
14
+
15
+ return {
16
+ block: true,
17
+ reason: 'Only one TodoList write is allowed per model step. Use the first update, then continue from its stored result in the next step.',
18
+ };
19
+ }
20
+
21
+ module.exports = { enforceSingleTodoWritePerStep };
package/blun.mjs CHANGED
@@ -79167,6 +79167,25 @@ var init_context$2 = __esmMin((() => {
79167
79167
  closeAbandonedToolExchange(output) {
79168
79168
  return this.closePendingToolResults(output).length;
79169
79169
  }
79170
+ recoverMissingOpenStep(event) {
79171
+ const resolvedStepUuid = resolveStepEventUuid(this.openSteps, event);
79172
+ const existingStep = this.openSteps.get(resolvedStepUuid);
79173
+ if (existingStep !== void 0) return existingStep;
79174
+ const recoveredStepUuid = event.stepUuid ?? `recovered-${this._history.length}-${event.type}`;
79175
+ const message = {
79176
+ role: "assistant",
79177
+ content: [],
79178
+ toolCalls: []
79179
+ };
79180
+ this.pushHistory(message);
79181
+ this.openSteps.set(recoveredStepUuid, message);
79182
+ this.agent.log.warn("recovered loop event without step.begin", {
79183
+ eventType: event.type,
79184
+ stepUuid: event.stepUuid,
79185
+ restoring: this.agent.records.restoring !== null
79186
+ });
79187
+ return message;
79188
+ }
79170
79189
  appendLoopEvent(event) {
79171
79190
  this.agent.records.logRecord({
79172
79191
  type: "context.append_loop_event",
@@ -79208,17 +79227,13 @@ var init_context$2 = __esmMin((() => {
79208
79227
  return;
79209
79228
  }
79210
79229
  case "content.part": {
79211
- const stepEventUuid = resolveStepEventUuid(this.openSteps, event);
79212
- const openStep = this.openSteps.get(stepEventUuid);
79213
- if (openStep === void 0) throw new Error(`Received content_part for unknown step_uuid '${stepEventUuid}' (no open step_begin)`);
79230
+ const openStep = this.recoverMissingOpenStep(event);
79214
79231
  openStep.content.push(event.part);
79215
79232
  this.markPendingTokenEstimateDirty();
79216
79233
  return;
79217
79234
  }
79218
79235
  case "tool.call": {
79219
- const stepEventUuid = resolveStepEventUuid(this.openSteps, event);
79220
- const openStep = this.openSteps.get(stepEventUuid);
79221
- if (openStep === void 0) throw new Error(`Received tool_call for unknown step_uuid '${stepEventUuid}' (no open step_begin)`);
79236
+ const openStep = this.recoverMissingOpenStep(event);
79222
79237
  openStep.toolCalls.push({
79223
79238
  type: "function",
79224
79239
  id: event.toolCallId,
@@ -261610,6 +261625,13 @@ function blunTurnNeedsInitialMcp(input, origin) {
261610
261625
  if (blunTurnNeedsTools(input, origin)) return true;
261611
261626
  return BLUN_TELEGRAM_CHANNEL_RE.test(blunExtractText(input)) || blunTurnHasAttachment(input);
261612
261627
  }
261628
+ async function blunWaitForInitialMcpBudget(mcp, signal) {
261629
+ if (mcp === void 0) return;
261630
+ await Promise.race([
261631
+ mcp.waitForInitialLoad(signal),
261632
+ new Promise((resolve) => setTimeout(resolve, 2500))
261633
+ ]);
261634
+ }
261613
261635
  function blunMessageTextChars(message) {
261614
261636
  return message.content.reduce((total, part) => total + (part.type === "text" ? part.text.length : 0), 0);
261615
261637
  }
@@ -261919,7 +261941,7 @@ function durablePromptOrigin(origin) {
261919
261941
  void externalReportSources;
261920
261942
  return durableOrigin;
261921
261943
  }
261922
- var BLUN_CORE_TOOL_NAMES, BLUN_LEAN_TOOL_NAMES, BLUN_ATTACHMENT_MARKER_RE, BLUN_TELEGRAM_OUTBOUND_TOOL_RE, BLUN_TELEGRAM_CHANNEL_RE, BLUN_TOOL_BUDGET_RATIO, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens, projectRecurringCronHistory, TelegramDeliveryLedger, createRuntimeCognitiveTurnLifecycle, cognitiveFocusScopesForTurn, buildCognitiveWorkFocus, buildAttentionQueueItem, LLM_NOT_SET_MESSAGE, GOAL_CONTINUATION_ORIGIN, GOAL_COMPLETION_REMINDER_NAME, GOAL_BLOCKED_REMINDER_NAME, GOAL_RATE_LIMIT_PAUSE_REASON, GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, GOAL_PROVIDER_AUTH_PAUSE_PREFIX, GOAL_PROVIDER_API_PAUSE_PREFIX, GOAL_MODEL_CONFIG_PAUSE_PREFIX, GOAL_RUNTIME_PAUSE_PREFIX, GOAL_PROVIDER_FILTERED_PAUSE_REASON, GOAL_CONTINUATION_PROMPT, TurnFlow;
261944
+ var BLUN_CORE_TOOL_NAMES, BLUN_LEAN_TOOL_NAMES, BLUN_ATTACHMENT_MARKER_RE, BLUN_TELEGRAM_OUTBOUND_TOOL_RE, BLUN_TELEGRAM_CHANNEL_RE, BLUN_TOOL_BUDGET_RATIO, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens, enforceSingleTodoWritePerStep, projectRecurringCronHistory, TelegramDeliveryLedger, createRuntimeCognitiveTurnLifecycle, cognitiveFocusScopesForTurn, buildCognitiveWorkFocus, buildAttentionQueueItem, LLM_NOT_SET_MESSAGE, GOAL_CONTINUATION_ORIGIN, GOAL_COMPLETION_REMINDER_NAME, GOAL_BLOCKED_REMINDER_NAME, GOAL_RATE_LIMIT_PAUSE_REASON, GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, GOAL_PROVIDER_AUTH_PAUSE_PREFIX, GOAL_PROVIDER_API_PAUSE_PREFIX, GOAL_MODEL_CONFIG_PAUSE_PREFIX, GOAL_RUNTIME_PAUSE_PREFIX, GOAL_PROVIDER_FILTERED_PAUSE_REASON, GOAL_CONTINUATION_PROMPT, TurnFlow;
261923
261945
  var init_turn = __esmMin((() => {
261924
261946
  init_dist$4();
261925
261947
  init_src$4();
@@ -261937,6 +261959,7 @@ var init_turn = __esmMin((() => {
261937
261959
  init_user_message_offload();
261938
261960
  init_assistant_message_offload();
261939
261961
  ({ CORE_TOOL_NAMES: BLUN_CORE_TOOL_NAMES, createDeferredToolLoader, mediaToolNamesForTurnText, rankedSupportToolNamesForGoal, rankedToolNamesForTurnText, rememberDeferredToolAfterNotFound, toolSchemaBudgetTokens } = createRequire(import.meta.url)("./bin/turn-tool-performance-policy.cjs"));
261962
+ ({ enforceSingleTodoWritePerStep } = createRequire(import.meta.url)("./bin/todo-list-turn-policy.cjs"));
261940
261963
  ({ projectRecurringCronHistory } = createRequire(import.meta.url)("./bin/recurring-cron-history-policy.cjs"));
261941
261964
  ({ TelegramDeliveryLedger } = createRequire(import.meta.url)("./bin/repeated-user-message-projection.cjs"));
261942
261965
  ({ createRuntimeCognitiveTurnLifecycle } = createRequire(import.meta.url)("./bin/cognitive-turn-lifecycle.cjs"));
@@ -262750,7 +262773,7 @@ var init_turn = __esmMin((() => {
262750
262773
  let goalOutcomeMessageContinuationUsed = false;
262751
262774
  const directReplyTurnStop = createDirectReplyTurnStop(blunExtractText(input));
262752
262775
  const deduper = new ToolCallDeduplicator({ telemetry: this.agent.telemetry });
262753
- if (blunTurnNeedsInitialMcp(input, origin)) await this.agent.mcp?.waitForInitialLoad(signal);
262776
+ if (blunTurnNeedsInitialMcp(input, origin)) await blunWaitForInitialMcpBudget(this.agent.mcp, signal);
262754
262777
  const personalMemoryRecall = await this.agent.injection.injectPersonalMemoryForTurn(turnId, input, origin, signal);
262755
262778
  await this.agent.injection.injectGoal();
262756
262779
  const cognitiveProjection = this.projectCognitiveState(turnId, input);
@@ -262953,6 +262976,8 @@ var init_turn = __esmMin((() => {
262953
262976
  return { continue: false };
262954
262977
  },
262955
262978
  prepareToolExecution: async (ctx) => {
262979
+ const todoTurnPolicy = enforceSingleTodoWritePerStep(ctx);
262980
+ if (todoTurnPolicy !== void 0) return todoTurnPolicy;
262956
262981
  const goalTodoPolicy = enforceGoalTodoPolicy(this.agent, ctx);
262957
262982
  if (goalTodoPolicy !== void 0) return goalTodoPolicy;
262958
262983
  const ideaPolicy = enforceIdeaToolPolicy(this.agent, ctx);
@@ -298308,6 +298333,11 @@ var init_session$1 = __esmMin((() => {
298308
298333
  });
298309
298334
  }
298310
298335
  onMcpServerStatusChange(entry) {
298336
+ this.log.info("mcp server startup status", {
298337
+ server: entry.name,
298338
+ status: entry.status,
298339
+ toolCount: entry.toolCount
298340
+ });
298311
298341
  this.rpc.emitEvent({
298312
298342
  type: "mcp.server.status",
298313
298343
  agentId: "main",
@@ -419872,7 +419902,7 @@ const OWNERSHIP_SETTLE_MS = 50;
419872
419902
  const OWNERSHIP_HANDOFF_WARNING_MS = 5e3;
419873
419903
  const BRIDGE_STOP_TIMEOUT_MS = 2500;
419874
419904
  const BRIDGE_FORCE_STOP_TIMEOUT_MS = 500;
419875
- const CHANNEL_STEER_CONFIRM_TIMEOUT_MS = 15e3;
419905
+ const BRIDGE_RESTART_DELAY_MS = 1e3;
419876
419906
  const CHANNEL_DELIVERY_MAX_FAILURES = 3;
419877
419907
  const BLUN_NODE_FALLBACK_SUBCOMMAND = "__plugin_run_node";
419878
419908
  function isBlunNativeBinary(execPath) {
@@ -419990,6 +420020,7 @@ var TelegramChannelController = class {
419990
420020
  ownershipTimer;
419991
420021
  activationTimer;
419992
420022
  handoffTimer;
420023
+ bridgeRestartTimer;
419993
420024
  offset = 0;
419994
420025
  checkpointOffset = 0;
419995
420026
  queueFileId = "";
@@ -420006,6 +420037,7 @@ var TelegramChannelController = class {
420006
420037
  spawnBridge;
420007
420038
  bridgeStopTimeoutMs;
420008
420039
  bridgeForceStopTimeoutMs;
420040
+ bridgeRestartDelayMs;
420009
420041
  ownerId;
420010
420042
  ownershipPollMs;
420011
420043
  ownershipSettleMs;
@@ -420017,6 +420049,7 @@ var TelegramChannelController = class {
420017
420049
  this.spawnBridge = options.spawnBridge ?? spawn;
420018
420050
  this.bridgeStopTimeoutMs = options.bridgeStopTimeoutMs ?? BRIDGE_STOP_TIMEOUT_MS;
420019
420051
  this.bridgeForceStopTimeoutMs = options.bridgeForceStopTimeoutMs ?? BRIDGE_FORCE_STOP_TIMEOUT_MS;
420052
+ this.bridgeRestartDelayMs = options.bridgeRestartDelayMs ?? BRIDGE_RESTART_DELAY_MS;
420020
420053
  this.ownerId = options.ownerId ?? randomUUID();
420021
420054
  this.ownershipPollMs = options.ownershipPollMs ?? OWNERSHIP_POLL_MS;
420022
420055
  this.ownershipSettleMs = options.ownershipSettleMs ?? OWNERSHIP_SETTLE_MS;
@@ -420192,6 +420225,7 @@ var TelegramChannelController = class {
420192
420225
  if (this.ownershipTimer !== void 0) clearInterval(this.ownershipTimer);
420193
420226
  if (this.activationTimer !== void 0) clearTimeout(this.activationTimer);
420194
420227
  if (this.handoffTimer !== void 0) clearTimeout(this.handoffTimer);
420228
+ if (this.bridgeRestartTimer !== void 0) clearTimeout(this.bridgeRestartTimer);
420195
420229
  try {
420196
420230
  if (parseInt(readFileSync(this.leaseFile, "utf8"), 10) === process.pid) rmSync(this.leaseFile);
420197
420231
  } catch {}
@@ -420362,6 +420396,15 @@ var TelegramChannelController = class {
420362
420396
  * (it will see our fresh lease and route to us). Otherwise start one bridge
420363
420397
  * owned by this controller; shutdown never signals a pre-existing process.
420364
420398
  */
420399
+ scheduleBridgeRestart() {
420400
+ if (this.stopped || !this.activeOwner || !this.ownsChannel() || this.bridgeRestartTimer !== void 0) return;
420401
+ this.bridgeRestartTimer = setTimeout(() => {
420402
+ this.bridgeRestartTimer = void 0;
420403
+ if (this.stopped || !this.activeOwner || !this.ownsChannel()) return;
420404
+ this.ensureBridgeRunning();
420405
+ }, this.bridgeRestartDelayMs);
420406
+ this.bridgeRestartTimer.unref?.();
420407
+ }
420365
420408
  ensureBridgeRunning() {
420366
420409
  if (this.liveBridgePid() !== void 0) return;
420367
420410
  const bridge = this.resolveBridgeEntry();
@@ -420384,14 +420427,17 @@ var TelegramChannelController = class {
420384
420427
  child.once("exit", () => {
420385
420428
  this.removeOwnedBridgePid(child.pid);
420386
420429
  if (this.ownedBridge === child) this.ownedBridge = void 0;
420430
+ this.scheduleBridgeRestart();
420387
420431
  });
420388
420432
  child.once("error", (error) => {
420389
420433
  this.removeOwnedBridgePid(child.pid);
420390
420434
  if (this.ownedBridge === child) this.ownedBridge = void 0;
420391
420435
  if (!this.stopped) this.host.warn(uiText("telegramChannel.bridgeStartFailed", { error: String(error) }));
420436
+ this.scheduleBridgeRestart();
420392
420437
  });
420393
420438
  } catch (error) {
420394
420439
  this.host.warn(uiText("telegramChannel.bridgeStartFailed", { error: String(error) }));
420440
+ this.scheduleBridgeRestart();
420395
420441
  }
420396
420442
  }
420397
420443
  resolveBridgeEntry() {
@@ -518332,21 +518378,7 @@ var BlunTUI = class {
518332
518378
  try {
518333
518379
  const result = await this.harness.withInteractiveAgent(item.agentId ?? "main", () => session.steerActive(input, expectedTurnId === void 0 ? {} : { expectedTurnId }));
518334
518380
  if (!result.accepted) return this.recoverRejectedActiveSteer(inFlight);
518335
- inFlight.turnId = String(result.turnId);
518336
- inFlight.accepted = true;
518337
- for (const queued of items) this.traceTelegramDelivery?.({ stage: "accepted", ...queued.channelDeliveryTrace, route: "active_steer", turnId: inFlight.turnId });
518338
- if (result.duplicate === true) {
518339
- inFlight.duplicate = true;
518340
- return this.commitQueuedSteer(inFlight);
518341
- }
518342
- if (inFlight.turnEnded) return restoreHead();
518343
- if (!inFlight.stepStarted) {
518344
- inFlight.confirmationTimer = setTimeout(() => {
518345
- if (this.queueSteerInFlight === inFlight && inFlight.accepted && !inFlight.stepStarted) restoreHead(new Error("channel steer confirmation timed out"));
518346
- }, CHANNEL_STEER_CONFIRM_TIMEOUT_MS);
518347
- inFlight.confirmationTimer.unref?.();
518348
- }
518349
- this.commitQueuedSteerIfReady(inFlight);
518381
+ return this.acceptQueuedSteer(inFlight, result, true);
518350
518382
  } catch (error) {
518351
518383
  return restoreHead(error);
518352
518384
  }
@@ -518462,6 +518494,15 @@ var BlunTUI = class {
518462
518494
  this.updateQueueDisplay();
518463
518495
  this.state.ui.requestRender();
518464
518496
  }
518497
+ acceptQueuedSteer(inFlight, result, traceDelivery = false) {
518498
+ if (this.queueSteerInFlight !== inFlight || !result.accepted) return false;
518499
+ inFlight.turnId = String(result.turnId);
518500
+ inFlight.accepted = true;
518501
+ inFlight.duplicate = result.duplicate === true;
518502
+ if (traceDelivery) for (const queued of inFlight.items) this.traceTelegramDelivery?.({ stage: "accepted", ...queued.channelDeliveryTrace, route: "active_steer", turnId: inFlight.turnId });
518503
+ this.commitQueuedSteer(inFlight);
518504
+ return true;
518505
+ }
518465
518506
  commitQueuedSteerIfReady(inFlight) {
518466
518507
  if (this.queueSteerInFlight !== inFlight || !inFlight.accepted || !inFlight.stepStarted || inFlight.turnEnded) return;
518467
518508
  this.commitQueuedSteer(inFlight);
@@ -518647,12 +518688,7 @@ var BlunTUI = class {
518647
518688
  session.steerActive(items.map((item) => item.text.trim()).join("\n\n"), expectedTurnId === void 0 ? {} : { expectedTurnId }).then((result) => {
518648
518689
  if (this.queueSteerInFlight !== inFlight) return;
518649
518690
  if (!result.accepted) this.recoverRejectedActiveSteer(inFlight);
518650
- else {
518651
- inFlight.turnId = String(result.turnId);
518652
- inFlight.accepted = true;
518653
- if (inFlight.turnEnded) this.restoreQueuedSteer(inFlight);
518654
- else this.commitQueuedSteerIfReady(inFlight);
518655
- }
518691
+ else this.acceptQueuedSteer(inFlight, result);
518656
518692
  }).catch((error) => {
518657
518693
  this.restoreQueuedSteer(inFlight, error);
518658
518694
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blun-king-cli",
3
- "version": "9.1.515",
3
+ "version": "9.1.516",
4
4
  "description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "node --test test/*.test.js",
12
- "prepack": "node scripts/check-release-metadata.js && node scripts/check-todo-loop-regression.js",
12
+ "prepack": "node scripts/check-release-metadata.js && node scripts/check-todo-loop-regression.js && node scripts/check-queue-controls-regression.js && node scripts/check-telegram-bridge-watchdog.js && node scripts/check-resume-replay-regression.js && node scripts/check-plugin-startup-regression.js && node scripts/check-active-profile-plugin-startup.js && node scripts/check-mcp-startup-wait-budget.js",
13
13
  "release:verify": "node scripts/check-release-metadata.js --external",
14
14
  "postinstall": "node scripts/fix-node-pty-perms.js"
15
15
  },
@@ -36,6 +36,12 @@
36
36
  "native/",
37
37
  "release-planned-removals.json",
38
38
  "scripts/check-package-regression.js",
39
+ "scripts/check-active-profile-plugin-startup.js",
40
+ "scripts/check-mcp-startup-wait-budget.js",
41
+ "scripts/check-plugin-startup-regression.js",
42
+ "scripts/check-queue-controls-regression.js",
43
+ "scripts/check-telegram-bridge-watchdog.js",
44
+ "scripts/check-resume-replay-regression.js",
39
45
  "scripts/check-release-metadata.js",
40
46
  "scripts/check-todo-loop-regression.js",
41
47
  "scripts/fix-node-pty-perms.js",
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const { installAgentSpineForExistingProfiles } = require('../bin/launcher-runtime');
9
+
10
+ const packageRoot = path.resolve(__dirname, '..');
11
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'blun-active-profile-plugin-'));
12
+ const sharedHome = path.join(temporaryRoot, '.blun');
13
+ const currentProfile = path.join(sharedHome, 'profile', 'current');
14
+ const dormantProfile = path.join(sharedHome, 'profile', 'dormant');
15
+
16
+ try {
17
+ fs.mkdirSync(currentProfile, { recursive: true });
18
+ fs.mkdirSync(dormantProfile, { recursive: true });
19
+
20
+ installAgentSpineForExistingProfiles(packageRoot, sharedHome, currentProfile);
21
+
22
+ assert.equal(
23
+ fs.existsSync(path.join(currentProfile, 'plugins', 'installed.json')),
24
+ true,
25
+ 'the active profile must receive the managed AgentSpine plugin',
26
+ );
27
+ assert.equal(
28
+ fs.existsSync(path.join(dormantProfile, 'plugins', 'installed.json')),
29
+ false,
30
+ 'startup must not rescan or mutate dormant profiles',
31
+ );
32
+
33
+ process.stdout.write('active profile plugin startup regression: PASS\n');
34
+ } finally {
35
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
36
+ }
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const vm = require('node:vm');
7
+
8
+ const bundlePath = path.resolve(__dirname, '..', 'blun.mjs');
9
+ const source = fs.readFileSync(bundlePath, 'utf8');
10
+ const match = source.match(
11
+ /async function blunWaitForInitialMcpBudget\(mcp, signal\) \{[\s\S]*?\n\}/u,
12
+ );
13
+ assert.ok(match, 'the bundle must contain the bounded initial MCP wait helper');
14
+ assert.match(
15
+ source,
16
+ /await blunWaitForInitialMcpBudget\(this\.agent\.mcp, signal\)/u,
17
+ 'tool-capable turns must use the bounded initial MCP wait helper',
18
+ );
19
+
20
+ const context = vm.createContext({ setTimeout });
21
+ vm.runInContext(`${match[0]}; globalThis.waitForInitialMcp = blunWaitForInitialMcpBudget;`, context);
22
+
23
+ let initialLoadFinished = false;
24
+ const mcp = {
25
+ waitForInitialLoad() {
26
+ return new Promise((resolve) => {
27
+ const timer = setTimeout(() => {
28
+ initialLoadFinished = true;
29
+ resolve();
30
+ }, 20_000);
31
+ timer.unref();
32
+ });
33
+ },
34
+ };
35
+
36
+ (async () => {
37
+ const startedAt = performance.now();
38
+ await context.waitForInitialMcp(mcp, undefined);
39
+ const elapsedMs = performance.now() - startedAt;
40
+
41
+ assert.ok(elapsedMs >= 2_300, `wait budget returned too early: ${elapsedMs} ms`);
42
+ assert.ok(elapsedMs < 3_200, `slow optional MCP blocked the turn: ${elapsedMs} ms`);
43
+ assert.equal(initialLoadFinished, false, 'the slow MCP must continue loading in the background');
44
+ process.stdout.write(`MCP startup wait regression: PASS (${Math.round(elapsedMs)} ms)\n`);
45
+ })().catch((error) => {
46
+ process.stderr.write(`${error.stack || error}\n`);
47
+ process.exitCode = 1;
48
+ });
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const { installManagedAgentSpinePlugin } = require('../bin/plugin-bootstrap');
9
+
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'blun-plugin-startup-'));
11
+ const packageRoot = path.join(root, 'package');
12
+ const sourceRoot = path.join(packageRoot, 'agent-spine-plugin');
13
+ const profileA = path.join(root, 'profiles', 'a');
14
+ const profileB = path.join(root, 'profiles', 'b');
15
+
16
+ fs.mkdirSync(path.join(sourceRoot, 'src'), { recursive: true });
17
+ fs.writeFileSync(path.join(sourceRoot, 'package.json'), '{"name":"agent-spine"}\n');
18
+ fs.writeFileSync(path.join(sourceRoot, 'src', 'mcp.js'), 'export const ready = true;\n');
19
+
20
+ const originalReadFileSync = fs.readFileSync;
21
+ let sourceReads = 0;
22
+ fs.readFileSync = function countedReadFileSync(filePath, ...args) {
23
+ const absolutePath = path.resolve(String(filePath));
24
+ if (absolutePath === sourceRoot || absolutePath.startsWith(`${sourceRoot}${path.sep}`)) {
25
+ sourceReads += 1;
26
+ }
27
+ return originalReadFileSync.call(this, filePath, ...args);
28
+ };
29
+
30
+ try {
31
+ const first = installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA });
32
+ installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA });
33
+ installManagedAgentSpinePlugin({ packageRoot, blunDir: profileB });
34
+ installManagedAgentSpinePlugin({ packageRoot, blunDir: profileB });
35
+
36
+ assert.equal(
37
+ sourceReads,
38
+ 2,
39
+ 'one launcher process must read each managed source file only once',
40
+ );
41
+
42
+ fs.writeFileSync(path.join(first.pluginRoot, 'src', 'mcp.js'), 'tampered\n');
43
+ assert.throws(
44
+ () => installManagedAgentSpinePlugin({ packageRoot, blunDir: profileA }),
45
+ /digest mismatch/,
46
+ 'source caching must not weaken installed-tree verification',
47
+ );
48
+
49
+ process.stdout.write('Plugin startup regression gate: PASS\n');
50
+ } finally {
51
+ fs.readFileSync = originalReadFileSync;
52
+ fs.rmSync(root, { recursive: true, force: true });
53
+ }
@@ -0,0 +1,189 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { enqueueTelegramAddressed } = require('../bin/telegram-addressed-priority.cjs');
5
+ const { enqueueTelegramBotPriority } = require('../bin/telegram-bot-priority.cjs');
6
+ const { enqueueTelegramDirect } = require('../bin/telegram-direct-focus-policy.cjs');
7
+ const { enqueueTelegramUrgent } = require('../bin/telegram-urgent-policy.cjs');
8
+
9
+ const bundle = fs.readFileSync(path.join(__dirname, '..', 'blun.mjs'), 'utf8');
10
+
11
+ function between(start, end) {
12
+ const startIndex = bundle.indexOf(start);
13
+ assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
14
+ const endIndex = bundle.indexOf(end, startIndex + start.length);
15
+ assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
16
+ return bundle.slice(startIndex, endIndex);
17
+ }
18
+
19
+ function method(name, nextName) {
20
+ const source = between(`\n\t${name}(`, `\n\t${nextName}(`).trim();
21
+ return Function(`"use strict"; return ({${source}}).${name};`)();
22
+ }
23
+
24
+ const ctrlS = between('\n\t\teditor.onCtrlS = () => {', '\n\t\teditor.onCtrlB =');
25
+ assert.match(ctrlS, /host\.flushQueuedMessages\(/);
26
+ assert.doesNotMatch(ctrlS, /toggleTodoPanelExpansion|shortcut_todo_expand/);
27
+
28
+ const escape = between('\n\t\teditor.onEscape =', '\n\t\teditor.onShiftTab =');
29
+ const releaseIndex = escape.indexOf('host.releaseNextQueuedMessage()');
30
+ const cancelIndex = escape.indexOf('host.markCurrentRunCancelledByUser()');
31
+ assert.ok(releaseIndex >= 0, 'Escape must release queued work');
32
+ assert.ok(cancelIndex > releaseIndex, 'Escape must release queued work before cancelling the run');
33
+
34
+ const releaseNextQueuedMessage = method('releaseNextQueuedMessage', 'markCurrentRunCancelledByUser');
35
+ const releaseHost = {
36
+ queueSteerInFlight: undefined,
37
+ queueFlushBatchRemaining: 0,
38
+ state: {
39
+ queuedMessages: [{ text: 'one' }, { text: 'two' }, { text: 'three' }],
40
+ ui: { requestRender() {} },
41
+ },
42
+ steerQueueFlushPrefixCalls: 0,
43
+ updateQueueDisplayCalls: 0,
44
+ steerQueueFlushPrefix() { this.steerQueueFlushPrefixCalls += 1; },
45
+ updateQueueDisplay() { this.updateQueueDisplayCalls += 1; },
46
+ };
47
+
48
+ assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
49
+ assert.equal(releaseHost.queueFlushBatchRemaining, 1);
50
+ assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
51
+ assert.equal(releaseHost.queueFlushBatchRemaining, 2);
52
+ assert.equal(releaseNextQueuedMessage.call(releaseHost), true);
53
+ assert.equal(releaseHost.queueFlushBatchRemaining, 3);
54
+ assert.equal(releaseNextQueuedMessage.call(releaseHost), false);
55
+ assert.equal(releaseHost.queueFlushBatchRemaining, 3);
56
+ assert.equal(releaseHost.steerQueueFlushPrefixCalls, 3);
57
+
58
+ const flushQueuedMessages = method('flushQueuedMessages', 'steerQueueFlushPrefix');
59
+ const flushHost = {
60
+ queueSteerInFlight: { items: [{ text: 'already accepted' }] },
61
+ queueFlushBatchRemaining: 0,
62
+ state: {
63
+ queuedMessages: [{ text: 'one' }, { text: 'two' }, { text: 'three' }],
64
+ editor: { setText() {}, inputMode: 'prompt' },
65
+ },
66
+ steerQueueFlushPrefixCalls: 0,
67
+ scheduleQueueDrainCalls: 0,
68
+ steerQueueFlushPrefix() { this.steerQueueFlushPrefixCalls += 1; },
69
+ scheduleQueueDrain() { this.scheduleQueueDrainCalls += 1; },
70
+ };
71
+
72
+ flushQueuedMessages.call(flushHost, '', 'prompt');
73
+ assert.equal(flushHost.queueFlushBatchRemaining, 4);
74
+ assert.equal(flushHost.steerQueueFlushPrefixCalls, 1);
75
+ assert.equal(flushHost.scheduleQueueDrainCalls, 1);
76
+
77
+ const commitQueuedSteer = method('commitQueuedSteer', 'recoverRejectedActiveSteer');
78
+ let commits = 0;
79
+ const inFlight = {
80
+ items: [{ text: 'telegram message' }],
81
+ confirmationTimer: undefined,
82
+ onCommit() { commits += 1; },
83
+ };
84
+ const commitHost = {
85
+ queueSteerInFlight: inFlight,
86
+ updateQueueDisplay() {},
87
+ state: { ui: { requestRender() {} } },
88
+ steerQueueFlushPrefix() {},
89
+ scheduleQueueDrain() {},
90
+ };
91
+
92
+ commitQueuedSteer.call(commitHost, inFlight);
93
+ commitQueuedSteer.call(commitHost, inFlight);
94
+ assert.equal(commits, 1);
95
+ assert.equal(commitHost.queueSteerInFlight, undefined);
96
+
97
+ const acceptQueuedSteer = method('acceptQueuedSteer', 'commitQueuedSteerIfReady');
98
+ let acceptedCommits = 0;
99
+ const acceptedInFlight = {
100
+ items: [{ channelDeliveryTrace: { messageId: '63508' } }],
101
+ accepted: false,
102
+ stepStarted: false,
103
+ turnEnded: false,
104
+ onCommit() { acceptedCommits += 1; },
105
+ };
106
+ const acceptedHost = {
107
+ queueSteerInFlight: acceptedInFlight,
108
+ traced: [],
109
+ traceTelegramDelivery(event) { this.traced.push(event); },
110
+ commitQueuedSteer(item) {
111
+ assert.equal(item, acceptedInFlight);
112
+ if (this.queueSteerInFlight !== item) return;
113
+ this.queueSteerInFlight = undefined;
114
+ item.onCommit();
115
+ },
116
+ };
117
+
118
+ assert.equal(acceptQueuedSteer.call(acceptedHost, acceptedInFlight, {
119
+ accepted: true,
120
+ duplicate: false,
121
+ turnId: 77,
122
+ }, true), true);
123
+ assert.equal(acceptedCommits, 1, 'durably accepted steer must commit before a delayed step event');
124
+ assert.equal(acceptedHost.queueSteerInFlight, undefined);
125
+ assert.equal(acceptedHost.traced.length, 1);
126
+ assert.equal(acceptedHost.traced[0].stage, 'accepted');
127
+
128
+ const recoverRejectedActiveSteer = method('recoverRejectedActiveSteer', 'quarantineQueuedSteer');
129
+ let restored = 0;
130
+ const rejectedInFlight = {
131
+ items: [{ text: 'keep me queued', channelDeliveryFailures: 2 }],
132
+ onRestore() { restored += 1; },
133
+ };
134
+ const rejectedHost = {
135
+ queueSteerInFlight: rejectedInFlight,
136
+ state: { queuedMessages: [], ui: { requestRender() {} } },
137
+ traced: [],
138
+ traceTelegramDelivery(event) { this.traced.push(event); },
139
+ updateQueueDisplay() {},
140
+ streamingUI: { hasActiveTurn() { return true; } },
141
+ };
142
+
143
+ assert.equal(recoverRejectedActiveSteer.call(rejectedHost, rejectedInFlight), false);
144
+ assert.equal(restored, 1);
145
+ assert.equal(rejectedHost.state.queuedMessages.length, 1, 'rejected steer must remain queued');
146
+ assert.equal(rejectedHost.state.queuedMessages[0].text, 'keep me queued');
147
+ assert.equal('channelDeliveryFailures' in rejectedHost.state.queuedMessages[0], false);
148
+
149
+ const channelDelivery = between('\n\tasync deliverQueuedChannelHead() {', '\n\tinstallChannelReplyGuard(');
150
+ assert.match(
151
+ channelDelivery,
152
+ /if \(!result\.accepted\) return this\.recoverRejectedActiveSteer\(inFlight\);[\s\S]*return this\.acceptQueuedSteer\(inFlight, result, true\);/,
153
+ 'channel delivery must commit a durably accepted active steer',
154
+ );
155
+ assert.doesNotMatch(
156
+ channelDelivery,
157
+ /channel steer confirmation timed out|CHANNEL_STEER_CONFIRM_TIMEOUT_MS/,
158
+ 'durably accepted channel messages must not be restored by a wall-clock timeout',
159
+ );
160
+
161
+ const ctrlSDelivery = between('\n\tsteerQueueFlushPrefix() {', '\n\tdrainOneQueuedMessage() {');
162
+ assert.match(
163
+ ctrlSDelivery,
164
+ /if \(!result\.accepted\) this\.recoverRejectedActiveSteer\(inFlight\);[\s\S]*else this\.acceptQueuedSteer\(inFlight, result\);/,
165
+ 'Ctrl+S delivery must use the same durable acceptance rule',
166
+ );
167
+
168
+ const priorityQueue = [{ id: 'normal-a' }, { id: 'normal-b' }];
169
+ enqueueTelegramBotPriority(priorityQueue, { id: 'bot-a', channelBotPriority: true });
170
+ enqueueTelegramBotPriority(priorityQueue, { id: 'bot-b', channelBotPriority: true });
171
+ enqueueTelegramAddressed(priorityQueue, { id: 'addressed-a', channelAddressed: true });
172
+ enqueueTelegramAddressed(priorityQueue, { id: 'addressed-b', channelAddressed: true });
173
+ enqueueTelegramDirect(priorityQueue, { id: 'direct-a', channelDirect: true });
174
+ enqueueTelegramDirect(priorityQueue, { id: 'direct-b', channelDirect: true });
175
+ enqueueTelegramUrgent(priorityQueue, { id: 'urgent-a', channelUrgent: true });
176
+ enqueueTelegramUrgent(priorityQueue, { id: 'urgent-b', channelUrgent: true });
177
+ assert.deepEqual(
178
+ priorityQueue.map((item) => item.id),
179
+ [
180
+ 'urgent-a', 'urgent-b',
181
+ 'direct-a', 'direct-b',
182
+ 'addressed-a', 'addressed-b',
183
+ 'bot-a', 'bot-b',
184
+ 'normal-a', 'normal-b',
185
+ ],
186
+ 'Telegram queue priority must be urgent, direct, addressed, bot, normal with stable FIFO inside each class',
187
+ );
188
+
189
+ console.log('queue controls regression: PASS');
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
9
+ ? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
10
+ : path.join(__dirname, '..', 'blun.mjs');
11
+ const bundle = fs.readFileSync(bundlePath, 'utf8');
12
+
13
+ function between(start, end) {
14
+ const startIndex = bundle.indexOf(start);
15
+ assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
16
+ const endIndex = bundle.indexOf(end, startIndex + start.length);
17
+ assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
18
+ return bundle.slice(startIndex, endIndex);
19
+ }
20
+
21
+ function compileMethod(name, nextName, dependencies = {}) {
22
+ const source = between(`\n\t\t${name}(`, `\n\t\t${nextName}(`).trim();
23
+ const names = Object.keys(dependencies);
24
+ const values = Object.values(dependencies);
25
+ return Function(...names, `"use strict"; return ({${source}}).${name};`)(...values);
26
+ }
27
+
28
+ function resolveStepEventUuid(openSteps, event) {
29
+ if (event.stepUuid !== undefined) return event.stepUuid;
30
+ let latest;
31
+ for (const uuid of openSteps.keys()) latest = uuid;
32
+ return latest;
33
+ }
34
+
35
+ function resolveCompletedStepUuid(openSteps, event) {
36
+ if (event.uuid !== undefined) return event.uuid;
37
+ let latest;
38
+ for (const uuid of openSteps.keys()) latest = uuid;
39
+ return latest;
40
+ }
41
+
42
+ const recoverMissingOpenStep = compileMethod(
43
+ 'recoverMissingOpenStep',
44
+ 'appendLoopEvent',
45
+ { resolveStepEventUuid },
46
+ );
47
+ const appendLoopEvent = compileMethod(
48
+ 'appendLoopEvent',
49
+ 'appendMessage',
50
+ {
51
+ projectLoopEventForRecord: (event) => event,
52
+ resolveCompletedStepUuid,
53
+ createToolMessage: (toolCallId, content) => ({ toolCallId, content, toolCalls: [] }),
54
+ toolResultOutputForModel: (result) => result.output,
55
+ estimateTokensForMessages: () => 0,
56
+ },
57
+ );
58
+
59
+ const records = [];
60
+ const warnings = [];
61
+ const host = {
62
+ _history: [],
63
+ _tokenCount: 0,
64
+ tokenCountCoveredMessageCount: 0,
65
+ openSteps: new Map(),
66
+ pendingToolResultIds: new Set(),
67
+ agent: {
68
+ records: {
69
+ restoring: { time: Date.now() },
70
+ logRecord(record) { records.push(record); },
71
+ },
72
+ log: { warn(message, fields) { warnings.push({ message, fields }); } },
73
+ },
74
+ recoverMissingOpenStep,
75
+ pushHistory(...messages) { this._history.push(...messages); },
76
+ markPendingTokenEstimateDirty() {},
77
+ flushDeferredMessagesIfToolExchangeClosed() {},
78
+ closePendingToolResults() { return []; },
79
+ };
80
+
81
+ // Exact shape measured after context.apply_compaction: no step.begin survives,
82
+ // then text, a tool call/result pair, and step.end continue the interrupted turn.
83
+ appendLoopEvent.call(host, { type: 'content.part', part: { type: 'text', text: 'continued after compaction' } });
84
+ appendLoopEvent.call(host, { type: 'tool.call', toolCallId: 'tool-1', name: 'Read', args: { path: 'SPEC.md' } });
85
+ appendLoopEvent.call(host, { type: 'tool.result', toolCallId: 'tool-1', result: { output: 'ok', isError: false } });
86
+ appendLoopEvent.call(host, { type: 'step.end', contextTokens: 100 });
87
+
88
+ assert.equal(warnings.length, 1, 'one missing step must produce one bounded repair warning');
89
+ assert.equal(warnings[0].fields.eventType, 'content.part');
90
+ assert.equal(warnings[0].fields.restoring, true);
91
+ assert.equal(host._history.length, 2, 'repair must retain the assistant continuation and its tool result');
92
+ assert.deepEqual(host._history[0].content, [{ type: 'text', text: 'continued after compaction' }]);
93
+ assert.equal(host._history[0].toolCalls.length, 1);
94
+ assert.equal(host._history[0].toolCalls[0].id, 'tool-1');
95
+ assert.equal(host._history[1].toolCallId, 'tool-1');
96
+ assert.equal(host.pendingToolResultIds.size, 0);
97
+ assert.equal(host.openSteps.size, 0);
98
+ assert.equal(records.filter((record) => record.type === 'context.append_loop_event').length, 4);
99
+
100
+ process.stdout.write('resume-replay-regression PASS\n');
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const bundle = fs.readFileSync(path.join(__dirname, '..', 'blun.mjs'), 'utf8');
9
+
10
+ function between(start, end) {
11
+ const startIndex = bundle.indexOf(start);
12
+ assert.notEqual(startIndex, -1, `missing bundle marker: ${start}`);
13
+ const endIndex = bundle.indexOf(end, startIndex + start.length);
14
+ assert.notEqual(endIndex, -1, `missing bundle marker: ${end}`);
15
+ return bundle.slice(startIndex, endIndex);
16
+ }
17
+
18
+ function method(name, nextName) {
19
+ const source = between(`\n\t${name}(`, `\n\t${nextName}(`).trim();
20
+ return Function(`"use strict"; return ({${source}}).${name};`)();
21
+ }
22
+
23
+ async function main() {
24
+ const scheduleBridgeRestart = method('scheduleBridgeRestart', 'ensureBridgeRunning');
25
+ const host = {
26
+ stopped: false,
27
+ activeOwner: true,
28
+ bridgeRestartTimer: undefined,
29
+ bridgeRestartDelayMs: 5,
30
+ ownsChannel() { return true; },
31
+ starts: 0,
32
+ ensureBridgeRunning() { this.starts += 1; },
33
+ };
34
+
35
+ scheduleBridgeRestart.call(host);
36
+ const firstTimer = host.bridgeRestartTimer;
37
+ assert.notEqual(firstTimer, undefined);
38
+ scheduleBridgeRestart.call(host);
39
+ assert.equal(host.bridgeRestartTimer, firstTimer, 'restart scheduling must be deduplicated');
40
+ await new Promise((resolve) => setTimeout(resolve, 25));
41
+ assert.equal(host.starts, 1, 'a dead owned bridge must be restarted once');
42
+ assert.equal(host.bridgeRestartTimer, undefined);
43
+
44
+ host.stopped = true;
45
+ scheduleBridgeRestart.call(host);
46
+ assert.equal(host.bridgeRestartTimer, undefined, 'stopped controllers must not restart bridges');
47
+
48
+ const ensureBridgeRunning = between('\n\tensureBridgeRunning() {', '\n\tresolveBridgeEntry() {');
49
+ assert.match(ensureBridgeRunning, /child\.once\("exit",[\s\S]*this\.scheduleBridgeRestart\(\)/);
50
+ assert.match(ensureBridgeRunning, /child\.once\("error",[\s\S]*this\.scheduleBridgeRestart\(\)/);
51
+ const beginStop = between('\n\tbeginStop() {', '\n\tasync stopOwnedBridge(');
52
+ assert.match(beginStop, /clearTimeout\(this\.bridgeRestartTimer\)/);
53
+
54
+ process.stdout.write('telegram-bridge-watchdog PASS\n');
55
+ }
56
+
57
+ main().catch((error) => {
58
+ console.error(error);
59
+ process.exitCode = 1;
60
+ });
@@ -3,8 +3,11 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const { enforceSingleTodoWritePerStep } = require('../bin/todo-list-turn-policy.cjs');
6
7
 
7
- const bundlePath = path.resolve(__dirname, '..', 'blun.mjs');
8
+ const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
9
+ ? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
10
+ : path.resolve(__dirname, '..', 'blun.mjs');
8
11
  const bundle = fs.readFileSync(bundlePath, 'utf8');
9
12
 
10
13
  function assert(condition, message) {
@@ -23,5 +26,53 @@ assert(
23
26
  /isIdle:\s*\(\)\s*=>\s*!agent\.turn\.hasActiveTurn/.test(bundle),
24
27
  'TODO_LOOP_REGRESSION: expected idle-only session loop scheduler wiring is missing',
25
28
  );
29
+ assert(
30
+ /todos\.filter\(\(todo\)\s*=>\s*todo\.status\s*!==\s*["']done["']\)/.test(bundle),
31
+ 'TODO_LIFECYCLE_REGRESSION: completed items must be pruned from the stored visible list',
32
+ );
33
+ assert(
34
+ /const PENDING_TEXT\s*=\s*["']#aeb8c6["']/.test(bundle)
35
+ && !/const PENDING_TEXT\s*=\s*["']#2a3550["']/.test(bundle),
36
+ 'TODO_UI_REGRESSION: pending markers must remain visible on ANSI16 terminals',
37
+ );
38
+ assert(
39
+ /activeGoal\s*===\s*null\s*&&\s*!startsTrackedList/.test(bundle)
40
+ && /The first TodoList must contain at least one item, exactly one in_progress item/.test(bundle),
41
+ 'TODO_INITIAL_STATE_REGRESSION: a new non-goal TodoList must not accept an all-pending list',
42
+ );
43
+ assert(
44
+ /enforceSingleTodoWritePerStep\(ctx\)/.test(bundle),
45
+ 'TODO_PARALLEL_REGRESSION: per-step TodoList write guard is not wired',
46
+ );
47
+
48
+ const calls = [
49
+ { id: 'todo-first', name: 'TodoList' },
50
+ { id: 'todo-second', name: 'TodoList' },
51
+ ];
52
+ assert(
53
+ enforceSingleTodoWritePerStep({
54
+ toolCall: calls[0],
55
+ toolCalls: calls,
56
+ args: { todos: [{ title: 'first', status: 'in_progress' }] },
57
+ }) === undefined,
58
+ 'TODO_PARALLEL_REGRESSION: first TodoList write must remain executable',
59
+ );
60
+ const duplicateWrite = enforceSingleTodoWritePerStep({
61
+ toolCall: calls[1],
62
+ toolCalls: calls,
63
+ args: { todos: [{ title: 'second', status: 'in_progress' }] },
64
+ });
65
+ assert(
66
+ duplicateWrite?.block === true,
67
+ 'TODO_PARALLEL_REGRESSION: later TodoList writes in the same step must be blocked',
68
+ );
69
+ assert(
70
+ enforceSingleTodoWritePerStep({
71
+ toolCall: { id: 'todo-read', name: 'TodoList' },
72
+ toolCalls: calls,
73
+ args: {},
74
+ }) === undefined,
75
+ 'TODO_PARALLEL_REGRESSION: read-only TodoList queries must not be blocked',
76
+ );
26
77
 
27
78
  process.stdout.write('todo-loop-regression PASS\n');