@useshifu/coding-harness 0.3.29 → 0.3.31
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 +59 -6
- package/bin/continuity-preparation.js +186 -0
- package/bin/continuity-state.js +202 -0
- package/bin/cursor-config.js +86 -0
- package/bin/cursor-mcp.js +96 -0
- package/bin/cursor-native.js +70 -0
- package/bin/cursor-sessions.js +197 -0
- package/bin/native-preparation.js +14 -10
- package/bin/onboarding-ui.js +1 -0
- package/bin/shifu-harness.js +238 -48
- package/bin/source-review.js +28 -2
- package/bin/v5-payload.js +1 -1
- package/package.json +1 -1
- package/skills/shifu-context/SKILL.md +1 -1
- package/skills/shifu-sync/SKILL.md +7 -1
package/bin/shifu-harness.js
CHANGED
|
@@ -19,6 +19,7 @@ const HARNESS_NAMES = {
|
|
|
19
19
|
codex: "Codex",
|
|
20
20
|
claude_code: "Claude Code",
|
|
21
21
|
opencode: "OpenCode",
|
|
22
|
+
cursor: "Cursor",
|
|
22
23
|
};
|
|
23
24
|
const TOKEN_PATTERN = /^cs_sk_[a-f0-9]{16}_[a-f0-9]{64}$/;
|
|
24
25
|
const SENSITIVE_PATTERNS = ["-----begin", "authorization", "sk-", "sk_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xox", "github_pat_", "glpat-", "ignore previous instructions", "ignore all previous instructions", "system prompt", "@", "://", "`", "/"];
|
|
@@ -46,7 +47,7 @@ const MAX_TURNS_PER_SEGMENT = 12;
|
|
|
46
47
|
const MAX_TITLE_LENGTH = 160;
|
|
47
48
|
const MAX_WORK_DETAIL_LENGTH = 600;
|
|
48
49
|
const CONFIG_VERSION = 2;
|
|
49
|
-
const RUNNER_VERSION = "0.3.
|
|
50
|
+
const RUNNER_VERSION = "0.3.31";
|
|
50
51
|
const REDACTION_VERSION = 5;
|
|
51
52
|
const LEGACY_REDACTION_VERSION = 4;
|
|
52
53
|
const MAX_PREPARATIONS_PER_RUN = 4;
|
|
@@ -180,8 +181,9 @@ function openCodeDatabaseQuery(dbPath, query) {
|
|
|
180
181
|
if (tables.length !== 2) return read(query);
|
|
181
182
|
// V2 stores typed messages, ordered by sequence, instead of separate parts.
|
|
182
183
|
// Read-only CTEs retain the existing adapter contract without changing its database.
|
|
184
|
+
const hasDirectory = read("PRAGMA table_info(session_v2)").some((column) => column.name === "directory");
|
|
183
185
|
const projection = `WITH
|
|
184
|
-
session AS (SELECT id, time_created, time_updated FROM session_v2),
|
|
186
|
+
session AS (SELECT id, time_created, time_updated, ${hasDirectory ? "directory" : "NULL AS directory"} FROM session_v2),
|
|
185
187
|
message AS (
|
|
186
188
|
SELECT id, session_id, seq, time_created, json_object(
|
|
187
189
|
'role', type, 'finish', CASE WHEN json_extract(data, '$.time.completed') IS NOT NULL THEN json_extract(data, '$.finish') END,
|
|
@@ -211,7 +213,12 @@ function opencodeSessions(rows) {
|
|
|
211
213
|
return [...sessions.values()];
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
function cursorOptions() {
|
|
217
|
+
return { journalRoot: path.join(configRoot(), "cursor-capture") };
|
|
218
|
+
}
|
|
219
|
+
|
|
214
220
|
function localSessions(harness) {
|
|
221
|
+
if (harness === "cursor") return require("./cursor-sessions.js").localSessions(cursorOptions());
|
|
215
222
|
if (harness === "codex") return sessionFiles(codexSessionsRoot()).map(codexSession).filter(Boolean);
|
|
216
223
|
if (harness === "claude_code") return sessionFiles(claudeSessionsRoot()).map(claudeSession).filter(Boolean);
|
|
217
224
|
if (harness === "opencode") return opencodeSessions(opencodeQuery("SELECT s.id AS sessionRef, s.time_created AS startedAt, s.time_updated AS updatedAt, m.time_created AS turnAt FROM session s JOIN message m ON m.session_id = s.id WHERE json_extract(m.data, '$.role') = 'user' ORDER BY s.time_updated, m.time_created"));
|
|
@@ -219,7 +226,7 @@ function localSessions(harness) {
|
|
|
219
226
|
}
|
|
220
227
|
|
|
221
228
|
function requireHarness(value) {
|
|
222
|
-
if (!Object.hasOwn(HARNESS_NAMES, value)) throw new Error("Choose --harness codex, claude_code, or
|
|
229
|
+
if (!Object.hasOwn(HARNESS_NAMES, value)) throw new Error("Choose --harness codex, claude_code, opencode, or cursor.");
|
|
223
230
|
return value;
|
|
224
231
|
}
|
|
225
232
|
|
|
@@ -244,12 +251,15 @@ function validateSyncOptions(command, start = 3) {
|
|
|
244
251
|
"sync-files": common,
|
|
245
252
|
"scheduled-sync": common,
|
|
246
253
|
pending: common,
|
|
254
|
+
"retry-preparation": [...common, "--session-ref", "--from-turn"],
|
|
247
255
|
approve: common,
|
|
248
256
|
install: common,
|
|
249
257
|
connect: [...common, ...setup, ...syncSettings, "--api-url"],
|
|
250
258
|
"config-update": [...common, ...setup, ...syncSettings],
|
|
251
259
|
"mcp-setup": [...common, ...setup, "--mcp-url", "--api-url"],
|
|
252
260
|
"mcp-headers": common,
|
|
261
|
+
"mcp-bridge": common,
|
|
262
|
+
"cursor-hook": common,
|
|
253
263
|
};
|
|
254
264
|
const flags = {
|
|
255
265
|
connect: ["--interactive", "--non-interactive"], "mcp-setup": ["--interactive", "--non-interactive"], "config-update": ["--interactive", "--non-interactive"],
|
|
@@ -296,6 +306,7 @@ function writeJSON(file, value) {
|
|
|
296
306
|
}
|
|
297
307
|
|
|
298
308
|
function readConfig(harness) {
|
|
309
|
+
if (fs.existsSync(path.join(configRoot(), `${harness}-connection-transition.json`))) throw new Error("An interrupted connection replacement is held locally. Run connect again with the selected connection before syncing.");
|
|
299
310
|
const config = readJSON(configPath(harness), undefined);
|
|
300
311
|
if (!config || typeof config.token !== "string" || typeof config.apiUrl !== "string") {
|
|
301
312
|
throw new Error(`No ${HARNESS_NAMES[harness]} connection is configured. Run connect first.`);
|
|
@@ -305,6 +316,7 @@ function readConfig(harness) {
|
|
|
305
316
|
}
|
|
306
317
|
|
|
307
318
|
function environmentSessionRef(harness) {
|
|
319
|
+
if (harness === "cursor") return process.env.SHIFU_CURSOR_SESSION_REF;
|
|
308
320
|
return harness === "codex" ? process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID :
|
|
309
321
|
harness === "claude_code" ? process.env.CLAUDE_CODE_SESSION_ID : process.env.OPENCODE_SESSION_ID;
|
|
310
322
|
}
|
|
@@ -614,6 +626,11 @@ function opencodeReview(sessionRef, fromTurn, toTurn) {
|
|
|
614
626
|
}
|
|
615
627
|
|
|
616
628
|
function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
|
|
629
|
+
if (harness === "cursor") {
|
|
630
|
+
const records = sourceRecords(harness, sessionRef);
|
|
631
|
+
const { turnCount, sources } = require("./cursor-sessions.js").sources({ sessionRef, fromTurn, toTurn }, records);
|
|
632
|
+
return { sessionRef, turnCount, notes: sources.filter((source) => source.role === "assistant").map((source) => ({ turn: source.turn, note: source.content })) };
|
|
633
|
+
}
|
|
617
634
|
if (harness === "opencode") return opencodeReview(sessionRef, fromTurn, toTurn);
|
|
618
635
|
const selectedFile = sessionFile(harness, sessionRef);
|
|
619
636
|
return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
|
|
@@ -630,6 +647,7 @@ function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = f
|
|
|
630
647
|
}
|
|
631
648
|
|
|
632
649
|
function sourceRecords(harness, sessionRef) {
|
|
650
|
+
if (harness === "cursor") return require("./cursor-sessions.js").sourceRecords(sessionRef, cursorOptions());
|
|
633
651
|
if (harness === "opencode") {
|
|
634
652
|
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`);
|
|
635
653
|
}
|
|
@@ -1078,7 +1096,11 @@ function canonicalThinking(value) {
|
|
|
1078
1096
|
|
|
1079
1097
|
function localProfileTurns(harness, segment) {
|
|
1080
1098
|
const turns = [];
|
|
1081
|
-
if (harness === "
|
|
1099
|
+
if (harness === "cursor") {
|
|
1100
|
+
const records = sourceRecords(harness, segment.sessionRef);
|
|
1101
|
+
const sources = require("./cursor-sessions.js").sources({ ...segment, fromTurn: 1 }, records).sources;
|
|
1102
|
+
for (const source of sources.filter((source) => source.role === "user")) turns.push({ turn: source.turn, text: source.content });
|
|
1103
|
+
} else if (harness === "opencode") {
|
|
1082
1104
|
const rows = opencodeQuery(`SELECT m.id AS messageId, json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.modelID') AS model, p.data AS part FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = ${JSON.stringify(segment.sessionRef)} ORDER BY m.time_created, p.id`);
|
|
1083
1105
|
let lastMessage;
|
|
1084
1106
|
let turn = 0;
|
|
@@ -1186,8 +1208,8 @@ function selectedSessionSegments(harness, { hours = 24, turns = MAX_TURNS_PER_SE
|
|
|
1186
1208
|
}
|
|
1187
1209
|
return [...sessions.values()]
|
|
1188
1210
|
.sort((left, right) => count === undefined
|
|
1189
|
-
? left.updatedAt.localeCompare(right.updatedAt)
|
|
1190
|
-
: right.updatedAt.localeCompare(left.updatedAt) || left.sessionRef.localeCompare(right.sessionRef))
|
|
1211
|
+
? String(left.updatedAt || "").localeCompare(String(right.updatedAt || ""))
|
|
1212
|
+
: String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")) || left.sessionRef.localeCompare(right.sessionRef))
|
|
1191
1213
|
.slice(0, count ?? Infinity)
|
|
1192
1214
|
.map(({ turnTimes, ...session }) => {
|
|
1193
1215
|
const checkpoint = state.sessions[session.sessionRef]?.lastSyncedTurn || 0;
|
|
@@ -1213,7 +1235,7 @@ function pendingSegments(harness, state = readState(harness)) {
|
|
|
1213
1235
|
const segments = [];
|
|
1214
1236
|
for (const session of localSessions(harness)
|
|
1215
1237
|
.filter((candidate) => !excluded.has(candidate.sessionRef))
|
|
1216
|
-
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
|
|
1238
|
+
.sort((left, right) => String(left.updatedAt || "").localeCompare(String(right.updatedAt || "")))) {
|
|
1217
1239
|
let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
1218
1240
|
if (fromTurn > session.turnCount || !initialHistoryEligibility(session, fromTurn, config).eligible) continue;
|
|
1219
1241
|
const completedTo = require("./source-review.js").completedSourceTurn({ harness }, sourceRecords(harness, session.sessionRef));
|
|
@@ -1273,7 +1295,7 @@ function copyRunner() {
|
|
|
1273
1295
|
const installedDependency = path.join(configRoot(), "node_modules", name);
|
|
1274
1296
|
if (!sameFile(dependency, installedDependency)) copyDirectory(dependency, installedDependency);
|
|
1275
1297
|
}
|
|
1276
|
-
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"]) {
|
|
1298
|
+
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", "continuity-preparation.js", "continuity-state.js", "cursor-config.js", "cursor-mcp.js", "cursor-sessions.js", "cursor-native.js"]) {
|
|
1277
1299
|
const source = path.join(__dirname, name);
|
|
1278
1300
|
const destination = path.join(configRoot(), name);
|
|
1279
1301
|
if (!sameFile(source, destination)) {
|
|
@@ -1333,6 +1355,7 @@ function removeCodexHooks() {
|
|
|
1333
1355
|
}
|
|
1334
1356
|
|
|
1335
1357
|
function installDestination(harness) {
|
|
1358
|
+
if (harness === "cursor") return path.join(require("./cursor-config.js").cursorHome(), "skills", "shifu-sync");
|
|
1336
1359
|
if (harness === "codex") return path.join(codexHome(), "skills", "shifu-sync");
|
|
1337
1360
|
if (harness === "claude_code") return path.join(claudeHome(), "skills", "shifu-sync");
|
|
1338
1361
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode", "skills", "shifu-sync");
|
|
@@ -1382,6 +1405,7 @@ function install(harness, quiet = false, preferences = readJSON(configPath(harne
|
|
|
1382
1405
|
fs.appendFileSync(syncFile, `\n## ${HARNESS_NAMES[harness]} context connection\n\nAfter connecting ${HARNESS_NAMES[harness]}, run \`"${process.execPath}" "${installedRunnerPath()}" mcp-setup --harness ${harness}\` to configure Shifu context and memory tools with the saved key. Use \`--mcp-url\` when the user supplied a branch endpoint. If the endpoint is not deployed, report that limitation while leaving sync connected. Reload ${HARNESS_NAMES[harness]} or start a new chat after successful setup. Follow the installed shifu-context skill for context and memory use.\n`);
|
|
1383
1406
|
if (!quiet) refreshManagedContextInstructions(harness, preferences);
|
|
1384
1407
|
}
|
|
1408
|
+
if (harness === "cursor") require("./cursor-config.js").configureHooks(installedRunnerPath(), path.resolve(configRoot()));
|
|
1385
1409
|
const removedHooks = harness === "codex" && removeCodexHooks();
|
|
1386
1410
|
if (!quiet) {
|
|
1387
1411
|
printWelcome({ harness, upgraded });
|
|
@@ -1557,7 +1581,7 @@ async function connect(harness, readKey = readSecret, choices) {
|
|
|
1557
1581
|
const token = (await readKey()).trim();
|
|
1558
1582
|
if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
|
|
1559
1583
|
const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
|
|
1560
|
-
await validateConnection(apiUrl, token, harness);
|
|
1584
|
+
const connectionInfo = await validateConnection(apiUrl, token, harness);
|
|
1561
1585
|
const savedPolicy = existing && normalizedPolicy(existing);
|
|
1562
1586
|
const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
|
|
1563
1587
|
const selected = await chooseSyncPolicy(currentPolicy);
|
|
@@ -1570,15 +1594,17 @@ async function connect(harness, readKey = readSecret, choices) {
|
|
|
1570
1594
|
install(harness, true, choices.contextPreferences);
|
|
1571
1595
|
const scheduleFile = scheduleEnabled ? installSchedule(harness) : undefined;
|
|
1572
1596
|
if (!scheduleEnabled && savedPolicy?.scheduleEnabled) removeSchedule(harness);
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1597
|
+
const connection = { ...existing, ...choices, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder, capabilities: connectionInfo.capabilities || {} };
|
|
1598
|
+
const committed = await withSyncLock(harness, async () => {
|
|
1599
|
+
if (JSON.stringify(readJSON(configPath(harness), undefined)) !== JSON.stringify(existing)) throw new Error("The connection changed during setup. Run connect again.");
|
|
1600
|
+
require("./continuity-state.js").commitConnection(configRoot(), harness, existing, connection);
|
|
1601
|
+
refreshManagedContextInstructions(harness, choices.contextPreferences);
|
|
1602
|
+
if (refreshContextKey) writePrivateText(openCodeTokenPath(), token);
|
|
1603
|
+
const continuity = continuityState(harness, connection);
|
|
1604
|
+
continuity.load(); continuity.dictionary();
|
|
1605
|
+
scheduleNextRun(harness, syncPolicy.intervalHours);
|
|
1606
|
+
});
|
|
1607
|
+
if (committed?.skipped) throw new Error("Another Shifu sync is running. Retry connect after it finishes; the connection was not replaced.");
|
|
1582
1608
|
console.error(`${HARNESS_NAMES[harness]} is connected. ${scheduleEnabled ? `It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.` : "The sync schedule is off."}`);
|
|
1583
1609
|
console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
|
|
1584
1610
|
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
@@ -1938,6 +1964,7 @@ function readContextInstructionFile(file) {
|
|
|
1938
1964
|
}
|
|
1939
1965
|
|
|
1940
1966
|
function contextInstructionFile(harness) {
|
|
1967
|
+
if (harness === "cursor") return path.join(configRoot(), "cursor-context.md");
|
|
1941
1968
|
if (harness === "codex") {
|
|
1942
1969
|
const override = path.join(codexHome(), "AGENTS.override.md");
|
|
1943
1970
|
if (readContextInstructionFile(override).original.trim()) return override;
|
|
@@ -2021,7 +2048,7 @@ async function setupMcp(harness) {
|
|
|
2021
2048
|
printSetupIntro();
|
|
2022
2049
|
const choices = await onboardingOptions(readJSON(configPath(harness), undefined));
|
|
2023
2050
|
const instructions = prepareContextInstructions(harness, choices.contextPreferences);
|
|
2024
|
-
const helper =
|
|
2051
|
+
const helper = ["opencode", "cursor"].includes(harness) ? undefined : helperCommand(harness);
|
|
2025
2052
|
if (fs.existsSync(configPath(harness)) && !fs.lstatSync(configPath(harness)).isFile()) {
|
|
2026
2053
|
throw new Error(`The saved ${HARNESS_NAMES[harness]} connection must be a regular private file, not a link.`);
|
|
2027
2054
|
}
|
|
@@ -2038,7 +2065,7 @@ async function setupMcp(harness) {
|
|
|
2038
2065
|
if (!TOKEN_PATTERN.test(config.token)) throw new Error(`The saved ${HARNESS_NAMES[harness]} connection key is invalid. Run connect again.`);
|
|
2039
2066
|
await validateConnection(config.apiUrl, config.token, harness);
|
|
2040
2067
|
const tools = await verifyMcp(mcpUrl, config.token);
|
|
2041
|
-
const prepared = harness === "codex" ? prepareCodexMcpConfig(mcpUrl, helper) : harness === "opencode" ? prepareOpenCodeMcpConfig(mcpUrl) : prepareClaudeMcpConfig(mcpUrl, helper);
|
|
2068
|
+
const prepared = harness === "cursor" ? require("./cursor-config.js").prepareMcp(path.resolve(installedRunnerPath()), path.resolve(configRoot())) : harness === "codex" ? prepareCodexMcpConfig(mcpUrl, helper) : harness === "opencode" ? prepareOpenCodeMcpConfig(mcpUrl) : prepareClaudeMcpConfig(mcpUrl, helper);
|
|
2042
2069
|
if (!unchangedMcpConfig(prepared)) {
|
|
2043
2070
|
throw new Error(`${HARNESS_NAMES[harness]} configuration changed during setup. Run mcp-setup again to preserve those changes.`);
|
|
2044
2071
|
}
|
|
@@ -2068,6 +2095,11 @@ function disconnect(harness) {
|
|
|
2068
2095
|
if (fs.existsSync(lockPath(harness))) throw new Error("A Shifu sync is running. Try disconnecting again after it finishes.");
|
|
2069
2096
|
if (harness === "opencode" && (config.mcpUrl || fs.existsSync(openCodeTokenPath()))) removeOpenCodeMcpConfig(config.mcpConfigMajor);
|
|
2070
2097
|
if (harness === "claude_code" && config.mcpUrl) removeClaudeMcpConfig(config.mcpHeadersHelper || helperCommand("claude_code"));
|
|
2098
|
+
if (harness === "cursor") {
|
|
2099
|
+
const cursor = require("./cursor-config.js");
|
|
2100
|
+
cursor.removeMcp(path.resolve(installedRunnerPath()), path.resolve(configRoot()));
|
|
2101
|
+
cursor.configureHooks(installedRunnerPath(), path.resolve(configRoot()), true);
|
|
2102
|
+
}
|
|
2071
2103
|
removeSchedule(harness);
|
|
2072
2104
|
for (const file of [configPath(harness), statePath(harness), pendingPath(harness)]) {
|
|
2073
2105
|
try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
@@ -2217,10 +2249,11 @@ function retryDelay(response, attempt) {
|
|
|
2217
2249
|
return Math.min(base * (2 ** attempt), 30_000);
|
|
2218
2250
|
}
|
|
2219
2251
|
|
|
2220
|
-
async function postSync(config, input) {
|
|
2252
|
+
async function postSync(config, input, beforeAttempt, onPermanentFailure) {
|
|
2221
2253
|
const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
|
|
2222
2254
|
let lastError;
|
|
2223
2255
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
|
|
2256
|
+
beforeAttempt?.();
|
|
2224
2257
|
let response;
|
|
2225
2258
|
try {
|
|
2226
2259
|
response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
|
|
@@ -2237,7 +2270,7 @@ async function postSync(config, input) {
|
|
|
2237
2270
|
const payload = await response.json().catch(() => undefined);
|
|
2238
2271
|
if (response.ok) return payload?.data;
|
|
2239
2272
|
lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
|
|
2240
|
-
if (response.status !== 429 && response.status < 500) throw lastError;
|
|
2273
|
+
if (response.status !== 429 && response.status < 500) { onPermanentFailure?.(); throw lastError; }
|
|
2241
2274
|
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
|
|
2242
2275
|
}
|
|
2243
2276
|
throw lastError;
|
|
@@ -2446,20 +2479,27 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
2446
2479
|
validateSync(input, harness);
|
|
2447
2480
|
const state = readState(harness);
|
|
2448
2481
|
const checkpoint = state.sessions[input.sessionRef];
|
|
2482
|
+
const store = continuityState(harness, config);
|
|
2483
|
+
const frozen = store.find(input);
|
|
2449
2484
|
const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
|
|
2450
2485
|
const force = input.force || Boolean(input.supersedesSyncId);
|
|
2451
|
-
if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
2486
|
+
if (!force && !frozen && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
2452
2487
|
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.");
|
|
2453
|
-
if (!force) input = queuePayload(harness, input);
|
|
2488
|
+
if (!force && (!frozen || input.toTurn > (checkpoint?.lastSyncedTurn || 0))) input = queuePayload(harness, input);
|
|
2454
2489
|
let profile;
|
|
2455
2490
|
if (!force) {
|
|
2456
2491
|
try { profile = profileForSegment(harness, input); }
|
|
2457
2492
|
catch { console.error("AI Profile source is unavailable; the prepared activity payload remains retryable."); }
|
|
2458
2493
|
}
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2494
|
+
let receipt;
|
|
2495
|
+
try {
|
|
2496
|
+
receipt = frozen?.receipt || await postSync(config, input, frozen ? () => store.reserveBase(input) : undefined, frozen ? () => store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_rejected"; }) : undefined);
|
|
2497
|
+
if (!validSyncReceipt(receipt, input)) throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
2498
|
+
} catch (error) {
|
|
2499
|
+
if (frozen && store.find(input).baseAttempts >= 10) store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_transport_budget"; });
|
|
2500
|
+
throw error;
|
|
2462
2501
|
}
|
|
2502
|
+
if (frozen) store.bind(input, receipt);
|
|
2463
2503
|
if (profile) {
|
|
2464
2504
|
state.profilePending ||= {};
|
|
2465
2505
|
state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
|
|
@@ -2568,27 +2608,108 @@ async function reconcilePreparedSession(harness, sessionRef, config) {
|
|
|
2568
2608
|
saveState(harness, state);
|
|
2569
2609
|
}
|
|
2570
2610
|
|
|
2611
|
+
function continuityState(harness, config = readConfig(harness)) {
|
|
2612
|
+
return require("./continuity-state.js").continuityStore(configRoot(), harness, config);
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
function preparationPacket(harness, segment, config) {
|
|
2616
|
+
if (normalizedPolicy(config).excludedSessionRefs.includes(segment.sessionRef)) throw new Error("This session is excluded from preparation.");
|
|
2617
|
+
if (!initialHistoryEligibility(segment, segment.fromTurn, config).eligible) throw new Error("This range is outside the saved initial-history selection.");
|
|
2618
|
+
const records = sourceRecords(harness, segment.sessionRef);
|
|
2619
|
+
let minimumTurn = Math.max(1, segment.fromTurn - 6);
|
|
2620
|
+
for (let turn = segment.fromTurn - 1; turn >= minimumTurn; turn -= 1) {
|
|
2621
|
+
if (!initialHistoryEligibility(segment, turn, config).eligible) { minimumTurn = turn + 1; break; }
|
|
2622
|
+
}
|
|
2623
|
+
const packet = require("./source-review.js").continuitySourcePacket({ harness, sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn }, records, minimumTurn);
|
|
2624
|
+
let workspace;
|
|
2625
|
+
if (harness === "codex") workspace = records.find((record) => record.type === "session_meta")?.payload?.cwd;
|
|
2626
|
+
else if (harness === "claude_code") workspace = records.find((record) => typeof record.cwd === "string")?.cwd;
|
|
2627
|
+
else if (harness === "cursor") workspace = records.find((record) => typeof record.workspace === "string")?.workspace;
|
|
2628
|
+
else {
|
|
2629
|
+
try { workspace = opencodeQuery(`SELECT directory FROM session WHERE id = ${JSON.stringify(segment.sessionRef)}`)[0]?.directory; }
|
|
2630
|
+
catch { /* No trusted directory means a session-specific scope, never the scheduler workspace. */ }
|
|
2631
|
+
}
|
|
2632
|
+
return { packet, workspace: createHash("sha256").update(typeof workspace === "string" && workspace ? `workspace:${workspace}` : `${harness}:session:${segment.sessionRef}`).digest("hex") };
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2571
2635
|
async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
|
|
2572
|
-
const
|
|
2636
|
+
const config = readConfig(harness);
|
|
2637
|
+
const store = continuityState(harness, config);
|
|
2638
|
+
const prior = store.load().attempts[`${segment.sessionRef}:${segment.fromTurn}`];
|
|
2639
|
+
if (prior?.payloadKey) {
|
|
2640
|
+
const frozen = store.load().outbox.find((entry) => entry.key === prior.payloadKey);
|
|
2641
|
+
if (!frozen) throw new Error("Frozen preparation is unavailable; continuity needs review.");
|
|
2642
|
+
return frozen.payload;
|
|
2643
|
+
}
|
|
2644
|
+
if (prior?.toTurn) segment = { ...segment, toTurn: prior.toTurn };
|
|
2645
|
+
const { packet, workspace } = preparationPacket(harness, segment, config);
|
|
2646
|
+
const dictionary = store.dictionary();
|
|
2647
|
+
dictionary.scopes[workspace] ||= { scopeRef: `s_${randomUUID().replaceAll("-", "")}`, entities: {} };
|
|
2648
|
+
store.saveDictionary(dictionary);
|
|
2649
|
+
const relevantBindings = () => {
|
|
2650
|
+
const content = [...packet.sources, ...packet.referenceSources].map((source) => source.content).join("\n").toLocaleLowerCase();
|
|
2651
|
+
return Object.values(dictionary.scopes[workspace].entities).filter((entry) => entry.createdBy !== `${packet.sessionRef}:${packet.fromTurn}` && content.includes(entry.mention.toLocaleLowerCase()))
|
|
2652
|
+
.map(({ key, mention, kind }) => ({ bindingRef: key, mention, kind }));
|
|
2653
|
+
};
|
|
2654
|
+
packet.entityBindings = relevantBindings();
|
|
2655
|
+
while (packet.referenceSources.length && Buffer.byteLength(JSON.stringify(packet)) + 1 > 256 * 1024) {
|
|
2656
|
+
const oldest = packet.referenceSources[0].turn;
|
|
2657
|
+
packet.referenceSources = packet.referenceSources.filter((source) => source.turn > oldest);
|
|
2658
|
+
packet.referenceCoverage.limited = true;
|
|
2659
|
+
packet.referenceCoverage.droppedTurns += 1;
|
|
2660
|
+
packet.referenceCoverage.fromTurn = packet.referenceSources[0]?.turn || packet.fromTurn;
|
|
2661
|
+
packet.entityBindings = relevantBindings();
|
|
2662
|
+
}
|
|
2663
|
+
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.");
|
|
2664
|
+
if (packet.entityBindings.length > 32) throw new Error("Relevant private entity bindings exceed the bounded packet; this range needs review.");
|
|
2665
|
+
const bindings = { workspace, scopeRef: dictionary.scopes[workspace].scopeRef, catalog: require("./continuity-preparation.js").CATALOG };
|
|
2573
2666
|
const deadline = Date.now() + 180_000;
|
|
2574
2667
|
let validationFeedback;
|
|
2575
2668
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2576
2669
|
const remainingMs = deadline - Date.now();
|
|
2577
2670
|
if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
|
|
2578
|
-
const
|
|
2671
|
+
const reservation = store.reservation(packet, bindings);
|
|
2672
|
+
let output;
|
|
2579
2673
|
try {
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2674
|
+
output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs, continuity: true });
|
|
2675
|
+
const candidateDictionary = structuredClone(dictionary);
|
|
2676
|
+
const result = require("./continuity-preparation.js").bindContinuity(packet, output, candidateDictionary, workspace);
|
|
2677
|
+
validateSync(result.payload, harness);
|
|
2678
|
+
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.");
|
|
2679
|
+
store.saveDictionary(candidateDictionary);
|
|
2680
|
+
return store.freeze(packet, result).payload;
|
|
2583
2681
|
} catch (error) {
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
// Failed model output is never queued, echoed, or sent to Shifu.
|
|
2682
|
+
store.failure(packet);
|
|
2683
|
+
if (reservation.attempt >= 2 || output === undefined) throw error;
|
|
2587
2684
|
validationFeedback = preparationValidationFeedback(error, output);
|
|
2588
2685
|
}
|
|
2589
2686
|
}
|
|
2590
2687
|
}
|
|
2591
2688
|
|
|
2689
|
+
async function retryPreparation(harness) {
|
|
2690
|
+
const sessionRef = option("--session-ref");
|
|
2691
|
+
const fromTurn = numberOption("--from-turn");
|
|
2692
|
+
if (!sessionRefIsSafe(sessionRef) || !Number.isSafeInteger(fromTurn) || fromTurn < 1) throw new Error("Choose the held session and original from-turn explicitly.");
|
|
2693
|
+
const result = await withSyncLock(harness, async () => {
|
|
2694
|
+
const store = continuityState(harness);
|
|
2695
|
+
const state = store.load();
|
|
2696
|
+
const identity = `${sessionRef}:${fromTurn}`;
|
|
2697
|
+
const held = state.attempts[identity];
|
|
2698
|
+
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)) {
|
|
2699
|
+
throw new Error("Only an unqueued preparation hold can be retried; frozen payloads keep their exact transport path.");
|
|
2700
|
+
}
|
|
2701
|
+
if ((readState(harness).sessions[sessionRef]?.lastSyncedTurn || 0) >= fromTurn) throw new Error("Accepted ranges require separately authorized historical recovery.");
|
|
2702
|
+
const session = localSessions(harness).find((item) => item.sessionRef === sessionRef);
|
|
2703
|
+
if (!session) throw new Error("The original session is unavailable; the hold was preserved.");
|
|
2704
|
+
held.attempts = 0; held.status = "unresolved"; delete held.reason;
|
|
2705
|
+
store.save(state);
|
|
2706
|
+
const payload = await prepareV5Payload(harness, { ...session, fromTurn, toTurn: held.toTurn });
|
|
2707
|
+
queuePayload(harness, payload);
|
|
2708
|
+
console.error("The selected preparation is queued for its existing approval and transport policy.");
|
|
2709
|
+
});
|
|
2710
|
+
if (result?.skipped) throw new Error("Another sync is running; the hold was preserved.");
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2592
2713
|
function preparationValidationFeedback(error, output) {
|
|
2593
2714
|
const fields = [["title", output?.title, 160], ["summary", output?.summary, 1100]];
|
|
2594
2715
|
for (const name of ["decisions", "verification"]) {
|
|
@@ -2631,6 +2752,7 @@ function preparationState(harness, queue) {
|
|
|
2631
2752
|
}
|
|
2632
2753
|
|
|
2633
2754
|
async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
|
|
2755
|
+
restoreContinuityQueue(harness, config);
|
|
2634
2756
|
let queue = prunePendingQueue(harness);
|
|
2635
2757
|
const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
|
|
2636
2758
|
const errors = [];
|
|
@@ -2680,8 +2802,13 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
|
|
|
2680
2802
|
async function drainPendingQueue(harness, config, blockedSessions) {
|
|
2681
2803
|
let synced = 0;
|
|
2682
2804
|
let failures = 0;
|
|
2683
|
-
|
|
2684
|
-
|
|
2805
|
+
const store = continuityState(harness, config);
|
|
2806
|
+
const entries = store.load().outbox.filter((entry) => entry.baseStatus !== "needs_review" && (entry.baseStatus !== "accepted" || entry.toTurn > (readState(harness).sessions[entry.sessionRef]?.lastSyncedTurn || 0)));
|
|
2807
|
+
const queued = eligibleQueuedSegments(readPendingQueue(harness), config);
|
|
2808
|
+
const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
|
|
2809
|
+
const inputs = [...queued, ...entries.filter((entry) => !excluded.has(entry.sessionRef) && !queued.some((input) => JSON.stringify(input) === JSON.stringify(entry.payload))).map((entry) => entry.payload)];
|
|
2810
|
+
for (const input of inputs) {
|
|
2811
|
+
if (blockedSessions.has(input.sessionRef) || store.find(input)?.baseStatus === "needs_review") continue;
|
|
2685
2812
|
try { await sendSync(harness, input, config); synced += 1; }
|
|
2686
2813
|
catch (error) {
|
|
2687
2814
|
failures += 1;
|
|
@@ -2692,11 +2819,35 @@ async function drainPendingQueue(harness, config, blockedSessions) {
|
|
|
2692
2819
|
return { synced, failures };
|
|
2693
2820
|
}
|
|
2694
2821
|
|
|
2822
|
+
async function flushContinuityAttachments(harness, config) {
|
|
2823
|
+
const store = continuityState(harness, config);
|
|
2824
|
+
if (!store.load().outbox.some((entry) => entry.baseStatus === "accepted" && !["attached", "not_applicable", "needs_review"].includes(entry.continuityStatus))) return;
|
|
2825
|
+
let capable = false;
|
|
2826
|
+
try {
|
|
2827
|
+
const connection = await validateConnection(config.apiUrl, config.token, harness);
|
|
2828
|
+
capable = connection.capabilities?.continuityAttachment === 1;
|
|
2829
|
+
} catch { /* Base receipts remain valid while capability discovery is unavailable. */ }
|
|
2830
|
+
await require("./continuity-state.js").flushContinuity(store, config, capable);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function restoreContinuityQueue(harness, config) {
|
|
2834
|
+
const queue = readPendingQueue(harness);
|
|
2835
|
+
const state = readState(harness);
|
|
2836
|
+
for (const entry of continuityState(harness, config).load().outbox) {
|
|
2837
|
+
if (entry.toTurn > (state.sessions[entry.sessionRef]?.lastSyncedTurn || 0) && entry.baseStatus !== "needs_review" && !queue.segments.some((payload) => JSON.stringify(payload) === JSON.stringify(entry.payload))) queuePayload(harness, entry.payload);
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2695
2841
|
function eligibleQueuedSegments(queue, config) {
|
|
2696
2842
|
const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
|
|
2697
2843
|
return queue.segments.filter((input) => !excluded.has(input.sessionRef));
|
|
2698
2844
|
}
|
|
2699
2845
|
|
|
2846
|
+
function continuityRecoveries(harness, config, queued) {
|
|
2847
|
+
const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
|
|
2848
|
+
return continuityState(harness, config).load().outbox.filter((entry) => entry.baseStatus === "awaiting_receipt" && !excluded.has(entry.sessionRef) && !queued.some((payload) => JSON.stringify(payload) === JSON.stringify(entry.payload))).map((entry) => entry.payload);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2700
2851
|
function approvalNotificationInvocation(harness, count, sessionRef) {
|
|
2701
2852
|
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.`;
|
|
2702
2853
|
if (harness === "codex") {
|
|
@@ -2726,6 +2877,12 @@ function notificationSessionRef(output) {
|
|
|
2726
2877
|
}
|
|
2727
2878
|
|
|
2728
2879
|
function notifyManualApproval(harness, count, existingSessionRef) {
|
|
2880
|
+
if (harness === "cursor") {
|
|
2881
|
+
const message = `${count} redacted work summaries are waiting. Ask Cursor to show pending Shifu sync and approve it once.`;
|
|
2882
|
+
if (process.platform === "darwin") execFileSync("osascript", ["-e", `display notification ${JSON.stringify(message)} with title "Shifu sync approval"`], { stdio: "ignore", timeout: 10_000 });
|
|
2883
|
+
else execFileSync("notify-send", ["Shifu sync approval", message], { stdio: "ignore", timeout: 10_000 });
|
|
2884
|
+
return "thread_cursor_approval";
|
|
2885
|
+
}
|
|
2729
2886
|
const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
|
|
2730
2887
|
const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
|
|
2731
2888
|
const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
|
|
@@ -2740,7 +2897,7 @@ function notifyManualApproval(harness, count, existingSessionRef) {
|
|
|
2740
2897
|
if (existingSessionRef) return existingSessionRef;
|
|
2741
2898
|
const created = notificationSessionRef(output) || localSessions(harness)
|
|
2742
2899
|
.filter((session) => !before.has(session.sessionRef))
|
|
2743
|
-
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.sessionRef;
|
|
2900
|
+
.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))[0]?.sessionRef;
|
|
2744
2901
|
if (!created) throw new Error("The approval session was created but its session reference could not be recorded safely.");
|
|
2745
2902
|
return created;
|
|
2746
2903
|
}
|
|
@@ -2748,7 +2905,9 @@ function notifyManualApproval(harness, count, existingSessionRef) {
|
|
|
2748
2905
|
async function showPending(harness) {
|
|
2749
2906
|
const config = readConfig(harness);
|
|
2750
2907
|
const result = await withSyncLock(harness, async () => {
|
|
2751
|
-
|
|
2908
|
+
const queue = await preparePendingQueue(harness, config);
|
|
2909
|
+
queue.segments = [...eligibleQueuedSegments(queue, config), ...continuityRecoveries(harness, config, queue.segments)];
|
|
2910
|
+
console.log(JSON.stringify(queue, null, 2));
|
|
2752
2911
|
});
|
|
2753
2912
|
if (result?.skipped) throw new Error("Another Shifu sync is already preparing this queue. Retry later.");
|
|
2754
2913
|
}
|
|
@@ -2756,18 +2915,21 @@ async function showPending(harness) {
|
|
|
2756
2915
|
async function approvePending(harness) {
|
|
2757
2916
|
const config = readConfig(harness);
|
|
2758
2917
|
const result = await withSyncLock(harness, async () => {
|
|
2918
|
+
restoreContinuityQueue(harness, config);
|
|
2759
2919
|
const queue = prunePendingQueue(harness);
|
|
2760
2920
|
queue.segments = eligibleQueuedSegments(queue, config);
|
|
2761
|
-
|
|
2921
|
+
const recoveries = continuityRecoveries(harness, config, queue.segments);
|
|
2922
|
+
if (queue.segments.length === 0 && recoveries.length === 0) {
|
|
2762
2923
|
console.error("There is no prepared Shifu activity to approve. Run pending to prepare the next ranges.");
|
|
2763
2924
|
return;
|
|
2764
2925
|
}
|
|
2765
|
-
console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
|
|
2926
|
+
console.error(`\nThese ${queue.segments.length + recoveries.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify([...queue.segments, ...recoveries], null, 2)}`);
|
|
2766
2927
|
if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
|
|
2767
2928
|
console.error("Nothing was sent. The prepared queue is unchanged.");
|
|
2768
2929
|
return;
|
|
2769
2930
|
}
|
|
2770
2931
|
const sent = await drainPendingQueue(harness, config, new Set());
|
|
2932
|
+
await flushContinuityAttachments(harness, config);
|
|
2771
2933
|
if (sent.failures) throw new Error("Some approved segments could not be acknowledged. Their exact payloads remain pending for retry.");
|
|
2772
2934
|
});
|
|
2773
2935
|
if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
|
|
@@ -2800,9 +2962,12 @@ async function scheduledSync(harness) {
|
|
|
2800
2962
|
}
|
|
2801
2963
|
}
|
|
2802
2964
|
await flushPendingProfiles(harness, config);
|
|
2965
|
+
restoreContinuityQueue(harness, config);
|
|
2966
|
+
await flushContinuityAttachments(harness, config);
|
|
2803
2967
|
if (policy.approvalMode === "manual") {
|
|
2804
2968
|
const queue = await preparePendingQueue(harness, config);
|
|
2805
|
-
const
|
|
2969
|
+
const queued = eligibleQueuedSegments(queue, config);
|
|
2970
|
+
const eligible = [...queued, ...continuityRecoveries(harness, config, queued)];
|
|
2806
2971
|
const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
2807
2972
|
const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
|
|
2808
2973
|
let approvalSessionRef = state.schedule?.approvalSessionRef;
|
|
@@ -2860,6 +3025,7 @@ async function scheduledSync(harness) {
|
|
|
2860
3025
|
nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
2861
3026
|
};
|
|
2862
3027
|
}
|
|
3028
|
+
await flushContinuityAttachments(harness, config);
|
|
2863
3029
|
const latest = readState(harness);
|
|
2864
3030
|
latest.schedule = state.schedule;
|
|
2865
3031
|
saveState(harness, latest);
|
|
@@ -2890,7 +3056,7 @@ function status(harness) {
|
|
|
2890
3056
|
if (!eligibility.eligible) initialHistoryBlocked.push({ sessionRef: session.sessionRef, reason: eligibility.reason, cutoffAt: eligibility.cutoffAt });
|
|
2891
3057
|
}
|
|
2892
3058
|
}
|
|
2893
|
-
console.log(JSON.stringify({ harness, runnerVersion: RUNNER_VERSION, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
|
|
3059
|
+
console.log(JSON.stringify({ continuity: continuityState(harness, config).summary(), harness, runnerVersion: RUNNER_VERSION, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
|
|
2894
3060
|
}
|
|
2895
3061
|
|
|
2896
3062
|
async function syncFilesNow(harness) {
|
|
@@ -3025,7 +3191,7 @@ async function review(harness) {
|
|
|
3025
3191
|
const state = readState(harness);
|
|
3026
3192
|
let fromTurn = (state.sessions[sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
3027
3193
|
const selected = localSessions(harness).filter((session) => session.sessionRef === sessionRef)
|
|
3028
|
-
.sort((left, right) => right.turnCount - left.turnCount || right.updatedAt.localeCompare(left.updatedAt))[0];
|
|
3194
|
+
.sort((left, right) => right.turnCount - left.turnCount || String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))[0];
|
|
3029
3195
|
if (!selected) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches this reference.`);
|
|
3030
3196
|
const hours = syncWindowHours();
|
|
3031
3197
|
const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
|
|
@@ -3104,6 +3270,25 @@ async function updateRecommendedPackage(harness, { currentVersion = RUNNER_VERSI
|
|
|
3104
3270
|
}
|
|
3105
3271
|
}
|
|
3106
3272
|
|
|
3273
|
+
async function cursorHook() {
|
|
3274
|
+
if (process.env.SHIFU_CURSOR_PREPARATION === "1" || !fs.existsSync(configPath("cursor"))) return console.log("{}");
|
|
3275
|
+
let input = "";
|
|
3276
|
+
process.stdin.setEncoding("utf8");
|
|
3277
|
+
for await (const chunk of process.stdin) {
|
|
3278
|
+
input += chunk.toString("utf8");
|
|
3279
|
+
if (Buffer.byteLength(input) > 1024 * 1024) throw new Error("Cursor hook input exceeded 1 MB. Nothing was captured.");
|
|
3280
|
+
}
|
|
3281
|
+
const payload = JSON.parse(input);
|
|
3282
|
+
if (payload.hook_event_name === "sessionStart") {
|
|
3283
|
+
const file = contextInstructionFile("cursor");
|
|
3284
|
+
const instruction = fs.existsSync(file) ? readContextInstructionFile(file).original : "";
|
|
3285
|
+
const sessionRef = require("./cursor-sessions.js").sessionRefForConversation(payload.conversation_id || payload.session_id);
|
|
3286
|
+
return console.log(JSON.stringify({ env: { SHIFU_CURSOR_SESSION_REF: sessionRef }, ...(instruction ? { additional_context: instruction } : {}) }));
|
|
3287
|
+
}
|
|
3288
|
+
require("./cursor-sessions.js").captureHook(payload, cursorOptions());
|
|
3289
|
+
console.log("{}");
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3107
3292
|
async function main() {
|
|
3108
3293
|
if (option("--config-root")) process.env.SHIFU_CONFIG_ROOT = path.resolve(option("--config-root"));
|
|
3109
3294
|
const command = process.argv[2];
|
|
@@ -3120,6 +3305,11 @@ async function main() {
|
|
|
3120
3305
|
});
|
|
3121
3306
|
return;
|
|
3122
3307
|
}
|
|
3308
|
+
if (command === "cursor-hook" && harness === "cursor") return cursorHook();
|
|
3309
|
+
if (command === "mcp-bridge" && harness === "cursor") {
|
|
3310
|
+
const config = readConfig(harness);
|
|
3311
|
+
return require("./cursor-mcp.js").runBridge({ url: requireSafeApiUrl(config.mcpUrl || `${config.apiUrl}/mcp`), credentials: () => mcpHeaders(harness) });
|
|
3312
|
+
}
|
|
3123
3313
|
if (command === "install") return install(harness);
|
|
3124
3314
|
if (command === "connect") return connect(harness);
|
|
3125
3315
|
if (command === "mcp-setup") return setupMcp(harness);
|
|
@@ -3130,21 +3320,21 @@ async function main() {
|
|
|
3130
3320
|
if (command === "sync-files") return syncFilesNow(harness);
|
|
3131
3321
|
if (command === "scheduled-sync") return scheduledSync(harness);
|
|
3132
3322
|
if (command === "pending") return showPending(harness);
|
|
3323
|
+
if (command === "retry-preparation") return retryPreparation(harness);
|
|
3133
3324
|
if (command === "approve") return approvePending(harness);
|
|
3134
3325
|
if (command === "status") return status(harness);
|
|
3135
3326
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
3136
3327
|
if (command === "review") return review(harness);
|
|
3137
3328
|
if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
|
|
3138
|
-
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.");
|
|
3329
|
+
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.");
|
|
3139
3330
|
}
|
|
3140
3331
|
|
|
3141
3332
|
if (require.main === module) {
|
|
3142
3333
|
main()
|
|
3143
|
-
.then(() => process.exit(0))
|
|
3144
3334
|
.catch((error) => {
|
|
3145
3335
|
console.error(error.message);
|
|
3146
|
-
process.
|
|
3336
|
+
process.exitCode = 1;
|
|
3147
3337
|
});
|
|
3148
3338
|
}
|
|
3149
3339
|
|
|
3150
|
-
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 };
|
|
3340
|
+
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, preparationPacket, continuityState, flushContinuityAttachments, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
|