@useshifu/coding-harness 0.3.23 → 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.
@@ -26,6 +26,8 @@ const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s
26
26
  const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
27
27
  const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
28
28
  const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
29
+ const REVIEWED_EVIDENCE_FIELDS = new Set([...EVIDENCE_FIELDS, "workContext", "decisionRefs", "sources"]);
30
+ const SOURCE_FIELDS = new Set(["role", "turn", "locator", "eventAt", "excerptMode"]);
29
31
  const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion", "supersedesSyncId", "force"]);
30
32
  const AI_PROFILE_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "category", "model", "thinking", "evidence"]);
31
33
  const AI_PROFILE_EVIDENCE_FIELDS = new Set(["dimension", "statement", "signal", "turn"]);
@@ -40,8 +42,11 @@ const MAX_TURNS_PER_SEGMENT = 12;
40
42
  const MAX_TITLE_LENGTH = 160;
41
43
  const MAX_WORK_DETAIL_LENGTH = 600;
42
44
  const CONFIG_VERSION = 2;
43
- const RUNNER_VERSION = "0.3.23";
44
- 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;
45
50
  const DEFAULT_INTERVAL_HOURS = 1;
46
51
  const LEGACY_INTERVAL_HOURS = 4;
47
52
  const MIN_INTERVAL_HOURS = 0.25;
@@ -110,11 +115,13 @@ function opencodeQuery(query) {
110
115
  throw new Error("Could not read OpenCode session metadata. Ensure the opencode command is installed and its local database is available.");
111
116
  }
112
117
  }
