@useshifu/coding-harness 0.3.36 → 0.3.38

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.
@@ -13,6 +13,9 @@ const { normalizeContextPreferences, contextInstructions, contextSkill, CONTEXT_
13
13
  const { chooseSetupOptions } = require("./setup-options.js");
14
14
  const { createInitialBackfill, initialHistoryEligibility } = require("./initial-history.js");
15
15
  const { printWelcome, printSetupIntro, printConnected } = require("./onboarding-ui.js");
16
+ const { deadlineError, requestTimeout } = require("./run-deadline.js");
17
+ const controls = require("./sync-controls.js");
18
+ const { assertNoCredentials } = require("./secret-check.js");
16
19
 
17
20
  const DEFAULT_API_URL = "https://api.useshifu.com";
18
21
  const HARNESS_NAMES = {
@@ -47,10 +50,11 @@ const MAX_TURNS_PER_SEGMENT = 12;
47
50
  const MAX_TITLE_LENGTH = 160;
48
51
  const MAX_WORK_DETAIL_LENGTH = 600;
49
52
  const CONFIG_VERSION = 2;
50
- const RUNNER_VERSION = "0.3.36";
53
+ const RUNNER_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, path.basename(__filename) === "runner.js" ? "runner-package.json" : "../package.json"), "utf8")).version;
51
54
  const REDACTION_VERSION = 5;
52
55
  const LEGACY_REDACTION_VERSION = 4;
53
56
  const MAX_PREPARATIONS_PER_RUN = 4;
57
+ let remainingNativeRequests;
54
58
  const PREPARATION_RUN_MS = 5 * 60_000;
55
59
  const DEFAULT_INTERVAL_HOURS = 1;
56
60
  const LEGACY_INTERVAL_HOURS = 4;
@@ -66,17 +70,20 @@ const ACTIVE_WORK_FILE_STATUSES = new Set(["queued", "processing", "retrying"]);
66
70
  const INPUT_FOLDER_README_NAME = "README.md";
67
71
  const INPUT_FOLDER_README = `# Shifu sync folder
68
72
 
69
- Put work logs, meeting transcripts, and other work documents directly in this folder. Shifu checks it every hour by default and keeps claims only when the file supports a contribution you made. Other people's work and meeting action items alone are not your claims.
73
+ Files in this folder are processed only after you review the folder and select Start sync in Shifu setup. File parsing is selected by default for new setups and can be turned off. Once approved, put work logs, meeting transcripts, and other work documents directly here; Shifu checks them on your selected schedule and keeps claims only when the file supports a contribution you made. Other people's work and meeting action items alone are not your claims.
70
74
 
71
75
  Supported files: .txt, .md, .rtf, and .docx, up to 20 MB each. Subfolders, links, images, and screenshots are not processed. This README is not synced. Unsupported files are counted but never uploaded.
72
76
 
73
77
  Uploading starts a background job. Shifu checks unfinished jobs before the next scheduled or manual sync; a file is complete only when the server reports its final result. A partial result stays pending for retry. A completed file may have published claims, needs-evidence suggestions, or neither; suggestions are not verified claims.
74
78
 
75
- Files you add are sent to Shifu for parsing and sanitization; they are not redacted on this device. Only place files here if you want them processed. For named meeting transcripts, set your display name in Shifu Profile so your speaker turns can be matched.
79
+ Files you add are sent as provided to your connected Shifu workspace for parsing and sanitization. In Shifu Desktop, parsing runs on your Mac; AI processing may send extracted text through Shifu’s gateway. In the web app, files are uploaded to your hosted workspace. Only place files here if you want them processed. For named meeting transcripts, set your display name in Shifu Profile so your speaker turns can be matched.
76
80
  `;
77
81
 
78
82
  function configRoot() {
79
- return process.env.SHIFU_CONFIG_ROOT || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
83
+ if (process.env.SHIFU_CONFIG_ROOT) return process.env.SHIFU_CONFIG_ROOT;
84
+ const installedRoot = path.dirname(__filename);
85
+ if (path.basename(__filename) === "runner.js" && readDesktopProfile(installedRoot)) return installedRoot;
86
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
80
87
  }
81
88
 
