clisbot 0.1.54-beta.2 → 0.1.54-beta.3

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 (2) hide show
  1. package/dist/main.js +473 -168
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -88771,6 +88771,11 @@ import { dirname as dirname6, join as join8, sep } from "node:path";
88771
88771
  // src/control/runner/runner-exit-diagnostics.ts
88772
88772
  import { unlink as unlink2 } from "node:fs/promises";
88773
88773
  import { dirname as dirname5, join as join7 } from "node:path";
88774
+ var RUNNER_EXIT_SENTINEL_PREFIX = "[clisbot] runner exited with status";
88775
+ var RUNNER_EXIT_LINGER_SECONDS = 8;
88776
+ function paneShowsRunnerExitSentinel(snapshot) {
88777
+ return snapshot.includes(RUNNER_EXIT_SENTINEL_PREFIX);
88778
+ }
88774
88779
  function shellQuote(value) {
88775
88780
  if (/^[a-zA-Z0-9_./:@=-]+$/.test(value)) {
88776
88781
  return value;
@@ -88787,6 +88792,7 @@ function getRunnerExitRecordPath(stateDir, sessionName) {
88787
88792
  return join7(stateDir, "runner-exits", `${sanitizeSessionName2(sessionName)}.json`);
88788
88793
  }
88789
88794
  function buildRunnerLaunchCommand(params) {
88795
+ const exitLingerSeconds = params.exitLingerSeconds ?? RUNNER_EXIT_LINGER_SECONDS;
88790
88796
  const runnerCommand = buildCommandString(params.command, params.args);
88791
88797
  const exitRecordPath = getRunnerExitRecordPath(params.stateDir, params.sessionName);
88792
88798
  const exitWriterScript = [
@@ -88809,6 +88815,7 @@ function buildRunnerLaunchCommand(params) {
88809
88815
  runnerCommand,
88810
88816
  "status=$?",
88811
88817
  `node -e ${shellQuote(exitWriterScript)} ${shellQuote(exitRecordPath)} ${shellQuote(params.sessionName)} "$status" ${shellQuote(runnerCommand)} || true`,
88818
+ `if [ "$status" -ne 0 ]; then echo ${shellQuote(`${RUNNER_EXIT_SENTINEL_PREFIX} `)}"$status"; sleep ${exitLingerSeconds}; fi`,
88812
88819
  'exit "$status"'
88813
88820
  ].join("; ");
88814
88821
  }
@@ -92148,9 +92155,8 @@ var PASTE_CONFIRM_MAX_ATTEMPTS = 3;
92148
92155
  var PASTE_CAPTURE_REVALIDATE_POLL_INTERVAL_MS = 40;
92149
92156
  var PASTE_CAPTURE_REVALIDATE_MAX_WAIT_MS = 160;
92150
92157
  var SUBMIT_CONFIRM_POLL_INTERVAL_MS = 40;
92151
- var SUBMIT_CONFIRM_MAX_WAIT_MS = 160;
92152
- var SUBMIT_SNAPSHOT_CONFIRM_POLL_INTERVAL_MS = 40;
92153
- var SUBMIT_SNAPSHOT_CONFIRM_MAX_WAIT_MS = 320;
92158
+ var SUBMIT_CONFIRM_MAX_WAIT_MS = 600;
92159
+ var SUBMIT_ENTER_MAX_ATTEMPTS = 3;
92154
92160
  var POST_STATUS_SETTLE_POLL_INTERVAL_MS = 40;
92155
92161
  var POST_STATUS_SETTLE_QUIET_WINDOW_MS = 80;
92156
92162
  var POST_STATUS_SETTLE_MAX_WAIT_MS = 240;
@@ -92160,9 +92166,11 @@ var TMUX_SERVER_UNAVAILABLE_PATTERN = /(?:No such file or directory|error connec
92160
92166
 
92161
92167
  class TmuxBootstrapSessionLostError extends Error {
92162
92168
  sessionName;
92163
- constructor(sessionName, detail) {
92169
+ lastSnapshot;
92170
+ constructor(sessionName, detail, lastSnapshot = "") {
92164
92171
  super(`tmux bootstrap lost session "${sessionName}": ${detail}`);
92165
92172
  this.sessionName = sessionName;
92173
+ this.lastSnapshot = lastSnapshot;
92166
92174
  this.name = "TmuxBootstrapSessionLostError";
92167
92175
  }
92168
92176
  }
@@ -92178,7 +92186,11 @@ class TmuxPasteUnconfirmedError extends Error {
92178
92186
 
92179
92187
  class TmuxSubmitUnconfirmedError extends Error {
92180
92188
  constructor() {
92181
- super("tmux submit was not confirmed after Enter. The pane state did not change, so clisbot did not treat the prompt as truthfully submitted.");
92189
+ super([
92190
+ "tmux submit was not confirmed after Enter: the pane did not change, so clisbot does not treat the prompt as truthfully submitted.",
92191
+ "The runner may be busy, redrawing slowly, or showing a blocking prompt.",
92192
+ "Check the live pane with `clisbot watch --latest --lines 100`; if your text is sitting unsubmitted there, send /nudge, otherwise resend the message."
92193
+ ].join(" "));
92182
92194
  this.name = "TmuxSubmitUnconfirmedError";
92183
92195
  }
92184
92196
  }
@@ -92215,36 +92227,97 @@ async function submitTmuxSessionInput(params) {
92215
92227
  }
92216
92228
  const preSubmitState = pasteDelivery.state;
92217
92229
  const preSubmitSnapshot = normalizePaneText(await params.tmux.capturePane(params.sessionName, captureLines));
92218
- await params.tmux.sendKey(params.sessionName, "Enter");
92219
- const submitted = await waitForPaneSubmitConfirmation({
92220
- tmux: params.tmux,
92221
- sessionName: params.sessionName,
92222
- baseline: preSubmitState,
92223
- baselineSnapshot: preSubmitSnapshot,
92224
- captureLines
92225
- });
92226
- if (submitted.confirmed) {
92227
- return { submittedSnapshot: preSubmitSnapshot };
92228
- }
92229
- logLatencyDebug("tmux-submit-enter-retry", params.timingContext, {
92230
- sessionName: params.sessionName
92231
- });
92232
- await params.tmux.sendKey(params.sessionName, "Enter");
92233
- const retrySubmitted = await waitForPaneSubmitConfirmation({
92234
- tmux: params.tmux,
92235
- sessionName: params.sessionName,
92236
- baseline: preSubmitState,
92237
- baselineSnapshot: preSubmitSnapshot,
92238
- captureLines
92239
- });
92240
- if (retrySubmitted.confirmed) {
92241
- return { submittedSnapshot: preSubmitSnapshot };
92230
+ for (let attempt = 1;attempt <= SUBMIT_ENTER_MAX_ATTEMPTS; attempt += 1) {
92231
+ if (attempt > 1) {
92232
+ logLatencyDebug("tmux-submit-enter-retry", params.timingContext, {
92233
+ sessionName: params.sessionName,
92234
+ attempt
92235
+ });
92236
+ }
92237
+ await params.tmux.sendKey(params.sessionName, "Enter");
92238
+ const outcome = await waitForSubmitSettled({
92239
+ tmux: params.tmux,
92240
+ sessionName: params.sessionName,
92241
+ baseline: preSubmitState,
92242
+ baselineSnapshot: preSubmitSnapshot,
92243
+ text: params.text,
92244
+ captureLines
92245
+ });
92246
+ if (outcome === "submitted") {
92247
+ return { submittedSnapshot: preSubmitSnapshot };
92248
+ }
92242
92249
  }
92243
92250
  logLatencyDebug("tmux-submit-unconfirmed", params.timingContext, {
92244
92251
  sessionName: params.sessionName
92245
92252
  });
92246
92253
  throw new TmuxSubmitUnconfirmedError;
92247
92254
  }
92255
+ async function waitForSubmitSettled(params) {
92256
+ const deadline = Date.now() + SUBMIT_CONFIRM_MAX_WAIT_MS;
92257
+ let sawChange = false;
92258
+ while (true) {
92259
+ let changed = sawChange;
92260
+ if (!changed) {
92261
+ const state = await params.tmux.getPaneState(params.sessionName);
92262
+ changed = hasPaneStateChanged(params.baseline, state);
92263
+ }
92264
+ const snapshot = normalizePaneText(await params.tmux.capturePane(params.sessionName, params.captureLines));
92265
+ if (!changed) {
92266
+ changed = snapshot !== params.baselineSnapshot;
92267
+ }
92268
+ if (changed) {
92269
+ sawChange = true;
92270
+ if (!paneShowsPendingComposerText(snapshot, params.text)) {
92271
+ return "submitted";
92272
+ }
92273
+ }
92274
+ const remainingMs = deadline - Date.now();
92275
+ if (remainingMs <= 0) {
92276
+ return sawChange ? "pending-input" : "unchanged";
92277
+ }
92278
+ await sleep(Math.min(SUBMIT_CONFIRM_POLL_INTERVAL_MS, remainingMs));
92279
+ }
92280
+ }
92281
+ var COMPOSER_HINT_LINE_PATTERN = /^\?\s+for shortcuts|^Type your message or @path\/to\/file|^\d+%\s+context left|^press enter to send/i;
92282
+ var COMPOSER_RUNNING_INDICATOR_PATTERN = /esc to interrupt/i;
92283
+ var COMPOSER_BORDER_LINE_PATTERN = /^[─━═╌╍╭╮╰╯┌┐└┘+|\-_=\s]+$/;
92284
+ var COMPOSER_TAIL_SCAN_LINES = 12;
92285
+ function paneShowsPendingComposerText(snapshot, text) {
92286
+ const lastPromptLine = collapseWhitespace(lastNonEmptyLine(text));
92287
+ if (!lastPromptLine) {
92288
+ return false;
92289
+ }
92290
+ const lines = trimBlankLines(splitNormalizedLines(snapshot));
92291
+ for (let index = lines.length - 1;index >= Math.max(0, lines.length - COMPOSER_TAIL_SCAN_LINES); index -= 1) {
92292
+ const line = (lines[index] ?? "").trim();
92293
+ if (!line || COMPOSER_HINT_LINE_PATTERN.test(line) || isPromptMetadataLine(line)) {
92294
+ continue;
92295
+ }
92296
+ if (COMPOSER_RUNNING_INDICATOR_PATTERN.test(line)) {
92297
+ return false;
92298
+ }
92299
+ if (COMPOSER_BORDER_LINE_PATTERN.test(line)) {
92300
+ continue;
92301
+ }
92302
+ const stripped = stripComposerChrome(line);
92303
+ if (!stripped) {
92304
+ return false;
92305
+ }
92306
+ return stripped === lastPromptLine;
92307
+ }
92308
+ return false;
92309
+ }
92310
+ function stripComposerChrome(line) {
92311
+ return collapseWhitespace(line.replace(/^[│┃]\s*/, "").replace(/\s*[│┃]$/, "").replace(/^[›❯>]+\s*/, ""));
92312
+ }
92313
+ function lastNonEmptyLine(text) {
92314
+ const lines = text.split(`
92315
+ `).map((line) => line.trim()).filter(Boolean);
92316
+ return lines[lines.length - 1] ?? "";
92317
+ }
92318
+ function collapseWhitespace(value) {
92319
+ return value.replace(/\s+/g, " ").trim();
92320
+ }
92248
92321
  async function captureTmuxSessionIdentity(params) {
92249
92322
  await acceptTmuxStartupContinuePromptIfPresent({
92250
92323
  tmux: params.tmux,
@@ -92389,7 +92462,7 @@ async function waitForTmuxSessionBootstrap(params) {
92389
92462
  continue;
92390
92463
  }
92391
92464
  if (isBootstrapSessionGoneError(error)) {
92392
- throw buildBootstrapSessionLostError(params.sessionName, error);
92465
+ throw buildBootstrapSessionLostError(params.sessionName, error, lastSnapshot);
92393
92466
  }
92394
92467
  throw error;
92395
92468
  }
@@ -92407,6 +92480,18 @@ async function waitForTmuxSessionBootstrap(params) {
92407
92480
  await sleep(SESSION_BOOTSTRAP_POLL_INTERVAL_MS);
92408
92481
  continue;
92409
92482
  }
92483
+ if (params.resumeRejection?.detect(snapshot)) {
92484
+ return {
92485
+ status: "resume-rejected",
92486
+ snapshot
92487
+ };
92488
+ }
92489
+ if (params.exitDetection?.detect(snapshot)) {
92490
+ return {
92491
+ status: "exited",
92492
+ snapshot
92493
+ };
92494
+ }
92410
92495
  for (const blocker of blockerPatterns) {
92411
92496
  if (blocker.regex.test(snapshot)) {
92412
92497
  return {
@@ -92457,56 +92542,6 @@ async function acceptStartupContinuePrompt(params) {
92457
92542
  return;
92458
92543
  }
92459
92544
  }
92460
- async function waitForPaneSubmitConfirmation(params) {
92461
- const deadline = Date.now() + SUBMIT_CONFIRM_MAX_WAIT_MS;
92462
- while (true) {
92463
- const state = await params.tmux.getPaneState(params.sessionName);
92464
- if (hasPaneStateChanged(params.baseline, state)) {
92465
- return {
92466
- confirmed: true,
92467
- snapshot: normalizePaneText(await params.tmux.capturePane(params.sessionName, params.captureLines))
92468
- };
92469
- }
92470
- const snapshotChange = await waitForPaneSubmitSnapshotConfirmation({
92471
- tmux: params.tmux,
92472
- sessionName: params.sessionName,
92473
- baselineSnapshot: params.baselineSnapshot,
92474
- captureLines: params.captureLines,
92475
- maxWaitMs: Math.min(SUBMIT_SNAPSHOT_CONFIRM_MAX_WAIT_MS, Math.max(0, deadline - Date.now()))
92476
- });
92477
- if (snapshotChange.confirmed) {
92478
- return snapshotChange;
92479
- }
92480
- const remainingMs = deadline - Date.now();
92481
- if (remainingMs <= 0) {
92482
- return {
92483
- confirmed: false,
92484
- snapshot: params.baselineSnapshot
92485
- };
92486
- }
92487
- await sleep(Math.min(SUBMIT_CONFIRM_POLL_INTERVAL_MS, remainingMs));
92488
- }
92489
- }
92490
- async function waitForPaneSubmitSnapshotConfirmation(params) {
92491
- const deadline = Date.now() + params.maxWaitMs;
92492
- while (true) {
92493
- const snapshot = normalizePaneText(await params.tmux.capturePane(params.sessionName, params.captureLines));
92494
- if (snapshot !== params.baselineSnapshot) {
92495
- return {
92496
- confirmed: true,
92497
- snapshot
92498
- };
92499
- }
92500
- const remainingMs = deadline - Date.now();
92501
- if (remainingMs <= 0) {
92502
- return {
92503
- confirmed: false,
92504
- snapshot: params.baselineSnapshot
92505
- };
92506
- }
92507
- await sleep(Math.min(SUBMIT_SNAPSHOT_CONFIRM_POLL_INTERVAL_MS, remainingMs));
92508
- }
92509
- }
92510
92545
  async function deliverTmuxPasteWithConfirmation(params) {
92511
92546
  for (let attempt = 1;attempt <= PASTE_CONFIRM_MAX_ATTEMPTS; attempt += 1) {
92512
92547
  if (attempt > 1) {
@@ -92645,9 +92680,9 @@ function isBootstrapSessionGoneError(error) {
92645
92680
  const message = error instanceof Error ? error.message : String(error);
92646
92681
  return TMUX_MISSING_SESSION_PATTERN.test(message) || TMUX_SERVER_UNAVAILABLE_PATTERN.test(message);
92647
92682
  }
92648
- function buildBootstrapSessionLostError(sessionName, error) {
92683
+ function buildBootstrapSessionLostError(sessionName, error, lastSnapshot = "") {
92649
92684
  const message = error instanceof Error ? error.message : String(error);
92650
- return new TmuxBootstrapSessionLostError(sessionName, message);
92685
+ return new TmuxBootstrapSessionLostError(sessionName, message, lastSnapshot);
92651
92686
  }
92652
92687
  function arePaneStatesEqual(left, right) {
92653
92688
  return left.cursorX === right.cursorX && left.cursorY === right.cursorY && left.historySize === right.historySize;
@@ -92863,6 +92898,132 @@ function escapeRegExp(raw) {
92863
92898
  return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92864
92899
  }
92865
92900
 
92901
+ // src/runners/resume-rejection.ts
92902
+ var RESUME_REJECTED_PATTERNS = [
92903
+ /no saved session found with id/i,
92904
+ /no conversation found with session id/i,
92905
+ /no conversation found to resume/i,
92906
+ /no session found with id/i,
92907
+ /could not find (?:a )?session (?:with )?id/i
92908
+ ];
92909
+ function paneShowsResumeRejected(snapshot) {
92910
+ if (!snapshot) {
92911
+ return false;
92912
+ }
92913
+ return RESUME_REJECTED_PATTERNS.some((pattern) => pattern.test(snapshot));
92914
+ }
92915
+
92916
+ class RunnerResumeRejectedError extends Error {
92917
+ sessionName;
92918
+ storedSessionId;
92919
+ lastPane;
92920
+ constructor(sessionName, storedSessionId, lastPane = "") {
92921
+ super(`Runner session "${sessionName}" could not resume stored session id ${storedSessionId}: the runner reported that this saved session no longer exists.`);
92922
+ this.sessionName = sessionName;
92923
+ this.storedSessionId = storedSessionId;
92924
+ this.lastPane = lastPane;
92925
+ this.name = "RunnerResumeRejectedError";
92926
+ }
92927
+ }
92928
+
92929
+ // src/runners/runner-state-failures.ts
92930
+ var STATE_CONTENTION_PATTERNS = [
92931
+ /database (?:table )?is locked/i,
92932
+ /SQLITE_BUSY/i,
92933
+ /timed out waiting for state db backfill/i,
92934
+ /unable to open database file/i
92935
+ ];
92936
+ var STATE_CORRUPTION_PATTERNS = [
92937
+ /file is not a database/i,
92938
+ /database disk image is malformed/i,
92939
+ /migration \d+ was previously applied but has been modified/i,
92940
+ /cannot access its local database/i
92941
+ ];
92942
+ function paneShowsRunnerStateContention(snapshot) {
92943
+ if (!snapshot) {
92944
+ return false;
92945
+ }
92946
+ return STATE_CONTENTION_PATTERNS.some((pattern) => pattern.test(snapshot));
92947
+ }
92948
+ function paneShowsRunnerStateCorruption(snapshot) {
92949
+ if (!snapshot) {
92950
+ return false;
92951
+ }
92952
+ return STATE_CORRUPTION_PATTERNS.some((pattern) => pattern.test(snapshot));
92953
+ }
92954
+
92955
+ class RunnerStateContentionError extends Error {
92956
+ sessionName;
92957
+ lastPane;
92958
+ constructor(sessionName, lastPane = "") {
92959
+ super([
92960
+ `Runner session "${sessionName}" could not start because the runner's local state database is locked by another running runner process (shared CODEX_HOME contention).`,
92961
+ "Nothing was lost: the stored session id and saved conversation were preserved.",
92962
+ "Resend your prompt to retry once the other run settles.",
92963
+ "If this keeps happening, avoid starting many runner sessions at the same time or give this agent its own CODEX_HOME."
92964
+ ].join(" "));
92965
+ this.sessionName = sessionName;
92966
+ this.lastPane = lastPane;
92967
+ this.name = "RunnerStateContentionError";
92968
+ }
92969
+ }
92970
+
92971
+ class RunnerStateCorruptionError extends Error {
92972
+ sessionName;
92973
+ lastPane;
92974
+ constructor(sessionName, lastPane = "") {
92975
+ super([
92976
+ `Runner session "${sessionName}" cannot start: the runner reported that its local state database is corrupted or version-mismatched.`,
92977
+ "Retrying will not help. An operator must repair the runner state in CODEX_HOME (for codex this is usually state_5.sqlite / logs_2.sqlite; see the runner's own error output).",
92978
+ "The stored session id was preserved for after the repair."
92979
+ ].join(" "));
92980
+ this.sessionName = sessionName;
92981
+ this.lastPane = lastPane;
92982
+ this.name = "RunnerStateCorruptionError";
92983
+ }
92984
+ }
92985
+
92986
+ // src/agents/session/run-recovery.ts
92987
+ var MID_RUN_RECOVERY_MAX_ATTEMPTS = 2;
92988
+ var MID_RUN_RECOVERY_CONTINUE_PROMPT = "continue exactly where you left off";
92989
+ function mergeRunSnapshot(snapshotPrefix, snapshot) {
92990
+ return appendInteractionText(snapshotPrefix, snapshot);
92991
+ }
92992
+ function buildRunRecoveryNote(kind, params) {
92993
+ const storedIdDetail = params?.storedSessionId ? ` (stored session id ${params.storedSessionId})` : "";
92994
+ if (kind === "resume-attempt") {
92995
+ const attempt = params?.attempt ?? 1;
92996
+ const maxAttempts = params?.maxAttempts ?? MID_RUN_RECOVERY_MAX_ATTEMPTS;
92997
+ return `Runner session was lost. Attempting recovery ${attempt}/${maxAttempts} by reopening the same conversation context.`;
92998
+ }
92999
+ if (kind === "resume-success") {
93000
+ return "Recovery succeeded. Asking the runner to continue exactly where it left off.";
93001
+ }
93002
+ if (kind === "fresh-attempt") {
93003
+ return "The previous runner session could not be resumed. Opening a fresh runner session 2/2 without replaying your prompt.";
93004
+ }
93005
+ if (kind === "resume-failed") {
93006
+ return `The previous runner session could not be resumed${storedIdDetail}. The stored session id was preserved so the conversation can still be retried.`;
93007
+ }
93008
+ if (kind === "manual-new-required") {
93009
+ return [
93010
+ `The previous runner session could not be resumed, so the interrupted run stopped${storedIdDetail}.`,
93011
+ "clisbot preserved the stored session id instead of silently opening a new conversation, because the run was already in progress and its context may matter.",
93012
+ "Next steps: resend your prompt - clisbot will retry resuming this conversation and automatically fall back to a fresh one if the runner no longer has it.",
93013
+ "Send `/new` first if you prefer to start a clean conversation."
93014
+ ].join(" ");
93015
+ }
93016
+ return "The previous runner session could not be resumed. clisbot opened a new fresh session, but did not replay your prompt because the prior conversation context is no longer guaranteed. Please resend the full prompt/context to continue.";
93017
+ }
93018
+ function buildResumeRejectedFreshStartNote(params) {
93019
+ const cause = params.reason === "rejected" ? `the runner reported that saved session ${params.storedSessionId} no longer exists` : `the runner kept exiting while resuming saved session ${params.storedSessionId}`;
93020
+ const inspectHint = params.resumeCommand ? ` To inspect the old session manually, try \`${params.resumeCommand}\` in the workspace terminal.` : "";
93021
+ return [
93022
+ `The previous runner conversation could not be resumed: ${cause}.`,
93023
+ "clisbot opened a fresh conversation and is running your prompt there; earlier conversation context is not carried over."
93024
+ ].join(" ") + inspectHint;
93025
+ }
93026
+
92866
93027
  // src/agents/runtime/runner-service.ts
92867
93028
  var TMUX_MISSING_SESSION_PATTERN2 = /(?:can't find session:|no server running on )/i;
92868
93029
  var TMUX_SERVER_UNAVAILABLE_PATTERN2 = /(?:No such file or directory|error connecting to|failed to connect to server)/i;
@@ -92873,7 +93034,6 @@ var SESSION_READY_CAPTURE_RETRY_DELAY_MS = 100;
92873
93034
  var STARTUP_SESSION_ID_CAPTURE_RETRY_COUNT = 2;
92874
93035
  var STARTUP_SESSION_ID_CAPTURE_RETRY_DELAY_MS = 500;
92875
93036
  var SESSION_ID_CAPTURE_FAILURE_COOLDOWN_MS = 15000;
92876
- var PRESERVED_SESSION_ID_RETRY_MESSAGE = "The previous runner session could not be resumed. clisbot preserved the stored session id instead of opening a new conversation automatically. Use `/new` if you want to trigger a new runner conversation, then resend the prompt.";
92877
93037
  function summarizeSnapshot(snapshot) {
92878
93038
  const compact = snapshot.split(`
92879
93039
  `).map((line) => line.trim()).filter(Boolean).join(" ").slice(0, 220);
@@ -92924,15 +93084,18 @@ class RunnerService {
92924
93084
  async mapSessionError(error, sessionName, action, lastSnapshot = "") {
92925
93085
  if (isRecoverableStartupSessionLoss(error)) {
92926
93086
  const exitRecord = await readRunnerExitRecord(this.loadedConfig.stateDir, sessionName);
93087
+ const paneSnapshot = lastSnapshot || (error instanceof TmuxBootstrapSessionLostError ? error.lastSnapshot : "");
92927
93088
  console.error("runner session disappeared", {
92928
93089
  sessionName,
92929
93090
  action,
92930
93091
  exitCode: exitRecord?.exitCode,
92931
93092
  exitedAt: exitRecord?.exitedAt,
92932
93093
  runnerCommand: exitRecord?.command,
92933
- lastVisiblePane: lastSnapshot ? summarizeSnapshot(lastSnapshot).trim() : undefined
93094
+ lastVisiblePane: paneSnapshot ? summarizeSnapshot(paneSnapshot).trim() : undefined
92934
93095
  });
92935
- return new Error(`Runner session "${sessionName}" disappeared ${action}.`);
93096
+ const exitDetail = typeof exitRecord?.exitCode === "number" ? ` The runner process exited with code ${exitRecord.exitCode}.` : "";
93097
+ const nextStep = action === "while the prompt was running" ? `The prompt may have partially run; check ${renderCliCommand("watch --latest --lines 100", { inline: true })} before resending.` : "Your prompt was not submitted; resend it to retry.";
93098
+ return new Error(`Runner session "${sessionName}" disappeared ${action}.${exitDetail} ${nextStep} If this keeps happening, verify the runner CLI starts cleanly in the workspace terminal and inspect ${renderCliCommand("logs", { inline: true })}.${summarizeSnapshot(paneSnapshot)}`);
92936
93099
  }
92937
93100
  if (isTransientTmuxTargetError(error)) {
92938
93101
  return new Error(`Runner session "${sessionName}" lost its tmux target ${action}. clisbot stayed alive, but this request could not continue cleanly. Retry once. If it keeps happening, inspect ${renderCliCommand("status", { inline: true })} and ${renderCliCommand("logs", { inline: true })}.${summarizeSnapshot(lastSnapshot)}`);
@@ -93030,7 +93193,7 @@ class RunnerService {
93030
93193
  pollIntervalMs: capture.pollIntervalMs
93031
93194
  });
93032
93195
  }
93033
- async retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries) {
93196
+ async retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries, startupNotes) {
93034
93197
  if (remainingFreshRetries <= 0) {
93035
93198
  return null;
93036
93199
  }
@@ -93039,44 +93202,113 @@ class RunnerService {
93039
93202
  await sleep(resolved.runner.startupRetryDelayMs);
93040
93203
  }
93041
93204
  return this.ensureSessionReady(target, {
93042
- remainingFreshRetries: remainingFreshRetries - 1
93205
+ remainingFreshRetries: remainingFreshRetries - 1,
93206
+ startupNotes
93043
93207
  });
93044
93208
  }
93045
- async retryAfterStartupFault(target, resolved, error, remainingFreshRetries, allowFreshResumeFallback) {
93209
+ classifyRunnerStartupExit(resolved, snapshot) {
93210
+ if (paneShowsRunnerStateCorruption(snapshot)) {
93211
+ return new RunnerStateCorruptionError(resolved.sessionName, snapshot);
93212
+ }
93213
+ if (paneShowsRunnerStateContention(snapshot)) {
93214
+ return new RunnerStateContentionError(resolved.sessionName, snapshot);
93215
+ }
93216
+ return new TmuxBootstrapSessionLostError(resolved.sessionName, "runner exited during startup", snapshot);
93217
+ }
93218
+ async retryAfterStateContention(target, resolved, remainingFreshRetries, startupNotes) {
93219
+ if (remainingFreshRetries <= 0) {
93220
+ return null;
93221
+ }
93222
+ const attempt = resolved.runner.startupRetryCount - remainingFreshRetries + 1;
93223
+ const backoffMs = resolved.runner.startupRetryDelayMs * attempt + Math.floor(Math.random() * 250);
93224
+ console.log(`clisbot runner state database contention for ${resolved.sessionName}; retrying startup with the preserved session id in ${backoffMs}ms`);
93225
+ await this.killRunnerAndPreserveSessionId(resolved);
93226
+ if (backoffMs > 0) {
93227
+ await sleep(backoffMs);
93228
+ }
93229
+ return this.ensureSessionReady(target, {
93230
+ remainingFreshRetries: remainingFreshRetries - 1,
93231
+ startupNotes
93232
+ });
93233
+ }
93234
+ async retryAfterStartupFault(target, resolved, error, remainingFreshRetries, allowFreshResumeFallback, startupNotes) {
93235
+ if (error instanceof RunnerStateContentionError) {
93236
+ return this.retryAfterStateContention(target, resolved, remainingFreshRetries, startupNotes);
93237
+ }
93238
+ if (error instanceof RunnerStateCorruptionError) {
93239
+ return null;
93240
+ }
93046
93241
  if (allowFreshResumeFallback) {
93047
- const resumedFresh = await this.retryFreshStartAfterStoredResumeFailure(target, resolved, error, remainingFreshRetries);
93048
- if (resumedFresh) {
93049
- return resumedFresh;
93242
+ const fallback = await this.fallBackToFreshAfterRejectedResume(target, resolved, error, remainingFreshRetries, startupNotes);
93243
+ if (fallback) {
93244
+ return fallback;
93050
93245
  }
93051
93246
  }
93052
93247
  if (!isRetryableFreshStartFault(error)) {
93053
93248
  return null;
93054
93249
  }
93055
- return this.retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries);
93250
+ return this.retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries, startupNotes);
93056
93251
  }
93057
- async retryFreshStartAfterStoredResumeFailure(target, resolved, error, remainingFreshRetries) {
93252
+ async classifyRejectedResumeStartup(resolved, error, remainingFreshRetries) {
93253
+ const storedSessionId = (await this.sessionMapping.get(resolved.sessionKey))?.sessionId?.trim() || "";
93254
+ if (!storedSessionId) {
93255
+ return null;
93256
+ }
93257
+ if (error instanceof RunnerResumeRejectedError) {
93258
+ return { storedSessionId, reason: "rejected" };
93259
+ }
93260
+ if (error instanceof TmuxBootstrapSessionLostError && paneShowsResumeRejected(error.lastSnapshot)) {
93261
+ return { storedSessionId, reason: "rejected" };
93262
+ }
93058
93263
  if (!isRecoverableStartupSessionLoss(error)) {
93059
93264
  return null;
93060
93265
  }
93061
93266
  if (resolved.runner.sessionId.resume.mode !== "command" || resolved.runner.sessionId.create.mode !== "runner") {
93062
93267
  return null;
93063
93268
  }
93064
- const existing = await this.sessionMapping.get(resolved.sessionKey);
93065
- if (!existing?.sessionId) {
93269
+ if (remainingFreshRetries > 0) {
93066
93270
  return null;
93067
93271
  }
93068
93272
  const exitRecord = await readRunnerExitRecord(this.loadedConfig.stateDir, resolved.sessionName);
93069
93273
  if (!exitRecord || exitRecord.exitCode === 0) {
93070
93274
  return null;
93071
93275
  }
93072
- console.log(`clisbot preserved stored sessionId after failed runner resume startup ${resolved.sessionName}`);
93073
- await this.sessionMapping.touch(resolved, {
93276
+ return { storedSessionId, reason: "exit" };
93277
+ }
93278
+ async fallBackToFreshAfterRejectedResume(target, resolved, error, remainingFreshRetries, startupNotes) {
93279
+ const rejection = await this.classifyRejectedResumeStartup(resolved, error, remainingFreshRetries);
93280
+ if (!rejection) {
93281
+ return null;
93282
+ }
93283
+ console.log(`clisbot resume rejected for ${resolved.sessionName}; opening a fresh runner conversation`, {
93284
+ storedSessionId: rejection.storedSessionId,
93285
+ reason: rejection.reason
93286
+ });
93287
+ await this.tmux.killSession(resolved.sessionName).catch(() => {
93288
+ return;
93289
+ });
93290
+ await this.sessionMapping.clearActive(resolved, {
93074
93291
  runnerCommand: resolved.runner.command
93075
93292
  });
93076
- throw new Error(PRESERVED_SESSION_ID_RETRY_MESSAGE);
93293
+ startupNotes?.push(buildResumeRejectedFreshStartNote({
93294
+ storedSessionId: rejection.storedSessionId,
93295
+ reason: rejection.reason,
93296
+ resumeCommand: this.renderRunnerResumeCommand(resolved, rejection.storedSessionId)
93297
+ }));
93298
+ return this.ensureSessionReady(target, {
93299
+ remainingFreshRetries: resolved.runner.startupRetryCount,
93300
+ startupNotes
93301
+ });
93077
93302
  }
93078
- async retryAfterStartupTimeout(target, resolved, remainingFreshRetries) {
93079
- return this.retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries);
93303
+ renderRunnerResumeCommand(resolved, sessionId) {
93304
+ if (resolved.runner.sessionId.resume.mode !== "command") {
93305
+ return;
93306
+ }
93307
+ const launch = this.buildRunnerArgs(resolved, { sessionId, resume: true });
93308
+ return [launch.command, ...launch.args].join(" ");
93309
+ }
93310
+ async retryAfterStartupTimeout(target, resolved, remainingFreshRetries, startupNotes) {
93311
+ return this.retryRunnerRestartPreservingSessionId(target, resolved, remainingFreshRetries, startupNotes);
93080
93312
  }
93081
93313
  resolveRemainingFreshRetries(resolved, options) {
93082
93314
  if (typeof options.remainingFreshRetries === "number") {
@@ -93170,21 +93402,28 @@ class RunnerService {
93170
93402
  const preparedMapping = await this.sessionMapping.prepareStartup(resolved);
93171
93403
  const serverRunning = await this.tmux.isServerRunning();
93172
93404
  if (serverRunning && await this.tmux.hasSession(resolved.sessionName)) {
93173
- logLatencyDebug("ensure-session-ready-existing-session", timingContext, {
93174
- hasStoredSessionId: Boolean(preparedMapping.storedSessionId)
93175
- });
93176
- try {
93177
- await clearRunnerExitRecord(this.loadedConfig.stateDir, resolved.sessionName);
93178
- await this.acceptStartupContinuePromptIfPresent(resolved);
93179
- await this.syncActiveSessionMappingForResolvedTarget(resolved);
93180
- } catch (error) {
93181
- throw await this.mapSessionError(error, resolved.sessionName, "during startup");
93405
+ const lingeringExitSnapshot = await this.captureSessionSnapshot(resolved).catch(() => "");
93406
+ if (paneShowsRunnerExitSentinel(lingeringExitSnapshot)) {
93407
+ await this.tmux.killSession(resolved.sessionName).catch(() => {
93408
+ return;
93409
+ });
93410
+ } else {
93411
+ logLatencyDebug("ensure-session-ready-existing-session", timingContext, {
93412
+ hasStoredSessionId: Boolean(preparedMapping.storedSessionId)
93413
+ });
93414
+ try {
93415
+ await clearRunnerExitRecord(this.loadedConfig.stateDir, resolved.sessionName);
93416
+ await this.acceptStartupContinuePromptIfPresent(resolved);
93417
+ await this.syncActiveSessionMappingForResolvedTarget(resolved);
93418
+ } catch (error) {
93419
+ throw await this.mapSessionError(error, resolved.sessionName, "during startup");
93420
+ }
93421
+ logLatencyDebug("ensure-session-ready-complete", timingContext, {
93422
+ startupDelayMs: 0,
93423
+ reusedSession: true
93424
+ });
93425
+ return resolved;
93182
93426
  }
93183
- logLatencyDebug("ensure-session-ready-complete", timingContext, {
93184
- startupDelayMs: 0,
93185
- reusedSession: true
93186
- });
93187
- return resolved;
93188
93427
  }
93189
93428
  if (!resolved.session.createIfMissing) {
93190
93429
  throw new Error(`tmux session "${resolved.sessionName}" does not exist`);
@@ -93229,24 +93468,38 @@ class RunnerService {
93229
93468
  startupDelayMs: resolved.runner.startupDelayMs,
93230
93469
  trustWorkspace: resolved.runner.trustWorkspace,
93231
93470
  readyPattern: resolved.runner.startupReadyPattern,
93232
- blockers: resolved.runner.startupBlockers
93471
+ blockers: resolved.runner.startupBlockers,
93472
+ resumeRejection: resumingExistingSession && storedOrExplicitSessionId ? { detect: paneShowsResumeRejected } : undefined,
93473
+ exitDetection: { detect: paneShowsRunnerExitSentinel }
93233
93474
  });
93475
+ if (bootstrapResult.status === "exited") {
93476
+ await this.tmux.killSession(resolved.sessionName).catch(() => {
93477
+ return;
93478
+ });
93479
+ throw this.classifyRunnerStartupExit(resolved, bootstrapResult.snapshot);
93480
+ }
93481
+ if (bootstrapResult.status === "resume-rejected") {
93482
+ await this.tmux.killSession(resolved.sessionName).catch(() => {
93483
+ return;
93484
+ });
93485
+ throw new RunnerResumeRejectedError(resolved.sessionName, storedOrExplicitSessionId, bootstrapResult.snapshot);
93486
+ }
93234
93487
  if (bootstrapResult.status === "blocked") {
93235
93488
  await this.abortUnreadySession(resolved, bootstrapResult.message, bootstrapResult.snapshot);
93236
93489
  }
93237
93490
  if (bootstrapResult.status === "timeout" && resolved.runner.startupReadyPattern) {
93238
- const retried = await this.retryAfterStartupTimeout(target, resolved, remainingFreshRetries);
93491
+ const retried = await this.retryAfterStartupTimeout(target, resolved, remainingFreshRetries, options.startupNotes);
93239
93492
  if (retried) {
93240
93493
  return retried;
93241
93494
  }
93242
- await this.abortUnreadySession(resolved, `Runner session "${resolved.sessionName}" did not reach the configured ready state within ${resolved.runner.startupDelayMs}ms.`, bootstrapResult.snapshot);
93495
+ await this.abortUnreadySession(resolved, `Runner session "${resolved.sessionName}" did not reach the configured ready state within ${resolved.runner.startupDelayMs}ms, so your prompt was not submitted. Verify that \`${resolved.runner.command}\` starts cleanly in the workspace terminal, then resend. Inspect ${renderCliCommand("runner inspect --latest --lines 120", { inline: true })} and ${renderCliCommand("logs", { inline: true })} if it keeps happening.`, bootstrapResult.snapshot);
93243
93496
  }
93244
93497
  await this.finalizeSessionStartup(resolved, {
93245
93498
  storedOrExplicitSessionId,
93246
93499
  runnerCommand: runnerLaunch.command
93247
93500
  });
93248
93501
  } catch (error) {
93249
- const retried = await this.retryAfterStartupFault(target, resolved, error, remainingFreshRetries, options.allowFreshRetry !== false);
93502
+ const retried = await this.retryAfterStartupFault(target, resolved, error, remainingFreshRetries, options.allowFreshRetry !== false, options.startupNotes);
93250
93503
  if (retried) {
93251
93504
  return retried;
93252
93505
  }
@@ -93309,30 +93562,65 @@ class RunnerService {
93309
93562
  return normalizePaneText(await this.tmux.capturePane(resolved.sessionName, resolved.stream.captureLines));
93310
93563
  }
93311
93564
  async ensureRunnerReady(target, options = {}) {
93565
+ const startupNotes = [];
93312
93566
  let resolved = await this.ensureSessionReady(target, {
93313
93567
  allowFreshRetry: options.allowFreshRetryBeforePrompt,
93314
- timingContext: options.timingContext
93568
+ timingContext: options.timingContext,
93569
+ startupNotes
93315
93570
  });
93316
93571
  try {
93317
93572
  return {
93318
93573
  resolved,
93319
- initialSnapshot: await this.captureSessionSnapshot(resolved)
93574
+ initialSnapshot: await this.captureSessionSnapshot(resolved),
93575
+ startupNotes
93320
93576
  };
93321
93577
  } catch (error) {
93322
93578
  if (options.allowFreshRetryBeforePrompt === false || !isRecoverableStartupSessionLoss(error)) {
93323
93579
  throw await this.mapSessionError(error, resolved.sessionName, "before prompt submission", resolved.sessionName ? await this.captureSessionSnapshot(resolved).catch(() => "") : "");
93324
93580
  }
93325
- const retried = await this.retryRunnerRestartPreservingSessionId(target, resolved, resolved.runner.startupRetryCount);
93581
+ const retried = await this.retryRunnerRestartPreservingSessionId(target, resolved, resolved.runner.startupRetryCount, startupNotes);
93326
93582
  if (!retried) {
93327
93583
  throw await this.mapSessionError(error, resolved.sessionName, "before prompt submission", resolved.sessionName ? await this.captureSessionSnapshot(resolved).catch(() => "") : "");
93328
93584
  }
93329
93585
  resolved = retried;
93330
93586
  return {
93331
93587
  resolved,
93332
- initialSnapshot: await this.captureSessionSnapshot(resolved)
93588
+ initialSnapshot: await this.captureSessionSnapshot(resolved),
93589
+ startupNotes
93333
93590
  };
93334
93591
  }
93335
93592
  }
93593
+ async recaptureSessionIdAfterRun(target) {
93594
+ const resolved = this.resolveTarget(target);
93595
+ if (resolved.runner.sessionId.capture.mode !== "status-command") {
93596
+ return null;
93597
+ }
93598
+ const existing = await this.sessionMapping.get(resolved.sessionKey);
93599
+ if (existing?.sessionId) {
93600
+ return existing.sessionId;
93601
+ }
93602
+ if (!await this.tmux.hasSession(resolved.sessionName)) {
93603
+ return null;
93604
+ }
93605
+ let sessionId = null;
93606
+ try {
93607
+ sessionId = await this.captureSessionIdFromRunner(resolved);
93608
+ } catch (error) {
93609
+ console.warn(`clisbot post-run session id recapture failed for ${resolved.sessionName}`, error);
93610
+ this.deferSessionIdCapture(resolved.sessionKey);
93611
+ return null;
93612
+ }
93613
+ if (!sessionId) {
93614
+ this.deferSessionIdCapture(resolved.sessionKey);
93615
+ return null;
93616
+ }
93617
+ const recorded = await this.recordActiveSessionIdBestEffort(resolved, sessionId);
93618
+ if (!recorded) {
93619
+ return null;
93620
+ }
93621
+ console.log(`clisbot captured durable session id after run completion for ${resolved.sessionName}`);
93622
+ return sessionId;
93623
+ }
93336
93624
  canRecoverMidRun(error) {
93337
93625
  return isRecoverableStartupSessionLoss(error) || isTransientTmuxTargetError(error);
93338
93626
  }
@@ -93467,7 +93755,9 @@ class RunnerService {
93467
93755
  throw new Error(`${command} completed and clisbot captured session id ${sessionId}, but could not persist it. The durable session mapping was left unchanged.${details}`);
93468
93756
  }
93469
93757
  async killRunnerAndPreserveSessionId(resolved) {
93470
- await this.tmux.killSession(resolved.sessionName);
93758
+ await this.tmux.killSession(resolved.sessionName).catch(() => {
93759
+ return;
93760
+ });
93471
93761
  await this.sessionMapping.touch(resolved, {
93472
93762
  runnerCommand: resolved.runner.command
93473
93763
  });
@@ -93589,33 +93879,6 @@ class RunnerService {
93589
93879
  }
93590
93880
  }
93591
93881
 
93592
- // src/agents/session/run-recovery.ts
93593
- var MID_RUN_RECOVERY_MAX_ATTEMPTS = 2;
93594
- var MID_RUN_RECOVERY_CONTINUE_PROMPT = "continue exactly where you left off";
93595
- function mergeRunSnapshot(snapshotPrefix, snapshot) {
93596
- return appendInteractionText(snapshotPrefix, snapshot);
93597
- }
93598
- function buildRunRecoveryNote(kind, params) {
93599
- if (kind === "resume-attempt") {
93600
- const attempt = params?.attempt ?? 1;
93601
- const maxAttempts = params?.maxAttempts ?? MID_RUN_RECOVERY_MAX_ATTEMPTS;
93602
- return `Runner session was lost. Attempting recovery ${attempt}/${maxAttempts} by reopening the same conversation context.`;
93603
- }
93604
- if (kind === "resume-success") {
93605
- return "Recovery succeeded. Asking the runner to continue exactly where it left off.";
93606
- }
93607
- if (kind === "fresh-attempt") {
93608
- return "The previous runner session could not be resumed. Opening a fresh runner session 2/2 without replaying your prompt.";
93609
- }
93610
- if (kind === "resume-failed") {
93611
- return "The previous runner session could not be resumed. The stored session id was preserved; use `/new` to intentionally trigger a new runner conversation.";
93612
- }
93613
- if (kind === "manual-new-required") {
93614
- return "The previous runner session could not be resumed. clisbot preserved the stored session id instead of opening a new conversation automatically. Use `/new` if you want to trigger a new runner conversation, then resend the prompt.";
93615
- }
93616
- return "The previous runner session could not be resumed. clisbot opened a new fresh session, but did not replay your prompt because the prior conversation context is no longer guaranteed. Please resend the full prompt/context to continue.";
93617
- }
93618
-
93619
93882
  // src/runners/tmux/run-monitor.ts
93620
93883
  var FIRST_OUTPUT_POLL_INTERVAL_MS = 250;
93621
93884
  var RUNNING_REWRITE_PREVIEW_MAX_LINES = 8;
@@ -93801,8 +94064,10 @@ function isRetryableObserverDeliveryError(error) {
93801
94064
  }
93802
94065
  function buildMissingSessionIdStartupWarning() {
93803
94066
  return [
93804
- "Runner session started, but clisbot could not capture a durable session id yet.",
93805
- "This session is running, but it is not resumable until a session id is captured and persisted."
94067
+ "Runner session started, but clisbot could not capture a durable session id yet,",
94068
+ "so this conversation is not resumable if the runner restarts.",
94069
+ "clisbot keeps retrying automatically and will persist the id once the runner reports it; no action is needed.",
94070
+ "If this warning keeps appearing, the runner status output may have changed; check the pane with `clisbot watch --latest`."
93806
94071
  ].join(" ");
93807
94072
  }
93808
94073
 
@@ -93929,7 +94194,7 @@ class SessionService {
93929
94194
  });
93930
94195
  try {
93931
94196
  await this.sessionState.markPromptAdmitted(provisionalResolved);
93932
- const { resolved, initialSnapshot } = await this.runnerSessions.ensureRunnerReady(target, {
94197
+ const { resolved, initialSnapshot, startupNotes } = await this.runnerSessions.ensureRunnerReady(target, {
93933
94198
  ...options,
93934
94199
  timingContext: observer.timingContext
93935
94200
  });
@@ -93960,6 +94225,17 @@ class SessionService {
93960
94225
  state: "running",
93961
94226
  startedAt
93962
94227
  });
94228
+ for (const startupNote of startupNotes ?? []) {
94229
+ await this.notifyRunObservers(run, this.createRunUpdate({
94230
+ resolved,
94231
+ status: "running",
94232
+ snapshot: "",
94233
+ fullSnapshot: initialSnapshot,
94234
+ initialSnapshot,
94235
+ note: startupNote,
94236
+ forceVisible: true
94237
+ }));
94238
+ }
93963
94239
  if (!run.sessionId) {
93964
94240
  await this.notifyRunObservers(run, this.createRunUpdate({
93965
94241
  resolved,
@@ -94227,6 +94503,14 @@ class SessionService {
94227
94503
  if (!run) {
94228
94504
  return;
94229
94505
  }
94506
+ if (!run.sessionId) {
94507
+ try {
94508
+ run.sessionId = await this.runnerSessions.recaptureSessionIdAfterRun({
94509
+ agentId: run.resolved.agentId,
94510
+ sessionKey: run.resolved.sessionKey
94511
+ }) ?? undefined;
94512
+ } catch {}
94513
+ }
94230
94514
  await this.sessionState.setSessionRuntime(run.resolved, {
94231
94515
  state: "idle"
94232
94516
  });
@@ -94364,8 +94648,10 @@ class SessionService {
94364
94648
  return true;
94365
94649
  }
94366
94650
  if (await this.requiresManualNewAfterFailedResume(currentRun)) {
94367
- await this.notifyRecoveryStep(currentRun, buildRunRecoveryNote("resume-failed"));
94368
- await this.failActiveRun(sessionKey, currentRun.runId, new Error(buildRunRecoveryNote("manual-new-required")));
94651
+ await this.notifyRecoveryStep(currentRun, buildRunRecoveryNote("resume-failed", { storedSessionId: currentRun.sessionId }));
94652
+ await this.failActiveRun(sessionKey, currentRun.runId, new Error(buildRunRecoveryNote("manual-new-required", {
94653
+ storedSessionId: currentRun.sessionId
94654
+ })));
94369
94655
  return true;
94370
94656
  }
94371
94657
  await this.notifyRecoveryStep(currentRun, buildRunRecoveryNote("fresh-attempt"));
@@ -101281,6 +101567,7 @@ import { readFile as readFile3 } from "node:fs/promises";
101281
101567
 
101282
101568
  // src/channels/slack/content.ts
101283
101569
  var SLACK_MAX_BLOCKS = 50;
101570
+ var SLACK_BULLET_MARKERS = ["•", "◦", "▪"];
101284
101571
  function normalizeMarkdownLinks(text) {
101285
101572
  return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, href) => {
101286
101573
  const trimmedHref = href.trim();
@@ -101293,6 +101580,20 @@ function normalizeMarkdownLinks(text) {
101293
101580
  function renderInlineMarkdownToSlackMrkdwn(text) {
101294
101581
  return normalizeMarkdownLinks(text).replace(/~~([^~]+)~~/g, "~$1~").replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, "*$1*");
101295
101582
  }
101583
+ function trimOuterBlankLines(text) {
101584
+ return text.replace(/^\n+/, "").replace(/\n+$/, "");
101585
+ }
101586
+ function getMarkdownListDepth(rawIndent) {
101587
+ const width = rawIndent.replaceAll("\t", " ").length;
101588
+ return Math.floor(width / 2);
101589
+ }
101590
+ function renderSlackListIndent(depth) {
101591
+ return " ".repeat(Math.min(Math.max(depth, 0), 6));
101592
+ }
101593
+ function renderSlackBulletPrefix(depth) {
101594
+ const marker = SLACK_BULLET_MARKERS[Math.min(depth, SLACK_BULLET_MARKERS.length - 1)] ?? SLACK_BULLET_MARKERS[0];
101595
+ return `${renderSlackListIndent(depth)}${marker}`;
101596
+ }
101296
101597
  function stripMarkdownInline(text) {
101297
101598
  return text.replace(/`([^`\n]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, "$1").replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, "$1").replace(/\*([^*\n][\s\S]*?[^*\n])\*/g, "$1").replace(/~~([^~]+)~~/g, "$1").trim();
101298
101599
  }
@@ -101373,10 +101674,10 @@ function parseSlackBlocksInput(text) {
101373
101674
  return validateSlackBlocksArray(parsed);
101374
101675
  }
101375
101676
  function renderMarkdownToSlackMrkdwn(markdown) {
101376
- const normalized = markdown.replaceAll(`\r
101677
+ const normalized = trimOuterBlankLines(markdown.replaceAll(`\r
101377
101678
  `, `
101378
101679
  `).replaceAll("\r", `
101379
- `).trim();
101680
+ `));
101380
101681
  if (!normalized) {
101381
101682
  return "";
101382
101683
  }
@@ -101391,13 +101692,15 @@ function renderMarkdownToSlackMrkdwn(markdown) {
101391
101692
  }
101392
101693
  return content;
101393
101694
  }
101394
- const bulletMatch = line.match(/^\s*[-*+]\s+(.+)$/);
101695
+ const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
101395
101696
  if (bulletMatch) {
101396
- return `• ${renderInlineMarkdownToSlackMrkdwn(bulletMatch[1] ?? "")}`;
101697
+ const depth = getMarkdownListDepth(bulletMatch[1] ?? "");
101698
+ return `${renderSlackBulletPrefix(depth)} ${renderInlineMarkdownToSlackMrkdwn(bulletMatch[2] ?? "")}`;
101397
101699
  }
101398
- const orderedMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
101700
+ const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
101399
101701
  if (orderedMatch) {
101400
- return `${orderedMatch[1]}. ${renderInlineMarkdownToSlackMrkdwn(orderedMatch[2] ?? "")}`;
101702
+ const depth = getMarkdownListDepth(orderedMatch[1] ?? "");
101703
+ return `${renderSlackListIndent(depth)}${orderedMatch[2]}. ${renderInlineMarkdownToSlackMrkdwn(orderedMatch[3] ?? "")}`;
101401
101704
  }
101402
101705
  return renderInlineMarkdownToSlackMrkdwn(line);
101403
101706
  }).join(`
@@ -101462,10 +101765,10 @@ function renderMarkdownTableToFallbackSlackBlock(headers, rows) {
101462
101765
  };
101463
101766
  }
101464
101767
  function renderMarkdownToSlackBlocks(markdown) {
101465
- const normalized = markdown.replaceAll(`\r
101768
+ const normalized = trimOuterBlankLines(markdown.replaceAll(`\r
101466
101769
  `, `
101467
101770
  `).replaceAll("\r", `
101468
- `).trim();
101771
+ `));
101469
101772
  if (!normalized) {
101470
101773
  return [];
101471
101774
  }
@@ -101499,7 +101802,7 @@ function renderMarkdownToSlackBlocks(markdown) {
101499
101802
  return;
101500
101803
  }
101501
101804
  const text = paragraph.join(`
101502
- `).trim();
101805
+ `).trimEnd();
101503
101806
  if (text) {
101504
101807
  const shouldRenderAsPreamble = !hasSeenHeading && firstHeadingLineIndex > 0 && blocks.length === 0;
101505
101808
  pushBlock({
@@ -101614,14 +101917,16 @@ ${codeLines.join(`
101614
101917
  }
101615
101918
  continue;
101616
101919
  }
101617
- const bulletMatch = line.match(/^\s*[-*+]\s+(.+)$/);
101920
+ const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
101618
101921
  if (bulletMatch) {
101619
- paragraph.push(`• ${renderInlineMarkdownToSlackMrkdwn(bulletMatch[1] ?? "")}`);
101922
+ const depth = getMarkdownListDepth(bulletMatch[1] ?? "");
101923
+ paragraph.push(`${renderSlackBulletPrefix(depth)} ${renderInlineMarkdownToSlackMrkdwn(bulletMatch[2] ?? "")}`);
101620
101924
  continue;
101621
101925
  }
101622
- const orderedMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
101926
+ const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
101623
101927
  if (orderedMatch) {
101624
- paragraph.push(`${orderedMatch[1]}. ${renderInlineMarkdownToSlackMrkdwn(orderedMatch[2] ?? "")}`);
101928
+ const depth = getMarkdownListDepth(orderedMatch[1] ?? "");
101929
+ paragraph.push(`${renderSlackListIndent(depth)}${orderedMatch[2]}. ${renderInlineMarkdownToSlackMrkdwn(orderedMatch[3] ?? "")}`);
101625
101930
  continue;
101626
101931
  }
101627
101932
  paragraph.push(renderInlineMarkdownToSlackMrkdwn(line));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clisbot",
3
- "version": "0.1.54-beta.2",
3
+ "version": "0.1.54-beta.3",
4
4
  "private": false,
5
5
  "description": "Chat surfaces for durable AI coding agents running in tmux",
6
6
  "license": "MIT",