113
- const temporary = path.join(os.tmpdir(), `shifu-opencode-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
118
+ const temporary = path.join(os.tmpdir(), `shifu-opencode-${randomUUID()}.json`);
114
119
  let fd;
115
120
  try {
116
- fd = fs.openSync(temporary, "w");
117
- execFileSync(process.env.SHIFU_OPENCODE_BIN || "opencode", ["db", query, "--format", "json"], {
121
+ const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
122
+ fd = fs.openSync(temporary, "wx", 0o600);
123
+ execFileSync(resolveNativeBinary("opencode"), ["db", query, "--format", "json"], {
124
+ env: nativeEnvironment(),
118
125
  stdio: ["ignore", fd, "pipe"],
119
126
  maxBuffer: 64 * 1024 * 1024,
120
127
  });
@@ -184,6 +191,7 @@ function validateSyncOptions(command, start = 3) {
184
191
  const options = {
185
192
  sessions: [...common, "--hours", "--days", "--count", "--turns", "--session-ref"],
186
193
  review: [...common, "--hours", "--days", "--turns", "--session-ref", "--from-turn", "--to-turn", "--supersedes-sync-id"],
194
+ "review-source": [...common, "--session-ref", "--from-turn", "--to-turn"],
187
195
  sync: common,
188
196
  "sync-files": common,
189
197
  "scheduled-sync": common,
@@ -559,6 +567,25 @@ function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
559
567
  return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
560
568
  }
561
569
 
570
+ function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = false) {
571
+ if (!sessionRefIsSafe(sessionRef) || !OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) throw new Error("Provide an opaque --session-ref for selected local source review.");
572
+ if (!Number.isInteger(fromTurn) || !Number.isInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn > 1_000_000 || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT) {
573
+ throw new Error("Select --from-turn and --to-turn explicitly, covering at most 12 original turns.");
574
+ }
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) {
581
+ if (harness === "opencode") {
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`);
583
+ }
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."); }
587
+ }
588
+
562
589
  function sessionFile(harness, sessionRef) {
563
590
  const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
564
591
  const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
@@ -934,6 +961,7 @@ function safeActivityEvidence(notes) {
934
961
  return evidence;
935
962
  }
936
963
 
964
+ // Retained for legacy comparison adapters; schedules use prepareV5Payload.
937
965
  function payloadForSegment(harness, segment) {
938
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.");
939
967
  const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
@@ -955,7 +983,7 @@ function payloadForSegment(harness, segment) {
955
983
  evidence,
956
984
  verification: [],
957
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*$/, "")}…`),
958
- redactionVersion: REDACTION_VERSION,
986
+ redactionVersion: LEGACY_REDACTION_VERSION,
959
987
  };
960
988
  }
961
989
 
@@ -1126,8 +1154,7 @@ function unsyncedCodexSessions(harness, options = {}) {
1126
1154
  return selectedSessionSegments(harness, options).filter((session) => session.fromTurn <= session.toTurn);
1127
1155
  }
1128
1156
 
1129
- function pendingSegments(harness) {
1130
- const state = readState(harness);
1157
+ function pendingSegments(harness, state = readState(harness)) {
1131
1158
  const policy = normalizedPolicy(readConfig(harness));
1132
1159
  const excluded = new Set(policy.excludedSessionRefs);
1133
1160
  const segments = [];
@@ -1135,15 +1162,10 @@ function pendingSegments(harness) {
1135
1162
  .filter((candidate) => !excluded.has(candidate.sessionRef))
1136
1163
  .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
1137
1164
  let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
1138
- while (fromTurn <= session.turnCount) {
1139
- let toTurn = Math.min(session.turnCount, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
1140
- if (Date.parse(session.updatedAt) > Date.now() - SCHEDULER_TICK_MINUTES * 60_000) {
1141
- const completedTurns = new Set(reviewSegment(harness, session.sessionRef, fromTurn, toTurn).notes.map((note) => note.turn));
1142
- let completedTo = fromTurn - 1;
1143
- while (completedTurns.has(completedTo + 1)) completedTo += 1;
1144
- if (completedTo < fromTurn) break;
1145
- toTurn = completedTo;
1146
- }
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);
1147
1169
  segments.push({ ...session, fromTurn, toTurn });
1148
1170
  fromTurn = toTurn + 1;
1149
1171
  }
@@ -1184,9 +1206,19 @@ async function readSecret(input = process.stdin, output = process.stderr) {
1184
1206
 
1185
1207
  function copyRunner() {
1186
1208
  fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
1187
- const dependency = path.dirname(require.resolve("jsonc-parser/package.json"));
1188
- const installedDependency = path.join(configRoot(), "node_modules", "jsonc-parser");
1189
- if (path.resolve(dependency) !== path.resolve(installedDependency)) copyDirectory(dependency, installedDependency);
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
+ }
1221
+ }
1190
1222
  const temporary = `${installedRunnerPath()}.${process.pid}.tmp`;
1191
1223
  try {
1192
1224
  fs.copyFileSync(__filename, temporary);
@@ -1975,8 +2007,8 @@ async function updateConfig(harness) {
1975
2007
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
1976
2008
  }
1977
2009
 
1978
- function textIsSafe(value, maximum) {
1979
- 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));
1980
2012
  }
1981
2013
 
1982
2014
  function sessionRefIsSafe(value) {
@@ -1987,29 +2019,30 @@ function sessionRefIsSafe(value) {
1987
2019
  }
1988
2020
 
1989
2021
  function validateSync(input, harness) {
2022
+ const safeText = (value, maximum) => textIsSafe(value, maximum, input?.redactionVersion === 5);
1990
2023
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
1991
2024
  if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
1992
2025
  if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
1993
2026
  if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
1994
2027
  if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
1995
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.");
1996
- if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
1997
- 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.");
1998
2031
  const forced = input.force || Boolean(input.supersedesSyncId);
1999
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.");
2000
- const minimumEvidence = forced || input.redactionVersion < REDACTION_VERSION ? 1 : 0;
2033
+ const minimumEvidence = forced || input.redactionVersion < 4 ? 1 : 0;
2001
2034
  if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
2002
- 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;
2003
- 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 === "";
2004
2037
  return SCOPE_LEVELS.has(item.scope);
2005
2038
  })) {
2006
2039
  throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
2007
2040
  }
2008
2041
  for (const field of ["verification", "decisions"]) {
2009
- 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.`);
2010
2043
  }