82
89
  function configPath(harness) {
@@ -204,10 +211,11 @@ function openCodeDatabaseQuery(dbPath, query) {
204
211
  function opencodeSessions(rows) {
205
212
  const sessions = new Map();
206
213
  for (const row of rows) {
207
- if (typeof row.sessionRef !== "string" || !Number.isFinite(row.startedAt) || !Number.isFinite(row.updatedAt) || !Number.isFinite(row.turnAt)) continue;
214
+ if (typeof row.sessionRef !== "string" || !Number.isFinite(row.startedAt) || !Number.isFinite(row.updatedAt)) continue;
208
215
  const session = sessions.get(row.sessionRef) || { sessionRef: row.sessionRef, startedAt: new Date(row.startedAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), turnCount: 0, turnTimes: [] };
216
+ if (typeof row.workspace === "string" && path.isAbsolute(row.workspace)) session.workspace = row.workspace;
209
217
  session.turnCount += 1;
210
- session.turnTimes.push(new Date(row.turnAt).toISOString());
218
+ session.turnTimes.push(Number.isFinite(row.turnAt) && Number.isFinite(new Date(row.turnAt).getTime()) ? new Date(row.turnAt).toISOString() : null);
211
219
  sessions.set(row.sessionRef, session);
212
220
  }
213
221
  return [...sessions.values()];
@@ -217,11 +225,48 @@ function cursorOptions() {
217
225
  return { journalRoot: path.join(configRoot(), "cursor-capture") };
218
226
  }
219
227
 
228
+ const sessionMetadataCache = new Map();
229
+
230
+ function sessionFileIndex(harness) {
231
+ const configuredRoot = harness === "codex" ? codexSessionsRoot() : claudeSessionsRoot();
232
+ const root = fs.existsSync(configuredRoot) ? fs.realpathSync(configuredRoot) : path.resolve(configuredRoot);
233
+ let cache = sessionMetadataCache.get(harness);
234
+ if (!cache || cache.root !== root) {
235
+ cache = { root, files: new Map() };
236
+ sessionMetadataCache.set(harness, cache);
237
+ }
238
+ const index = new Map();
239
+ const currentFiles = new Set();
240
+ for (const file of sessionFiles(root)) {
241
+ currentFiles.add(file);
242
+ const stat = fs.statSync(file, { bigint: true });
243
+ const signature = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
244
+ let cached = cache.files.get(file);
245
+ if (!cached || cached.signature !== signature) {
246
+ cached = { file, signature, session: harness === "codex" ? codexSession(file) : claudeSession(file) };
247
+ cache.files.set(file, cached);
248
+ }
249
+ if (!cached.session) continue;
250
+ const previous = index.get(cached.session.sessionRef);
251
+ if (!previous || cached.session.turnCount > previous.session.turnCount ||
252
+ (cached.session.turnCount === previous.session.turnCount && cached.session.updatedAt > previous.session.updatedAt)) {
253
+ index.set(cached.session.sessionRef, cached);
254
+ }
255
+ }
256
+ for (const file of cache.files.keys()) if (!currentFiles.has(file)) cache.files.delete(file);
257
+ return index;
258
+ }
259
+
220
260
  function localSessions(harness) {
221
261
  if (harness === "cursor") return require("./cursor-sessions.js").localSessions(cursorOptions());
222
- if (harness === "codex") return sessionFiles(codexSessionsRoot()).map(codexSession).filter(Boolean);
223
- if (harness === "claude_code") return sessionFiles(claudeSessionsRoot()).map(claudeSession).filter(Boolean);
224
- if (harness === "opencode") return opencodeSessions(opencodeQuery("SELECT s.id AS sessionRef, s.time_created AS startedAt, s.time_updated AS updatedAt, m.time_created AS turnAt FROM session s JOIN message m ON m.session_id = s.id WHERE json_extract(m.data, '$.role') = 'user' ORDER BY s.time_updated, m.time_created"));
262
+ if (harness === "codex" || harness === "claude_code") return [...sessionFileIndex(harness).values()].map(({ session }) => session);
263
+ if (harness === "opencode") {
264
+ const query = "SELECT s.id AS sessionRef, s.time_created AS startedAt, s.time_updated AS updatedAt, m.time_created AS turnAt FROM session s JOIN message m ON m.session_id = s.id WHERE json_extract(m.data, '$.role') = 'user' ORDER BY s.time_updated, m.time_created";
265
+ let rows;
266
+ try { rows = opencodeQuery(query.replace("SELECT s.id", "SELECT s.directory AS workspace, s.id")); }
267
+ catch { rows = opencodeQuery(query); }
268
+ return opencodeSessions(rows);
269
+ }
225
270
  throw new Error("Choose a supported coding harness.");
226
271
  }
227
272
 
@@ -251,21 +296,25 @@ function validateSyncOptions(command, start = 3) {
251
296
  "sync-files": common,
252
297
  "scheduled-sync": common,
253
298
  pending: common,
299
+ prepare: [...common, "--session-ref", "--from-turn", "--to-turn"],
300
+ recover: [...common, "--selection"],
254
301
  "retry-preparation": [...common, "--session-ref", "--from-turn"],
255
302
  approve: common,
256
303
  install: common,
257
304
  connect: [...common, ...setup, ...syncSettings, "--api-url"],
258
- "config-update": [...common, ...setup, ...syncSettings],
305
+ "config-update": [...common, ...setup, ...syncSettings, "--background-reasoning", "--exclude-session", "--include-session"],
259
306
  "mcp-setup": [...common, ...setup, "--mcp-url", "--api-url"],
260
- onboard: [...common, "--run", "--api-url"],
307
+ onboard: [...common, "--run", "--api-url", "--mcp-url"],
308
+ pause: common, resume: common, "purge-local": common,
261
309
  "mcp-headers": common,
262
310
  "mcp-bridge": common,
263
311
  "cursor-hook": common,
264
312
  };
265
313
  const flags = {
266
314
  onboard: ["--status", "--worker", "--serve", "--helpful", "--new", "--abandon"],
315
+ "purge-local": ["--approved"],
267
316
  connect: ["--interactive", "--non-interactive"], "mcp-setup": ["--interactive", "--non-interactive"], "config-update": ["--interactive", "--non-interactive"],
268
- sessions: ["--all", "--force", "--initial-history"], review: ["--all", "--force"], sync: ["--approved", "--force"], approve: ["--approved"],
317
+ sessions: ["--all", "--force", "--initial-history"], review: ["--all", "--force"], prepare: ["--all"], "retry-preparation": ["--all"], sync: ["--approved", "--force"], approve: ["--approved"],
269
318
  };
270
319
  if (!options[command]) return;
271
320
  const seen = new Set();
@@ -389,6 +438,134 @@ function readState(harness) {
389
438
  return readJSON(statePath(harness), { sessions: {} });
390
439
  }
391
440
 
441
+ function currentProcessingConfig(harness, expected, selection = {}) {
442
+ const config = readConfig(harness);
443
+ if (controls.connectionFingerprint(config) !== controls.connectionFingerprint(expected) ||
444
+ (config.syncGeneration || 0) !== (expected.syncGeneration || 0) || JSON.stringify(config.syncAuthorization) !== JSON.stringify(expected.syncAuthorization)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
445
+ const current = controls.captureConfig(config);
446
+ if (selection.sessionRef) {
447
+ const session = localSessions(harness).find((item) => item.sessionRef === selection.sessionRef);
448
+ if ((selection.force || selection.supersedesSyncId) && (!session || selection.toTurn > session.turnCount)) throw new Error("The original local turns must still exist before force syncing this range.");
449
+ if (!session || normalizedPolicy(config).excludedSessionRefs.includes(selection.sessionRef) || !controls.sessionAllowed(config, session)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
450
+ if (selection.fromTurn) {
451
+ const through = selection.toTurn || selection.fromTurn;
452
+ if (!Number.isSafeInteger(through) || through < selection.fromTurn || through - selection.fromTurn >= MAX_TURNS_PER_SEGMENT) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
453
+ for (let turn = selection.fromTurn; turn <= through; turn++) {
454
+ if (!initialHistoryEligibility(session, turn, current).eligible) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
455
+ }
456
+ }
457
+ }
458
+ if (selection.folder && !controls.filesAllowed(config, selection.folder)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
459
+ return current;
460
+ }
461
+
462
+ function processingLease(harness, config, selection) {
463
+ currentProcessingConfig(harness, config, selection);
464
+ const controller = new AbortController();
465
+ const interval = setInterval(() => {
466
+ try { currentProcessingConfig(harness, config, selection?.folder ? selection : {}); }
467
+ catch (error) { controller.abort(error); }
468
+ }, 250);
469
+ interval.unref();
470
+ return { signal: controller.signal, close: () => clearInterval(interval) };
471
+ }
472
+
473
+ function requestSignal(timeoutMs, signal) {
474
+ const timeout = AbortSignal.timeout(timeoutMs);
475
+ if (!signal) return timeout;
476
+ const controller = new AbortController();
477
+ for (const source of [signal, timeout]) {
478
+ if (source.aborted) controller.abort(source.reason);
479
+ else source.addEventListener("abort", () => controller.abort(source.reason), { once: true });
480
+ }
481
+ return controller.signal;
482
+ }
483
+
484
+ async function processingFetch(harness, config, selection, url, options) {
485
+ const lease = processingLease(harness, config, selection);
486
+ const controller = new AbortController();
487
+ const abort = () => controller.abort(lease.signal.reason || options.signal?.reason);
488
+ lease.signal.addEventListener("abort", abort, { once: true });
489
+ options.signal?.addEventListener("abort", abort, { once: true });
490
+ if (options.signal?.aborted) abort();
491
+ try { return await fetch(url, { ...options, redirect: "error", signal: controller.signal }); }
492
+ finally {
493
+ lease.close();
494
+ lease.signal.removeEventListener("abort", abort);
495
+ options.signal?.removeEventListener("abort", abort);
496
+ }
497
+ }
498
+
499
+ function updateSavedConfig(harness, change) {
500
+ fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
501
+ const file = lockPath(`${harness}-settings`);
502
+ let descriptor;
503
+ for (let attempt = 0; attempt < 100 && descriptor === undefined; attempt++) {
504
+ try { descriptor = fs.openSync(file, "wx", 0o600); }
505
+ catch (error) {
506
+ if (error.code !== "EEXIST") throw error;
507
+ recoverSyncLock(file, 1_000);
508
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
509
+ }
510
+ }
511
+ if (descriptor === undefined) throw new Error("Shifu settings are being updated. Retry this action.");
512
+ try {
513
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
514
+ const next = change(readJSON(configPath(harness), null));
515
+ if (next !== undefined) writeJSON(configPath(harness), next);
516
+ return next;
517
+ } finally { releaseOwnedLock(file, descriptor); }
518
+ }
519
+
520
+ function setSyncPaused(harness, paused) {
521
+ const config = updateSavedConfig(harness, (current) => {
522
+ if (!current) throw new Error("No saved Shifu connection was found.");
523
+ if (!paused) controls.captureConfig({ ...current, syncPaused: false });
524
+ return { ...current, syncPaused: paused, syncGeneration: (current.syncGeneration || 0) + 1 };
525
+ });
526
+ if (paused) stopLegacyWorker(harness);
527
+ if (!paused) scheduleNextRun(harness, 0);
528
+ console.error(paused ? "All Shifu capture is paused. Accepted work and pending ranges are preserved." : "Shifu capture resumed within the approved scope.");
529
+ return config;
530
+ }
531
+
532
+ function stopLegacyWorker(harness) {
533
+ const file = lockPath(harness);
534
+ if (!fs.existsSync(file)) return;
535
+ const stat = fs.lstatSync(file);
536
+ if (!stat.isFile() || stat.size > 64) return;
537
+ const owner = fs.readFileSync(file, "utf8").trim();
538
+ if (!/^[1-9]\d{0,9}$/.test(owner) || Number(owner) === process.pid) return;
539
+ const active = readJSON(path.join(configRoot(), `${harness}-active-run.json`), null);
540
+ if (active?.pid === Number(owner) && active.lockInode === stat.ino) return;
541
+ let command;
542
+ try { command = execFileSync("ps", ["-p", owner, "-o", "command="], { encoding: "utf8", timeout: 1_000, stdio: ["ignore", "pipe", "ignore"] }); }
543
+ catch { return; }
544
+ const paths = [installedRunnerPath(), __filename].flatMap((value) => [value, fs.existsSync(value) ? fs.realpathSync(value) : value]);
545
+ const executable = command.trimStart().match(/^(?:\S*\/)?node(?:js)?[ \t]+/)?.[0];
546
+ if (!executable) return;
547
+ const invocation = command.trimStart().slice(executable.length);
548
+ if (!paths.some((value) => invocation.startsWith(`${value} `)) || !new RegExp(`--harness\\s+${harness}(?:\\s|$)`).test(invocation)) return;
549
+ try { process.kill(Number(owner), "SIGTERM"); }
550
+ catch (error) { if (error.code !== "ESRCH") throw new Error("Capture is paused, but an older worker could not be stopped. Stop that Shifu worker before continuing."); }
551
+ }
552
+
553
+ function purgeLocal(harness) {
554
+ const config = readJSON(configPath(harness), null);
555
+ if (config && config.syncPaused !== true) throw new Error("Pause Shifu capture before clearing its local preparation data.");
556
+ recoverSyncLock(lockPath(harness));
557
+ if (fs.existsSync(lockPath(harness))) throw new Error("A sync is still finishing. Retry the local purge after it stops.");
558
+ const names = [`${harness}-continuity.json`, `${harness}-entity-dictionary.json`, `${harness}-recovery.json`, `${harness}-onboarding.json`, `${harness}-wizard.json`, `${harness}-pending.json`];
559
+ if (harness === "cursor") names.push("cursor-capture");
560
+ const selected = fs.readdirSync(configRoot()).filter((name) => names.some((base) => name === base || (name.startsWith(`${base}.quarantined.`) && /^[a-f0-9-]+$/i.test(name.slice(base.length + 13)))));
561
+ if (!hasFlag("--approved")) {
562
+ console.log(JSON.stringify({ harness, artifacts: selected, confirmationRequired: true, preserves: ["connection", "accepted checkpoints", "coding tool history", "user documents", "saved Shifu work"] }));
563
+ return;
564
+ }
565
+ for (const name of selected) fs.rmSync(path.join(configRoot(), name), { recursive: true, force: true });
566
+ console.log(JSON.stringify({ harness, removedArtifacts: selected.length, paused: true }));
567
+ }
568
+
392
569
  function defaultInputFolder(home = os.homedir()) {
393
570
  return path.join(home, "Desktop", "Shifu");
394
571
  }
@@ -464,6 +641,7 @@ function codexSession(file) {
464
641
  let startedAt;
465
642
  let updatedAt;
466
643
  let turnCount = 0;
644
+ let workspace;
467
645
  const turnTimes = [];
468
646
  for (const line of lines) {
469
647
  let entry;
@@ -474,6 +652,7 @@ function codexSession(file) {
474
652
  }
475
653
  if (entry.type === "session_meta") {
476
654
  sessionRef = entry.payload?.id || entry.payload?.session_id;
655
+ if (typeof entry.payload?.cwd === "string" && path.isAbsolute(entry.payload.cwd)) workspace = entry.payload.cwd;
477
656
  startedAt ||= entry.timestamp;
478
657
  }
479
658
  if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "user") {
@@ -483,7 +662,7 @@ function codexSession(file) {
483
662
  if (typeof entry.timestamp === "string") updatedAt = entry.timestamp;
484
663
  }
485
664
  if (typeof sessionRef !== "string" || sessionRef.length < 8 || !updatedAt || turnCount === 0) return undefined;
486
- return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes };
665
+ return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes, ...(workspace ? { workspace } : {}) };
487
666
  }
488
667
 
489
668
  function claudeEntries(file) {
@@ -504,14 +683,17 @@ function claudeSession(file) {
504
683
  if (!OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) return undefined;
505
684
  let startedAt;
506
685
  let updatedAt;
686
+ let workspace;
507
687
  const turnTimes = [];
508
688
  for (const entry of claudeEntries(file)) {
509
- if (typeof entry.timestamp !== "string" || !Number.isFinite(Date.parse(entry.timestamp))) continue;
689
+ if (typeof entry.cwd === "string" && path.isAbsolute(entry.cwd)) workspace ||= entry.cwd;
690
+ const timestampValid = typeof entry.timestamp === "string" && Number.isFinite(Date.parse(entry.timestamp));
691
+ if (isClaudeUserTurn(entry)) turnTimes.push(timestampValid ? entry.timestamp : null);
692
+ if (!timestampValid) continue;
510
693
  startedAt ||= entry.timestamp;
511
694
  updatedAt = entry.timestamp;
512
- if (isClaudeUserTurn(entry)) turnTimes.push(entry.timestamp);
513
695
  }
514
- return updatedAt && turnTimes.length ? { sessionRef, startedAt, updatedAt, turnCount: turnTimes.length, turnTimes } : undefined;
696
+ return updatedAt && turnTimes.length ? { sessionRef, startedAt, updatedAt, turnCount: turnTimes.length, turnTimes, ...(workspace ? { workspace } : {}) } : undefined;
515
697
  }
516
698
 
517
699
  function claudeReview(file, fromTurn, toTurn) {
@@ -654,19 +836,29 @@ function sourceRecords(harness, sessionRef) {
654
836
  return opencodeQuery(`SELECT m.id AS messageId, m.time_created AS createdAt, json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.finish') AS finish, json_extract(m.data, '$.error') AS error, json_extract(m.data, '$.storageVersion') AS storageVersion, p.data AS part FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = ${JSON.stringify(sessionRef)} ORDER BY m.time_created, p.id`);
655
837
  }
656
838
  const file = sessionFile(harness, sessionRef);
839
+ return readSessionRecords(file);
840
+ }
841
+
842
+ function readSessionRecords(file) {
657
843
  try { return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
658
844
  catch { throw new Error("Could not read selected original messages locally. Nothing was reconstructed or synced."); }
659
845
  }
660
846
 
661
847
  function sessionFile(harness, sessionRef) {
662
- const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
663
- const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
664
- .filter(({ session }) => session?.sessionRef === sessionRef)
665
- .sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt))[0]?.file;
848
+ const selectedFile = sessionFileIndex(harness).get(sessionRef)?.file;
666
849
  if (!selectedFile) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
667
850
  return selectedFile;
668
851
  }
669
852
 
853
+ function completedSessionTurn(harness, sessionRef, fileIndex) {
854
+ const adapter = require("./source-review.js");
855
+ if (harness !== "codex" && harness !== "claude_code") return adapter.completedSourceTurn({ harness }, sourceRecords(harness, sessionRef));
856
+ const cached = (fileIndex || sessionFileIndex(harness)).get(sessionRef);
857
+ if (!cached) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
858
+ if (cached.completedTo === undefined) cached.completedTo = adapter.completedSourceTurn({ harness }, readSessionRecords(cached.file));
859
+ return cached.completedTo;
860
+ }
861
+
670
862
  function reviewCandidates(notes) {
671
863
  const candidates = [];
672
864
  const seen = new Set();
@@ -1231,16 +1423,20 @@ function unsyncedCodexSessions(harness, options = {}) {
1231
1423
  }
1232
1424
 
1233
1425
  function pendingSegments(harness, state = readState(harness)) {
1234
- const config = readConfig(harness);
1426
+ const saved = readConfig(harness);
1427
+ if (!controls.validAuthorization(saved) || saved.syncPaused) return [];
1428
+ const config = controls.captureConfig(saved);
1235
1429
  const policy = normalizedPolicy(config);
1236
1430
  const excluded = new Set(policy.excludedSessionRefs);
1237
1431
  const segments = [];
1238
- for (const session of localSessions(harness)
1239
- .filter((candidate) => !excluded.has(candidate.sessionRef))
1432
+ const fileIndex = harness === "codex" || harness === "claude_code" ? sessionFileIndex(harness) : undefined;
1433
+ const sessions = fileIndex ? [...fileIndex.values()].map(({ session }) => session) : localSessions(harness);
1434
+ for (const session of sessions
1435
+ .filter((candidate) => !excluded.has(candidate.sessionRef) && controls.sessionAllowed(config, candidate))
1240
1436
  .sort((left, right) => String(left.updatedAt || "").localeCompare(String(right.updatedAt || "")))) {
1241
1437
  let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
1242
1438
  if (fromTurn > session.turnCount || !initialHistoryEligibility(session, fromTurn, config).eligible) continue;
1243
- const completedTo = require("./source-review.js").completedSourceTurn({ harness }, sourceRecords(harness, session.sessionRef));
1439
+ const completedTo = completedSessionTurn(harness, session.sessionRef, fileIndex);
1244
1440
  while (fromTurn <= completedTo) {
1245
1441
  const toTurn = Math.min(completedTo, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
1246
1442
  segments.push({ ...session, fromTurn, toTurn });
@@ -1292,12 +1488,13 @@ function sameFile(source, destination) {
1292
1488
 
1293
1489
  function copyRunner() {
1294
1490
  fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
1491
+ writeJSON(path.join(configRoot(), "runner-package.json"), { version: RUNNER_VERSION });
1295
1492
  for (const name of ["jsonc-parser", "smol-toml"]) {
1296
1493
  const dependency = name === "smol-toml" ? path.dirname(path.dirname(require.resolve(name))) : path.dirname(require.resolve(`${name}/package.json`));
1297
1494
  const installedDependency = path.join(configRoot(), "node_modules", name);
1298
1495
  if (!sameFile(dependency, installedDependency)) copyDirectory(dependency, installedDependency);
1299
1496
  }
1300
- for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js", "opencode-v2-account.js", "context-preferences.js", "setup-options.js", "initial-history.js", "onboarding-ui.js", "onboarding.js", "continuity-preparation.js", "continuity-state.js", "cursor-config.js", "cursor-mcp.js", "cursor-sessions.js", "cursor-native.js"]) {
1497
+ for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js", "opencode-v2-account.js", "context-preferences.js", "setup-options.js", "initial-history.js", "onboarding-ui.js", "onboarding.js", "continuity-preparation.js", "continuity-state.js", "recovery-state.js", "run-deadline.js", "sync-controls.js", "secret-check.js", "cursor-config.js", "cursor-mcp.js", "cursor-sessions.js", "cursor-native.js"]) {
1301
1498
  const source = path.join(__dirname, name);
1302
1499
  const destination = path.join(configRoot(), name);
1303
1500
  if (!sameFile(source, destination)) {
@@ -1429,13 +1626,37 @@ function xml(value) {
1429
1626
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1430
1627
  }
1431
1628
 
1629
+ function readDesktopProfile(directory) {
1630
+ let marker;
1631
+ try {
1632
+ marker = fs.readFileSync(path.join(directory, "desktop-profile.json"), "utf8");
1633
+ } catch (error) {
1634
+ if (error.code === "ENOENT") return undefined;
1635
+ throw error;
1636
+ }
1637
+ let profile;
1638
+ try { profile = JSON.parse(marker); }
1639
+ catch { throw new Error("The desktop profile is invalid. Reopen the desktop app before changing its schedule."); }
1640
+ if (profile?.version !== 1 || typeof profile.namespace !== "string" || !/^desktop-[a-f0-9]{12}$/.test(profile.namespace)) {
1641
+ throw new Error("The desktop profile is invalid. Reopen the desktop app before changing its schedule.");
1642
+ }
1643
+ return profile;
1644
+ }
1645
+
1646
+ function macSchedulerLabel(harness) {
1647
+ const label = `com.useshifu.coding-harness.${harness}`;
1648
+ const profile = readDesktopProfile(configRoot());
1649
+ return profile ? `${label}.${profile.namespace}` : label;
1650
+ }
1651
+
1432
1652
  function schedulerArtifact(harness, platform = process.platform) {
1433
1653
  const runner = installedRunnerPath();
1434
- const label = `com.useshifu.coding-harness.${harness}`;
1435
1654
  if (platform === "darwin") {
1655
+ const label = macSchedulerLabel(harness);
1436
1656
  const file = path.join(os.homedir(), "Library", "LaunchAgents", `${label}.plist`);
1437
1657
  return {
1438
1658
  file,
1659
+ label,
1439
1660
  content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${label}</string>\n<key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(runner)}</string><string>scheduled-sync</string><string>--harness</string><string>${harness}</string><string>--config-root</string><string>${xml(configRoot())}</string></array>\n<key>StartInterval</key><integer>${SCHEDULER_TICK_MINUTES * 60}</integer>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`,
1440
1661
  activate() {
1441
1662
  const domain = `gui/${process.getuid()}`;
@@ -1495,7 +1716,7 @@ function removeSchedule(harness) {
1495
1716
  if (process.env.SHIFU_SKIP_SCHEDULER_ACTIVATION !== "1") {
1496
1717
  if (process.platform === "darwin") {
1497
1718
  const domain = `gui/${process.getuid()}`;
1498
- const label = `com.useshifu.coding-harness.${harness}`;
1719
+ const label = artifact.label;
1499
1720
  const scheduleIsLoaded = () => {
1500
1721
  let listing;
1501
1722
  try { listing = execFileSync("launchctl", ["list"], { encoding: "utf8" }); }
@@ -1544,7 +1765,7 @@ async function chooseSyncPolicy(existing) {
1544
1765
  };
1545
1766
  }
1546
1767
 
1547
- async function onboardingOptions(config, allowQuestions = true) {
1768
+ async function onboardingOptions(config, allowQuestions = true, includeHistory = true) {
1548
1769
  if (hasFlag("--interactive") && hasFlag("--non-interactive")) throw new Error("Choose interactive or non-interactive setup, not both.");
1549
1770
  if (hasFlag("--interactive") && !process.stdin.isTTY) throw new Error("Interactive setup needs a terminal. Use --backfill-days, --growth-feedback and --decision-alignment from your assistant instead.");
1550
1771
  const interactive = allowQuestions && process.stdin.isTTY && !hasFlag("--non-interactive");
@@ -1556,25 +1777,28 @@ async function onboardingOptions(config, allowQuestions = true) {
1556
1777
  terminal.once("close", closed);
1557
1778
  terminal.question(question).then(resolve, reject).finally(() => terminal.removeListener("close", closed));
1558
1779
  }));
1559
- return await chooseSetupOptions(config, { option, ask });
1780
+ return await chooseSetupOptions(config, { option, ask, includeHistory });
1560
1781
  } finally {
1561
1782
  terminal?.close();
1562
1783
  }
1563
1784
  }
1564
1785
 
1565
- async function validateConnection(apiUrl, token, harness) {
1786
+ async function validateConnection(apiUrl, token, harness, deadlineAt = Infinity) {
1787
+ const timeoutMs = requestTimeout(deadlineAt, 10_000);
1566
1788
  let response;
1567
1789
  try {
1568
1790
  response = await fetch(`${apiUrl}/v1/connectors/coding-sessions/connection?harness=${encodeURIComponent(harness)}`, {
1569
1791
  headers: { Authorization: `Bearer ${token}` },
1570
- signal: AbortSignal.timeout(10_000),
1792
+ signal: AbortSignal.timeout(timeoutMs),
1571
1793
  });
1572
1794
  } catch {
1795
+ if (Date.now() >= deadlineAt) throw deadlineError();
1573
1796
  throw new Error("Could not reach Shifu to verify the connection key. Nothing was changed.");
1574
1797
  }
1575
1798
  if (response.status === 401 || response.status === 422) throw new Error("Shifu rejected this connection key for the selected coding tool. Nothing was changed.");
1576
1799
  if (!response.ok) throw new Error(`Shifu could not verify the connection key (HTTP ${response.status}). Nothing was changed.`);
1577
1800
  const result = await response.json().catch(() => null);
1801
+ if (result === null && Date.now() >= deadlineAt) throw deadlineError();
1578
1802
  if (result?.data?.connected !== true) throw new Error("Shifu returned an invalid connection check. Nothing was changed.");
1579
1803
  return result.data;
1580
1804
  }
@@ -1595,7 +1819,8 @@ async function connect(harness, readKey = readSecret, choices, deferSchedule = f
1595
1819
  const selected = await chooseSyncPolicy(currentPolicy);
1596
1820
  const schedule = option("--schedule");
1597
1821
  if (schedule !== undefined && schedule !== "on" && schedule !== "off") throw new Error("Choose on or off for the sync schedule.");
1598
- const scheduleEnabled = !deferSchedule && (schedule === undefined ? (savedPolicy?.onboardingComplete ? savedPolicy.scheduleEnabled : true) : schedule === "on");
1822
+ if (schedule === "on" && !controls.validAuthorization(existing)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
1823
+ const scheduleEnabled = !deferSchedule && Boolean(controls.validAuthorization(existing)) && (schedule === undefined ? savedPolicy?.scheduleEnabled === true : schedule === "on");
1599
1824
  const syncPolicy = configuredSyncPolicy(existing || {}, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
1600
1825
  const inputFolder = configuredInputFolder(existing);
1601
1826
  const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
@@ -1603,9 +1828,14 @@ async function connect(harness, readKey = readSecret, choices, deferSchedule = f
1603
1828
  const scheduleFile = scheduleEnabled ? installSchedule(harness) : undefined;
1604
1829
  if (!scheduleEnabled && savedPolicy?.scheduleEnabled) removeSchedule(harness);
1605
1830
  const connection = { ...existing, ...choices, onboardingSchedulePending: deferSchedule || undefined, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder, capabilities: connectionInfo.capabilities || {} };
1831
+ if (schedule === "off" || (existing && selected.approvalMode !== currentPolicy.approvalMode)) connection.syncGeneration = (existing?.syncGeneration || 0) + 1;
1832
+ if (schedule === "off") connection.syncPaused = true;
1606
1833
  const committed = await withSyncLock(harness, async () => {
1607
1834
  if (JSON.stringify(readJSON(configPath(harness), undefined)) !== JSON.stringify(existing)) throw new Error("The connection changed during setup. Run connect again.");
1608
- require("./continuity-state.js").commitConnection(configRoot(), harness, existing, connection);
1835
+ updateSavedConfig(harness, (current) => {
1836
+ if (JSON.stringify(current) !== JSON.stringify(existing || null)) throw new Error("The connection changed during setup. Run connect again.");
1837
+ require("./continuity-state.js").commitConnection(configRoot(), harness, existing, connection);
1838
+ });
1609
1839
  refreshManagedContextInstructions(harness, choices.contextPreferences);
1610
1840
  if (refreshContextKey) writePrivateText(openCodeTokenPath(), token);
1611
1841
  const continuity = continuityState(harness, connection);
@@ -1613,6 +1843,7 @@ async function connect(harness, readKey = readSecret, choices, deferSchedule = f
1613
1843
  scheduleNextRun(harness, syncPolicy.intervalHours);
1614
1844
  });
1615
1845
  if (committed?.skipped) throw new Error("Another Shifu sync is running. Retry connect after it finishes; the connection was not replaced.");
1846
+ if (schedule === "off") stopLegacyWorker(harness);
1616
1847
  console.error(`${HARNESS_NAMES[harness]} is connected. ${scheduleEnabled ? `It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.` : "The sync schedule is off."}`);
1617
1848
  console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
1618
1849
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
@@ -2054,7 +2285,7 @@ async function setupMcp(harness) {
2054
2285
  throw new Error(`The saved ${HARNESS_NAMES[harness]} connection must be a regular private file, not a link.`);
2055
2286
  }
2056
2287
  printSetupIntro();
2057
- const choices = await onboardingOptions(readJSON(configPath(harness), undefined));
2288
+ const choices = await onboardingOptions(readJSON(configPath(harness), undefined), true, false);
2058
2289
  const instructions = prepareContextInstructions(harness, choices.contextPreferences);
2059
2290
  const helper = ["opencode", "cursor"].includes(harness) ? undefined : helperCommand(harness);
2060
2291
  if (fs.existsSync(configPath(harness)) && !fs.lstatSync(configPath(harness)).isFile()) {
@@ -2062,7 +2293,7 @@ async function setupMcp(harness) {
2062
2293
  }
2063
2294
  let config = readJSON(configPath(harness), undefined);
2064
2295
  if (!config) {
2065
- await connect(harness, readSecret, choices);
2296
+ await connect(harness, readSecret, choices, true);
2066
2297
  config = readConfig(harness);
2067
2298
  }
2068
2299
  const originalConnection = fs.readFileSync(configPath(harness), "utf8");
@@ -2089,7 +2320,10 @@ async function setupMcp(harness) {
2089
2320
  if (!unchangedContextInstructions(instructions)) throw new Error("The global instructions changed during setup. Run mcp-setup again to preserve those changes.");
2090
2321
  if (harness === "opencode") writePrivateText(openCodeTokenPath(), config.token);
2091
2322
  writePrivateText(prepared.file, prepared.candidate);
2092
- writeJSON(configPath(harness), { ...config, ...choices, mcpUrl, ...(harness === "opencode" ? { mcpConfigMajor: prepared.openCodeMajor } : {}), ...(harness === "claude_code" ? { mcpHeadersHelper: helper } : {}) });
2323
+ updateSavedConfig(harness, (current) => {
2324
+ if (JSON.stringify(current) !== JSON.stringify(config)) throw new Error("The saved Shifu connection changed during setup. Run mcp-setup again to preserve those changes.");
2325
+ return { ...current, ...choices, mcpUrl, ...(harness === "opencode" ? { mcpConfigMajor: prepared.openCodeMajor } : {}), ...(harness === "claude_code" ? { mcpHeadersHelper: helper } : {}) };
2326
+ });
2093
2327
  writeContextInstructions(instructions);
2094
2328
  console.error(`Verified Shifu MCP tools: ${tools.join(", ")}. ${HARNESS_NAMES[harness]} configuration and usage instructions are installed.`);
2095
2329
  console.error(`The existing connection key is read ${harness === "opencode" ? "from a private credential file" : "by a local credential helper"}; it was not copied into MCP configuration. The sync endpoint and schedule were preserved.`);
@@ -2117,36 +2351,70 @@ function disconnect(harness) {
2117
2351
 
2118
2352
  async function updateConfig(harness) {
2119
2353
  const config = readConfig(harness);
2354
+ const excludedSession = option("--exclude-session");
2355
+ const includedSession = option("--include-session");
2356
+ if ((excludedSession !== undefined || includedSession !== undefined) &&
2357
+ ((excludedSession !== undefined && includedSession !== undefined) || !sessionRefIsSafe(excludedSession || includedSession))) {
2358
+ throw new Error("Choose one opaque session reference to exclude or include.");
2359
+ }
2360
+ const originalConfig = JSON.stringify(config);
2361
+ const backgroundReasoning = option("--background-reasoning");
2362
+ if (backgroundReasoning !== undefined && (harness !== "codex" || !["medium", "high", "inherit"].includes(backgroundReasoning))) {
2363
+ throw new Error("Codex background reasoning must be medium, high or inherit. Chat reasoning stays unchanged.");
2364
+ }
2120
2365
  const choices = await onboardingOptions(config, hasFlag("--interactive"));
2121
2366
  const instructions = config.mcpUrl ? prepareContextInstructions(harness, choices.contextPreferences) : undefined;
2122
- const syncOptions = ["--interval-hours", "--approval-mode", "--schedule", "--input-folder"];
2123
- const preferencesOnly = (hasFlag("--interactive") || ["--growth-feedback", "--decision-alignment", "--backfill-days"].some((name) => option(name) !== undefined)) && !syncOptions.some((name) => option(name) !== undefined);
2367
+ const syncOptions = ["--interval-hours", "--approval-mode", "--schedule", "--input-folder", "--exclude-session", "--include-session"];
2368
+ const preferencesOnly = (hasFlag("--interactive") || ["--growth-feedback", "--decision-alignment", "--backfill-days", "--background-reasoning"].some((name) => option(name) !== undefined)) && !syncOptions.some((name) => option(name) !== undefined);
2124
2369
  if (!preferencesOnly) {
2125
2370
  const savedPolicy = normalizedPolicy(config);
2126
2371
  const selected = await chooseSyncPolicy(savedPolicy.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic" });
2127
2372
  const schedule = option("--schedule");
2128
2373
  if (schedule !== undefined && schedule !== "on" && schedule !== "off") throw new Error("Choose on or off for the sync schedule.");
2129
- const scheduleEnabled = schedule === undefined ? (savedPolicy.onboardingComplete ? savedPolicy.scheduleEnabled : true) : schedule === "on";
2374
+ if (schedule === "on" && !controls.validAuthorization(config)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
2375
+ const scheduleEnabled = Boolean(controls.validAuthorization(config)) && (schedule === undefined ? savedPolicy.scheduleEnabled : schedule === "on");
2130
2376
  const inputFolder = configuredInputFolder(config);
2131
2377
  const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
2132
2378
  config.version = CONFIG_VERSION;
2133
2379
  config.syncPolicy = configuredSyncPolicy(config, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
2380
+ if (selected.approvalMode !== savedPolicy.approvalMode) config.syncGeneration = (config.syncGeneration || 0) + 1;
2381
+ if (config.inputFolder !== inputFolder && config.syncAuthorization) {
2382
+ config.syncAuthorization = { ...config.syncAuthorization, filesEnabled: false, inputFolder: null };
2383
+ config.syncGeneration = (config.syncGeneration || 0) + 1;
2384
+ }
2134
2385
  config.inputFolder = inputFolder;
2135
2386
  if (scheduleEnabled) installSchedule(harness);
2136
2387
  else if (savedPolicy.scheduleEnabled) removeSchedule(harness);
2137
2388
  scheduleNextRun(harness, config.syncPolicy.intervalHours);
2138
2389
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
2139
2390
  }
2140
- if (option("--schedule") !== undefined) delete config.onboardingSchedulePending;
2391
+ if (excludedSession !== undefined || includedSession !== undefined) {
2392
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2393
+ if (excludedSession) excluded.add(excludedSession); else excluded.delete(includedSession);
2394
+ config.syncPolicy.excludedSessionRefs = [...excluded];
2395
+ config.syncGeneration = (config.syncGeneration || 0) + 1;
2396
+ }
2397
+ if (option("--schedule") !== undefined) {
2398
+ delete config.onboardingSchedulePending;
2399
+ config.syncPaused = option("--schedule") === "off";
2400
+ config.syncGeneration = (config.syncGeneration || 0) + 1;
2401
+ }
2141
2402
  Object.assign(config, choices);
2403
+ if (backgroundReasoning === "inherit") delete config.backgroundReasoning;
2404
+ else if (backgroundReasoning !== undefined) config.backgroundReasoning = backgroundReasoning;
2142
2405
  install(harness, true, choices.contextPreferences);
2143
- writeJSON(configPath(harness), config);
2406
+ updateSavedConfig(harness, (current) => {
2407
+ if (JSON.stringify(current) !== originalConfig) throw new Error("Shifu settings changed while this update was running. Your latest settings were preserved; retry the update.");
2408
+ return config;
2409
+ });
2410
+ if (config.syncPaused) stopLegacyWorker(harness);
2144
2411
  if (instructions) writeContextInstructions(instructions);
2145
2412
  else refreshManagedContextInstructions(harness, choices.contextPreferences);
2146
2413
  console.error(`Shifu preferences saved for ${HARNESS_NAMES[harness]}. Historical context remains on after MCP setup.`);
2147
2414
  console.error(`Growth feedback: ${choices.contextPreferences.growthFeedback ? "On" : "Off"}. Decision alignment: ${choices.contextPreferences.decisionAlignment ? "On" : "Off"}.`);
2148
2415
  const policy = normalizedPolicy(config);
2149
2416
  console.error(`Work-summary sync: ${policy.scheduleEnabled ? `every ${policy.intervalHours} hours with ${policy.approvalMode} approval` : "schedule off"}.`);
2417
+ if (harness === "codex") console.error(`Background extraction reasoning: ${config.backgroundReasoning || "inherit"}. Codex chat settings are unchanged.`);
2150
2418
  if (config.mcpUrl) console.error(`Reload ${HARNESS_NAMES[harness]} or start a new chat to load the updated preferences.`);
2151
2419
  }
2152
2420
 
@@ -2259,62 +2527,75 @@ function retryDelay(response, attempt) {
2259
2527
  return Math.min(base * (2 ** attempt), 30_000);
2260
2528
  }
2261
2529
 
2262
- async function postSync(config, input, beforeAttempt, onPermanentFailure) {
2530
+ async function postSync(config, input, beforeAttempt, onPermanentFailure, deadlineAt = Infinity, signal) {
2531
+ assertNoCredentials(input);
2263
2532
  const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
2264
2533
  let lastError;
2265
2534
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
2535
+ requestTimeout(deadlineAt);
2266
2536
  beforeAttempt?.();
2537
+ const timeoutMs = requestTimeout(deadlineAt);
2267
2538
  let response;
2268
2539
  try {
2269
2540
  response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
2541
+ redirect: "error",
2270
2542
  method: "POST",
2271
2543
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
2272
2544
  body,
2273
- signal: AbortSignal.timeout(30_000),
2545
+ signal: requestSignal(timeoutMs, signal),
2274
2546
  });
2275
2547
  } catch (error) {
2548
+ if (signal?.aborted) throw signal.reason;
2549
+ if (Date.now() >= deadlineAt) throw deadlineError();
2276
2550
  lastError = new Error(`Could not reach Shifu at ${config.apiUrl}: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
2277
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
2551
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(undefined, attempt)));
2278
2552
  continue;
2279
2553
  }
2280
2554
  const payload = await response.json().catch(() => undefined);
2555
+ if (payload === undefined && Date.now() >= deadlineAt) throw deadlineError();
2281
2556
  if (response.ok) return payload?.data;
2282
2557
  lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
2283
2558
  if (response.status !== 429 && response.status < 500) { onPermanentFailure?.(); throw lastError; }
2284
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
2559
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(response, attempt)));
2285
2560
  }
2286
2561
  throw lastError;
2287
2562
  }
2288
2563
 
2289
- async function flushPendingProfiles(harness, config) {
2564
+ async function flushPendingProfiles(harness, config, deadlineAt = Infinity) {
2290
2565
  const pending = readState(harness).profilePending || {};
2291
2566
  for (const [key, profile] of Object.entries(pending)) {
2567
+ if (Date.now() >= deadlineAt) break;
2568
+ let httpStatus;
2292
2569
  try {
2293
2570
  validateGeneratedProfile(profile, harness);
2294
- const response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/ai-profile-observations`, {
2571
+ assertNoCredentials(profile);
2572
+ const response = await processingFetch(harness, config, profile, `${config.apiUrl}/v1/connectors/coding-sessions/ai-profile-observations`, {
2295
2573
  method: "POST",
2296
2574
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
2297
2575
  body: JSON.stringify(profile),
2298
- signal: AbortSignal.timeout(30_000),
2576
+ signal: AbortSignal.timeout(requestTimeout(deadlineAt)),
2299
2577
  });
2578
+ httpStatus = response.status;
2300
2579
  const receipt = await response.json().catch(() => undefined);
2301
- if (response.status !== 409 && (!response.ok || receipt?.data?.accepted !== true)) throw new Error(`HTTP ${response.status}`);
2580
+ if (!response.ok || receipt?.data?.accepted !== true) throw new Error("AI Profile acceptance was not confirmed.");
2302
2581
  const state = readState(harness);
2303
2582
  delete state.profilePending[key];
2304
2583
  saveState(harness, state);
2305
2584
  } catch {
2306
- console.error(`AI Profile observation for ${profile.sessionRef} turns ${profile.fromTurn}–${profile.toTurn} remains pending.`);
2585
+ console.error(`AI Profile observation for ${profile.sessionRef} turns ${profile.fromTurn}–${profile.toTurn} remains pending${Number.isInteger(httpStatus) ? ` (HTTP ${httpStatus})` : ""}.`);
2307
2586
  }
2308
2587
  }
2309
2588
  }
2310
2589
 
2311
2590
  function scanWorkFiles(folder) {
2312
- const entries = fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
2313
- .filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME);
2591
+ const all = fs.readdirSync(validateInputFolder(folder), { withFileTypes: true });
2592
+ const entries = all.filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME);
2314
2593
  return {
2315
2594
  files: entries.filter((entry) => WORK_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
2316
2595
  .map((entry) => path.join(folder, entry.name)).sort(),
2317
2596
  unsupported: entries.filter((entry) => !WORK_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())).length,
2597
+ skippedSymlinks: all.filter((entry) => entry.isSymbolicLink()).length,
2598
+ skippedDirectories: all.filter((entry) => entry.isDirectory()).length,
2318
2599
  };
2319
2600
  }
2320
2601
 
@@ -2338,26 +2619,33 @@ function readWorkFile(file) {
2338
2619
  }
2339
2620
  }
2340
2621
 
2341
- async function postWorkFile(config, file, contents) {
2622
+ async function postWorkFile(config, file, contents, deadlineAt = Infinity, signal, beforeAttempt) {
2623
+ assertNoCredentials(contents.bytes);
2342
2624
  let lastError;
2343
2625
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
2626
+ requestTimeout(deadlineAt, 2 * 60_000);
2627
+ beforeAttempt?.();
2344
2628
  let response;
2345
2629
  try {
2346
2630
  const form = new FormData();
2347
2631
  form.append("file", new Blob([contents.bytes]), path.basename(file));
2348
2632
  form.append("sourceModifiedAt", contents.modifiedAt);
2349
2633
  response = await fetch(`${config.apiUrl}/v1/connectors/work-files/jobs`, {
2634
+ redirect: "error",
2350
2635
  method: "POST",
2351
2636
  headers: { Authorization: `Bearer ${config.token}` },
2352
2637
  body: form,
2353
- signal: AbortSignal.timeout(2 * 60_000),
2638
+ signal: requestSignal(requestTimeout(deadlineAt, 2 * 60_000), signal),
2354
2639
  });
2355
2640
  } catch (error) {
2641
+ if (signal?.aborted) throw signal.reason;
2642
+ if (Date.now() >= deadlineAt) throw deadlineError();
2356
2643
  lastError = new Error(`Could not send ${path.basename(file)} to Shifu: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
2357
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
2644
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(undefined, attempt)));
2358
2645
  continue;
2359
2646
  }
2360
2647
  const payload = await response.json().catch(() => undefined);
2648
+ if (payload === undefined && Date.now() >= deadlineAt) throw deadlineError();
2361
2649
  if (response.ok) {
2362
2650
  const receipt = payload?.data;
2363
2651
  if (validWorkFileJob(receipt)) return receipt;
@@ -2365,7 +2653,7 @@ async function postWorkFile(config, file, contents) {
2365
2653
  }
2366
2654
  lastError = new Error(payload?.error?.message || `Shifu rejected ${path.basename(file)} (${response.status}).`);
2367
2655
  if (response.status !== 429 && response.status < 500) throw lastError;
2368
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
2656
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(response, attempt)));
2369
2657
  }
2370
2658
  throw lastError;
2371
2659
  }
@@ -2393,28 +2681,31 @@ function workFileEntries(state, folder) {
2393
2681
  .map(([name, entry]) => [name, { ...entry, replayOnCompletion: true }]));
2394
2682
  }
2395
2683
 
2396
- async function getWorkFileJob(config, jobId) {
2684
+ async function getWorkFileJob(config, jobId, deadlineAt = Infinity) {
2397
2685
  let lastError;
2398
2686
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
2687
+ const timeoutMs = requestTimeout(deadlineAt);
2399
2688
  let response;
2400
2689
  try {
2401
2690
  response = await fetch(`${config.apiUrl}/v1/connectors/work-files/jobs/${encodeURIComponent(jobId)}`, {
2402
2691
  headers: { Authorization: `Bearer ${config.token}` },
2403
- signal: AbortSignal.timeout(30_000),
2692
+ signal: AbortSignal.timeout(timeoutMs),
2404
2693
  });
2405
2694
  } catch (error) {
2695
+ if (Date.now() >= deadlineAt) throw deadlineError();
2406
2696
  lastError = new Error(`Could not check a Shifu file job: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
2407
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
2697
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(undefined, attempt)));
2408
2698
  continue;
2409
2699
  }
2410
2700
  const payload = await response.json().catch(() => undefined);
2701
+ if (payload === undefined && Date.now() >= deadlineAt) throw deadlineError();
2411
2702
  if (response.ok) {
2412
2703
  if (validWorkFileJob(payload?.data) && payload.data.id === jobId) return payload.data;
2413
2704
  throw new Error("Shifu returned an invalid file-job status. Local state was not advanced.");
2414
2705
  }
2415
2706
  lastError = new Error(payload?.error?.message || `Shifu could not check a file job (${response.status}).`);
2416
2707
  if (response.status !== 429 && response.status < 500) throw lastError;
2417
- if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
2708
+ if (attempt + 1 < MAX_RETRIES) await sleep(requestTimeout(deadlineAt, retryDelay(response, attempt)));
2418
2709
  }
2419
2710
  throw lastError;
2420
2711
  }
@@ -2426,8 +2717,9 @@ function saveWorkFileEntry(harness, folder, name, entry) {
2426
2717
  saveState(harness, state);
2427
2718
  }
2428
2719
 
2429
- async function syncWorkFiles(harness, config = readConfig(harness)) {
2430
- if (typeof config.inputFolder !== "string") throw new Error("No input folder is configured. Run connect again to enable file sync.");
2720
+ async function syncWorkFiles(harness, config = readConfig(harness), deadlineAt = Infinity) {
2721
+ if (typeof config.inputFolder !== "string") throw new Error("Choose a file folder in Shifu onboarding before syncing files.");
2722
+ currentProcessingConfig(harness, config, { folder: configuredInputFolder(config) });
2431
2723
  const folder = validateInputFolder(configuredInputFolder(config));
2432
2724
  const state = readState(harness);
2433
2725
  const prior = workFileEntries(state, folder);
@@ -2435,9 +2727,10 @@ async function syncWorkFiles(harness, config = readConfig(harness)) {
2435
2727
  const failedFiles = new Set();
2436
2728
  const unverifiedJobs = new Set();
2437
2729
  for (const [name, entry] of Object.entries(prior)) {
2730
+ if (Date.now() >= deadlineAt) break;
2438
2731
  if (typeof entry !== "object" || !entry?.jobId) continue;
2439
2732
  try {
2440
- const job = await getWorkFileJob(config, entry.jobId);
2733
+ const job = await getWorkFileJob(config, entry.jobId, deadlineAt);
2441
2734
  if (FINAL_WORK_FILE_STATUSES.has(job.status)) {
2442
2735
  console.error(`${name}: ${workFileResult(job)}.`);
2443
2736
  if (entry.replayOnCompletion) {
@@ -2459,15 +2752,22 @@ async function syncWorkFiles(harness, config = readConfig(harness)) {
2459
2752
  }
2460
2753
  }
2461
2754
  const scan = scanWorkFiles(folder);
2755
+ if (scan.skippedSymlinks) console.error(`${scan.skippedSymlinks} symbolic link(s) skipped; linked files and folders are not uploaded.`);
2756
+ if (scan.skippedDirectories) console.error(`${scan.skippedDirectories} subfolder(s) skipped; only files directly in the approved folder are uploaded.`);
2462
2757
  if (scan.unsupported) console.error(`${scan.unsupported} unsupported file${scan.unsupported === 1 ? "" : "s"} skipped (only .txt, .md, .rtf, and .docx are uploaded).`);
2463
2758
  for (const file of scan.files) {
2759
+ if (Date.now() >= deadlineAt) break;
2464
2760
  const name = path.basename(file);
2465
2761
  try {
2466
2762
  if (unverifiedJobs.has(name)) continue;
2467
2763
  const contents = readWorkFile(file);
2468
2764
  const previous = prior[name];
2469
2765
  if (previous === contents.sha256 || (previous?.sha256 === contents.sha256 && previous.status !== "partial")) continue;
2470
- const job = await postWorkFile(config, file, contents);
2766
+ const selection = { folder };
2767
+ const lease = processingLease(harness, config, selection);
2768
+ let job;
2769
+ try { job = await postWorkFile(config, file, contents, deadlineAt, lease.signal, () => currentProcessingConfig(harness, config, selection)); }
2770
+ finally { lease.close(); }
2471
2771
  const entry = FINAL_WORK_FILE_STATUSES.has(job.status) ? contents.sha256 : { sha256: contents.sha256, jobId: job.id, status: job.status };
2472
2772
  saveWorkFileEntry(harness, folder, name, entry);
2473
2773
  prior[name] = entry;
@@ -2482,10 +2782,12 @@ async function syncWorkFiles(harness, config = readConfig(harness)) {
2482
2782
  for (const [name, entry] of Object.entries(prior)) {
2483
2783
  if (entry?.status === "partial") failedFiles.add(name);
2484
2784
  }
2485
- return { submitted, pending, failures: failedFiles.size, unsupported: scan.unsupported };
2785
+ return { submitted, pending, failures: failedFiles.size, unsupported: scan.unsupported, skippedSymlinks: scan.skippedSymlinks, skippedDirectories: scan.skippedDirectories };
2486
2786
  }
2487
2787
 
2488
- async function sendSync(harness, input, config = readConfig(harness)) {
2788
+ async function sendSync(harness, input, config = readConfig(harness), deadlineAt = Infinity) {
2789
+ requestTimeout(deadlineAt);
2790
+ currentProcessingConfig(harness, config, input);
2489
2791
  validateSync(input, harness);
2490
2792
  const state = readState(harness);
2491
2793
  const checkpoint = state.sessions[input.sessionRef];
@@ -2503,7 +2805,10 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2503
2805
  }
2504
2806
  let receipt;
2505
2807
  try {
2506
- receipt = frozen?.receipt || await postSync(config, input, frozen ? () => store.reserveBase(input) : undefined, frozen ? () => store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_rejected"; }) : undefined);
2808
+ requestTimeout(deadlineAt);
2809
+ const lease = processingLease(harness, config, input);
2810
+ try { receipt = frozen?.receipt || await postSync(config, input, () => { currentProcessingConfig(harness, config, input); if (frozen) store.reserveBase(input); }, frozen ? () => store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_rejected"; }) : undefined, deadlineAt, lease.signal); }
2811
+ finally { lease.close(); }
2507
2812
  if (!validSyncReceipt(receipt, input)) throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
2508
2813
  } catch (error) {
2509
2814
  if (frozen && store.find(input).baseAttempts >= 10) store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_transport_budget"; });
@@ -2517,11 +2822,54 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2517
2822
  state.sessions[input.sessionRef] = { ...checkpoint, turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
2518
2823
  saveState(harness, state);
2519
2824
  if (!force) prunePendingQueue(harness);
2520
- if (profile) await flushPendingProfiles(harness, config);
2825
+ if (profile) await flushPendingProfiles(harness, config, deadlineAt);
2521
2826
  console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : force ? `Force synced the reviewed range for turns ${input.fromTurn}–${input.toTurn}.` : `Synced through turn ${receipt.lastSyncedTurn}.`);
2522
2827
  return receipt;
2523
2828
  }
2524
2829
 
2830
+ function releaseOwnedLock(file, descriptor) {
2831
+ const owned = fs.fstatSync(descriptor);
2832
+ fs.closeSync(descriptor);
2833
+ try {
2834
+ const current = fs.lstatSync(file);
2835
+ if (current.isFile() && current.dev === owned.dev && current.ino === owned.ino) fs.unlinkSync(file);
2836
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
2837
+ }
2838
+
2839
+ function recoverableSyncLock(file, unknownOwnerMs = 2 * 3_600_000) {
2840
+ let previous;
2841
+ try { previous = fs.lstatSync(file); }
2842
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
2843
+ if (!previous.isFile()) throw new Error("The sync lock is not a regular file; existing work was left untouched.");
2844
+ const owner = previous.size <= 64 ? fs.readFileSync(file, "utf8").trim() : "";
2845
+ if (/^[1-9]\d{0,9}$/.test(owner)) {
2846
+ try { process.kill(Number(owner), 0); return; }
2847
+ catch (error) { if (error.code !== "ESRCH") return; }
2848
+ } else if (Date.now() - previous.mtimeMs <= unknownOwnerMs) return;
2849
+ return previous;
2850
+ }
2851
+
2852
+ function recoverSyncLock(file, unknownOwnerMs) {
2853
+ if (!recoverableSyncLock(file, unknownOwnerMs)) return;
2854
+ const guard = `${file}.recovery`;
2855
+ let descriptor;
2856
+ try {
2857
+ try { descriptor = fs.openSync(guard, "wx", 0o600); }
2858
+ catch (error) {
2859
+ if (error.code !== "EEXIST") throw error;
2860
+ if (recoverableSyncLock(guard, 30_000)) throw new Error("A previous sync lock recovery was interrupted; review the recovery guard before retrying. Existing work was left untouched.");
2861
+ return;
2862
+ }
2863
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
2864
+ const previous = recoverableSyncLock(file, unknownOwnerMs);
2865
+ if (!previous) return;
2866
+ const current = fs.lstatSync(file);
2867
+ if (current.dev === previous.dev && current.ino === previous.ino && current.ctimeMs === previous.ctimeMs) fs.unlinkSync(file);
2868
+ } finally {
2869
+ if (descriptor !== undefined) releaseOwnedLock(guard, descriptor);
2870
+ }
2871
+ }
2872
+
2525
2873
  async function withSyncLock(harness, action) {
2526
2874
  fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
2527
2875
  const file = lockPath(harness);
@@ -2532,12 +2880,12 @@ async function withSyncLock(harness, action) {
2532
2880
  descriptor = fs.openSync(file, "wx", 0o600);
2533
2881
  } catch (error) {
2534
2882
  if (error?.code !== "EEXIST") throw error;
2535
- const stale = Date.now() - fs.statSync(file).mtimeMs > 2 * 3_600_000;
2536
- if (!stale) return { skipped: true };
2537
- fs.unlinkSync(file);
2538
- descriptor = fs.openSync(file, "wx", 0o600);
2883
+ recoverSyncLock(file);
2884
+ try { descriptor = fs.openSync(file, "wx", 0o600); }
2885
+ catch (error) { if (error.code === "EEXIST") return { skipped: true }; throw error; }
2539
2886
  }
2540
2887
  fs.writeFileSync(descriptor, `${process.pid}\n`);
2888
+ if (HARNESS_NAMES[harness]) writeJSON(path.join(configRoot(), `${harness}-active-run.json`), { pid: process.pid, version: RUNNER_VERSION, lockInode: fs.fstatSync(descriptor).ino });
2541
2889
  heartbeat = setInterval(() => {
2542
2890
  try { fs.futimesSync(descriptor, new Date(), new Date()); } catch {}
2543
2891
  }, 60_000);
@@ -2545,10 +2893,11 @@ async function withSyncLock(harness, action) {
2545
2893
  return await action();
2546
2894
  } finally {
2547
2895
  if (heartbeat) clearInterval(heartbeat);
2548
- if (descriptor !== undefined) {
2549
- fs.closeSync(descriptor);
2550
- try { fs.unlinkSync(file); } catch {}
2896
+ if (HARNESS_NAMES[harness] && descriptor !== undefined) {
2897
+ const marker = path.join(configRoot(), `${harness}-active-run.json`);
2898
+ if (readJSON(marker, null)?.pid === process.pid) fs.rmSync(marker, { force: true });
2551
2899
  }
2900
+ if (descriptor !== undefined) releaseOwnedLock(file, descriptor);
2552
2901
  }
2553
2902
  }
2554
2903
 
@@ -2565,7 +2914,11 @@ async function sync(harness) {
2565
2914
  return;
2566
2915
  }
2567
2916
  }
2568
- const result = await withSyncLock(harness, () => sendSync(harness, input, config));
2917
+ const result = await withSyncLock(harness, async () => {
2918
+ const receipt = await sendSync(harness, input, config);
2919
+ await flushContinuityAttachments(harness, config);
2920
+ return receipt;
2921
+ });
2569
2922
  if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
2570
2923
  }
2571
2924
 
@@ -2600,14 +2953,15 @@ function queuePayload(harness, input) {
2600
2953
  return input;
2601
2954
  }
2602
2955
 
2603
- async function reconcilePreparedSession(harness, sessionRef, config) {
2956
+ async function reconcilePreparedSession(harness, sessionRef, config, { refresh = false, deadlineAt = Infinity } = {}) {
2957
+ requestTimeout(deadlineAt);
2604
2958
  const state = readState(harness);
2605
2959
  const checkpoint = state.sessions[sessionRef] || {};
2606
- if (checkpoint.preparationVersion === REDACTION_VERSION) return;
2960
+ if (!refresh && checkpoint.preparationVersion === REDACTION_VERSION) return;
2607
2961
  if (readPendingQueue(harness).segments.some((input) => input.sessionRef === sessionRef)) return;
2608
2962
  // Older runners did not persist automatic payloads before HTTP. Recover an
2609
2963
  // accepted prefix before switching an uncertain legacy range to v5.
2610
- const accepted = await acceptedSegments(config, sessionRef);
2964
+ const accepted = await acceptedSegments(config, sessionRef, deadlineAt);
2611
2965
  let lastSyncedTurn = checkpoint.lastSyncedTurn || 0;
2612
2966
  for (const segment of accepted) {
2613
2967
  if (segment.toTurn <= lastSyncedTurn) continue;
@@ -2622,9 +2976,12 @@ function continuityState(harness, config = readConfig(harness)) {
2622
2976
  return require("./continuity-state.js").continuityStore(configRoot(), harness, config);
2623
2977
  }
2624
2978
 
2625
- function preparationPacket(harness, segment, config) {
2979
+ function preparationPacket(harness, segment, config, { allowBackfill = false } = {}) {
2626
2980
  if (normalizedPolicy(config).excludedSessionRefs.includes(segment.sessionRef)) throw new Error("This session is excluded from preparation.");
2627
- if (!initialHistoryEligibility(segment, segment.fromTurn, config).eligible) throw new Error("This range is outside the saved initial-history selection.");
2981
+ const source = localSessions(harness).find((session) => session.sessionRef === segment.sessionRef);
2982
+ if (!source) throw new Error("The original session is unavailable; nothing was prepared.");
2983
+ segment = { ...segment, turnTimes: source.turnTimes };
2984
+ if (!allowBackfill && !initialHistoryEligibility(segment, segment.fromTurn, config).eligible) throw new Error("This range is outside the saved initial-history selection.");
2628
2985
  const records = sourceRecords(harness, segment.sessionRef);
2629
2986
  let minimumTurn = Math.max(1, segment.fromTurn - 6);
2630
2987
  for (let turn = segment.fromTurn - 1; turn >= minimumTurn; turn -= 1) {
@@ -2642,8 +2999,8 @@ function preparationPacket(harness, segment, config) {
2642
2999
  return { packet, workspace: createHash("sha256").update(typeof workspace === "string" && workspace ? `workspace:${workspace}` : `${harness}:session:${segment.sessionRef}`).digest("hex") };
2643
3000
  }
2644
3001
 
2645
- async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
2646
- const config = readConfig(harness);
3002
+ async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative, options = {}) {
3003
+ const config = currentProcessingConfig(harness, readConfig(harness), segment);
2647
3004
  const store = continuityState(harness, config);
2648
3005
  const prior = store.load().attempts[`${segment.sessionRef}:${segment.fromTurn}`];
2649
3006
  if (prior?.payloadKey) {
@@ -2652,7 +3009,8 @@ async function prepareV5Payload(harness, segment, prepare = require("./native-pr
2652
3009
  return frozen.payload;
2653
3010
  }
2654
3011
  if (prior?.toTurn) segment = { ...segment, toTurn: prior.toTurn };
2655
- const { packet, workspace } = preparationPacket(harness, segment, config);
3012
+ const { packet, workspace } = preparationPacket(harness, segment, config, options);
3013
+ assertNoCredentials(packet);
2656
3014
  const dictionary = store.dictionary();
2657
3015
  dictionary.scopes[workspace] ||= { scopeRef: `s_${randomUUID().replaceAll("-", "")}`, entities: {} };
2658
3016
  store.saveDictionary(dictionary);
@@ -2671,34 +3029,243 @@ async function prepareV5Payload(harness, segment, prepare = require("./native-pr
2671
3029
  if (Buffer.byteLength(JSON.stringify(packet)) + 1 > 256 * 1024) throw new Error("The selected source packet exceeds 128 messages or 256 KiB. Nothing was truncated or synced.");
2672
3030
  if (packet.entityBindings.length > 32) throw new Error("Relevant private entity bindings exceed the bounded packet; this range needs review.");
2673
3031
  const bindings = { workspace, scopeRef: dictionary.scopes[workspace].scopeRef, catalog: require("./continuity-preparation.js").CATALOG };
2674
- const deadline = Date.now() + 180_000;
3032
+ const deadline = Math.min(Date.now() + require("./native-preparation.js").preparationTimeoutMs(harness, config.backgroundReasoning), Number.isFinite(options.deadlineAt) ? options.deadlineAt : Infinity);
3033
+ const { validationFeedbackText } = require("./continuity-state.js");
2675
3034
  let validationFeedback;
2676
3035
  for (let attempt = 0; attempt < 2; attempt += 1) {
2677
3036
  const remainingMs = deadline - Date.now();
2678
- if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
3037
+ if (remainingMs < 30_000) throw new Error("Native preparation remains pending because the run has insufficient time for another bounded attempt.");
3038
+ if (remainingNativeRequests !== undefined && remainingNativeRequests <= 0) throw Object.assign(new Error("The native model request budget for this run is used; remaining work stays pending."), { code: "SHIFU_PREPARATION_BUDGET" });
2679
3039
  const reservation = store.reservation(packet, bindings);
3040
+ validationFeedback ||= validationFeedbackText(reservation.validationFeedback);
2680
3041
  let output;
3042
+ const nativeStartedAt = Date.now();
3043
+ let diagnostic = { stage: "native_execution", category: "native_process_failed" };
2681
3044
  try {
2682
- output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs, continuity: true });
3045
+ const lease = processingLease(harness, config, segment);
3046
+ try {
3047
+ if (remainingNativeRequests !== undefined) remainingNativeRequests--;
3048
+ output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs, continuity: true, signal: lease.signal, ...(config.backgroundReasoning ? { backgroundReasoning: config.backgroundReasoning } : {}) });
3049
+ currentProcessingConfig(harness, config, segment);
3050
+ } finally { lease.close(); }
2683
3051
  const candidateDictionary = structuredClone(dictionary);
3052
+ diagnostic = { stage: "continuity_validation", category: "continuity_validation_failed" };
2684
3053
  const result = require("./continuity-preparation.js").bindContinuity(packet, output, candidateDictionary, workspace);
3054
+ diagnostic = { stage: "payload_validation", category: "payload_validation_failed" };
2685
3055
  validateSync(result.payload, harness);
2686
3056
  if (result.assessment.disposition === "no_safe_context" && reservation.attempt < 2 && packet.sources.length) throw new Error("Recheck this proposed empty result against the original sources; preserve meaningful context or confirm a legitimate neutral empty result.");
3057
+ diagnostic = { stage: "preparation_persistence", category: "preparation_persistence_failed" };
2687
3058
  store.saveDictionary(candidateDictionary);
2688
3059
  return store.freeze(packet, result).payload;
2689
3060
  } catch (error) {
2690
- store.failure(packet);
2691
- if (reservation.attempt >= 2 || output === undefined) throw error;
2692
- validationFeedback = preparationValidationFeedback(error, output);
3061
+ if (["SHIFU_SYNC_PAUSED", "SHIFU_SCOPE_REQUIRED"].includes(error.code)) error.preparationDiagnostic = { stage: "native_execution", category: "native_cancelled" };
3062
+ const feedback = output !== undefined && ["continuity_validation", "payload_validation"].includes(diagnostic.stage)
3063
+ ? preparationValidationFeedback(error, output) : undefined;
3064
+ error.preparationDiagnostic ||= diagnostic;
3065
+ store.failure(packet, error.preparationDiagnostic, feedback);
3066
+ if (reservation.attempt >= 2 || output === undefined || !feedback) throw error;
3067
+ if (deadline - Date.now() < Math.max(30_000, Date.now() - nativeStartedAt)) {
3068
+ const deferred = new Error("Preparation correction remains pending; retry the unchanged range with its saved validation guidance and remaining native attempt.");
3069
+ deferred.preparationDiagnostic = diagnostic;
3070
+ throw deferred;
3071
+ }
3072
+ validationFeedback = validationFeedbackText(feedback);
2693
3073
  }
2694
3074
  }
2695
3075
  }
2696
3076
 
3077
+ function selectedPreparationRange(harness, config, { sessionRef, fromTurn, toTurn }, { allowBackfill = false } = {}) {
3078
+ if (!sessionRefIsSafe(sessionRef) || !Number.isSafeInteger(fromTurn) || !Number.isSafeInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT) {
3079
+ throw new Error("Choose one opaque session and an exact completed range of at most 12 turns.");
3080
+ }
3081
+ const session = localSessions(harness).filter((item) => item.sessionRef === sessionRef)
3082
+ .sort((left, right) => right.turnCount - left.turnCount || String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))[0];
3083
+ if (!session) throw new Error("The original session is unavailable; existing work was left untouched.");
3084
+ if (toTurn > completedSessionTurn(harness, sessionRef)) throw new Error("The selected original range includes an unfinished or unavailable turn. Nothing was prepared.");
3085
+ const segment = { ...session, fromTurn, toTurn };
3086
+ if (normalizedPolicy(config).excludedSessionRefs.includes(sessionRef)) throw new Error("This session is excluded from preparation.");
3087
+ if (!allowBackfill && !initialHistoryEligibility(segment, fromTurn, config).eligible) throw new Error("This range is outside the saved initial-history selection.");
3088
+ return segment;
3089
+ }
3090
+
3091
+ async function prepareSelectedRange(harness) {
3092
+ const config = controls.captureConfig(readConfig(harness));
3093
+ const selection = { sessionRef: option("--session-ref"), fromTurn: numberOption("--from-turn"), toTurn: numberOption("--to-turn") };
3094
+ const options = { allowBackfill: hasFlag("--all") };
3095
+ const result = await withSyncLock(harness, async () => {
3096
+ const segment = selectedPreparationRange(harness, config, selection, options);
3097
+ currentProcessingConfig(harness, config, segment);
3098
+ const queue = readPendingQueue(harness);
3099
+ const queued = queue.segments.find((input) => input.sessionRef === segment.sessionRef);
3100
+ if (queued && (queued.fromTurn !== segment.fromTurn || queued.toTurn !== segment.toTurn)) throw new Error("Retry the existing queued range before preparing another range in this session.");
3101
+ if (!queued) await reconcilePreparedSession(harness, segment.sessionRef, config, { refresh: true });
3102
+ const acceptedThrough = readState(harness).sessions[segment.sessionRef]?.lastSyncedTurn || 0;
3103
+ if (segment.fromTurn <= acceptedThrough && !queued) {
3104
+ const outcome = { sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, acceptedThrough, status: segment.toTurn <= acceptedThrough ? "already_accepted" : "rediscover" };
3105
+ console.log(JSON.stringify(outcome));
3106
+ console.error("The selected range includes already accepted turns; rediscover the next range. Nothing was prepared or replayed.");
3107
+ return outcome;
3108
+ }
3109
+ if (segment.fromTurn !== acceptedThrough + 1) throw new Error("Preparation must start at the next reconciled unsynced turn. Discover this session again.");
3110
+ const prior = continuityState(harness, config).load().attempts[`${segment.sessionRef}:${segment.fromTurn}`];
3111
+ if (prior && prior.toTurn !== segment.toTurn) throw new Error("The unresolved preparation range remains pinned; use its exact original boundaries.");
3112
+ const payload = queued || await prepareV5Payload(harness, segment, undefined, options);
3113
+ if (!queued) queuePayload(harness, payload);
3114
+ const outcome = { sessionRef: payload.sessionRef, fromTurn: payload.fromTurn, toTurn: payload.toTurn, status: "queued" };
3115
+ console.log(JSON.stringify(outcome));
3116
+ console.error("The exact selected range is queued under the existing approval and transport policy; nothing was uploaded.");
3117
+ return outcome;
3118
+ });
3119
+ if (result?.skipped) throw new Error("Another sync is running; the selected range was left untouched.");
3120
+ return result;
3121
+ }
3122
+
3123
+ function recoveryState(harness, config = readConfig(harness)) {
3124
+ return require("./recovery-state.js").recoveryStore(configRoot(), harness, continuityState(harness, config).load().connection);
3125
+ }
3126
+
3127
+ function recoveryFailureReason(error) {
3128
+ if (error.code === "SHIFU_PREPARATION_BUDGET") return "preparation_deferred";
3129
+ if (error.code === "SHIFU_RUN_DEADLINE") return "preparation_deferred";
3130
+ const category = error.preparationDiagnostic?.category;
3131
+ if (["native_timeout", "native_rate_limited", "native_authentication_failed", "native_provider_unavailable", "native_invalid_result", "continuity_validation_failed", "payload_validation_failed", "native_process_failed"].includes(category)) return category;
3132
+ if (/exceeds 128 messages or 256 KiB/.test(error.message)) return "source_oversized";
3133
+ if (/insufficient time/.test(error.message)) return "preparation_deferred";
3134
+ if (/unfinished/.test(error.message)) return "unfinished_turn";
3135
+ if (/original.*unavailable|session.*not found/i.test(error.message)) return "source_unavailable";
3136
+ if (/exhausted|needs review/.test(error.message)) return "preparation_needs_review";
3137
+ if (/checkpoint.*match/.test(error.message)) return "receipt_mismatch";
3138
+ return "preparation_failed";
3139
+ }
3140
+
3141
+ function recoverySessionComplete(harness, session, config) {
3142
+ const checkpoint = readState(harness).sessions[session.sessionRef]?.lastSyncedTurn || 0;
3143
+ if (checkpoint < session.throughTurn) return false;
3144
+ return continuityState(harness, config).load().outbox
3145
+ .filter((entry) => entry.sessionRef === session.sessionRef && entry.toTurn >= session.fromTurn && entry.fromTurn <= session.throughTurn)
3146
+ .every((entry) => entry.baseStatus === "accepted" && validSyncReceipt(entry.receipt, entry.payload) && ["attached", "not_applicable"].includes(entry.continuityStatus));
3147
+ }
3148
+
3149
+ function preparationRunMs(harness, config) {
3150
+ const nativeMs = require("./native-preparation.js").preparationTimeoutMs(harness, config.backgroundReasoning);
3151
+ return nativeMs > PREPARATION_RUN_MS ? nativeMs + PREPARATION_RUN_MS : PREPARATION_RUN_MS;
3152
+ }
3153
+
3154
+ async function runRecoveryBatch(harness, config, deadlineAt = Date.now() + preparationRunMs(harness, config)) {
3155
+ const store = recoveryState(harness, config);
3156
+ const recovery = store.load();
3157
+ if (!recovery || recovery.status === "complete") return { synced: 0, active: false, ...store.summary() };
3158
+ if (normalizedPolicy(config).approvalMode !== "automatic") throw new Error("Recovery requires the saved automatic approval mode; no work was sent.");
3159
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
3160
+ const continuity = continuityState(harness, config);
3161
+ restoreContinuityQueue(harness, config);
3162
+ let visited = 0;
3163
+ let prepared = 0;
3164
+ let synced = 0;
3165
+ while (visited < recovery.sessions.length && prepared < MAX_PREPARATIONS_PER_RUN && Date.now() + 30_000 < deadlineAt) {
3166
+ const index = recovery.nextSession;
3167
+ const session = recovery.sessions[index];
3168
+ recovery.nextSession = (index + 1) % recovery.sessions.length;
3169
+ visited += 1;
3170
+ store.save(recovery);
3171
+ if (session.completed) continue;
3172
+ try {
3173
+ if (excluded.has(session.sessionRef)) { session.reason = "excluded_session"; continue; }
3174
+ if (!session.reconciled) {
3175
+ await reconcilePreparedSession(harness, session.sessionRef, config, { refresh: true, deadlineAt });
3176
+ session.reconciled = true;
3177
+ }
3178
+ if (recoverySessionComplete(harness, session, config)) {
3179
+ session.completed = true; session.reason = null; continue;
3180
+ }
3181
+ const checkpoint = readState(harness).sessions[session.sessionRef]?.lastSyncedTurn || 0;
3182
+ if (checkpoint >= session.throughTurn) { session.reason = "continuity_pending"; continue; }
3183
+ const identity = `${session.sessionRef}:${checkpoint + 1}`;
3184
+ const queued = readPendingQueue(harness).segments.find((entry) => entry.sessionRef === session.sessionRef);
3185
+ const state = continuity.load();
3186
+ const held = state.attempts[identity];
3187
+ const selected = require("./recovery-state.js").nextRecoveryRange({ session, checkpoint, completedTo: queued ? queued.toTurn : completedSessionTurn(harness, session.sessionRef), queued, held });
3188
+ if (selected.status !== "range") { session.reason = selected.reason || "preparation_failed"; continue; }
3189
+ if (!queued && held && recovery.retryCandidates.includes(identity) && !recovery.retriedHolds.includes(identity)) {
3190
+ recovery.retriedHolds.push(identity);
3191
+ store.save(recovery); // Consume this explicit authorization before changing an attempt budget.
3192
+ held.attempts = 0; held.status = "unresolved";
3193
+ delete held.reason; delete held.diagnostic;
3194
+ continuity.save(state);
3195
+ }
3196
+ if (!queued && held?.attempts >= 2) { session.reason = "preparation_needs_review"; continue; }
3197
+ if (!queued && Date.now() + require("./native-preparation.js").preparationTimeoutMs(harness, config.backgroundReasoning) > deadlineAt) {
3198
+ session.reason = "preparation_deferred";
3199
+ recovery.nextSession = index;
3200
+ break;
3201
+ }
3202
+ const segment = queued || selectedPreparationRange(harness, config, { sessionRef: session.sessionRef, fromTurn: selected.fromTurn, toTurn: selected.toTurn }, { allowBackfill: true });
3203
+ prepared += 1;
3204
+ const payload = queued || await prepareV5Payload(harness, segment, undefined, { allowBackfill: true, deadlineAt });
3205
+ if (!queued) queuePayload(harness, payload);
3206
+ try {
3207
+ await sendSync(harness, payload, config, deadlineAt);
3208
+ } catch (error) {
3209
+ session.reason = error.code === "SHIFU_RUN_DEADLINE" ? "preparation_deferred" : "transport_failed";
3210
+ continue;
3211
+ }
3212
+ synced += 1;
3213
+ session.reason = null;
3214
+ } catch (error) {
3215
+ session.reason = recoveryFailureReason(error);
3216
+ console.error(`Selected recovery range remains pending: ${session.reason}.`);
3217
+ if (error.nativeObservation) console.error(JSON.stringify({ nativeObservation: error.nativeObservation }));
3218
+ } finally {
3219
+ store.save(recovery);
3220
+ }
3221
+ }
3222
+ await flushContinuityAttachments(harness, config, deadlineAt);
3223
+ for (const session of recovery.sessions) {
3224
+ if (session.reconciled && !session.completed && recoverySessionComplete(harness, session, config)) { session.completed = true; session.reason = null; }
3225
+ }
3226
+ recovery.status = recovery.sessions.every((session) => session.completed) ? "complete"
3227
+ : recovery.sessions.some((session) => !session.completed && session.reason === null) ? "running" : "pending";
3228
+ store.save(recovery);
3229
+ return { synced, active: recovery.status !== "complete", ...store.summary() };
3230
+ }
3231
+
3232
+ async function recoverSelectedSessions(harness) {
3233
+ const config = controls.captureConfig(readConfig(harness));
3234
+ if (normalizedPolicy(config).approvalMode !== "automatic") throw new Error("Recovery requires the configured automatic approval mode; settings were left unchanged.");
3235
+ const result = await withSyncLock(harness, async () => {
3236
+ const store = recoveryState(harness, config);
3237
+ const selection = option("--selection");
3238
+ if (selection) {
3239
+ const input = readJSON(path.resolve(selection));
3240
+ if (input.harness !== harness) throw new Error("Recovery selection belongs to another harness.");
3241
+ const sessions = input.sessions || input.selected;
3242
+ if (!Array.isArray(sessions)) throw new Error("A fixed recovery session selection is required.");
3243
+ const selected = new Map(sessions.map((session) => [session.sessionRef, session]));
3244
+ const retryCandidates = Object.entries(continuityState(harness, config).load().attempts)
3245
+ .filter(([identity, attempt]) => {
3246
+ const session = selected.get(attempt.sessionRef || identity.slice(0, identity.lastIndexOf(":")));
3247
+ return session && attempt.fromTurn >= session.fromTurn && attempt.toTurn <= session.throughTurn && !attempt.payloadKey && ["unresolved", "needs_review", "preparing"].includes(attempt.status);
3248
+ })
3249
+ .map(([identity]) => identity);
3250
+ store.start({ selectedAt: input.selectedAt, sessions, retryCandidates });
3251
+ }
3252
+ if (!store.load()) throw new Error("No saved recovery selection exists. Provide its fixed selection file once.");
3253
+ const { sessions = [], ...progress } = await runRecoveryBatch(harness, config);
3254
+ const pendingReasons = sessions.reduce((counts, session) => {
3255
+ if (session.reason) counts[session.reason] = (counts[session.reason] || 0) + 1;
3256
+ return counts;
3257
+ }, {});
3258
+ console.log(JSON.stringify({ ...progress, pendingReasons }));
3259
+ });
3260
+ if (result?.skipped) throw new Error("Another sync is running; the saved recovery selection is unchanged.");
3261
+ }
3262
+
2697
3263
  async function retryPreparation(harness) {
2698
3264
  const sessionRef = option("--session-ref");
2699
3265
  const fromTurn = numberOption("--from-turn");
2700
3266
  if (!sessionRefIsSafe(sessionRef) || !Number.isSafeInteger(fromTurn) || fromTurn < 1) throw new Error("Choose the held session and original from-turn explicitly.");
2701
3267
  const result = await withSyncLock(harness, async () => {
3268
+ const config = readConfig(harness);
2702
3269
  const store = continuityState(harness);
2703
3270
  const state = store.load();
2704
3271
  const identity = `${sessionRef}:${fromTurn}`;
@@ -2706,12 +3273,14 @@ async function retryPreparation(harness) {
2706
3273
  if (!held || held.payloadKey || state.outbox.some((entry) => entry.sessionRef === sessionRef && entry.fromTurn === fromTurn) || readPendingQueue(harness).segments.some((input) => input.sessionRef === sessionRef && input.toTurn >= fromTurn)) {
2707
3274
  throw new Error("Only an unqueued preparation hold can be retried; frozen payloads keep their exact transport path.");
2708
3275
  }
2709
- if ((readState(harness).sessions[sessionRef]?.lastSyncedTurn || 0) >= fromTurn) throw new Error("Accepted ranges require separately authorized historical recovery.");
2710
- const session = localSessions(harness).find((item) => item.sessionRef === sessionRef);
2711
- if (!session) throw new Error("The original session is unavailable; the hold was preserved.");
2712
- held.attempts = 0; held.status = "unresolved"; delete held.reason;
3276
+ const options = { allowBackfill: hasFlag("--all") };
3277
+ const segment = selectedPreparationRange(harness, config, { sessionRef, fromTurn, toTurn: held.toTurn }, options);
3278
+ await reconcilePreparedSession(harness, sessionRef, config, { refresh: true });
3279
+ if (fromTurn !== (readState(harness).sessions[sessionRef]?.lastSyncedTurn || 0) + 1) throw new Error("Only the next reconciled unsynced range can be retried; the hold was preserved.");
3280
+ preparationPacket(harness, segment, config, options);
3281
+ held.attempts = 0; held.status = "unresolved"; delete held.reason; delete held.diagnostic;
2713
3282
  store.save(state);
2714
- const payload = await prepareV5Payload(harness, { ...session, fromTurn, toTurn: held.toTurn });
3283
+ const payload = await prepareV5Payload(harness, segment, undefined, options);
2715
3284
  queuePayload(harness, payload);
2716
3285
  console.error("The selected preparation is queued for its existing approval and transport policy.");
2717
3286
  });
@@ -2734,18 +3303,22 @@ function preparationValidationFeedback(error, output) {
2734
3303
  if (field.endsWith(".workContext") && value === "") continue;
2735
3304
  if (typeof value !== "string" || textIsSafe(value, maximum, true)) continue;
2736
3305
  let reason = "empty or oversized text";
3306
+ let fragment;
2737
3307
  if (/[\x00-\x1f\x7f]/.test(value)) reason = "control characters or line breaks";
2738
3308
  else if (PHONE_NUMBER_PATTERN.test(value)) reason = "a phone-like numeric sequence";
2739
3309
  else if (UNSAFE_CODE_PATTERN.test(value)) reason = "a code-like declaration";
2740
3310
  else if (CREDENTIAL_ASSIGNMENT_PATTERN.test(value) || CLOUD_CREDENTIAL_PATTERN.test(value)) reason = "a credential-like pattern";
2741
3311
  else {
2742
- const fragment = SENSITIVE_PATTERNS.find((pattern) => value.toLowerCase().includes(pattern));
2743
- if (fragment) reason = `the forbidden literal fragment ${JSON.stringify(fragment)}`;
3312
+ fragment = SENSITIVE_PATTERNS.find((pattern) => value.toLowerCase().includes(pattern));
3313
+ if (fragment) reason = "a forbidden literal fragment";
2744
3314
  }
2745
3315
  // Field names and rule literals are code-owned; never echo rejected text.
2746
- issues.push(`Field ${field} fails the privacy or length rule for ${reason}.`);
3316
+ issues.push({ path: field, reason, ...(fragment ? { fragment } : {}) });
2747
3317
  }
2748
- return `${error.message}${issues.length ? ` ${issues.join(" ")} Rephrase those fields without copying the rejected content.` : ""} Recheck every text field before returning. Describe a user's go-ahead as approval, permission or adoption; the literal word authorization is rejected by the privacy filter. Preserve supported results, failed or pending checks, unresolved problems and release status while correcting the validation error.`;
3318
+ const { safeValidationFeedback } = require("./continuity-state.js");
3319
+ const feedbackFields = issues.slice(0, 64);
3320
+ const rule = "Preparation failed strict local validation; recheck schema, text limits and references.";
3321
+ return safeValidationFeedback({ rule: error.message, fields: feedbackFields }) || safeValidationFeedback({ rule, fields: feedbackFields }) || safeValidationFeedback({ rule, fields: [] });
2749
3322
  }
2750
3323
 
2751
3324
  function preparationState(harness, queue) {
@@ -2760,15 +3333,26 @@ function preparationState(harness, queue) {
2760
3333
  return { virtual, blocked };
2761
3334
  }
2762
3335
 
2763
- async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
3336
+ function preparationFailureSessions(harness, config, errors = []) {
3337
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
3338
+ const state = readState(harness);
3339
+ const failures = new Set(errors.map((error) => error.sessionRef));
3340
+ for (const attempt of continuityState(harness, config).summary().preparation) {
3341
+ if (["unresolved", "needs_review"].includes(attempt.status) && attempt.toTurn > (state.sessions[attempt.sessionRef]?.lastSyncedTurn || 0)) failures.add(attempt.sessionRef);
3342
+ }
3343
+ for (const sessionRef of excluded) failures.delete(sessionRef);
3344
+ return failures;
3345
+ }
3346
+
3347
+ async function preparePendingQueue(harness, config, blockedSessions = new Set(), deadlineAt = Date.now() + preparationRunMs(harness, config)) {
2764
3348
  restoreContinuityQueue(harness, config);
2765
3349
  let queue = prunePendingQueue(harness);
2766
3350
  const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
2767
3351
  const errors = [];
2768
3352
  const attempted = new Set();
2769
- const deadline = Date.now() + PREPARATION_RUN_MS;
3353
+ const deadline = deadlineAt;
2770
3354
  let attempts = 0;
2771
- while (attempts < MAX_PREPARATIONS_PER_RUN && Date.now() < deadline) {
3355
+ while (attempts < MAX_PREPARATIONS_PER_RUN && Date.now() + require("./native-preparation.js").preparationTimeoutMs(harness, config.backgroundReasoning) < deadline) {
2772
3356
  const { virtual, blocked } = preparationState(harness, queue);
2773
3357
  const candidates = pendingSegments(harness, virtual).filter((item) => !blocked.has(item.sessionRef) && !blockedSessions.has(item.sessionRef));
2774
3358
  const segment = candidates.find((item) => !previousFailures.has(item.sessionRef)) || candidates[0];
@@ -2781,14 +3365,14 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
2781
3365
  }
2782
3366
  if (!attempted.has(segment.sessionRef)) {
2783
3367
  attempted.add(segment.sessionRef);
2784
- await reconcilePreparedSession(harness, segment.sessionRef, config);
3368
+ await reconcilePreparedSession(harness, segment.sessionRef, config, { deadlineAt: deadline });
2785
3369
  // Remote acknowledgement can change the next unsynced range.
2786
3370
  continue;
2787
3371
  }
2788
3372
  let payload;
2789
3373
  attempts += 1;
2790
3374
  for (let toTurn = segment.toTurn; toTurn >= segment.fromTurn; toTurn -= 1) {
2791
- try { payload = await prepareV5Payload(harness, { ...segment, toTurn }); break; }
3375
+ try { payload = await prepareV5Payload(harness, { ...segment, toTurn }, undefined, { deadlineAt: deadline }); break; }
2792
3376
  catch (error) {
2793
3377
  if (toTurn > segment.fromTurn && /exceeds 128 messages or 256 KiB/.test(error.message)) continue;
2794
3378
  throw error;
@@ -2797,6 +3381,7 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
2797
3381
  queuePayload(harness, payload);
2798
3382
  queue = readPendingQueue(harness);
2799
3383
  } catch (error) {
3384
+ if (error.code === "SHIFU_PREPARATION_BUDGET") break;
2800
3385
  blockedSessions.add(segment.sessionRef);
2801
3386
  errors.push({ sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, reason: error.message });
2802
3387
  console.error(`${HARNESS_NAMES[harness]} preparation remains pending: ${error.message}`);
@@ -2808,7 +3393,7 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
2808
3393
  return queue;
2809
3394
  }
2810
3395
 
2811
- async function drainPendingQueue(harness, config, blockedSessions) {
3396
+ async function drainPendingQueue(harness, config, blockedSessions, deadlineAt = Infinity) {
2812
3397
  let synced = 0;
2813
3398
  let failures = 0;
2814
3399
  const store = continuityState(harness, config);
@@ -2817,8 +3402,9 @@ async function drainPendingQueue(harness, config, blockedSessions) {
2817
3402
  const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2818
3403
  const inputs = [...queued, ...entries.filter((entry) => !excluded.has(entry.sessionRef) && !queued.some((input) => JSON.stringify(input) === JSON.stringify(entry.payload))).map((entry) => entry.payload)];
2819
3404
  for (const input of inputs) {
3405
+ if (Date.now() >= deadlineAt) break;
2820
3406
  if (blockedSessions.has(input.sessionRef) || store.find(input)?.baseStatus === "needs_review") continue;
2821
- try { await sendSync(harness, input, config); synced += 1; }
3407
+ try { await sendSync(harness, input, config, deadlineAt); synced += 1; }
2822
3408
  catch (error) {
2823
3409
  failures += 1;
2824
3410
  blockedSessions.add(input.sessionRef);
@@ -2828,15 +3414,26 @@ async function drainPendingQueue(harness, config, blockedSessions) {
2828
3414
  return { synced, failures };
2829
3415
  }
2830
3416
 
2831
- async function flushContinuityAttachments(harness, config) {
3417
+ async function flushContinuityAttachments(harness, config, deadlineAt = Infinity) {
3418
+ if (Date.now() >= deadlineAt) return;
2832
3419
  const store = continuityState(harness, config);
2833
3420
  if (!store.load().outbox.some((entry) => entry.baseStatus === "accepted" && !["attached", "not_applicable", "needs_review"].includes(entry.continuityStatus))) return;
2834
3421
  let capable = false;
2835
3422
  try {
2836
- const connection = await validateConnection(config.apiUrl, config.token, harness);
3423
+ const connection = await validateConnection(config.apiUrl, config.token, harness, deadlineAt);
2837
3424
  capable = connection.capabilities?.continuityAttachment === 1;
2838
3425
  } catch { /* Base receipts remain valid while capability discovery is unavailable. */ }
2839
- await require("./continuity-state.js").flushContinuity(store, config, capable);
3426
+ if (Date.now() >= deadlineAt) return;
3427
+ await require("./continuity-state.js").flushContinuity(store, config, capable, (url, options) => {
3428
+ const syncId = String(url).match(/\/syncs\/([^/]+)\/continuity$/)?.[1];
3429
+ const source = store.load().outbox.find((entry) => String(entry.receipt?.id) === syncId);
3430
+ assertNoCredentials(JSON.parse(options.body || "{}"));
3431
+ if (!source) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
3432
+ return processingFetch(harness, config, source, url, options);
3433
+ }, Date.now(), deadlineAt, (entry) => {
3434
+ try { currentProcessingConfig(harness, config, entry); return true; }
3435
+ catch (error) { if (["SHIFU_SCOPE_REQUIRED", "SHIFU_SYNC_PAUSED"].includes(error.code)) return false; throw error; }
3436
+ });
2840
3437
  }
2841
3438
 
2842
3439
  function restoreContinuityQueue(harness, config) {
@@ -2945,23 +3542,32 @@ async function approvePending(harness) {
2945
3542
  }
2946
3543
 
2947
3544
  async function scheduledSync(harness) {
3545
+ const startingConfig = readConfig(harness);
3546
+ if (!controls.validAuthorization(startingConfig) || startingConfig.syncPaused) {
3547
+ console.error(startingConfig.syncPaused ? "Shifu capture is paused." : "Shifu capture needs scope approval. Open onboarding to review and start sync.");
3548
+ return;
3549
+ }
2948
3550
  await require("./onboarding.js").resumeBackfill(onboardingRuntime(harness));
2949
- const config = readConfig(harness);
3551
+ const config = currentProcessingConfig(harness, startingConfig);
2950
3552
  const policy = normalizedPolicy(config);
2951
3553
  if (!policy.onboardingComplete || !policy.scheduleEnabled) return;
2952
3554
  const state = readState(harness);
2953
3555
  if (Number.isFinite(state.schedule?.nextRunAt) && state.schedule.nextRunAt > Date.now()) return;
2954
3556
  const result = await withSyncLock(harness, async () => {
2955
3557
  const startedAt = Date.now();
3558
+ const deadlineAt = startedAt + preparationRunMs(harness, config);
3559
+ const running = readState(harness);
3560
+ running.schedule = { ...running.schedule, lastAttemptAt: startedAt, lastResult: "running", runDeadlineAt: deadlineAt };
3561
+ saveState(harness, running);
2956
3562
  let failures = 0;
2957
3563
  let synced = 0;
2958
3564
  let submittedFiles = 0;
2959
3565
  let pendingFiles = 0;
2960
3566
  let failedFiles = 0;
2961
3567
  let unsupportedFiles = 0;
2962
- if (config.inputFolder) {
3568
+ if (config.inputFolder && controls.filesAllowed(config, configuredInputFolder(config))) {
2963
3569
  try {
2964
- const files = await syncWorkFiles(harness, config);
3570
+ const files = await syncWorkFiles(harness, config, deadlineAt);
2965
3571
  failedFiles = files.failures;
2966
3572
  submittedFiles = files.submitted;
2967
3573
  pendingFiles = files.pending;
@@ -2971,11 +3577,11 @@ async function scheduledSync(harness) {
2971
3577
  console.error(`Shifu input folder remains pending: ${error.message}`);
2972
3578
  }
2973
3579
  }
2974
- await flushPendingProfiles(harness, config);
3580
+ await flushPendingProfiles(harness, config, deadlineAt);
2975
3581
  restoreContinuityQueue(harness, config);
2976
- await flushContinuityAttachments(harness, config);
3582
+ await flushContinuityAttachments(harness, config, deadlineAt);
2977
3583
  if (policy.approvalMode === "manual") {
2978
- const queue = await preparePendingQueue(harness, config);
3584
+ const queue = await preparePendingQueue(harness, config, new Set(), deadlineAt);
2979
3585
  const queued = eligibleQueuedSegments(queue, config);
2980
3586
  const eligible = [...queued, ...continuityRecoveries(harness, config, queued)];
2981
3587
  const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
@@ -2986,8 +3592,11 @@ async function scheduledSync(harness) {
2986
3592
  try {
2987
3593
  approvalSessionRef = notifyManualApproval(harness, eligible.length, approvalSessionRef);
2988
3594
  if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
2989
- config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
2990
- writeJSON(configPath(harness), config);
3595
+ updateSavedConfig(harness, (current) => {
3596
+ if (controls.connectionFingerprint(current) !== controls.connectionFingerprint(config)) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
3597
+ const excludedSessionRefs = [...new Set([...normalizedPolicy(current).excludedSessionRefs, approvalSessionRef])];
3598
+ return { ...current, syncPolicy: { ...current.syncPolicy, excludedSessionRefs } };
3599
+ });
2991
3600
  }
2992
3601
  } catch (error) {
2993
3602
  notificationError = error?.message || "Could not create the approval session.";
@@ -2995,7 +3604,7 @@ async function scheduledSync(harness) {
2995
3604
  }
2996
3605
  const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
2997
3606
  const unprepared = pendingSegments(harness, preparationState(harness, queue).virtual).length;
2998
- const preparationFailures = queue.preparationErrors.length;
3607
+ const preparationFailures = preparationFailureSessions(harness, config, queue.preparationErrors).size;
2999
3608
  state.schedule = {
3000
3609
  ...state.schedule,
3001
3610
  lastAttemptAt: startedAt,
@@ -3014,28 +3623,32 @@ async function scheduledSync(harness) {
3014
3623
  nextRunAt: Date.now() + (notificationError || failedFiles || pendingProfiles || preparationFailures || unprepared ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
3015
3624
  };
3016
3625
  } else {
3626
+ const recovery = await runRecoveryBatch(harness, config, deadlineAt);
3017
3627
  const blockedSessions = new Set();
3018
- const previous = await drainPendingQueue(harness, config, blockedSessions);
3019
- const queue = await preparePendingQueue(harness, config, blockedSessions);
3020
- const current = await drainPendingQueue(harness, config, blockedSessions);
3021
- synced = previous.synced + current.synced;
3022
- failures = previous.failures + current.failures + queue.preparationErrors.length;
3628
+ const previous = await drainPendingQueue(harness, config, blockedSessions, deadlineAt);
3629
+ const queue = Date.now() + 30_000 < deadlineAt ? await preparePendingQueue(harness, config, blockedSessions, deadlineAt) : prunePendingQueue(harness);
3630
+ const current = await drainPendingQueue(harness, config, blockedSessions, deadlineAt);
3631
+ synced = recovery.synced + previous.synced + current.synced;
3632
+ const preparationFailures = preparationFailureSessions(harness, config, queue.preparationErrors).size;
3633
+ failures = previous.failures + current.failures + preparationFailures;
3023
3634
  const remaining = pendingSegments(harness).length || eligibleQueuedSegments(readPendingQueue(harness), config).length;
3024
3635
  const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
3636
+ const deferred = Date.now() >= deadlineAt;
3025
3637
  state.schedule = {
3026
3638
  lastAttemptAt: startedAt,
3027
- lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : remaining ? "pending_preparation" : "complete",
3639
+ lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : recovery.active ? "recovering" : remaining || deferred ? "pending_preparation" : "complete",
3028
3640
  syncedSegments: synced,
3029
3641
  failedSessions: failures,
3642
+ preparationFailures,
3030
3643
  submittedFiles,
3031
3644
  pendingFiles,
3032
3645
  failedFiles,
3033
3646
  unsupportedFiles,
3034
3647
  pendingProfiles,
3035
- nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
3648
+ nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining || recovery.active || deferred ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
3036
3649
  };
3037
3650
  }
3038
- await flushContinuityAttachments(harness, config);
3651
+ await flushContinuityAttachments(harness, config, deadlineAt);
3039
3652
  const latest = readState(harness);
3040
3653
  latest.schedule = state.schedule;
3041
3654
  saveState(harness, latest);
@@ -3047,12 +3660,21 @@ function status(harness) {
3047
3660
  const config = readConfig(harness);
3048
3661
  const state = readState(harness);
3049
3662
  const pending = readJSON(pendingPath(harness), { segments: [] });
3663
+ const preparationFailures = preparationFailureSessions(harness, config, pending.preparationErrors).size;
3664
+ const schedule = state.schedule ? { ...state.schedule } : null;
3665
+ if (schedule && preparationFailures) {
3666
+ schedule.preparationFailures = preparationFailures;
3667
+ if (normalizedPolicy(config).approvalMode === "automatic") schedule.failedSessions = Math.max(schedule.failedSessions || 0, preparationFailures);
3668
+ if (["complete", "pending_preparation", "awaiting_approval"].includes(schedule.lastResult)) schedule.lastResult = "partial_failure";
3669
+ }
3050
3670
  const folder = config.inputFolder ? configuredInputFolder(config) : null;
3051
3671
  const entries = folder ? Object.values(workFileEntries(state, folder)) : [];
3052
3672
  let unsupportedFiles = null;
3673
+ let skippedSymlinks = null;
3674
+ let skippedDirectories = null;
3053
3675
  let inputFolderError = null;
3054
3676
  if (folder) {
3055
- try { unsupportedFiles = scanWorkFiles(folder).unsupported; } catch (error) { inputFolderError = error.message; }
3677
+ try { ({ unsupported: unsupportedFiles, skippedSymlinks, skippedDirectories } = scanWorkFiles(folder)); } catch (error) { inputFolderError = error.message; }
3056
3678
  }
3057
3679
  const initialHistoryBlocked = [];
3058
3680
  if (config.initialBackfill) {
@@ -3066,7 +3688,7 @@ function status(harness) {
3066
3688
  if (!eligibility.eligible) initialHistoryBlocked.push({ sessionRef: session.sessionRef, reason: eligibility.reason, cutoffAt: eligibility.cutoffAt });
3067
3689
  }
3068
3690
  }
3069
- console.log(JSON.stringify({ continuity: continuityState(harness, config).summary(), harness, runnerVersion: RUNNER_VERSION, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
3691
+ console.log(JSON.stringify({ ...onboardingRuntime(harness).scopeStatus(), skippedSymlinks, skippedDirectories, continuity: continuityState(harness, config).summary(), recovery: recoveryState(harness, config).summary(), harness, runnerVersion: RUNNER_VERSION, backgroundReasoning: harness === "codex" ? config.backgroundReasoning || "inherit" : null, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
3070
3692
  }
3071
3693
 
3072
3694
  async function syncFilesNow(harness) {
@@ -3076,14 +3698,14 @@ async function syncFilesNow(harness) {
3076
3698
  if (result.failures) throw new Error("Some input files need retry. They will be checked on the next sync.");
3077
3699
  }
3078
3700
 
3079
- async function acceptedSegments(config, sessionRef) {
3701
+ async function acceptedSegments(config, sessionRef, deadlineAt = Infinity) {
3080
3702
  const segments = [];
3081
3703
  let cursor = 0;
3082
3704
  do {
3083
3705
  const url = new URL(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`);
3084
3706
  url.searchParams.set("sessionRef", sessionRef);
3085
3707
  if (cursor) url.searchParams.set("cursor", String(cursor));
3086
- const response = await fetch(url, { headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(30_000) });
3708
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(requestTimeout(deadlineAt)) });
3087
3709
  const payload = await response.json().catch(() => undefined);
3088
3710
  if (!response.ok) throw new Error(payload?.error?.message || `Could not inspect accepted sync segments (${response.status}).`);
3089
3711
  const page = payload?.data;
@@ -3288,42 +3910,122 @@ async function cursorHook() {
3288
3910
  input += chunk.toString("utf8");
3289
3911
  if (Buffer.byteLength(input) > 1024 * 1024) throw new Error("Cursor hook input exceeded 1 MB. Nothing was captured.");
3290
3912
  }
3291
- const payload = JSON.parse(input);
3913
+ let payload;
3914
+ try { payload = JSON.parse(input); } catch { throw new Error("Cursor hook input is not valid JSON. Nothing was captured."); }
3292
3915
  if (payload.hook_event_name === "sessionStart") {
3293
3916
  const file = contextInstructionFile("cursor");
3294
3917
  const instruction = fs.existsSync(file) ? readContextInstructionFile(file).original : "";
3295
3918
  const sessionRef = require("./cursor-sessions.js").sessionRefForConversation(payload.conversation_id || payload.session_id);
3296
3919
  return console.log(JSON.stringify({ env: { SHIFU_CURSOR_SESSION_REF: sessionRef }, ...(instruction ? { additional_context: instruction } : {}) }));
3297
3920
  }
3298
- require("./cursor-sessions.js").captureHook(payload, cursorOptions());
3921
+ const config = readConfig("cursor");
3922
+ if (!controls.validAuthorization(config) || config.syncPaused) return console.log("{}");
3923
+ const sessionRef = require("./cursor-sessions.js").sessionRefForConversation(payload.conversation_id || payload.session_id);
3924
+ const workspace = payload.workspace_roots?.[0] || localSessions("cursor").find((session) => session.sessionRef === sessionRef)?.workspace;
3925
+ if (controls.sessionAllowed(config, { sessionRef, workspace })) require("./cursor-sessions.js").captureHook(payload, cursorOptions());
3299
3926
  console.log("{}");
3300
3927
  }
3301
3928
 
3302
3929
  function onboardingRuntime(harness, { prepareNative } = {}) {
3930
+ const scopeStatus = () => {
3931
+ const config = readJSON(configPath(harness), null);
3932
+ const authorization = controls.validAuthorization(config);
3933
+ const desktop = readDesktopProfile(configRoot());
3934
+ const apiUrl = requireSafeApiUrl(config?.apiUrl || option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL);
3935
+ let connectionKeyUrl;
3936
+ if (desktop?.connectionKeyLinks === true) connectionKeyUrl = `shifu://sources/coding-sessions?${new URLSearchParams({ harness, profile: desktop.namespace, connection: 'key' })}`;
3937
+ else if (!desktop && apiUrl === DEFAULT_API_URL) connectionKeyUrl = `https://app.useshifu.com/#/sources/coding-sessions?${new URLSearchParams({ harness, connection: 'key' })}`;
3938
+ let projects = [], projectDiscoveryError;
3939
+ try { projects = controls.projectCatalog(localSessions(harness)); }
3940
+ catch { projectDiscoveryError = "Local project history is unavailable. Check the coding tool installation before selecting history."; }
3941
+ const current = controls.projectIdentity({ workspace: process.cwd() }).ref;
3942
+ const projectRefs = authorization?.projectRefs || (projects.some((item) => item.ref === current) ? [current] : projects.length === 1 ? [projects[0].ref] : []);
3943
+ return { runnerVersion: RUNNER_VERSION, connectionKeyUrl, projects, projectDiscoveryError, projectScope: authorization?.projectScope || "selected", projectRefs,
3944
+ filesEnabled: authorization ? authorization.filesEnabled : true, inputFolder: config?.inputFolder || defaultInputFolder(),
3945
+ paused: config?.syncPaused === true, scopeReviewRequired: !authorization,
3946
+ syncPolicy: authorization ? normalizedPolicy(config) : { intervalHours: 1, approvalMode: "automatic", scheduleEnabled: true } };
3947
+ };
3948
+ const preview = (choices, state) => {
3949
+ const config = readJSON(configPath(harness), null);
3950
+ const status = scopeStatus();
3951
+ const sessions = localSessions(harness);
3952
+ const known = new Set(status.projects.map((item) => item.ref));
3953
+ if (choices.projectRefs.some((ref) => !known.has(ref))) throw new Error("The project list changed. Review the selection again.");
3954
+ if (choices.projectScope === "selected" && !choices.projectRefs.length) throw new Error("Choose a project or explicitly choose all projects.");
3955
+ const selectedAt = state.previewSelectedAt || (state.authorized && state.historyDays === choices.historyDays ? state.selectedAt : null) || new Date().toISOString();
3956
+ const history = createInitialBackfill(choices.historyDays, selectedAt);
3957
+ const selected = sessions.filter((item) => choices.projectScope === "all" || choices.projectRefs.includes(controls.projectIdentity(item).ref));
3958
+ const cutoff = Date.parse(history.selectedAt) - history.days * 86_400_000;
3959
+ const selectedSessions = history.days ? selected.filter((item) => Date.parse(item.updatedAt) >= cutoff) : [];
3960
+ const requestedFolder = choices.inputFolder || config?.inputFolder || defaultInputFolder();
3961
+ let inputFolder = path.resolve(requestedFolder.startsWith("~/") ? path.join(os.homedir(), requestedFolder.slice(2)) : requestedFolder);
3962
+ let scan = { files: [], unsupported: 0, skippedSymlinks: 0, skippedDirectories: 0 };
3963
+ if (choices.filesEnabled) {
3964
+ if (fs.existsSync(inputFolder)) { inputFolder = fs.realpathSync(validateInputFolder(inputFolder)); scan = scanWorkFiles(inputFolder); }
3965
+ else if (inputFolder !== defaultInputFolder()) throw new Error("Choose an existing readable folder.");
3966
+ }
3967
+ const files = scan.files.map((file) => { const stat = fs.statSync(file); return { name: path.basename(file), bytes: stat.size, modifiedAt: stat.mtimeMs }; });
3968
+ const projectRefs = choices.projectScope === "all" ? [] : [...choices.projectRefs].sort();
3969
+ const metadata = { destination: requireSafeApiUrl(config?.apiUrl || option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL), connection: controls.connectionFingerprint(config), generation: config?.syncGeneration || 0, history, projectScope: choices.projectScope, projectRefs, filesEnabled: choices.filesEnabled,
3970
+ inputFolder: choices.filesEnabled ? inputFolder : null, scheduleEnabled: choices.scheduleEnabled, files,
3971
+ sessions: selectedSessions.map((item) => [item.sessionRef, item.turnCount, item.updatedAt]).sort() };
3972
+ const scopeRevision = parseInt(createHash("sha256").update(JSON.stringify(metadata)).digest("hex").slice(0, 12), 16) || 1;
3973
+ return { ...metadata, connection: undefined, generation: undefined, files: files.map(({ name, bytes }) => ({ name, bytes })), sessions: undefined,
3974
+ projects: status.projects.filter((item) => choices.projectScope === "all" || projectRefs.includes(item.ref)),
3975
+ historyDays: history.days, selectedAt: history.selectedAt, scopeRevision,
3976
+ sessionCount: selectedSessions.length, fileCount: files.length, totalFileBytes: files.reduce((sum, file) => sum + file.bytes, 0),
3977
+ skippedSymlinks: scan.skippedSymlinks, skippedDirectories: scan.skippedDirectories, unsupportedFiles: scan.unsupported,
3978
+ inputFolder, syncPolicy: { ...status.syncPolicy, scheduleEnabled: choices.scheduleEnabled },
3979
+ quotaNotice: "Preparing selected history uses your coding tool’s configured model account and counts toward its usage allowance. Each background run makes at most four native model requests, including corrections." };
3980
+ };
3303
3981
  return {
3304
3982
  harness, root: configRoot(), runner: installedRunnerPath(), assets: path.join(resourceRoot(), "wizard"),
3305
- readConfig: () => readJSON(configPath(harness), null),
3983
+ readConfig: () => readJSON(configPath(harness), null), scopeStatus, preview,
3984
+ canProcess: () => { const config = readJSON(configPath(harness), null); return Boolean(controls.validAuthorization(config)) && config.syncPaused !== true; },
3985
+ pause: () => setSyncPaused(harness, true),
3986
+ resume: () => setSyncPaused(harness, false),
3306
3987
  checkpoint: (reference) => readState(harness).sessions[reference]?.lastSyncedTurn || 0,
3307
3988
  copyRunner,
3308
3989
  configure: async (choices, key) => {
3309
3990
  const existing = readJSON(configPath(harness), null);
3991
+ let generation;
3310
3992
  if (existing) {
3311
3993
  await validateConnection(existing.apiUrl, existing.token, harness);
3312
- writeJSON(configPath(harness), { ...existing, ...choices });
3313
- } else await connect(harness, async () => key, choices, true);
3314
- await setupMcp(harness);
3315
- if (readConfig(harness).onboardingSchedulePending) {
3316
- const config = readConfig(harness);
3317
- config.syncPolicy.scheduleEnabled = true;
3318
- delete config.onboardingSchedulePending;
3319
- writeJSON(configPath(harness), config);
3320
- installSchedule(harness);
3321
- scheduleNextRun(harness, config.syncPolicy.intervalHours);
3994
+ generation = (existing.syncGeneration || 0) + 1;
3995
+ updateSavedConfig(harness, (current) => {
3996
+ if (JSON.stringify(current) !== JSON.stringify(existing)) throw new Error("Shifu settings changed. Review setup again.");
3997
+ return { ...current, ...choices.preferences, syncPaused: true, syncGeneration: generation };
3998
+ });
3999
+ } else {
4000
+ await connect(harness, async () => key, choices.preferences, true);
4001
+ generation = readConfig(harness).syncGeneration || 0;
3322
4002
  }
4003
+ try { await setupMcp(harness); }
4004
+ catch { throw new Error("Your connection key is saved, but the coding tool connection could not be completed. Open or update your coding tool, choose Edit selection, review it again and retry. You do not need a new key."); }
4005
+ const config = readConfig(harness);
4006
+ if ((config.syncGeneration || 0) !== generation) throw new Error("Setup was paused or changed. Review and start again.");
4007
+ if (choices.scope.filesEnabled) prepareInputFolder(choices.scope.inputFolder, choices.scope.inputFolder === defaultInputFolder());
4008
+ const policy = normalizedPolicy(config);
4009
+ config.syncAuthorization = controls.createAuthorization(config, choices.scope);
4010
+ if (choices.scope.filesEnabled && config.syncAuthorization.inputFolder !== choices.scope.inputFolder) throw controls.processingError("SHIFU_SCOPE_REQUIRED");
4011
+ config.initialBackfill = choices.scope.history;
4012
+ config.inputFolder = choices.scope.inputFolder || config.inputFolder;
4013
+ config.syncPaused = false;
4014
+ config.syncGeneration = generation + 1;
4015
+ config.syncPolicy = configuredSyncPolicy(config, harness, policy.intervalHours, policy.approvalMode, choices.scheduleEnabled);
4016
+ delete config.onboardingSchedulePending;
4017
+ updateSavedConfig(harness, (current) => {
4018
+ if ((current?.syncGeneration || 0) !== generation || controls.connectionFingerprint(current) !== controls.connectionFingerprint(config)) throw new Error("Setup was paused or changed. Review and start again.");
4019
+ return config;
4020
+ });
4021
+ if (choices.scheduleEnabled) installSchedule(harness); else removeSchedule(harness);
4022
+ scheduleNextRun(harness, 0);
3323
4023
  },
3324
4024
  discover: (cutoff) => {
3325
- const eligible = selectedSessionSegments(harness, { since: cutoff });
3326
- const all = selectedSessionSegments(harness, { since: cutoff, includeOlder: true });
4025
+ const config = readConfig(harness);
4026
+ const allowed = (item) => controls.sessionAllowed(config, item);
4027
+ const eligible = selectedSessionSegments(harness, { since: cutoff }).filter(allowed);
4028
+ const all = selectedSessionSegments(harness, { since: cutoff, includeOlder: true }).filter(allowed);
3327
4029
  const bounded = (item) => ({ sessionRef: item.sessionRef, fromTurn: item.fromTurn, throughTurn: Math.min(item.turnCount, require("./source-review.js").completedSourceTurn({ harness }, sourceRecords(harness, item.sessionRef))) });
3328
4030
  const blockedSessions = all.filter((item) => item.fromTurn <= item.turnCount && !eligible.some((candidate) => candidate.sessionRef === item.sessionRef)).map(bounded).filter((item) => item.fromTurn <= item.throughTurn);
3329
4031
  return { sessions: eligible.filter((item) => item.fromTurn <= item.turnCount).map(bounded).filter((item) => item.fromTurn <= item.throughTurn), blocked: blockedSessions.length, blockedSessions };
@@ -3331,7 +4033,7 @@ function onboardingRuntime(harness, { prepareNative } = {}) {
3331
4033
  lock: (action) => withSyncLock(harness, action),
3332
4034
  setupLock: (action) => withSyncLock(`${harness}-onboarding-setup`, action),
3333
4035
  prepareAndSend: async (segment) => {
3334
- const config = readConfig(harness);
4036
+ const config = currentProcessingConfig(harness, readConfig(harness), segment);
3335
4037
  await reconcilePreparedSession(harness, segment.sessionRef, config, { refresh: true });
3336
4038
  const checkpoint = readState(harness).sessions[segment.sessionRef]?.lastSyncedTurn || 0;
3337
4039
  if (checkpoint >= segment.toTurn) return;
@@ -3348,10 +4050,18 @@ function onboardingRuntime(harness, { prepareNative } = {}) {
3348
4050
  async function main() {
3349
4051
  if (option("--config-root")) process.env.SHIFU_CONFIG_ROOT = path.resolve(option("--config-root"));
3350
4052
  const command = process.argv[2];
4053
+ if (["scheduled-sync", "pending", "prepare", "recover", "onboard"].includes(command)) remainingNativeRequests = MAX_PREPARATIONS_PER_RUN;
3351
4054
  const subCommand = process.argv[3];
3352
4055
  validateSyncOptions(command === "config" && subCommand === "update" ? "config-update" : command === "discover" || command === "session" ? "sessions" : command, (command === "config" && subCommand === "update") || (command === "session" && ["discover", "list"].includes(subCommand)) ? 4 : 3);
3353
4056
  const harness = requireHarness(option("--harness"));
3354
- if (["sync", "sync-files", "scheduled-sync", "approve"].includes(command) && process.env.SHIFU_UPDATE_CHECKED !== "1" && await updateRecommendedPackage(harness)) {
4057
+ if (["sync", "sync-files", "scheduled-sync", "approve", "recover", "prepare"].includes(command)) {
4058
+ const config = readConfig(harness);
4059
+ if (!controls.validAuthorization(config) || config.syncPaused) {
4060
+ if (command === "scheduled-sync") { console.error(config.syncPaused ? "Shifu capture is paused." : "Review and start Shifu sync before scheduled processing."); return; }
4061
+ controls.captureConfig(config);
4062
+ }
4063
+ }
4064
+ if (["sync", "sync-files", "scheduled-sync", "approve", "recover"].includes(command) && process.env.SHIFU_UPDATE_CHECKED !== "1" && await updateRecommendedPackage(harness)) {
3355
4065
  const child = spawn(process.execPath, [installedRunnerPath(), ...process.argv.slice(2)], {
3356
4066
  env: { ...process.env, SHIFU_UPDATE_CHECKED: "1" }, stdio: "inherit",
3357
4067
  });
@@ -3366,7 +4076,14 @@ async function main() {
3366
4076
  const config = readConfig(harness);
3367
4077
  return require("./cursor-mcp.js").runBridge({ url: requireSafeApiUrl(config.mcpUrl || `${config.apiUrl}/mcp`), credentials: () => mcpHeaders(harness) });
3368
4078
  }
3369
- if (command === "onboard") return require("./onboarding.js").main(onboardingRuntime(harness), { runId: option("--run"), status: hasFlag("--status"), worker: hasFlag("--worker"), serve: hasFlag("--serve"), helpful: hasFlag("--helpful"), fresh: hasFlag("--new"), abandon: hasFlag("--abandon") });
4079
+ if (command === "onboard") {
4080
+ const saved = readJSON(configPath(harness), null);
4081
+ if (saved && !controls.validAuthorization(saved) && !hasFlag("--status")) setSyncPaused(harness, true);
4082
+ return require("./onboarding.js").main(onboardingRuntime(harness), { runId: option("--run"), status: hasFlag("--status"), worker: hasFlag("--worker"), serve: hasFlag("--serve"), helpful: hasFlag("--helpful"), fresh: hasFlag("--new"), abandon: hasFlag("--abandon") });
4083
+ }
4084
+ if (command === "purge-local") return purgeLocal(harness);
4085
+ if (command === "pause") return setSyncPaused(harness, true);
4086
+ if (command === "resume") return setSyncPaused(harness, false);
3370
4087
  if (command === "install") return install(harness);
3371
4088
  if (command === "connect") return connect(harness);
3372
4089
  if (command === "mcp-setup") return setupMcp(harness);
@@ -3377,13 +4094,15 @@ async function main() {
3377
4094
  if (command === "sync-files") return syncFilesNow(harness);
3378
4095
  if (command === "scheduled-sync") return scheduledSync(harness);
3379
4096
  if (command === "pending") return showPending(harness);
4097
+ if (command === "prepare") return prepareSelectedRange(harness);
4098
+ if (command === "recover") return recoverSelectedSessions(harness);
3380
4099
  if (command === "retry-preparation") return retryPreparation(harness);
3381
4100
  if (command === "approve") return approvePending(harness);
3382
4101
  if (command === "status") return status(harness);
3383
4102
  if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
3384
4103
  if (command === "review") return review(harness);
3385
4104
  if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
3386
- throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, review-source, sync, sync-files, pending, retry-preparation, approve, or scheduled-sync.");
4105
+ throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, review-source, prepare, sync, sync-files, pending, recover, retry-preparation, approve, or scheduled-sync.");
3387
4106
  }
3388
4107
 
3389
4108
  if (require.main === module) {
@@ -3394,4 +4113,4 @@ if (require.main === module) {
3394
4113
  });
3395
4114
  }
3396
4115
 
3397
- module.exports = { onboardingRuntime, setupMcp, olderPackageVersion, updateRecommendedPackage, installPackageVersion, approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareV5Payload, preparationPacket, continuityState, flushContinuityAttachments, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
4116
+ module.exports = { updateSavedConfig, currentProcessingConfig, processingLease, processingFetch, setSyncPaused, scanWorkFiles, onboardingRuntime, postWorkFile, getWorkFileJob, acceptedSegments, reconcilePreparedSession, postSync, sendSync, flushPendingProfiles, drainPendingQueue, withSyncLock, setupMcp, olderPackageVersion, updateRecommendedPackage, installPackageVersion, approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareSelectedRange, prepareV5Payload, preparationPacket, continuityState, flushContinuityAttachments, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };