@useshifu/coding-harness 0.3.24 → 0.3.25

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.
@@ -42,8 +42,11 @@ const MAX_TURNS_PER_SEGMENT = 12;
42
42
  const MAX_TITLE_LENGTH = 160;
43
43
  const MAX_WORK_DETAIL_LENGTH = 600;
44
44
  const CONFIG_VERSION = 2;
45
- const RUNNER_VERSION = "0.3.24";
46
- const REDACTION_VERSION = 4;
45
+ const RUNNER_VERSION = "0.3.25";
46
+ const REDACTION_VERSION = 5;
47
+ const LEGACY_REDACTION_VERSION = 4;
48
+ const MAX_PREPARATIONS_PER_RUN = 4;
49
+ const PREPARATION_RUN_MS = 5 * 60_000;
47
50
  const DEFAULT_INTERVAL_HOURS = 1;
48
51
  const LEGACY_INTERVAL_HOURS = 4;
49
52
  const MIN_INTERVAL_HOURS = 0.25;
@@ -115,8 +118,10 @@ function opencodeQuery(query) {
115
118
  const temporary = path.join(os.tmpdir(), `shifu-opencode-${randomUUID()}.json`);
116
119
  let fd;
117
120
  try {
121
+ const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
118
122
  fd = fs.openSync(temporary, "wx", 0o600);
119
- execFileSync(process.env.SHIFU_OPENCODE_BIN || "opencode", ["db", query, "--format", "json"], {
123
+ execFileSync(resolveNativeBinary("opencode"), ["db", query, "--format", "json"], {
124
+ env: nativeEnvironment(),
120
125
  stdio: ["ignore", fd, "pipe"],
121
126
  maxBuffer: 64 * 1024 * 1024,
122
127
  });
@@ -562,20 +567,23 @@ function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
562
567
  return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
563
568
  }
564
569
 
565
- function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn) {
570
+ function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = false) {
566
571
  if (!sessionRefIsSafe(sessionRef) || !OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) throw new Error("Provide an opaque --session-ref for selected local source review.");
567
572
  if (!Number.isInteger(fromTurn) || !Number.isInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn > 1_000_000 || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT) {
568
573
  throw new Error("Select --from-turn and --to-turn explicitly, covering at most 12 original turns.");
569
574
  }
570
- let records;
575
+ const records = sourceRecords(harness, sessionRef);
576
+ const adapter = require("./source-review.js");
577
+ return (scheduled ? adapter.scheduledSourcePacket : adapter.selectedSourcePacket)({ harness, sessionRef, fromTurn, toTurn }, records);
578
+ }
579
+
580
+ function sourceRecords(harness, sessionRef) {
571
581
  if (harness === "opencode") {
572
- records = 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, 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`);
573
- } else {
574
- const file = sessionFile(harness, sessionRef);
575
- try { records = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
576
- catch { throw new Error("Could not read selected original messages locally. Nothing was reconstructed or synced."); }
582
+ 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, 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`);
577
583
  }
578
- return require("./source-review.js").selectedSourcePacket({ harness, sessionRef, fromTurn, toTurn }, records);
584
+ const file = sessionFile(harness, sessionRef);
585
+ try { return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
586
+ catch { throw new Error("Could not read selected original messages locally. Nothing was reconstructed or synced."); }
579
587
  }
580
588
 
581
589
  function sessionFile(harness, sessionRef) {
@@ -953,6 +961,7 @@ function safeActivityEvidence(notes) {
953
961
  return evidence;
954
962
  }
955
963
 
964
+ // Retained for legacy comparison adapters; schedules use prepareV5Payload.
956
965
  function payloadForSegment(harness, segment) {
957
966
  if (segment.force || segment.supersedesSyncId) throw new Error("Force sync requires reviewing final notes and preparing meaningful redacted evidence; do not use the unattended payload projection.");
958
967
  const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
@@ -974,7 +983,7 @@ function payloadForSegment(harness, segment) {
974
983
  evidence,
975
984
  verification: [],
976
985
  decisions: evidence.filter((item) => /^(?:Decided|Rejected) /.test(item.title)).slice(0, MAX_SYNC_ITEMS).map((item) => item.statement.length <= 240 ? item.statement : `${item.statement.slice(0, 239).replace(/\s+\S*$/, "")}…`),
977
- redactionVersion: REDACTION_VERSION,
986
+ redactionVersion: LEGACY_REDACTION_VERSION,
978
987
  };
979
988
  }
980
989
 
@@ -1145,8 +1154,7 @@ function unsyncedCodexSessions(harness, options = {}) {
1145
1154
  return selectedSessionSegments(harness, options).filter((session) => session.fromTurn <= session.toTurn);
1146
1155
  }
1147
1156
 
1148
- function pendingSegments(harness) {
1149
- const state = readState(harness);
1157
+ function pendingSegments(harness, state = readState(harness)) {
1150
1158
  const policy = normalizedPolicy(readConfig(harness));
1151
1159
  const excluded = new Set(policy.excludedSessionRefs);
1152
1160
  const segments = [];
@@ -1154,15 +1162,10 @@ function pendingSegments(harness) {
1154
1162
  .filter((candidate) => !excluded.has(candidate.sessionRef))
1155
1163
  .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
1156
1164
  let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
1157
- while (fromTurn <= session.turnCount) {
1158
- let toTurn = Math.min(session.turnCount, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
1159
- if (Date.parse(session.updatedAt) > Date.now() - SCHEDULER_TICK_MINUTES * 60_000) {
1160
- const completedTurns = new Set(reviewSegment(harness, session.sessionRef, fromTurn, toTurn).notes.map((note) => note.turn));
1161
- let completedTo = fromTurn - 1;
1162
- while (completedTurns.has(completedTo + 1)) completedTo += 1;
1163
- if (completedTo < fromTurn) break;
1164
- toTurn = completedTo;
1165
- }
1165
+ if (fromTurn > session.turnCount) continue;
1166
+ const completedTo = require("./source-review.js").completedSourceTurn({ harness }, sourceRecords(harness, session.sessionRef));
1167
+ while (fromTurn <= completedTo) {
1168
+ const toTurn = Math.min(completedTo, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
1166
1169
  segments.push({ ...session, fromTurn, toTurn });
1167
1170
  fromTurn = toTurn + 1;
1168
1171
  }
@@ -1203,14 +1206,18 @@ async function readSecret(input = process.stdin, output = process.stderr) {
1203
1206
 
1204
1207
  function copyRunner() {
1205
1208
  fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
1206
- const dependency = path.dirname(require.resolve("jsonc-parser/package.json"));
1207
- const installedDependency = path.join(configRoot(), "node_modules", "jsonc-parser");
1208
- if (path.resolve(dependency) !== path.resolve(installedDependency)) copyDirectory(dependency, installedDependency);
1209
- const sourceReviewModule = path.join(__dirname, "source-review.js");
1210
- const installedSourceReviewModule = path.join(configRoot(), "source-review.js");
1211
- if (path.resolve(sourceReviewModule) !== path.resolve(installedSourceReviewModule)) {
1212
- fs.copyFileSync(sourceReviewModule, installedSourceReviewModule);
1213
- fs.chmodSync(installedSourceReviewModule, 0o600);
1209
+ for (const name of ["jsonc-parser", "smol-toml"]) {
1210
+ const dependency = name === "smol-toml" ? path.dirname(path.dirname(require.resolve(name))) : path.dirname(require.resolve(`${name}/package.json`));
1211
+ const installedDependency = path.join(configRoot(), "node_modules", name);
1212
+ if (path.resolve(dependency) !== path.resolve(installedDependency)) copyDirectory(dependency, installedDependency);
1213
+ }
1214
+ for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js"]) {
1215
+ const source = path.join(__dirname, name);
1216
+ const destination = path.join(configRoot(), name);
1217
+ if (path.resolve(source) !== path.resolve(destination)) {
1218
+ fs.copyFileSync(source, destination);
1219
+ fs.chmodSync(destination, 0o600);
1220
+ }
1214
1221
  }
1215
1222
  const temporary = `${installedRunnerPath()}.${process.pid}.tmp`;
1216
1223
  try {
@@ -2000,8 +2007,8 @@ async function updateConfig(harness) {
2000
2007
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
2001
2008
  }
2002
2009
 
2003
- function textIsSafe(value, maximum) {
2004
- return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
2010
+ function textIsSafe(value, maximum, unicode = false) {
2011
+ return typeof value === "string" && value.trim().length > 0 && (unicode ? [...value].length : value.length) <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
2005
2012
  }
2006
2013
 
2007
2014
  function sessionRefIsSafe(value) {
@@ -2012,26 +2019,27 @@ function sessionRefIsSafe(value) {
2012
2019
  }
2013
2020
 
2014
2021
  function validateSync(input, harness) {
2022
+ const safeText = (value, maximum) => textIsSafe(value, maximum, input?.redactionVersion === 5);
2015
2023
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
2016
2024
  if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
2017
2025
  if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
2018
2026
  if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
2019
2027
  if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
2020
2028
  if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn || input.toTurn > 1_000_000 || input.toTurn - input.fromTurn >= MAX_TURNS_PER_SEGMENT) throw new Error("Use a valid incremental range of at most 12 turns.");
2021
- if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
2022
- if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
2029
+ if (input.redactionVersion >= 3 && !safeText(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
2030
+ if (!safeText(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
2023
2031
  const forced = input.force || Boolean(input.supersedesSyncId);
2024
2032
  if (forced && Array.isArray(input.evidence) && input.evidence.length === 0) throw new Error("Force sync has no supported evidence. Re-review the final notes and prepare meaningful redacted work items; existing work and checkpoints were left untouched.");
2025
- const minimumEvidence = forced || input.redactionVersion < REDACTION_VERSION ? 1 : 0;
2033
+ const minimumEvidence = forced || input.redactionVersion < 4 ? 1 : 0;
2026
2034
  if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
2027
- if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !textIsSafe(item.title, MAX_TITLE_LENGTH)) || !textIsSafe(item.statement, MAX_WORK_DETAIL_LENGTH)) return false;
2028
- if (item.kind === "activity" && input.redactionVersion >= REDACTION_VERSION) return item.scope === undefined || item.scope === "";
2035
+ if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !safeText(item.title, MAX_TITLE_LENGTH)) || !safeText(item.statement, MAX_WORK_DETAIL_LENGTH)) return false;
2036
+ if (item.kind === "activity" && input.redactionVersion >= 4) return item.scope === undefined || item.scope === "";
2029
2037
  return SCOPE_LEVELS.has(item.scope);
2030
2038
  })) {
2031
2039
  throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
2032
2040
  }
2033
2041
  for (const field of ["verification", "decisions"]) {
2034
- if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) => textIsSafe(item, 240))) throw new Error(`${field} must contain at most ${MAX_SYNC_ITEMS} redacted statements.`);
2042
+ if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) => safeText(item, 240))) throw new Error(`${field} must contain at most ${MAX_SYNC_ITEMS} redacted statements.`);
2035
2043
  }
2036
2044
  if (!input.evidence.every((item) =>
2037
2045
  Object.keys(item).every((field) => (input.redactionVersion === 5 ? REVIEWED_EVIDENCE_FIELDS : EVIDENCE_FIELDS).has(field)) &&
@@ -2051,7 +2059,7 @@ function validateSync(input, harness) {
2051
2059
  if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
2052
2060
  identities.add(identity);
2053
2061
  }
2054
- if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION && input.redactionVersion !== 5) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs, or 5 only after selected review and backend support.`);
2062
+ if (![2, 3, 4, 5].includes(input.redactionVersion)) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs; existing version-two through version-four retries remain supported.`);
2055
2063
  }
2056
2064
 
2057
2065
  function validUTCEventTime(value) {
@@ -2340,7 +2348,12 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2340
2348
  const force = input.force || Boolean(input.supersedesSyncId);
2341
2349
  if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
2342
2350
  if (force && reviewSegment(harness, input.sessionRef, input.fromTurn, input.toTurn).turnCount < input.toTurn) throw new Error("The original local turns must still exist before force syncing this range.");
2343
- const profile = force ? undefined : profileForSegment(harness, input);
2351
+ if (!force) input = queuePayload(harness, input);
2352
+ let profile;
2353
+ if (!force) {
2354
+ try { profile = profileForSegment(harness, input); }
2355
+ catch { console.error("AI Profile source is unavailable; the prepared activity payload remains retryable."); }
2356
+ }
2344
2357
  const receipt = await postSync(config, input);
2345
2358
  if (!validSyncReceipt(receipt, input)) {
2346
2359
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
@@ -2349,8 +2362,9 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2349
2362
  state.profilePending ||= {};
2350
2363
  state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
2351
2364
  }
2352
- state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
2365
+ state.sessions[input.sessionRef] = { ...checkpoint, turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
2353
2366
  saveState(harness, state);
2367
+ if (!force) prunePendingQueue(harness);
2354
2368
  if (profile) await flushPendingProfiles(harness, config);
2355
2369
  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}.`);
2356
2370
  return receipt;
@@ -2403,13 +2417,142 @@ async function sync(harness) {
2403
2417
  if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
2404
2418
  }
2405
2419
 
2406
- function rebuildPendingQueue(harness) {
2407
- const segments = pendingSegments(harness).map((segment) => payloadForSegment(harness, segment));
2408
- const queue = { updatedAt: new Date().toISOString(), segments };
2420
+ function readPendingQueue(harness) {
2421
+ const queue = readJSON(pendingPath(harness), { segments: [] });
2422
+ if (!Array.isArray(queue.segments)) throw new Error("The prepared sync queue is invalid. Existing work was left untouched.");
2423
+ for (const input of queue.segments) {
2424
+ validateSync(input, harness);
2425
+ if (input.force || input.supersedesSyncId) throw new Error("Historical replacements cannot enter the automatic queue.");
2426
+ }
2427
+ return queue;
2428
+ }
2429
+
2430
+ function prunePendingQueue(harness) {
2431
+ const queue = readPendingQueue(harness);
2432
+ const state = readState(harness);
2433
+ queue.segments = queue.segments.filter((input) => input.toTurn > (state.sessions[input.sessionRef]?.lastSyncedTurn || 0));
2434
+ queue.updatedAt = new Date().toISOString();
2409
2435
  writeJSON(pendingPath(harness), queue);
2410
2436
  return queue;
2411
2437
  }
2412
2438
 
2439
+ function queuePayload(harness, input) {
2440
+ const queue = prunePendingQueue(harness);
2441
+ const overlap = queue.segments.find((queued) => queued.sessionRef === input.sessionRef && queued.fromTurn <= input.toTurn && queued.toTurn >= input.fromTurn);
2442
+ if (overlap) {
2443
+ if (JSON.stringify(overlap) !== JSON.stringify(input)) throw new Error("This range already has a different prepared payload. Retry the queued payload before preparing another version.");
2444
+ return overlap;
2445
+ }
2446
+ queue.segments.push(input);
2447
+ writeJSON(pendingPath(harness), queue);
2448
+ return input;
2449
+ }
2450
+
2451
+ async function reconcilePreparedSession(harness, sessionRef, config) {
2452
+ const state = readState(harness);
2453
+ const checkpoint = state.sessions[sessionRef] || {};
2454
+ if (checkpoint.preparationVersion === REDACTION_VERSION) return;
2455
+ if (readPendingQueue(harness).segments.some((input) => input.sessionRef === sessionRef)) return;
2456
+ // Older runners did not persist automatic payloads before HTTP. Recover an
2457
+ // accepted prefix before switching an uncertain legacy range to v5.
2458
+ const accepted = await acceptedSegments(config, sessionRef);
2459
+ let lastSyncedTurn = checkpoint.lastSyncedTurn || 0;
2460
+ for (const segment of accepted) {
2461
+ if (segment.toTurn <= lastSyncedTurn) continue;
2462
+ if (segment.fromTurn !== lastSyncedTurn + 1) throw new Error("Accepted sync history has a gap. This session remains pending.");
2463
+ lastSyncedTurn = segment.toTurn;
2464
+ }
2465
+ state.sessions[sessionRef] = { ...checkpoint, lastSyncedTurn, preparationVersion: REDACTION_VERSION };
2466
+ saveState(harness, state);
2467
+ }
2468
+
2469
+ async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
2470
+ const packet = reviewSourcePacket(harness, segment.sessionRef, segment.fromTurn, segment.toTurn, true);
2471
+ const output = await prepare(harness, packet);
2472
+ const payload = require("./v5-payload.js").preparedPayload(packet, output);
2473
+ validateSync(payload, harness);
2474
+ return payload;
2475
+ }
2476
+
2477
+ function preparationState(harness, queue) {
2478
+ const state = readState(harness);
2479
+ const virtual = { ...state, sessions: structuredClone(state.sessions) };
2480
+ const blocked = new Set();
2481
+ for (const input of [...queue.segments].sort((a, b) => a.fromTurn - b.fromTurn)) {
2482
+ const prior = virtual.sessions[input.sessionRef] || {};
2483
+ if (input.fromTurn !== (prior.lastSyncedTurn || 0) + 1) blocked.add(input.sessionRef);
2484
+ else virtual.sessions[input.sessionRef] = { ...prior, lastSyncedTurn: input.toTurn };
2485
+ }
2486
+ return { virtual, blocked };
2487
+ }
2488
+
2489
+ async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
2490
+ let queue = prunePendingQueue(harness);
2491
+ const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
2492
+ const errors = [];
2493
+ const attempted = new Set();
2494
+ const deadline = Date.now() + PREPARATION_RUN_MS;
2495
+ let attempts = 0;
2496
+ while (attempts < MAX_PREPARATIONS_PER_RUN && Date.now() < deadline) {
2497
+ const { virtual, blocked } = preparationState(harness, queue);
2498
+ const candidates = pendingSegments(harness, virtual).filter((item) => !blocked.has(item.sessionRef) && !blockedSessions.has(item.sessionRef));
2499
+ const segment = candidates.find((item) => !previousFailures.has(item.sessionRef)) || candidates[0];
2500
+ if (!segment) break;
2501
+ try {
2502
+ const original = readState(harness).sessions[segment.sessionRef];
2503
+ if (original?.preparationVersion !== REDACTION_VERSION && queue.segments.some((input) => input.sessionRef === segment.sessionRef)) {
2504
+ blockedSessions.add(segment.sessionRef);
2505
+ continue;
2506
+ }
2507
+ if (!attempted.has(segment.sessionRef)) {
2508
+ attempted.add(segment.sessionRef);
2509
+ await reconcilePreparedSession(harness, segment.sessionRef, config);
2510
+ // Remote acknowledgement can change the next unsynced range.
2511
+ continue;
2512
+ }
2513
+ let payload;
2514
+ attempts += 1;
2515
+ for (let toTurn = segment.toTurn; toTurn >= segment.fromTurn; toTurn -= 1) {
2516
+ try { payload = await prepareV5Payload(harness, { ...segment, toTurn }); break; }
2517
+ catch (error) {
2518
+ if (toTurn > segment.fromTurn && /exceeds 128 messages or 256 KiB/.test(error.message)) continue;
2519
+ throw error;
2520
+ }
2521
+ }
2522
+ queuePayload(harness, payload);
2523
+ queue = readPendingQueue(harness);
2524
+ } catch (error) {
2525
+ blockedSessions.add(segment.sessionRef);
2526
+ errors.push({ sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, reason: error.message });
2527
+ console.error(`${HARNESS_NAMES[harness]} preparation remains pending: ${error.message}`);
2528
+ }
2529
+ }
2530
+ queue = prunePendingQueue(harness);
2531
+ queue.preparationErrors = errors;
2532
+ writeJSON(pendingPath(harness), queue);
2533
+ return queue;
2534
+ }
2535
+
2536
+ async function drainPendingQueue(harness, config, blockedSessions) {
2537
+ let synced = 0;
2538
+ let failures = 0;
2539
+ for (const input of eligibleQueuedSegments(readPendingQueue(harness), config)) {
2540
+ if (blockedSessions.has(input.sessionRef)) continue;
2541
+ try { await sendSync(harness, input, config); synced += 1; }
2542
+ catch (error) {
2543
+ failures += 1;
2544
+ blockedSessions.add(input.sessionRef);
2545
+ console.error(`${HARNESS_NAMES[harness]} prepared segment remains pending: ${error.message}`);
2546
+ }
2547
+ }
2548
+ return { synced, failures };
2549
+ }
2550
+
2551
+ function eligibleQueuedSegments(queue, config) {
2552
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2553
+ return queue.segments.filter((input) => !excluded.has(input.sessionRef));
2554
+ }
2555
+
2413
2556
  function approvalNotificationInvocation(harness, count, sessionRef) {
2414
2557
  const prompt = `Shifu sync approval: ${count} redacted segment${count === 1 ? " is" : "s are"} waiting locally. Tell the user to ask you to show the pending Shifu sync and approve it once. Do not inspect files, run tools, or sync anything in this session.`;
2415
2558
  if (harness === "codex") {
@@ -2440,8 +2583,10 @@ function notificationSessionRef(output) {
2440
2583
 
2441
2584
  function notifyManualApproval(harness, count, existingSessionRef) {
2442
2585
  const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
2586
+ const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
2443
2587
  const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
2444
- const output = execFileSync(invocation.command, invocation.args, {
2588
+ const output = execFileSync(resolveNativeBinary(harness), invocation.args, {
2589
+ env: nativeEnvironment(),
2445
2590
  cwd: os.homedir(),
2446
2591
  encoding: "utf8",
2447
2592
  stdio: ["ignore", "pipe", "pipe"],
@@ -2456,29 +2601,32 @@ function notifyManualApproval(harness, count, existingSessionRef) {
2456
2601
  return created;
2457
2602
  }
2458
2603
 
2459
- function showPending(harness) {
2460
- readConfig(harness);
2461
- const queue = rebuildPendingQueue(harness);
2462
- console.log(JSON.stringify(queue, null, 2));
2604
+ async function showPending(harness) {
2605
+ const config = readConfig(harness);
2606
+ const result = await withSyncLock(harness, async () => {
2607
+ console.log(JSON.stringify(await preparePendingQueue(harness, config), null, 2));
2608
+ });
2609
+ if (result?.skipped) throw new Error("Another Shifu sync is already preparing this queue. Retry later.");
2463
2610
  }
2464
2611
 
2465
2612
  async function approvePending(harness) {
2466
2613
  const config = readConfig(harness);
2467
- const queue = rebuildPendingQueue(harness);
2468
- if (queue.segments.length === 0) {
2469
- console.error("There is no pending Shifu activity to approve.");
2470
- return;
2471
- }
2472
- console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
2473
- if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
2474
- console.error("Nothing was sent. The pending list will keep accumulating.");
2475
- return;
2476
- }
2477
2614
  const result = await withSyncLock(harness, async () => {
2478
- for (const input of queue.segments) await sendSync(harness, input, config);
2615
+ const queue = prunePendingQueue(harness);
2616
+ queue.segments = eligibleQueuedSegments(queue, config);
2617
+ if (queue.segments.length === 0) {
2618
+ console.error("There is no prepared Shifu activity to approve. Run pending to prepare the next ranges.");
2619
+ return;
2620
+ }
2621
+ console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
2622
+ if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
2623
+ console.error("Nothing was sent. The prepared queue is unchanged.");
2624
+ return;
2625
+ }
2626
+ const sent = await drainPendingQueue(harness, config, new Set());
2627
+ if (sent.failures) throw new Error("Some approved segments could not be acknowledged. Their exact payloads remain pending for retry.");
2479
2628
  });
2480
2629
  if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
2481
- rebuildPendingQueue(harness);
2482
2630
  }
2483
2631
 
2484
2632
  async function scheduledSync(harness) {
@@ -2509,14 +2657,15 @@ async function scheduledSync(harness) {
2509
2657
  }
2510
2658
  await flushPendingProfiles(harness, config);
2511
2659
  if (policy.approvalMode === "manual") {
2512
- const queue = rebuildPendingQueue(harness);
2513
- const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
2660
+ const queue = await preparePendingQueue(harness, config);
2661
+ const eligible = eligibleQueuedSegments(queue, config);
2662
+ const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
2514
2663
  const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
2515
2664
  let approvalSessionRef = state.schedule?.approvalSessionRef;
2516
2665
  let notificationError;
2517
- if (queue.segments.length > 0 && changed) {
2666
+ if (eligible.length > 0 && changed) {
2518
2667
  try {
2519
- approvalSessionRef = notifyManualApproval(harness, queue.segments.length, approvalSessionRef);
2668
+ approvalSessionRef = notifyManualApproval(harness, eligible.length, approvalSessionRef);
2520
2669
  if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
2521
2670
  config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
2522
2671
  writeJSON(configPath(harness), config);
@@ -2526,38 +2675,37 @@ async function scheduledSync(harness) {
2526
2675
  }
2527
2676
  }
2528
2677
  const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
2678
+ const unprepared = pendingSegments(harness, preparationState(harness, queue).virtual).length;
2679
+ const preparationFailures = queue.preparationErrors.length;
2529
2680
  state.schedule = {
2530
2681
  ...state.schedule,
2531
2682
  lastAttemptAt: startedAt,
2532
- lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles ? "partial_failure" : "awaiting_approval",
2683
+ lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles || preparationFailures ? "partial_failure" : "awaiting_approval",
2533
2684
  submittedFiles,
2534
2685
  pendingFiles,
2535
2686
  failedFiles,
2536
2687
  unsupportedFiles,
2537
2688
  pendingProfiles,
2538
- pendingSegments: queue.segments.length,
2689
+ pendingSegments: eligible.length,
2690
+ unpreparedSegments: unprepared,
2691
+ preparationFailures,
2539
2692
  pendingFingerprint,
2540
2693
  approvalSessionRef,
2541
2694
  notificationError,
2542
- nextRunAt: startedAt + (notificationError || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2695
+ nextRunAt: Date.now() + (notificationError || failedFiles || pendingProfiles || preparationFailures || unprepared ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2543
2696
  };
2544
2697
  } else {
2545
2698
  const blockedSessions = new Set();
2546
- for (const segment of pendingSegments(harness)) {
2547
- if (blockedSessions.has(segment.sessionRef)) continue;
2548
- try {
2549
- await sendSync(harness, payloadForSegment(harness, segment), config);
2550
- synced += 1;
2551
- } catch (error) {
2552
- failures += 1;
2553
- blockedSessions.add(segment.sessionRef);
2554
- console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
2555
- }
2556
- }
2699
+ const previous = await drainPendingQueue(harness, config, blockedSessions);
2700
+ const queue = await preparePendingQueue(harness, config, blockedSessions);
2701
+ const current = await drainPendingQueue(harness, config, blockedSessions);
2702
+ synced = previous.synced + current.synced;
2703
+ failures = previous.failures + current.failures + queue.preparationErrors.length;
2704
+ const remaining = pendingSegments(harness).length || eligibleQueuedSegments(readPendingQueue(harness), config).length;
2557
2705
  const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
2558
2706
  state.schedule = {
2559
2707
  lastAttemptAt: startedAt,
2560
- lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
2708
+ lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : remaining ? "pending_preparation" : "complete",
2561
2709
  syncedSegments: synced,
2562
2710
  failedSessions: failures,
2563
2711
  submittedFiles,
@@ -2565,7 +2713,7 @@ async function scheduledSync(harness) {
2565
2713
  failedFiles,
2566
2714
  unsupportedFiles,
2567
2715
  pendingProfiles,
2568
- nextRunAt: startedAt + (failures || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2716
+ nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2569
2717
  };
2570
2718
  }
2571
2719
  const latest = readState(harness);
@@ -2825,4 +2973,4 @@ if (require.main === module) {
2825
2973
  });
2826
2974
  }
2827
2975
 
2828
- module.exports = { 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, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
2976
+ module.exports = { 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, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };