@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.
- package/README.md +43 -42
- package/bin/continuity-preparation.js +4 -2
- package/bin/continuity-state.js +101 -9
- package/bin/cursor-sessions.js +2 -1
- package/bin/native-preparation.js +104 -22
- package/bin/onboarding-ui.js +6 -4
- package/bin/onboarding.js +54 -21
- package/bin/recovery-state.js +132 -0
- package/bin/run-deadline.js +11 -0
- package/bin/secret-check.js +32 -0
- package/bin/setup-options.js +3 -4
- package/bin/shifu-harness.js +869 -150
- package/bin/sync-controls.js +97 -0
- package/bin/v5-payload.js +2 -0
- package/commands/shifu-sync.md +9 -7
- package/package.json +1 -1
- package/skills/shifu-context/SKILL.md +3 -3
- package/skills/shifu-onboarding/SKILL.md +7 -3
- package/skills/shifu-sync/SKILL.md +17 -32
- package/wizard/index.html +46 -52
- package/wizard/wizard.css +8 -3
- package/wizard/wizard.js +250 -45
package/bin/shifu-harness.js
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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)
|
|
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
|
|
223
|
-
if (harness === "
|
|
224
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1239
|
-
|
|
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 =
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
2576
|
+
signal: AbortSignal.timeout(requestTimeout(deadlineAt)),
|
|
2299
2577
|
});
|
|
2578
|
+
httpStatus = response.status;
|
|
2300
2579
|
const receipt = await response.json().catch(() => undefined);
|
|
2301
|
-
if (
|
|
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
|
|
2313
|
-
|
|
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:
|
|
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(
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
2550
|
-
|
|
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, () =>
|
|
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
|
-
|
|
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() +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
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,
|
|
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
|
-
|
|
2743
|
-
if (fragment) reason =
|
|
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(
|
|
3316
|
+
issues.push({ path: field, reason, ...(fragment ? { fragment } : {}) });
|
|
2747
3317
|
}
|
|
2748
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2990
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
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
|
|
3326
|
-
const
|
|
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)
|
|
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")
|
|
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 };
|