2011
2044
  if (!input.evidence.every((item) =>
2012
- Object.keys(item).every((field) => EVIDENCE_FIELDS.has(field)) &&
2045
+ Object.keys(item).every((field) => (input.redactionVersion === 5 ? REVIEWED_EVIDENCE_FIELDS : EVIDENCE_FIELDS).has(field)) &&
2013
2046
  (!Object.hasOwn(item, "area") || item.area === "" || WORK_AREAS.has(item.area)) &&
2014
2047
  (!Object.hasOwn(item, "itemRef") || item.itemRef === "" || (typeof item.itemRef === "string" && ITEM_REF_PATTERN.test(item.itemRef))) &&
2015
2048
  (!Object.hasOwn(item, "verificationRefs") || (Array.isArray(item.verificationRefs) &&
@@ -2018,12 +2051,39 @@ function validateSync(input, harness) {
2018
2051
  throw new Error("Each work item must use a supported area, safe itemRef, and distinct indexes into verification.");
2019
2052
  }
2020
2053
  const identities = new Set();
2054
+ if (input.redactionVersion === 5 && !input.evidence.every((item) => validReviewedContext(item, input))) {
2055
+ throw new Error("Each reviewed work item must use safe bounded workContext, distinct decisionRefs, and synthesized source metadata within its turn range.");
2056
+ }
2021
2057
  for (const item of input.evidence) {
2022
2058
  const identity = item.itemRef || `${item.kind}:${item.scope}:${item.statement.trim()}`;
2023
2059
  if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
2024
2060
  identities.add(identity);
2025
2061
  }
2026
- if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
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.`);
2063
+ }
2064
+
2065
+ function validUTCEventTime(value) {
2066
+ if (value === "") return true;
2067
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value)) return false;
2068
+ if (/^0001-01-01T00:00:00(?:\.0{1,9})?Z$/.test(value)) return false;
2069
+ const instant = Date.parse(value);
2070
+ return Number.isFinite(instant) && new Date(instant).toISOString().slice(0, 19) === value.slice(0, 19);
2071
+ }
2072
+
2073
+ function validReviewedContext(item, input) {
2074
+ if (Object.hasOwn(item, "workContext")) {
2075
+ if (typeof item.workContext !== "string") return false;
2076
+ const context = item.workContext.trim();
2077
+ if (context !== "" && ([...context].length > 300 || !textIsSafe(context, context.length))) return false;
2078
+ }
2079
+ if (Object.hasOwn(item, "decisionRefs") && (!Array.isArray(item.decisionRefs) || item.decisionRefs.length > MAX_SYNC_ITEMS ||
2080
+ new Set(item.decisionRefs).size !== item.decisionRefs.length || !item.decisionRefs.every((ref) => Number.isInteger(ref) && ref >= 0 && ref < input.decisions.length))) return false;
2081
+ if (!Object.hasOwn(item, "sources")) return true;
2082
+ return Array.isArray(item.sources) && item.sources.length <= 8 && new Set(item.sources.map((source) => source?.locator)).size === item.sources.length &&
2083
+ item.sources.every((source) => source && typeof source === "object" && !Array.isArray(source) && Object.keys(source).every((field) => SOURCE_FIELDS.has(field)) &&
2084
+ ["user", "assistant"].includes(source.role) && Number.isInteger(source.turn) && source.turn >= input.fromTurn && source.turn <= input.toTurn &&
2085
+ typeof source.locator === "string" && ITEM_REF_PATTERN.test(source.locator) && source.excerptMode === "synthesized" &&
2086
+ (!Object.hasOwn(source, "eventAt") || validUTCEventTime(source.eventAt)));
2027
2087
  }
2028
2088
 
2029
2089
  function validateAIProfileObservation(input, harness) {
@@ -2288,7 +2348,12 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2288
2348
  const force = input.force || Boolean(input.supersedesSyncId);
2289
2349
  if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
2290
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.");
2291
- 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
+ }
2292
2357
  const receipt = await postSync(config, input);
2293
2358
  if (!validSyncReceipt(receipt, input)) {
2294
2359
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
@@ -2297,8 +2362,9 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2297
2362
  state.profilePending ||= {};
2298
2363
  state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
2299
2364
  }
2300
- 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 };
2301
2366
  saveState(harness, state);
2367
+ if (!force) prunePendingQueue(harness);
2302
2368
  if (profile) await flushPendingProfiles(harness, config);
2303
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}.`);
2304
2370
  return receipt;
@@ -2351,13 +2417,142 @@ async function sync(harness) {
2351
2417
  if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
2352
2418
  }
2353
2419
 
2354
- function rebuildPendingQueue(harness) {
2355
- const segments = pendingSegments(harness).map((segment) => payloadForSegment(harness, segment));
2356
- 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();
2435
+ writeJSON(pendingPath(harness), queue);
2436
+ return queue;
2437
+ }
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;
2357
2532
  writeJSON(pendingPath(harness), queue);
2358
2533
  return queue;
2359
2534
  }
2360
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
+
2361
2556
  function approvalNotificationInvocation(harness, count, sessionRef) {
2362
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.`;
2363
2558
  if (harness === "codex") {
@@ -2388,8 +2583,10 @@ function notificationSessionRef(output) {
2388
2583
 
2389
2584
  function notifyManualApproval(harness, count, existingSessionRef) {
2390
2585
  const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
2586
+ const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
2391
2587
  const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
2392
- const output = execFileSync(invocation.command, invocation.args, {
2588
+ const output = execFileSync(resolveNativeBinary(harness), invocation.args, {
2589
+ env: nativeEnvironment(),
2393
2590
  cwd: os.homedir(),
2394
2591
  encoding: "utf8",
2395
2592
  stdio: ["ignore", "pipe", "pipe"],
@@ -2404,29 +2601,32 @@ function notifyManualApproval(harness, count, existingSessionRef) {
2404
2601
  return created;
2405
2602
  }
2406
2603
 
2407
- function showPending(harness) {
2408
- readConfig(harness);
2409
- const queue = rebuildPendingQueue(harness);
2410
- 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.");
2411
2610
  }
2412
2611
 
2413
2612
  async function approvePending(harness) {
2414
2613
  const config = readConfig(harness);
2415
- const queue = rebuildPendingQueue(harness);
2416
- if (queue.segments.length === 0) {
2417
- console.error("There is no pending Shifu activity to approve.");
2418
- return;
2419
- }
2420
- console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
2421
- if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
2422
- console.error("Nothing was sent. The pending list will keep accumulating.");
2423
- return;
2424
- }
2425
2614
  const result = await withSyncLock(harness, async () => {
2426
- 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.");
2427
2628
  });
2428
2629
  if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
2429
- rebuildPendingQueue(harness);
2430
2630
  }
2431
2631
 
2432
2632
  async function scheduledSync(harness) {
@@ -2457,14 +2657,15 @@ async function scheduledSync(harness) {
2457
2657
  }
2458
2658
  await flushPendingProfiles(harness, config);
2459
2659
  if (policy.approvalMode === "manual") {
2460
- const queue = rebuildPendingQueue(harness);
2461
- 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");
2462
2663
  const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
2463
2664
  let approvalSessionRef = state.schedule?.approvalSessionRef;
2464
2665
  let notificationError;
2465
- if (queue.segments.length > 0 && changed) {
2666
+ if (eligible.length > 0 && changed) {
2466
2667
  try {
2467
- approvalSessionRef = notifyManualApproval(harness, queue.segments.length, approvalSessionRef);
2668
+ approvalSessionRef = notifyManualApproval(harness, eligible.length, approvalSessionRef);
2468
2669
  if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
2469
2670
  config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
2470
2671
  writeJSON(configPath(harness), config);
@@ -2474,38 +2675,37 @@ async function scheduledSync(harness) {
2474
2675
  }
2475
2676
  }
2476
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;
2477
2680
  state.schedule = {
2478
2681
  ...state.schedule,
2479
2682
  lastAttemptAt: startedAt,
2480
- 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",
2481
2684
  submittedFiles,
2482
2685
  pendingFiles,
2483
2686
  failedFiles,
2484
2687
  unsupportedFiles,
2485
2688
  pendingProfiles,
2486
- pendingSegments: queue.segments.length,
2689
+ pendingSegments: eligible.length,
2690
+ unpreparedSegments: unprepared,
2691
+ preparationFailures,
2487
2692
  pendingFingerprint,
2488
2693
  approvalSessionRef,
2489
2694
  notificationError,
2490
- 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,
2491
2696
  };
2492
2697
  } else {
2493
2698
  const blockedSessions = new Set();
2494
- for (const segment of pendingSegments(harness)) {
2495
- if (blockedSessions.has(segment.sessionRef)) continue;
2496
- try {
2497
- await sendSync(harness, payloadForSegment(harness, segment), config);
2498
- synced += 1;
2499
- } catch (error) {
2500
- failures += 1;
2501
- blockedSessions.add(segment.sessionRef);
2502
- console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
2503
- }
2504
- }
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;
2505
2705
  const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
2506
2706
  state.schedule = {
2507
2707
  lastAttemptAt: startedAt,
2508
- lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
2708
+ lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : remaining ? "pending_preparation" : "complete",
2509
2709
  syncedSegments: synced,
2510
2710
  failedSessions: failures,
2511
2711
  submittedFiles,
@@ -2513,7 +2713,7 @@ async function scheduledSync(harness) {
2513
2713
  failedFiles,
2514
2714
  unsupportedFiles,
2515
2715
  pendingProfiles,
2516
- 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,
2517
2717
  };
2518
2718
  }
2519
2719
  const latest = readState(harness);
@@ -2760,7 +2960,8 @@ async function main() {
2760
2960
  if (command === "status") return status(harness);
2761
2961
  if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
2762
2962
  if (command === "review") return review(harness);
2763
- throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
2963
+ if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
2964
+ throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, review-source, sync, sync-files, pending, approve, or scheduled-sync.");
2764
2965
  }
2765
2966
 
2766
2967
  if (require.main === module) {
@@ -2772,4 +2973,4 @@ if (require.main === module) {
2772
2973
  });
2773
2974
  }
2774
2975
 
2775
- 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, 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 };