@synkro-sh/cli 1.7.95 → 1.9.0
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/dist/bootstrap.js +2766 -246
- package/dist/bootstrap.js.map +1 -1
- package/package.json +6 -4
package/dist/bootstrap.js
CHANGED
|
@@ -145,9 +145,9 @@ function hostnameHash() {
|
|
|
145
145
|
}
|
|
146
146
|
function getIdentity() {
|
|
147
147
|
if (cached2) return cached2;
|
|
148
|
-
let
|
|
148
|
+
let cliVersion2 = "0.0.0";
|
|
149
149
|
try {
|
|
150
|
-
|
|
150
|
+
cliVersion2 = "1.9.0";
|
|
151
151
|
} catch {
|
|
152
152
|
}
|
|
153
153
|
const creds = loadCredentialsIdentity();
|
|
@@ -157,7 +157,7 @@ function getIdentity() {
|
|
|
157
157
|
user_id: creds.user_id,
|
|
158
158
|
org_id: creds.org_id,
|
|
159
159
|
email: creds.email,
|
|
160
|
-
cli_version:
|
|
160
|
+
cli_version: cliVersion2,
|
|
161
161
|
platform: platform(),
|
|
162
162
|
hostname_hash: hostnameHash(),
|
|
163
163
|
node_version: process.version,
|
|
@@ -183,8 +183,8 @@ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2 } fr
|
|
|
183
183
|
import { homedir as homedir3 } from "os";
|
|
184
184
|
import { join as join3 } from "path";
|
|
185
185
|
function deriveGit(cwd) {
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
186
|
+
const cached5 = gitCache.get(cwd);
|
|
187
|
+
if (cached5) return cached5;
|
|
188
188
|
const info = { branch: null, sha: null };
|
|
189
189
|
try {
|
|
190
190
|
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
@@ -234,7 +234,7 @@ function emit(eventType, context, opts) {
|
|
|
234
234
|
const cwd = opts?.cwd ?? process.cwd();
|
|
235
235
|
const git = deriveGit(cwd);
|
|
236
236
|
const emitter = process.env.SYNKRO_TELEMETRY_EMITTER || "bootstrap";
|
|
237
|
-
const
|
|
237
|
+
const row2 = {
|
|
238
238
|
client_event_id: randomUUID2(),
|
|
239
239
|
event_type: eventType,
|
|
240
240
|
occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -263,7 +263,7 @@ function emit(eventType, context, opts) {
|
|
|
263
263
|
context: redactContext(context)
|
|
264
264
|
};
|
|
265
265
|
ensureDir2();
|
|
266
|
-
appendFileSync(PENDING_PATH, JSON.stringify(
|
|
266
|
+
appendFileSync(PENDING_PATH, JSON.stringify(row2) + "\n", { mode: 384 });
|
|
267
267
|
} catch (err) {
|
|
268
268
|
const msg = err instanceof Error ? err.message : String(err);
|
|
269
269
|
process.stderr.write(`[synkro] telemetry emit(${eventType}) failed: ${msg}
|
|
@@ -297,9 +297,9 @@ function shiftPlaceholders(text, offset) {
|
|
|
297
297
|
function bulkFragment(rows, columns) {
|
|
298
298
|
if (!rows.length || !columns.length) throw new Error("empty SQL helper");
|
|
299
299
|
const params = [];
|
|
300
|
-
const tuples = rows.map((
|
|
300
|
+
const tuples = rows.map((row2) => {
|
|
301
301
|
const placeholders = columns.map((column) => {
|
|
302
|
-
let value =
|
|
302
|
+
let value = row2[column];
|
|
303
303
|
let cast = "";
|
|
304
304
|
if (column === "context" && typeof value === "object" && value !== null) {
|
|
305
305
|
value = JSON.stringify(value);
|
|
@@ -1169,7 +1169,7 @@ async function getStats() {
|
|
|
1169
1169
|
GROUP BY event_type
|
|
1170
1170
|
ORDER BY c DESC
|
|
1171
1171
|
`;
|
|
1172
|
-
for (const
|
|
1172
|
+
for (const row2 of types) base.by_type[String(row2.event_type)] = Number(row2.c);
|
|
1173
1173
|
const newest = await sql`
|
|
1174
1174
|
SELECT occurred_at FROM telemetry_events ORDER BY occurred_at DESC LIMIT 1
|
|
1175
1175
|
`;
|
|
@@ -1224,8 +1224,8 @@ async function exportEvents(path) {
|
|
|
1224
1224
|
const lines = [];
|
|
1225
1225
|
try {
|
|
1226
1226
|
const rows = await sql`SELECT * FROM telemetry_events ORDER BY occurred_at ASC`;
|
|
1227
|
-
for (const
|
|
1228
|
-
lines.push(JSON.stringify(
|
|
1227
|
+
for (const row2 of rows) {
|
|
1228
|
+
lines.push(JSON.stringify(row2));
|
|
1229
1229
|
}
|
|
1230
1230
|
} catch {
|
|
1231
1231
|
}
|
|
@@ -1777,6 +1777,27 @@ function uninstallCCHooks(settingsPath) {
|
|
|
1777
1777
|
writeSettingsAtomic(settingsPath, settings);
|
|
1778
1778
|
return true;
|
|
1779
1779
|
}
|
|
1780
|
+
function inspectCCHooks(settingsPath) {
|
|
1781
|
+
if (!existsSync11(settingsPath)) {
|
|
1782
|
+
return { installed: false, preToolUseBash: false, postToolUseEdit: false, sessionEnd: false, sessionStart: false };
|
|
1783
|
+
}
|
|
1784
|
+
const settings = readSettings(settingsPath);
|
|
1785
|
+
const pre = settings.hooks?.PreToolUse ?? [];
|
|
1786
|
+
const post = settings.hooks?.PostToolUse ?? [];
|
|
1787
|
+
const sessionEndHooks = settings.hooks?.SessionEnd ?? [];
|
|
1788
|
+
const sessionStartHooks = settings.hooks?.SessionStart ?? [];
|
|
1789
|
+
const preToolUseBash = pre.some((e) => e?.[SYNKRO_MARKER] === true);
|
|
1790
|
+
const postToolUseEdit = post.some((e) => e?.[SYNKRO_MARKER] === true);
|
|
1791
|
+
const sessionEnd = sessionEndHooks.some((e) => e?.[SYNKRO_MARKER] === true);
|
|
1792
|
+
const sessionStart = sessionStartHooks.some((e) => e?.[SYNKRO_MARKER] === true);
|
|
1793
|
+
return {
|
|
1794
|
+
installed: preToolUseBash || postToolUseEdit || sessionEnd || sessionStart,
|
|
1795
|
+
preToolUseBash,
|
|
1796
|
+
postToolUseEdit,
|
|
1797
|
+
sessionEnd,
|
|
1798
|
+
sessionStart
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1780
1801
|
var SYNKRO_MARKER, USAGE_PROXY_URL;
|
|
1781
1802
|
var init_ccHookConfig = __esm({
|
|
1782
1803
|
"cli/installer/ccHookConfig.ts"() {
|
|
@@ -1985,6 +2006,74 @@ function uninstallCursorHooks(hooksJsonPath) {
|
|
|
1985
2006
|
writeHooksFileAtomic(hooksJsonPath, file);
|
|
1986
2007
|
return true;
|
|
1987
2008
|
}
|
|
2009
|
+
function preToolUseUsesScript(hooks, scriptBasename) {
|
|
2010
|
+
return (hooks ?? []).some(
|
|
2011
|
+
(e) => isSynkroEntry2(e) && typeof e.command === "string" && e.command.includes(scriptBasename)
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
function inspectCursorHooks(hooksJsonPath) {
|
|
2015
|
+
let file;
|
|
2016
|
+
try {
|
|
2017
|
+
file = readHooksFile(hooksJsonPath);
|
|
2018
|
+
} catch {
|
|
2019
|
+
return {
|
|
2020
|
+
installed: false,
|
|
2021
|
+
sessionStart: false,
|
|
2022
|
+
sessionEnd: false,
|
|
2023
|
+
beforeSubmitPrompt: false,
|
|
2024
|
+
stop: false,
|
|
2025
|
+
beforeShellExecution: false,
|
|
2026
|
+
afterShellExecution: false,
|
|
2027
|
+
preToolUse: false,
|
|
2028
|
+
preToolUseBash: false,
|
|
2029
|
+
preToolUseEdit: false,
|
|
2030
|
+
preToolUseCwe: false,
|
|
2031
|
+
preToolUseCve: false,
|
|
2032
|
+
preToolUseAgent: false,
|
|
2033
|
+
preToolUsePlan: false,
|
|
2034
|
+
afterFileEdit: false,
|
|
2035
|
+
postToolUse: false,
|
|
2036
|
+
beforeMCPExecution: false
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
const h = file.hooks ?? {};
|
|
2040
|
+
const sessionStart = (h.sessionStart ?? []).some((e) => isSynkroEntry2(e));
|
|
2041
|
+
const sessionEnd = (h.sessionEnd ?? []).some((e) => isSynkroEntry2(e));
|
|
2042
|
+
const beforeSubmitPrompt = (h.beforeSubmitPrompt ?? []).some((e) => isSynkroEntry2(e));
|
|
2043
|
+
const stop = (h.stop ?? []).some((e) => isSynkroEntry2(e));
|
|
2044
|
+
const beforeShellExecution = (h.beforeShellExecution ?? []).some((e) => isSynkroEntry2(e));
|
|
2045
|
+
const afterShellExecution = (h.afterShellExecution ?? []).some((e) => isSynkroEntry2(e));
|
|
2046
|
+
const pre = h.preToolUse ?? [];
|
|
2047
|
+
const preToolUseBash = preToolUseUsesScript(pre, "cc-bash-judge") || preToolUseUsesScript(pre, "cursor-bash-judge");
|
|
2048
|
+
const preToolUseEdit = preToolUseUsesScript(pre, "cc-edit-precheck") || preToolUseUsesScript(pre, "cursor-edit-precheck");
|
|
2049
|
+
const preToolUseCwe = preToolUseUsesScript(pre, "cc-cwe-precheck");
|
|
2050
|
+
const preToolUseCve = preToolUseUsesScript(pre, "cc-cve-precheck");
|
|
2051
|
+
const preToolUseAgent = preToolUseUsesScript(pre, "cc-agent-judge");
|
|
2052
|
+
const preToolUsePlan = preToolUseUsesScript(pre, "cc-plan-judge");
|
|
2053
|
+
const preToolUse = preToolUseBash || preToolUseEdit || preToolUseCwe || preToolUseCve || preToolUseAgent || preToolUsePlan;
|
|
2054
|
+
const afterFileEdit = (h.afterFileEdit ?? []).some((e) => isSynkroEntry2(e));
|
|
2055
|
+
const postToolUse = (h.postToolUse ?? []).some((e) => isSynkroEntry2(e));
|
|
2056
|
+
const beforeMCPExecution = (h.beforeMCPExecution ?? []).some((e) => isSynkroEntry2(e));
|
|
2057
|
+
return {
|
|
2058
|
+
installed: sessionStart || sessionEnd || beforeSubmitPrompt || stop || beforeShellExecution || afterShellExecution || preToolUse || afterFileEdit || postToolUse || beforeMCPExecution,
|
|
2059
|
+
sessionStart,
|
|
2060
|
+
sessionEnd,
|
|
2061
|
+
beforeSubmitPrompt,
|
|
2062
|
+
stop,
|
|
2063
|
+
beforeShellExecution,
|
|
2064
|
+
afterShellExecution,
|
|
2065
|
+
preToolUse,
|
|
2066
|
+
preToolUseBash,
|
|
2067
|
+
preToolUseEdit,
|
|
2068
|
+
preToolUseCwe,
|
|
2069
|
+
preToolUseCve,
|
|
2070
|
+
preToolUseAgent,
|
|
2071
|
+
preToolUsePlan,
|
|
2072
|
+
afterFileEdit,
|
|
2073
|
+
postToolUse,
|
|
2074
|
+
beforeMCPExecution
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
1988
2077
|
var SYNKRO_MARKER2, ALLOWED_PARENT_DIRS, ALL_EVENTS;
|
|
1989
2078
|
var init_cursorHookConfig = __esm({
|
|
1990
2079
|
"cli/installer/cursorHookConfig.ts"() {
|
|
@@ -2128,6 +2217,52 @@ function uninstallCodexHooks(hooksJsonPath) {
|
|
|
2128
2217
|
writeHooksFileAtomic2(hooksJsonPath, file);
|
|
2129
2218
|
return true;
|
|
2130
2219
|
}
|
|
2220
|
+
function inspectCodexHooks(hooksJsonPath) {
|
|
2221
|
+
let file;
|
|
2222
|
+
try {
|
|
2223
|
+
file = readHooksFile2(hooksJsonPath);
|
|
2224
|
+
} catch {
|
|
2225
|
+
return {
|
|
2226
|
+
installed: false,
|
|
2227
|
+
preToolUse: false,
|
|
2228
|
+
preToolUseEdit: false,
|
|
2229
|
+
preToolUseCwe: false,
|
|
2230
|
+
preToolUseCve: false,
|
|
2231
|
+
postToolUse: false,
|
|
2232
|
+
postToolUseEdit: false,
|
|
2233
|
+
sessionStart: false,
|
|
2234
|
+
sessionEnd: false
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
const h = file.hooks ?? {};
|
|
2238
|
+
const preToolUse = (h.PreToolUse ?? []).some((e) => isSynkroEntry3(e));
|
|
2239
|
+
const editEntries = (h.PreToolUse ?? []).filter(
|
|
2240
|
+
(e) => typeof e?.matcher === "string" && (e.matcher.includes("apply_patch") || e.matcher.includes("ApplyPatch") || e.matcher.includes("Edit"))
|
|
2241
|
+
);
|
|
2242
|
+
const entryUsesScript = (needle) => editEntries.some(
|
|
2243
|
+
(entry) => (entry.hooks ?? []).some((handler) => String(handler?.command || "").includes(needle))
|
|
2244
|
+
);
|
|
2245
|
+
const preToolUseEdit = entryUsesScript("cc-edit-precheck");
|
|
2246
|
+
const preToolUseCwe = entryUsesScript("cc-cwe-precheck");
|
|
2247
|
+
const preToolUseCve = entryUsesScript("cc-cve-precheck");
|
|
2248
|
+
const postToolUse = (h.PostToolUse ?? []).some((e) => isSynkroEntry3(e));
|
|
2249
|
+
const postToolUseEdit = (h.PostToolUse ?? []).some(
|
|
2250
|
+
(entry) => entry.matcher === CODEX_EDIT_MATCHER && (entry.hooks ?? []).some((handler) => String(handler?.command || "").includes("cc-edit-followup"))
|
|
2251
|
+
);
|
|
2252
|
+
const sessionStart = (h.SessionStart ?? []).some((e) => isSynkroEntry3(e));
|
|
2253
|
+
const sessionEnd = (h.SessionEnd ?? []).some((e) => isSynkroEntry3(e));
|
|
2254
|
+
return {
|
|
2255
|
+
installed: preToolUseEdit && preToolUseCwe && preToolUseCve && postToolUseEdit,
|
|
2256
|
+
preToolUse,
|
|
2257
|
+
preToolUseEdit,
|
|
2258
|
+
preToolUseCwe,
|
|
2259
|
+
preToolUseCve,
|
|
2260
|
+
postToolUse,
|
|
2261
|
+
postToolUseEdit,
|
|
2262
|
+
sessionStart,
|
|
2263
|
+
sessionEnd
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2131
2266
|
var SYNKRO_MARKER3, CODEX_BASH_MATCHER, M_BASH, CODEX_EDIT_MATCHER, M_EDIT, M_AGENT, M_MCP, M_ACTIVATE, ALL_EVENTS2;
|
|
2132
2267
|
var init_codexHookConfig = __esm({
|
|
2133
2268
|
"cli/installer/codexHookConfig.ts"() {
|
|
@@ -2189,7 +2324,7 @@ function buildCodexHookTrustEdits(hooks) {
|
|
|
2189
2324
|
return [...edits.values()];
|
|
2190
2325
|
}
|
|
2191
2326
|
function queryCodexHookTrust(codexBinary = "codex", cwd = process.cwd(), autoTrust = false) {
|
|
2192
|
-
return new Promise((
|
|
2327
|
+
return new Promise((resolve7) => {
|
|
2193
2328
|
let settled = false;
|
|
2194
2329
|
let stdout = "";
|
|
2195
2330
|
let pending = "";
|
|
@@ -2207,7 +2342,7 @@ function queryCodexHookTrust(codexBinary = "codex", cwd = process.cwd(), autoTru
|
|
|
2207
2342
|
child.kill();
|
|
2208
2343
|
} catch {
|
|
2209
2344
|
}
|
|
2210
|
-
|
|
2345
|
+
resolve7(summary);
|
|
2211
2346
|
};
|
|
2212
2347
|
const timer = setTimeout(() => finish(null), 1e4);
|
|
2213
2348
|
try {
|
|
@@ -2745,8 +2880,8 @@ Print a short readiness summary: rules now active, security scanning on, the gra
|
|
|
2745
2880
|
// cli/installer/skillParser.ts
|
|
2746
2881
|
import { existsSync as existsSync15 } from "fs";
|
|
2747
2882
|
import { resolve as resolve3 } from "path";
|
|
2748
|
-
function resolveSkillPaths(skills,
|
|
2749
|
-
return skills.filter((s) => s.endsWith(".md")).map((s) => resolve3(
|
|
2883
|
+
function resolveSkillPaths(skills, repoRoot3) {
|
|
2884
|
+
return skills.filter((s) => s.endsWith(".md")).map((s) => resolve3(repoRoot3, s)).filter((p) => existsSync15(p));
|
|
2750
2885
|
}
|
|
2751
2886
|
var init_skillParser = __esm({
|
|
2752
2887
|
"cli/installer/skillParser.ts"() {
|
|
@@ -2762,10 +2897,10 @@ var STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_EDIT_FOLLOWUP_TS, STUB_CWE_PRECH
|
|
|
2762
2897
|
var init_hookScriptsTs = __esm({
|
|
2763
2898
|
"cli/installer/hookScriptsTs.ts"() {
|
|
2764
2899
|
"use strict";
|
|
2765
|
-
STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
|
|
2766
|
-
import { execSync } from 'node:child_process';
|
|
2900
|
+
STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
|
|
2901
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2767
2902
|
import { homedir } from 'node:os';
|
|
2768
|
-
import { basename, join, resolve, relative, isAbsolute } from 'node:path';
|
|
2903
|
+
import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path';
|
|
2769
2904
|
import { randomUUID, createHash } from 'node:crypto';
|
|
2770
2905
|
|
|
2771
2906
|
const HOME = homedir();
|
|
@@ -3001,6 +3136,650 @@ function gitRoot(cwd: string): string {
|
|
|
3001
3136
|
} catch { return ''; }
|
|
3002
3137
|
}
|
|
3003
3138
|
|
|
3139
|
+
// synkro.toml is per-machine and untracked, so a LINKED git worktree never has
|
|
3140
|
+
// its own copy — its root is a fresh checkout. Resolve the main checkout through
|
|
3141
|
+
// the worktree's .git pointer file (a linked worktree's .git is a FILE reading
|
|
3142
|
+
// "gitdir: <main>/.git/worktrees/<name>") so worktrees inherit the repo's
|
|
3143
|
+
// config instead of silently skipping all grading. Opt-in semantics survive: a
|
|
3144
|
+
// repo whose MAIN root has no synkro.toml stays dormant. Pure fs — no
|
|
3145
|
+
// subprocess on the hook hot path.
|
|
3146
|
+
function mainWorktreeRoot(root: string): string {
|
|
3147
|
+
try {
|
|
3148
|
+
const dotGit = join(root, '.git');
|
|
3149
|
+
if (statSync(dotGit).isDirectory()) return root;
|
|
3150
|
+
const pointer = readFileSync(dotGit, 'utf-8');
|
|
3151
|
+
const match = pointer.match(/^gitdir:\s*(.+?)\s*$/m);
|
|
3152
|
+
if (!match) return root;
|
|
3153
|
+
const gitDir = isAbsolute(match[1]) ? match[1] : join(root, match[1]);
|
|
3154
|
+
const marker = join('.git', 'worktrees') + '/';
|
|
3155
|
+
const at = gitDir.lastIndexOf('/' + marker);
|
|
3156
|
+
if (at === -1) return root;
|
|
3157
|
+
return gitDir.slice(0, at) || root;
|
|
3158
|
+
} catch { return root; }
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function taskRepoContext(root: string): any {
|
|
3162
|
+
if (!root) return undefined;
|
|
3163
|
+
try {
|
|
3164
|
+
const run = (args: string[]) => execFileSync('git', args, {
|
|
3165
|
+
cwd: root,
|
|
3166
|
+
timeout: 2000,
|
|
3167
|
+
encoding: 'utf-8',
|
|
3168
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3169
|
+
}).trim();
|
|
3170
|
+
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
3171
|
+
const sha = run(['rev-parse', 'HEAD']);
|
|
3172
|
+
const remote = run(['remote', 'get-url', 'origin']);
|
|
3173
|
+
if (!branch || !sha || !remote) return undefined;
|
|
3174
|
+
let baseBranch = branch === 'HEAD' ? '' : branch;
|
|
3175
|
+
let baseSha = sha;
|
|
3176
|
+
try {
|
|
3177
|
+
const remoteHead = run(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
|
|
3178
|
+
if (remoteHead.startsWith('origin/')) {
|
|
3179
|
+
baseBranch = remoteHead.slice('origin/'.length);
|
|
3180
|
+
baseSha = run(['rev-parse', remoteHead]);
|
|
3181
|
+
}
|
|
3182
|
+
} catch {}
|
|
3183
|
+
if (!baseBranch || baseBranch === branch) {
|
|
3184
|
+
for (const candidate of ['main', 'master']) {
|
|
3185
|
+
try {
|
|
3186
|
+
let candidateSha = '';
|
|
3187
|
+
try { candidateSha = run(['rev-parse', '--verify', 'refs/remotes/origin/' + candidate]); }
|
|
3188
|
+
catch { candidateSha = run(['rev-parse', '--verify', 'refs/heads/' + candidate]); }
|
|
3189
|
+
if (candidateSha) {
|
|
3190
|
+
baseBranch = candidate;
|
|
3191
|
+
baseSha = candidateSha;
|
|
3192
|
+
break;
|
|
3193
|
+
}
|
|
3194
|
+
} catch {
|
|
3195
|
+
continue;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (!baseBranch || !baseSha) return undefined;
|
|
3200
|
+
let worktree = false;
|
|
3201
|
+
try { worktree = statSync(join(root, '.git')).isFile(); } catch {}
|
|
3202
|
+
const commonRoot = sharedRepoRoot(root);
|
|
3203
|
+
return { root, commonRoot, remote, branch, sha, baseBranch, baseSha, worktree };
|
|
3204
|
+
} catch { return undefined; }
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// One task-workspace message per TOOL CALL, not per hook. A single Bash call fans
|
|
3208
|
+
// out to install-scan + bash-judge + skill-judge (an Edit fans out to four), and
|
|
3209
|
+
// every hook is its own process, so the dedupe has to live on disk rather than in
|
|
3210
|
+
// a Map the way the server-side status-line dedupe does (see stableHookEventIdentity
|
|
3211
|
+
// in scanning/scanRouter). Atomic first-writer-wins via an exclusive create: the
|
|
3212
|
+
// hook that wins says it in full, the rest stay terse.
|
|
3213
|
+
const TOOL_CALL_MARK_DIR = join(HOME, '.synkro', 'tool-call-marks');
|
|
3214
|
+
const TOOL_CALL_MARK_WINDOW_MS = 15000;
|
|
3215
|
+
|
|
3216
|
+
function pruneToolCallMarks(now: number): void {
|
|
3217
|
+
try {
|
|
3218
|
+
const entries = readdirSync(TOOL_CALL_MARK_DIR);
|
|
3219
|
+
if (entries.length < 200) return;
|
|
3220
|
+
for (const name of entries) {
|
|
3221
|
+
const path = join(TOOL_CALL_MARK_DIR, name);
|
|
3222
|
+
try {
|
|
3223
|
+
if (now - statSync(path).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) unlinkSync(path);
|
|
3224
|
+
} catch {}
|
|
3225
|
+
}
|
|
3226
|
+
} catch {}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// The user's answer to "move into the task worktree, or stay here?". Written by the
|
|
3230
|
+
// "synkro workspace stay" command (the CLI owns ~/.synkro, so nothing hand-writes it)
|
|
3231
|
+
// and read here. Keyed by task alone: the decision resets when the active task
|
|
3232
|
+
// changes, which is the scope the workspace gate is asking about.
|
|
3233
|
+
const WORKSPACE_CHOICE_DIR = join(HOME, '.synkro', 'workspace-choice');
|
|
3234
|
+
|
|
3235
|
+
// The user answers the workspace question in plain language on their next
|
|
3236
|
+
// prompt. Writing the marker is exactly what the CLI's "workspace stay"
|
|
3237
|
+
// command does — but the consent matcher only accepts one literal spelling,
|
|
3238
|
+
// and on a machine where PATH shadows the binary (observed: a venv python
|
|
3239
|
+
// named synkro, and an older global without the command) that spelling cannot
|
|
3240
|
+
// succeed, which deadlocked the ask. Patterns are DIRECTIVES, never
|
|
3241
|
+
// questions: a trailing question mark rejects, and the bare verb matches only
|
|
3242
|
+
// as a short standalone reply. Callers gate on a PENDING ask, so ordinary
|
|
3243
|
+
// conversation containing "stay" is never scanned against this.
|
|
3244
|
+
function isWorkspaceStayIntent(prompt: string): boolean {
|
|
3245
|
+
const normalized = String(prompt || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
3246
|
+
if (!normalized || normalized.length > 240) return false;
|
|
3247
|
+
if (/\?\s*$/.test(normalized)) return false;
|
|
3248
|
+
if (/^(?:yes[,.\s]+)?(?:please\s+)?stay(?:\s+(?:here|put))?(?:\s*(?:pls|please))?[.!]?$/.test(normalized)) return true;
|
|
3249
|
+
return [
|
|
3250
|
+
/\b(?:stay|remain|keep working|keep going|continue)\b[^?]{0,60}\b(?:current|same|this)\s+(?:worktree|workspace|checkout|directory)\b/,
|
|
3251
|
+
/\b(?:do not|don't|dont|no need to)\s+(?:move|switch|change)\b[^?]{0,40}\b(?:worktrees?|workspaces?|checkouts?)\b/,
|
|
3252
|
+
].some((pattern) => pattern.test(normalized));
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function taskWorkspaceStayRecorded(taskId: string): boolean {
|
|
3256
|
+
if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || ''))) return false;
|
|
3257
|
+
try { return existsSync(join(WORKSPACE_CHOICE_DIR, taskId + '.stay')); } catch { return false; }
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
// Our own context string, so the shape is stable: '... task=<id> ...'.
|
|
3261
|
+
function taskIdFromScmContext(context: string): string {
|
|
3262
|
+
const marker = ' task=';
|
|
3263
|
+
const at = String(context || '').indexOf(marker);
|
|
3264
|
+
if (at === -1) return '';
|
|
3265
|
+
const rest = context.slice(at + marker.length);
|
|
3266
|
+
const end = rest.indexOf(' ');
|
|
3267
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim();
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// Consent must never deadlock behind the block it resolves: the command that records
|
|
3271
|
+
// the answer is allowed through for the task currently being asked about, and nothing
|
|
3272
|
+
// else is.
|
|
3273
|
+
function isWorkspaceConsentCommand(payload: any, taskId: string): boolean {
|
|
3274
|
+
if (!taskId || String(payload?.tool_name || '') !== 'Bash') return false;
|
|
3275
|
+
const input = payload?.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
3276
|
+
const command = String(input.command || input.cmd || '').trim();
|
|
3277
|
+
return command === 'synkro workspace stay ' + taskId;
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
function firstHookForToolCall(sessionId: string, payload: any): boolean {
|
|
3281
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
3282
|
+
const id = String(p.tool_use_id || p.tool_call_id || p.call_id || p.event_id || '').trim();
|
|
3283
|
+
// Fallback when the harness omits a call id: tool name + input is identical across
|
|
3284
|
+
// the surfaces of one call and distinct across different calls. Identical
|
|
3285
|
+
// back-to-back commands can collide inside the window, which costs one repeated
|
|
3286
|
+
// line and nothing else.
|
|
3287
|
+
const material = id || (String(p.tool_name || '') + ':' + (p.tool_input ? JSON.stringify(p.tool_input) : ''));
|
|
3288
|
+
if (!sessionId || !material) return true;
|
|
3289
|
+
const key = createHash('sha256').update(sessionId + '\0' + material).digest('hex').slice(0, 24);
|
|
3290
|
+
const marker = join(TOOL_CALL_MARK_DIR, key);
|
|
3291
|
+
const now = Date.now();
|
|
3292
|
+
try { mkdirSync(TOOL_CALL_MARK_DIR, { recursive: true }); } catch {}
|
|
3293
|
+
pruneToolCallMarks(now);
|
|
3294
|
+
try {
|
|
3295
|
+
writeFileSync(marker, '', { flag: 'wx', mode: 0o600 });
|
|
3296
|
+
return true;
|
|
3297
|
+
} catch (err: any) {
|
|
3298
|
+
if (err && err.code === 'EEXIST') {
|
|
3299
|
+
// Outside the burst window this is a genuinely new call reusing the fallback
|
|
3300
|
+
// key, so re-arm the marker and let it speak.
|
|
3301
|
+
try {
|
|
3302
|
+
if (now - statSync(marker).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) {
|
|
3303
|
+
writeFileSync(marker, '', { mode: 0o600 });
|
|
3304
|
+
return true;
|
|
3305
|
+
}
|
|
3306
|
+
} catch {}
|
|
3307
|
+
return false;
|
|
3308
|
+
}
|
|
3309
|
+
// Any other failure (read-only home, quota) must not silence the message.
|
|
3310
|
+
return true;
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
function taskScmBlockResponse(harness: string, reason: string, verbose = true): string {
|
|
3315
|
+
// Every fanned-out hook still denies — suppressing the decision itself would let the
|
|
3316
|
+
// tool through if the winning hook's response were ever dropped. Only the TEXT
|
|
3317
|
+
// collapses: the later surfaces of one tool call deny with no systemMessage and no
|
|
3318
|
+
// additionalContext, so the transcript carries one workspace message, not three.
|
|
3319
|
+
const tag = synkroOriginTag('synkro:scm', harness);
|
|
3320
|
+
const message = tag + ' ' + reason;
|
|
3321
|
+
if (harness === 'cursor') {
|
|
3322
|
+
return verbose
|
|
3323
|
+
? JSON.stringify({ permission: 'deny', user_message: message, agent_message: message })
|
|
3324
|
+
: JSON.stringify({ permission: 'deny', user_message: '', agent_message: '' });
|
|
3325
|
+
}
|
|
3326
|
+
if (!verbose) {
|
|
3327
|
+
return JSON.stringify({
|
|
3328
|
+
systemMessage: '',
|
|
3329
|
+
hookSpecificOutput: {
|
|
3330
|
+
hookEventName: 'PreToolUse',
|
|
3331
|
+
permissionDecision: 'deny',
|
|
3332
|
+
// Only surfaces if this hook's denial is the one the harness reports.
|
|
3333
|
+
permissionDecisionReason: tag + ' blocked — see the workspace message above',
|
|
3334
|
+
additionalContext: '',
|
|
3335
|
+
},
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
return JSON.stringify({
|
|
3339
|
+
systemMessage: message,
|
|
3340
|
+
hookSpecificOutput: {
|
|
3341
|
+
hookEventName: 'PreToolUse',
|
|
3342
|
+
permissionDecision: 'deny',
|
|
3343
|
+
permissionDecisionReason: message,
|
|
3344
|
+
additionalContext: message,
|
|
3345
|
+
},
|
|
3346
|
+
});
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
function safeTaskBranch(value: string): boolean {
|
|
3350
|
+
return /^[a-z][a-z0-9]*-[0-9]+\/[a-z0-9][a-z0-9-]{0,47}$/.test(value);
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
function gitOutput(root: string, args: string[], timeout = 5000): string {
|
|
3354
|
+
return execFileSync('git', args, {
|
|
3355
|
+
cwd: root,
|
|
3356
|
+
timeout,
|
|
3357
|
+
encoding: 'utf-8',
|
|
3358
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
3359
|
+
}).trim();
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
function gitSucceeds(root: string, args: string[]): boolean {
|
|
3363
|
+
try { execFileSync('git', args, { cwd: root, timeout: 5000, stdio: 'ignore' }); return true; }
|
|
3364
|
+
catch { return false; }
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
function samePath(left: string, right: string): boolean {
|
|
3368
|
+
try { return realpathSync(left) === realpathSync(right); }
|
|
3369
|
+
catch { return resolve(left) === resolve(right); }
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
function sharedRepoRoot(root: string): string {
|
|
3373
|
+
const raw = gitOutput(root, ['rev-parse', '--git-common-dir']);
|
|
3374
|
+
const common = resolve(root, raw);
|
|
3375
|
+
if (basename(common) !== '.git') {
|
|
3376
|
+
throw new Error('task worktrees require a non-bare repository with shared Git metadata');
|
|
3377
|
+
}
|
|
3378
|
+
return dirname(common);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// Claude Code only permits switching BETWEEN worktrees when the target lives under
|
|
3382
|
+
// <repo>/.claude/worktrees, so a cc session that activates a second task can never
|
|
3383
|
+
// reach another .synkro-worktrees path. Provisioning cc task worktrees under
|
|
3384
|
+
// .claude/worktrees keeps mid-session task switching working; every other harness
|
|
3385
|
+
// keeps the original location.
|
|
3386
|
+
const CC_MANAGED_WORKTREE_DIR = join('.claude', 'worktrees');
|
|
3387
|
+
const LEGACY_MANAGED_WORKTREE_DIR = '.synkro-worktrees';
|
|
3388
|
+
|
|
3389
|
+
function managedWorktreeDir(harness: string): string {
|
|
3390
|
+
return harness === 'cc' ? CC_MANAGED_WORKTREE_DIR : LEGACY_MANAGED_WORKTREE_DIR;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function taskWorktreePath(root: string, taskId: string, harness: string): string {
|
|
3394
|
+
return join(sharedRepoRoot(root), managedWorktreeDir(harness), taskId);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
function taskWorktreeCandidatePaths(root: string, taskId: string): string[] {
|
|
3398
|
+
const shared = sharedRepoRoot(root);
|
|
3399
|
+
return [CC_MANAGED_WORKTREE_DIR, LEGACY_MANAGED_WORKTREE_DIR]
|
|
3400
|
+
.map((dir) => join(shared, dir, taskId));
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
// A task bound before the cc relocation keeps the worktree it already owns: the
|
|
3404
|
+
// canonical branch is checked out there, so provisioning a second one would fail
|
|
3405
|
+
// on 'the canonical task branch already exists in another workspace'.
|
|
3406
|
+
function resolveTaskWorktreePath(root: string, taskId: string, harness: string): string {
|
|
3407
|
+
const records = taskWorktreeRecords(root);
|
|
3408
|
+
const registered = taskWorktreeCandidatePaths(root, taskId)
|
|
3409
|
+
.find((candidate) => records.some((item) => samePath(item.path, candidate)));
|
|
3410
|
+
return registered || taskWorktreePath(root, taskId, harness);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
function codexDesktopOrigin(): boolean {
|
|
3414
|
+
const origin = String(process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '').toLowerCase();
|
|
3415
|
+
const bundle = String(process.env.__CFBundleIdentifier || '').toLowerCase();
|
|
3416
|
+
return origin === 'codex desktop' || bundle === 'com.openai.codex';
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
function ignoreManagedWorktreeDirectory(root: string, worktreePath: string): void {
|
|
3420
|
+
const shared = sharedRepoRoot(root);
|
|
3421
|
+
const common = join(shared, '.git');
|
|
3422
|
+
const excludePath = join(common, 'info', 'exclude');
|
|
3423
|
+
// Ignore the directory the worktree actually landed in, which differs per harness.
|
|
3424
|
+
const entry = relative(shared, dirname(worktreePath)) + '/';
|
|
3425
|
+
if (entry.startsWith('..')) return;
|
|
3426
|
+
let existing = '';
|
|
3427
|
+
try { existing = readFileSync(excludePath, 'utf-8'); } catch {}
|
|
3428
|
+
if (existing.split(/\r?\n/).includes(entry)) return;
|
|
3429
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
3430
|
+
appendFileSync(excludePath, (existing && !existing.endsWith('\n') ? '\n' : '') + entry + '\n');
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
interface TaskWorktreeRecord { path: string; branch: string }
|
|
3434
|
+
|
|
3435
|
+
function taskWorktreeRecords(root: string): TaskWorktreeRecord[] {
|
|
3436
|
+
const records: TaskWorktreeRecord[] = [];
|
|
3437
|
+
let current: TaskWorktreeRecord = { path: '', branch: '' };
|
|
3438
|
+
const flush = () => {
|
|
3439
|
+
if (current.path) records.push(current);
|
|
3440
|
+
current = { path: '', branch: '' };
|
|
3441
|
+
};
|
|
3442
|
+
for (const line of (gitOutput(root, ['worktree', 'list', '--porcelain']) + '\n').split('\n')) {
|
|
3443
|
+
if (!line) { flush(); continue; }
|
|
3444
|
+
if (line.startsWith('worktree ')) current.path = line.slice('worktree '.length);
|
|
3445
|
+
else if (line.startsWith('branch refs/heads/')) current.branch = line.slice('branch refs/heads/'.length);
|
|
3446
|
+
}
|
|
3447
|
+
return records;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
function gitOperationInProgress(root: string): boolean {
|
|
3451
|
+
if (gitSucceeds(root, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])) return true;
|
|
3452
|
+
for (const state of ['rebase-merge', 'rebase-apply']) {
|
|
3453
|
+
try {
|
|
3454
|
+
const raw = gitOutput(root, ['rev-parse', '--git-path', state]);
|
|
3455
|
+
const path = isAbsolute(raw) ? raw : resolve(root, raw);
|
|
3456
|
+
if (existsSync(path)) return true;
|
|
3457
|
+
} catch {}
|
|
3458
|
+
}
|
|
3459
|
+
return false;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
function validateTaskWorktree(root: string, currentRoot: string, op: any): string {
|
|
3463
|
+
// Accept either managed location: a task bound before the cc relocation still
|
|
3464
|
+
// pushes from its legacy .synkro-worktrees path.
|
|
3465
|
+
const expected = taskWorktreeCandidatePaths(root, String(op.taskId || ''));
|
|
3466
|
+
const supplied = String(op.worktreePath || '');
|
|
3467
|
+
const managed = Boolean(supplied) && expected.some((candidate) => samePath(supplied, candidate));
|
|
3468
|
+
const nativeCurrent = Boolean(supplied) && samePath(supplied, currentRoot);
|
|
3469
|
+
if (!managed && !nativeCurrent) {
|
|
3470
|
+
throw new Error('task worktree path does not match the managed or current native workspace');
|
|
3471
|
+
}
|
|
3472
|
+
const record = taskWorktreeRecords(root).find((item) => samePath(item.path, supplied));
|
|
3473
|
+
if (!record || record.branch !== String(op.branchName || '')) {
|
|
3474
|
+
throw new Error('task worktree is stale or no longer owns the canonical branch');
|
|
3475
|
+
}
|
|
3476
|
+
if (gitOutput(supplied, ['rev-parse', '--abbrev-ref', 'HEAD']) !== String(op.branchName || '')) {
|
|
3477
|
+
throw new Error('task worktree is not on the canonical branch');
|
|
3478
|
+
}
|
|
3479
|
+
return supplied;
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
async function completeTaskScm(sessionId: string, op: any, ok: boolean, error: string, worktreePath: string): Promise<void> {
|
|
3483
|
+
try {
|
|
3484
|
+
await fetch('http://127.0.0.1:' + PORT + '/api/local/task-scm/complete', {
|
|
3485
|
+
method: 'POST',
|
|
3486
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
|
|
3487
|
+
body: JSON.stringify({ taskId: op.taskId, sessionId, kind: op.kind, ok, error, worktreePath }),
|
|
3488
|
+
signal: AbortSignal.timeout(5000),
|
|
3489
|
+
});
|
|
3490
|
+
} catch {}
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function executeTaskScm(root: string, op: any, harness: string): string {
|
|
3494
|
+
if (!op || typeof op !== 'object') throw new Error('invalid SCM operation');
|
|
3495
|
+
if (!/^task_[a-z0-9]{8}$/i.test(String(op.taskId || ''))) throw new Error('invalid task id');
|
|
3496
|
+
const branch = String(op.branchName || '');
|
|
3497
|
+
if (!safeTaskBranch(branch)) throw new Error('invalid canonical task branch');
|
|
3498
|
+
const boundRoot = String(op.repoRoot || '');
|
|
3499
|
+
if (!boundRoot || (!samePath(boundRoot, root) && !samePath(sharedRepoRoot(boundRoot), sharedRepoRoot(root)))) {
|
|
3500
|
+
throw new Error('task workspace does not match this repository');
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
if (op.kind === 'branch') {
|
|
3504
|
+
if (op.prepareHandoffBranch === true) {
|
|
3505
|
+
if (gitOperationInProgress(root)) {
|
|
3506
|
+
throw new Error('finish the active merge or rebase before task automation');
|
|
3507
|
+
}
|
|
3508
|
+
const originalBranch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
3509
|
+
const head = gitOutput(root, ['rev-parse', 'HEAD']);
|
|
3510
|
+
if (!op.startSha || head !== String(op.startSha)) {
|
|
3511
|
+
throw new Error('Codex local checkout HEAD does not match the task creation revision');
|
|
3512
|
+
}
|
|
3513
|
+
if (gitSucceeds(boundRoot, ['show-ref', '--verify', '--quiet', 'refs/heads/' + branch])) {
|
|
3514
|
+
throw new Error('the canonical task branch already exists in another workspace');
|
|
3515
|
+
}
|
|
3516
|
+
if (!op.baseSha || !gitSucceeds(boundRoot, ['cat-file', '-e', String(op.baseSha) + '^{commit}'])) {
|
|
3517
|
+
throw new Error('task base revision is unavailable in this repository');
|
|
3518
|
+
}
|
|
3519
|
+
gitOutput(root, ['branch', branch, String(op.baseSha)], 30000);
|
|
3520
|
+
if (gitOutput(root, ['rev-parse', 'HEAD']) !== head
|
|
3521
|
+
|| gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']) !== originalBranch) {
|
|
3522
|
+
throw new Error('Git changed the shared checkout while reserving the task branch');
|
|
3523
|
+
}
|
|
3524
|
+
return '';
|
|
3525
|
+
}
|
|
3526
|
+
if (op.adoptExistingWorktree === true) {
|
|
3527
|
+
const supplied = String(op.worktreePath || '');
|
|
3528
|
+
if (!supplied || !samePath(root, supplied)) {
|
|
3529
|
+
throw new Error('Codex native worktree does not match the current session workspace');
|
|
3530
|
+
}
|
|
3531
|
+
const record = taskWorktreeRecords(boundRoot).find((item) => samePath(item.path, root));
|
|
3532
|
+
if (!record) throw new Error('Codex native worktree is not registered with Git');
|
|
3533
|
+
if (gitOperationInProgress(root)) {
|
|
3534
|
+
throw new Error('finish the active merge or rebase before task automation');
|
|
3535
|
+
}
|
|
3536
|
+
if (gitOutput(root, ['status', '--porcelain'])) {
|
|
3537
|
+
throw new Error('Codex native worktree must be clean before it can be assigned to a new task');
|
|
3538
|
+
}
|
|
3539
|
+
const head = gitOutput(root, ['rev-parse', 'HEAD']);
|
|
3540
|
+
if (!op.startSha || head !== String(op.startSha)) {
|
|
3541
|
+
throw new Error('Codex native worktree HEAD does not match the task creation revision');
|
|
3542
|
+
}
|
|
3543
|
+
const currentBranch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
3544
|
+
if (currentBranch !== branch) {
|
|
3545
|
+
if (gitSucceeds(boundRoot, ['show-ref', '--verify', '--quiet', 'refs/heads/' + branch])) {
|
|
3546
|
+
throw new Error('the canonical task branch already exists in another workspace');
|
|
3547
|
+
}
|
|
3548
|
+
if (!op.baseSha || !gitSucceeds(boundRoot, ['cat-file', '-e', String(op.baseSha) + '^{commit}'])) {
|
|
3549
|
+
throw new Error('task base revision is unavailable in this repository');
|
|
3550
|
+
}
|
|
3551
|
+
gitOutput(root, ['switch', '-c', branch, String(op.baseSha)], 30000);
|
|
3552
|
+
}
|
|
3553
|
+
if (gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']) !== branch) {
|
|
3554
|
+
throw new Error('Codex native worktree did not adopt the canonical task branch');
|
|
3555
|
+
}
|
|
3556
|
+
return root;
|
|
3557
|
+
}
|
|
3558
|
+
const worktreePath = resolveTaskWorktreePath(boundRoot, String(op.taskId), harness);
|
|
3559
|
+
const existing = taskWorktreeRecords(boundRoot).find((item) => samePath(item.path, worktreePath));
|
|
3560
|
+
if (existing) {
|
|
3561
|
+
if (existing.branch !== branch || gitOutput(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']) !== branch) {
|
|
3562
|
+
throw new Error('managed task worktree exists on a conflicting branch');
|
|
3563
|
+
}
|
|
3564
|
+
return worktreePath;
|
|
3565
|
+
}
|
|
3566
|
+
if (existsSync(worktreePath)) {
|
|
3567
|
+
throw new Error('managed task worktree path exists but is not registered with Git');
|
|
3568
|
+
}
|
|
3569
|
+
if (gitOperationInProgress(root)) {
|
|
3570
|
+
throw new Error('finish the active merge or rebase before task automation');
|
|
3571
|
+
}
|
|
3572
|
+
const head = gitOutput(root, ['rev-parse', 'HEAD']);
|
|
3573
|
+
if (!op.startSha || head !== String(op.startSha)) {
|
|
3574
|
+
throw new Error('repository HEAD changed after task creation');
|
|
3575
|
+
}
|
|
3576
|
+
if (gitSucceeds(root, ['show-ref', '--verify', '--quiet', 'refs/heads/' + branch])) {
|
|
3577
|
+
throw new Error('the canonical task branch already exists in another workspace');
|
|
3578
|
+
}
|
|
3579
|
+
if (!op.baseSha) throw new Error('task base revision is missing');
|
|
3580
|
+
if (!gitSucceeds(root, ['cat-file', '-e', String(op.baseSha) + '^{commit}'])) {
|
|
3581
|
+
throw new Error('task base revision is unavailable in this repository');
|
|
3582
|
+
}
|
|
3583
|
+
const originalBranch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
3584
|
+
ignoreManagedWorktreeDirectory(boundRoot, worktreePath);
|
|
3585
|
+
mkdirSync(dirname(worktreePath), { recursive: true });
|
|
3586
|
+
gitOutput(root, ['worktree', 'add', '-b', branch, worktreePath, String(op.baseSha)], 30000);
|
|
3587
|
+
if (gitOutput(root, ['rev-parse', 'HEAD']) !== head
|
|
3588
|
+
|| gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']) !== originalBranch) {
|
|
3589
|
+
throw new Error('Git changed the originating checkout while creating the task worktree');
|
|
3590
|
+
}
|
|
3591
|
+
const record = taskWorktreeRecords(boundRoot).find((item) => samePath(item.path, worktreePath));
|
|
3592
|
+
if (!record || record.branch !== branch
|
|
3593
|
+
|| gitOutput(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']) !== branch) {
|
|
3594
|
+
throw new Error('Git did not register the canonical task worktree');
|
|
3595
|
+
}
|
|
3596
|
+
return worktreePath;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
if (op.kind !== 'push') throw new Error('unknown SCM operation');
|
|
3600
|
+
const worktreePath = validateTaskWorktree(boundRoot, root, op);
|
|
3601
|
+
if (gitOperationInProgress(worktreePath)) {
|
|
3602
|
+
throw new Error('finish the active merge or rebase in the task worktree before pushing');
|
|
3603
|
+
}
|
|
3604
|
+
if (gitOutput(worktreePath, ['status', '--porcelain'])) {
|
|
3605
|
+
gitOutput(worktreePath, ['add', '-A']);
|
|
3606
|
+
if (!gitSucceeds(worktreePath, ['diff', '--cached', '--quiet'])) {
|
|
3607
|
+
gitOutput(worktreePath, ['commit', '-m', String(op.commitMessage || 'Synkro task')], 30000);
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
gitOutput(worktreePath, ['push', '-u', 'origin', branch], 60000);
|
|
3611
|
+
return worktreePath;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
interface TaskScmReconcileResult { reason: string; context: string }
|
|
3615
|
+
|
|
3616
|
+
// Who acted and where it ran: a message in a shared terminal should say which
|
|
3617
|
+
// harness produced it and whether this install is grading locally or in the cloud.
|
|
3618
|
+
function synkroOriginTag(prefix: string, harness: string): string {
|
|
3619
|
+
return '[' + prefix
|
|
3620
|
+
+ (harness ? ':' + harness : '')
|
|
3621
|
+
+ ':' + (deployIsCloud() ? 'cloud' : 'local')
|
|
3622
|
+
+ ']';
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
function taskScmWorkspaceContext(workspace: any, harness = ''): string {
|
|
3626
|
+
if (!workspace || typeof workspace !== 'object') return '';
|
|
3627
|
+
const taskId = String(workspace.taskId || '');
|
|
3628
|
+
const linearRef = String(workspace.linearRef || '');
|
|
3629
|
+
const branchName = String(workspace.branchName || '');
|
|
3630
|
+
const worktreePath = String(workspace.worktreePath || '');
|
|
3631
|
+
if (!taskId || !branchName) return '';
|
|
3632
|
+
return synkroOriginTag('synkro:task-workspace', harness) + ' task=' + taskId
|
|
3633
|
+
+ (linearRef ? ' linear=' + linearRef : '')
|
|
3634
|
+
+ ' branch=' + branchName
|
|
3635
|
+
+ (worktreePath ? ' worktree=' + worktreePath : ' worktree=pending-native-handoff');
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
function shellTaskWorkspaceArg(value: string): string {
|
|
3639
|
+
return "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspace: any): string {
|
|
3643
|
+
const context = taskScmWorkspaceContext(workspace, harness);
|
|
3644
|
+
const worktreePath = String(workspace?.worktreePath || '');
|
|
3645
|
+
const branchName = String(workspace?.branchName || '');
|
|
3646
|
+
if (!context || !branchName) return '';
|
|
3647
|
+
const requirement = 'Do not continue in the shared checkout, use a per-command workdir, prefix commands with cd, '
|
|
3648
|
+
+ 'or ask the user to perform the transition. Synkro keeps substantive tools blocked until this exact session reports '
|
|
3649
|
+
+ 'the task worktree and canonical branch.';
|
|
3650
|
+
if (harness === 'cc') {
|
|
3651
|
+
if (!worktreePath) return context + '\nTask worktree creation failed closed; retry workspace preparation.';
|
|
3652
|
+
// Ask, do not command. The user may legitimately want to keep working where they
|
|
3653
|
+
// are, and Synkro should not move them without their say-so.
|
|
3654
|
+
return context + '\nAsk the user whether to move this task into its isolated worktree '
|
|
3655
|
+
+ JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName)
|
|
3656
|
+
+ ', or keep working in the current workspace. Do not decide for them. '
|
|
3657
|
+
+ 'To move: invoke EnterWorktree on that path. '
|
|
3658
|
+
+ 'To stay: run "synkro workspace stay ' + String(workspace?.taskId || '') + '".';
|
|
3659
|
+
}
|
|
3660
|
+
if (harness === 'cursor') {
|
|
3661
|
+
if (!worktreePath) return context + '\n[synkro:workspace-handoff] Task worktree creation failed closed; retry workspace preparation.';
|
|
3662
|
+
return context + '\n[synkro:workspace-handoff] Resume this exact Cursor chat in the existing task worktree now: cursor-agent --resume '
|
|
3663
|
+
+ shellTaskWorkspaceArg(sessionId) + ' --workspace ' + shellTaskWorkspaceArg(worktreePath)
|
|
3664
|
+
+ '. Confirm the resumed workspace is on branch ' + JSON.stringify(branchName) + '. ' + requirement;
|
|
3665
|
+
}
|
|
3666
|
+
if (!worktreePath) {
|
|
3667
|
+
return context + '\n[synkro:workspace-handoff] Invoke Codex\'s native task/worktree Handoff now for this exact task. '
|
|
3668
|
+
+ 'Check out branch ' + JSON.stringify(branchName) + ' in a new worktree and continue this same chat there. '
|
|
3669
|
+
+ 'Do not switch the shared Local checkout or ask the user to switch branches. '
|
|
3670
|
+
+ 'Synkro keeps substantive tools blocked until this exact session reports the new worktree path and canonical branch.';
|
|
3671
|
+
}
|
|
3672
|
+
return context + '\n[synkro:workspace-handoff] Invoke Codex\'s native task/worktree Handoff now for this exact task, '
|
|
3673
|
+
+ 'using the existing worktree ' + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName) + '. '
|
|
3674
|
+
+ 'The Codex Environment panel must show that worktree and branch before retrying the blocked action. ' + requirement;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
function withTaskScmContext(responseText: string, harness: string, context: string): string {
|
|
3678
|
+
if (!context) return responseText;
|
|
3679
|
+
try {
|
|
3680
|
+
const response = JSON.parse(responseText || '{}') as any;
|
|
3681
|
+
if (harness === 'cursor') {
|
|
3682
|
+
response.agent_message = [response.agent_message, context].filter(Boolean).join('\n');
|
|
3683
|
+
return JSON.stringify(response);
|
|
3684
|
+
}
|
|
3685
|
+
response.systemMessage = [response.systemMessage, context].filter(Boolean).join('\n');
|
|
3686
|
+
response.hookSpecificOutput = response.hookSpecificOutput || { hookEventName: 'PreToolUse' };
|
|
3687
|
+
response.hookSpecificOutput.additionalContext = [response.hookSpecificOutput.additionalContext, context]
|
|
3688
|
+
.filter(Boolean).join('\n');
|
|
3689
|
+
return JSON.stringify(response);
|
|
3690
|
+
} catch { return responseText; }
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
async function reconcileTaskScm(
|
|
3694
|
+
root: string,
|
|
3695
|
+
sessionId: string,
|
|
3696
|
+
harness: string,
|
|
3697
|
+
payload?: any,
|
|
3698
|
+
): Promise<TaskScmReconcileResult> {
|
|
3699
|
+
if (!root || !sessionId) return { reason: '', context: '' };
|
|
3700
|
+
try {
|
|
3701
|
+
let branch = '';
|
|
3702
|
+
let repoRoot = '';
|
|
3703
|
+
let isWorktree = false;
|
|
3704
|
+
try { branch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch {}
|
|
3705
|
+
try { repoRoot = sharedRepoRoot(root); } catch {}
|
|
3706
|
+
try { isWorktree = statSync(join(root, '.git')).isFile(); } catch {}
|
|
3707
|
+
const nativeCodexWorktree = harness === 'codex' && codexDesktopOrigin();
|
|
3708
|
+
const parentSessionId = codexForkedFromSession(payload, harness, sessionId);
|
|
3709
|
+
const claimWorkspace = (reportedBranch: string) => fetch(
|
|
3710
|
+
'http://127.0.0.1:' + PORT + '/api/local/task-scm/claim', {
|
|
3711
|
+
method: 'POST',
|
|
3712
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
|
|
3713
|
+
body: JSON.stringify({
|
|
3714
|
+
sessionId, cwd: root, repoRoot, branch: reportedBranch, harness,
|
|
3715
|
+
nativeCodexWorktree, isWorktree, parentSessionId,
|
|
3716
|
+
}),
|
|
3717
|
+
signal: AbortSignal.timeout(5000),
|
|
3718
|
+
},
|
|
3719
|
+
);
|
|
3720
|
+
const claim = await claimWorkspace(branch);
|
|
3721
|
+
if (!claim.ok) return { reason: '', context: '' };
|
|
3722
|
+
let result = await claim.json() as any;
|
|
3723
|
+
for (let attempt = 0; result?.retry === true && attempt < 50; attempt++) {
|
|
3724
|
+
await new Promise((resolveRetry) => setTimeout(resolveRetry, 100));
|
|
3725
|
+
try { branch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch {}
|
|
3726
|
+
const retried = await claimWorkspace(branch);
|
|
3727
|
+
if (!retried.ok) break;
|
|
3728
|
+
result = await retried.json() as any;
|
|
3729
|
+
}
|
|
3730
|
+
if (!result?.op) {
|
|
3731
|
+
const workspace = result?.workspace && typeof result.workspace === 'object'
|
|
3732
|
+
? { ...result.workspace }
|
|
3733
|
+
: result?.workspace;
|
|
3734
|
+
return {
|
|
3735
|
+
reason: result?.waiting
|
|
3736
|
+
? (taskScmWorkspaceInstruction(harness, sessionId, workspace)
|
|
3737
|
+
|| String(result.reason || 'task source-control preparation is pending'))
|
|
3738
|
+
: '',
|
|
3739
|
+
context: taskScmWorkspaceContext(workspace, harness),
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
try {
|
|
3743
|
+
const worktreePath = executeTaskScm(root, result.op, harness);
|
|
3744
|
+
await completeTaskScm(sessionId, result.op, true, '', worktreePath);
|
|
3745
|
+
if (result.op.kind === 'branch') {
|
|
3746
|
+
if (result.op.adoptExistingWorktree === true) {
|
|
3747
|
+
const rebound = await claimWorkspace(String(result.op.branchName || ''));
|
|
3748
|
+
if (rebound.ok) {
|
|
3749
|
+
const reboundResult = await rebound.json() as any;
|
|
3750
|
+
if (!reboundResult?.waiting && reboundResult?.workspace) {
|
|
3751
|
+
return { reason: '', context: taskScmWorkspaceContext(reboundResult.workspace, harness) };
|
|
3752
|
+
}
|
|
3753
|
+
if (reboundResult?.waiting) {
|
|
3754
|
+
return {
|
|
3755
|
+
reason: String(reboundResult.reason || 'Codex native worktree binding is pending'),
|
|
3756
|
+
context: taskScmWorkspaceContext(reboundResult.workspace, harness),
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
return { reason: 'Codex native worktree branch was created but task binding is still pending', context: '' };
|
|
3761
|
+
}
|
|
3762
|
+
const workspace = {
|
|
3763
|
+
taskId: result.op.taskId,
|
|
3764
|
+
linearRef: result.op.linearRef,
|
|
3765
|
+
branchName: result.op.branchName,
|
|
3766
|
+
worktreePath,
|
|
3767
|
+
bindingState: 'handoff_pending',
|
|
3768
|
+
};
|
|
3769
|
+
return {
|
|
3770
|
+
reason: taskScmWorkspaceInstruction(harness, sessionId, workspace),
|
|
3771
|
+
context: '',
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
return { reason: '', context: '' };
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3777
|
+
await completeTaskScm(sessionId, result.op, false, message, root);
|
|
3778
|
+
return { reason: message, context: '' };
|
|
3779
|
+
}
|
|
3780
|
+
} catch { return { reason: '', context: '' }; }
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3004
3783
|
// Once-per-session onboarding hint for repos with no synkro.toml file.
|
|
3005
3784
|
function noSynkroHint(sessionId: string): string | null {
|
|
3006
3785
|
const dir = join(HOME, '.synkro', '.no-synkro-hint');
|
|
@@ -3022,13 +3801,11 @@ function filePathFromToolInput(ti: any): string {
|
|
|
3022
3801
|
// The container reconstructs the proposed post-edit content by finding the edit's old_string
|
|
3023
3802
|
// inside baseContent, then computes the changed line-range + per-requirement file snapshot off
|
|
3024
3803
|
// it. The old flat 64KB HEAD silently truncated any edit past ~the first 1000 lines of a big
|
|
3025
|
-
// file (e.g. an 890KB server file): old_string wasn't present,
|
|
3026
|
-
//
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
3029
|
-
//
|
|
3030
|
-
// keys off — are preserved) instead of the head. This also fixes CWE/CVE scans that previously
|
|
3031
|
-
// never saw anything below 64KB in a large file.
|
|
3804
|
+
// file (e.g. an 890KB server file): old_string wasn't present, so reconstruction and the durable
|
|
3805
|
+
// edit fact were incomplete. Ship the WHOLE file (covers virtually all source, incl. large
|
|
3806
|
+
// single-file servers) so reconstruction, audit diffs, and policy/security scans are exact. Only
|
|
3807
|
+
// a pathological >2MB file falls back to a line-aligned WINDOW around the edit (front-padded with
|
|
3808
|
+
// newlines so ABSOLUTE line numbers are preserved) instead of the head.
|
|
3032
3809
|
const BASE_CONTENT_WHOLE_CAP = 2000000;
|
|
3033
3810
|
function editAnchorString(ti: any): string {
|
|
3034
3811
|
if (!ti || typeof ti !== 'object') return '';
|
|
@@ -3436,11 +4213,19 @@ function readJsonlHeader(path: string): string {
|
|
|
3436
4213
|
let fd = -1;
|
|
3437
4214
|
try {
|
|
3438
4215
|
fd = openSync(path, 'r');
|
|
3439
|
-
const
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
4216
|
+
const chunks: Buffer[] = [];
|
|
4217
|
+
let position = 0;
|
|
4218
|
+
while (position < 2 * 1024 * 1024) {
|
|
4219
|
+
const buf = Buffer.alloc(Math.min(65536, 2 * 1024 * 1024 - position));
|
|
4220
|
+
const count = readSync(fd, buf, 0, buf.length, position);
|
|
4221
|
+
if (count <= 0) break;
|
|
4222
|
+
const chunk = buf.subarray(0, count);
|
|
4223
|
+
const newline = chunk.indexOf(10);
|
|
4224
|
+
chunks.push(newline >= 0 ? chunk.subarray(0, newline) : chunk);
|
|
4225
|
+
position += count;
|
|
4226
|
+
if (newline >= 0) break;
|
|
4227
|
+
}
|
|
4228
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
3444
4229
|
} catch { return ''; }
|
|
3445
4230
|
finally { if (fd >= 0) { try { closeSync(fd); } catch {} } }
|
|
3446
4231
|
}
|
|
@@ -3518,6 +4303,37 @@ export function resolveHookTranscriptPath(payload: any, harness: string, session
|
|
|
3518
4303
|
return found;
|
|
3519
4304
|
}
|
|
3520
4305
|
|
|
4306
|
+
function codexForkedFromSession(payload: any, harness: string, sessionId: string): string {
|
|
4307
|
+
if (harness !== 'codex' || !safeCodexSessionId(sessionId)) return '';
|
|
4308
|
+
const cachePath = join(
|
|
4309
|
+
HOME,
|
|
4310
|
+
'.synkro',
|
|
4311
|
+
'codex-parent-' + createHash('sha256').update(sessionId).digest('hex').slice(0, 16),
|
|
4312
|
+
);
|
|
4313
|
+
try {
|
|
4314
|
+
const cached = readFileSync(cachePath, 'utf-8').trim();
|
|
4315
|
+
if (cached === '-') return '';
|
|
4316
|
+
if (safeCodexSessionId(cached)) return cached;
|
|
4317
|
+
} catch {}
|
|
4318
|
+
|
|
4319
|
+
let parent = '';
|
|
4320
|
+
const transcriptPath = resolveHookTranscriptPath(payload, harness, sessionId);
|
|
4321
|
+
if (transcriptPath) {
|
|
4322
|
+
try {
|
|
4323
|
+
const first = JSON.parse(readJsonlHeader(transcriptPath));
|
|
4324
|
+
const candidate = String(first?.payload?.forked_from_id || first?.payload?.forkedFromId || '');
|
|
4325
|
+
if (first?.type === 'session_meta' && safeCodexSessionId(candidate) && candidate !== sessionId) {
|
|
4326
|
+
parent = candidate;
|
|
4327
|
+
}
|
|
4328
|
+
} catch {}
|
|
4329
|
+
}
|
|
4330
|
+
try {
|
|
4331
|
+
mkdirSync(join(HOME, '.synkro'), { recursive: true });
|
|
4332
|
+
writeFileSync(cachePath, parent || '-', { encoding: 'utf-8', mode: 0o600 });
|
|
4333
|
+
} catch {}
|
|
4334
|
+
return parent;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
3521
4337
|
export async function runStub(surface: string, opts: StubOpts = {}): Promise<void> {
|
|
3522
4338
|
const harness = isCursor(opts.harness) ? 'cursor' : isCodex(opts.harness) ? 'codex' : 'cc';
|
|
3523
4339
|
const startedAt = Date.now();
|
|
@@ -3564,14 +4380,24 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3564
4380
|
const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
|
|
3565
4381
|
const sessionId = String(payload.session_id || payload.conversation_id || '');
|
|
3566
4382
|
const root = gitRoot(cwd);
|
|
4383
|
+
telemCwd = root || cwd;
|
|
3567
4384
|
|
|
3568
4385
|
// Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
|
|
3569
4386
|
// root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
|
|
3570
4387
|
// this a home-rooted cwd (dotfiles in git, non-git dir under home) could
|
|
3571
|
-
// look onboarded.
|
|
4388
|
+
// look onboarded. A linked worktree checked out at a commit predating the
|
|
4389
|
+
// tracked synkro.toml has no copy of its own — fall back to the main
|
|
4390
|
+
// checkout's config rather than silently skipping enforcement there.
|
|
3572
4391
|
let synkroFileText = '';
|
|
3573
|
-
if (root && root !== HOME
|
|
3574
|
-
|
|
4392
|
+
if (root && root !== HOME) {
|
|
4393
|
+
let configRoot = root;
|
|
4394
|
+
if (!existsSync(join(configRoot, 'synkro.toml'))) {
|
|
4395
|
+
const mainRoot = mainWorktreeRoot(root);
|
|
4396
|
+
if (mainRoot !== root && mainRoot !== HOME && existsSync(join(mainRoot, 'synkro.toml'))) {
|
|
4397
|
+
configRoot = mainRoot;
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
try { synkroFileText = readFileSync(join(configRoot, 'synkro.toml'), 'utf-8'); } catch {}
|
|
3575
4401
|
}
|
|
3576
4402
|
if (!synkroFileText) {
|
|
3577
4403
|
// Repo not onboarded — emit a minimal tool_call so usage analytics still
|
|
@@ -3598,6 +4424,31 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3598
4424
|
return;
|
|
3599
4425
|
}
|
|
3600
4426
|
|
|
4427
|
+
const scm = await reconcileTaskScm(root || cwd, sessionId, harness, payload);
|
|
4428
|
+
const substantiveTool = /^(?:Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch|file_change)$/i.test(String(payload.tool_name || ''));
|
|
4429
|
+
const scmTaskId = taskIdFromScmContext(scm.context);
|
|
4430
|
+
// The user can settle the workspace question in plain language: while the
|
|
4431
|
+
// ask is pending for this session's task, a stay-directive on the user's
|
|
4432
|
+
// prompt records the same durable marker the CLI command writes. Reconcile
|
|
4433
|
+
// just told us WHICH task is being asked, so no extra state is needed and
|
|
4434
|
+
// ordinary prompts outside a pending ask are never scanned.
|
|
4435
|
+
if (surface === 'prompt-submit' && scm.reason && scmTaskId && !taskWorkspaceStayRecorded(scmTaskId)) {
|
|
4436
|
+
const promptText = String(payload.prompt || payload.user_message || '');
|
|
4437
|
+
if (isWorkspaceStayIntent(promptText)) {
|
|
4438
|
+
try {
|
|
4439
|
+
mkdirSync(WORKSPACE_CHOICE_DIR, { recursive: true });
|
|
4440
|
+
writeFileSync(join(WORKSPACE_CHOICE_DIR, scmTaskId + '.stay'), new Date().toISOString() + '\n');
|
|
4441
|
+
} catch { /* fail-open: the CLI command and the exact-string path remain */ }
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
// The user was asked and chose to keep working here, or is answering right now.
|
|
4445
|
+
const workspaceConsentSettled = Boolean(scmTaskId)
|
|
4446
|
+
&& (taskWorkspaceStayRecorded(scmTaskId) || isWorkspaceConsentCommand(payload, scmTaskId));
|
|
4447
|
+
if (scm.reason && substantiveTool && !workspaceConsentSettled) {
|
|
4448
|
+
out(taskScmBlockResponse(harness, scm.reason, firstHookForToolCall(sessionId, payload)));
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
|
|
3601
4452
|
if (harness === 'codex' && opts.needsFile && payload.__synkro_codex_patch_error) {
|
|
3602
4453
|
const reason = String(payload.__synkro_codex_patch_error);
|
|
3603
4454
|
out(JSON.stringify({
|
|
@@ -3613,7 +4464,17 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3613
4464
|
}
|
|
3614
4465
|
|
|
3615
4466
|
// Gather host-only inputs the container can't read.
|
|
3616
|
-
const envelope: any = {
|
|
4467
|
+
const envelope: any = {
|
|
4468
|
+
payload,
|
|
4469
|
+
harness,
|
|
4470
|
+
cwd: root || cwd,
|
|
4471
|
+
sessionId,
|
|
4472
|
+
synkroFileText,
|
|
4473
|
+
taskWorkspaceContext: scm.context || undefined,
|
|
4474
|
+
};
|
|
4475
|
+
// Task creation is the one moment the container needs a stable Git snapshot.
|
|
4476
|
+
// Capture it on the MCP gate only; ordinary tool hooks avoid extra Git calls.
|
|
4477
|
+
if (surface === 'mcp-gate') envelope.repoContext = taskRepoContext(root || cwd);
|
|
3617
4478
|
|
|
3618
4479
|
if (opts.needsFile) {
|
|
3619
4480
|
const normalizedEdits = harness === 'codex' && Array.isArray(payload.__synkro_codex_edits)
|
|
@@ -3694,15 +4555,10 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3694
4555
|
envelope.planText = String(ti.plan || ti.content || payload.plan || '');
|
|
3695
4556
|
}
|
|
3696
4557
|
|
|
3697
|
-
//
|
|
3698
|
-
// result (the server waits up to 30s for the completion grade), so it needs a
|
|
3699
|
-
// wait above that budget — not the 6s telemetry default — or the stub abandons
|
|
3700
|
-
// the request before the result lands and the completion shows up a hook late.
|
|
3701
|
-
// prompt-submit is the opposite: it's INTERACTIVE (blocks the user's prompt from
|
|
4558
|
+
// prompt-submit is INTERACTIVE (blocks the user's prompt from
|
|
3702
4559
|
// being sent) under a short 5s hook timeout, so it gets ONE short attempt (below)
|
|
3703
4560
|
// and a tight per-attempt budget that fits inside 5s — never the 6s×3 telemetry path.
|
|
3704
4561
|
const timeoutMs = surface === 'cwe-precheck' ? 48000
|
|
3705
|
-
: surface === 'bash-followup' ? 32000
|
|
3706
4562
|
: surface === 'prompt-submit' ? 3500
|
|
3707
4563
|
: (opts.telemetry ? 6000 : 28000);
|
|
3708
4564
|
// Cloud has no local container to reach. Post the SAME envelope to the org's grader
|
|
@@ -3727,8 +4583,8 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3727
4583
|
// latency: retry a few times with backoff. Right after a server restart the
|
|
3728
4584
|
// event loop can be briefly busy / not-yet-listening and a single POST silently
|
|
3729
4585
|
// drops the event (this was the followup flakiness). Double-delivery is safe here
|
|
3730
|
-
//
|
|
3731
|
-
//
|
|
4586
|
+
// because the server deduplicates durable hook events and native evidence.
|
|
4587
|
+
// Blocking hooks make ONE attempt: their response IS the verdict
|
|
3732
4588
|
// and a retry would double-grade. Normal case: first attempt wins, no delay.
|
|
3733
4589
|
// prompt-submit is EXCLUDED from the retry: it runs on the interactive prompt
|
|
3734
4590
|
// path under a 5s hook timeout, and 3×6s+backoff (19.5s) blew that budget and
|
|
@@ -3744,9 +4600,21 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
|
|
|
3744
4600
|
if (i < attempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
3745
4601
|
}
|
|
3746
4602
|
const rawResponseText = text || failOpen(harness);
|
|
3747
|
-
|
|
4603
|
+
// A completion-report sync can arm the push after the pre-scan reconciliation ran.
|
|
4604
|
+
// Reconcile once more so the same Stop/PostToolUse hook can push immediately.
|
|
4605
|
+
await reconcileTaskScm(root || cwd, sessionId, harness, payload);
|
|
4606
|
+
const contractResponseText = opts.stopContract && harness === 'codex'
|
|
3748
4607
|
? codexStopResponse(rawResponseText)
|
|
3749
4608
|
: rawResponseText;
|
|
4609
|
+
// Only the first hook of this tool call carries the workspace context; the other
|
|
4610
|
+
// surfaces of the same call would otherwise repeat it verbatim 2-4 times.
|
|
4611
|
+
let scmContext = scm.context && firstHookForToolCall(sessionId, payload) ? scm.context : '';
|
|
4612
|
+
// Say plainly that work is continuing outside the task worktree by the user's choice,
|
|
4613
|
+
// so the state is never mistaken for a binding that silently failed.
|
|
4614
|
+
if (scmContext && scmTaskId && taskWorkspaceStayRecorded(scmTaskId)) {
|
|
4615
|
+
scmContext += ' workspace=staying-here-by-user-choice';
|
|
4616
|
+
}
|
|
4617
|
+
const responseText = withTaskScmContext(contractResponseText, harness, scmContext);
|
|
3750
4618
|
out(responseText);
|
|
3751
4619
|
emitStubTelemetry(surface, harness, telemPayload, responseText, Date.now() - startedAt, telemCwd, telemSessionId);
|
|
3752
4620
|
} catch (err) {
|
|
@@ -4129,7 +4997,7 @@ function createCallbackServer() {
|
|
|
4129
4997
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
4130
4998
|
"Vary": "Origin"
|
|
4131
4999
|
};
|
|
4132
|
-
return new Promise((
|
|
5000
|
+
return new Promise((resolve7, reject) => {
|
|
4133
5001
|
const server = createServer((req, res) => {
|
|
4134
5002
|
if (req.method === "OPTIONS") {
|
|
4135
5003
|
const origin = req.headers.origin;
|
|
@@ -4218,7 +5086,7 @@ function createCallbackServer() {
|
|
|
4218
5086
|
res.end(JSON.stringify({ ok: true }));
|
|
4219
5087
|
setTimeout(() => {
|
|
4220
5088
|
server.close();
|
|
4221
|
-
|
|
5089
|
+
resolve7(authData);
|
|
4222
5090
|
}, 200);
|
|
4223
5091
|
});
|
|
4224
5092
|
req.on("error", (e) => {
|
|
@@ -4549,7 +5417,7 @@ function detectSubdirRepos() {
|
|
|
4549
5417
|
}
|
|
4550
5418
|
}
|
|
4551
5419
|
function ask(rl, question) {
|
|
4552
|
-
return new Promise((
|
|
5420
|
+
return new Promise((resolve7) => rl.question(question, resolve7));
|
|
4553
5421
|
}
|
|
4554
5422
|
async function linkRepo(repo, linkedNames) {
|
|
4555
5423
|
try {
|
|
@@ -4788,7 +5656,7 @@ async function runClaudeDesktopTap(opts = {}) {
|
|
|
4788
5656
|
writeFileSync12(join13(sessionDir, "mcp_patch.py"), MCP_PATCH_PY, "utf-8");
|
|
4789
5657
|
const runnerPath = join13(sessionDir, "run.sh");
|
|
4790
5658
|
writeFileSync12(runnerPath, buildRunner(sessionDir), { mode: 493 });
|
|
4791
|
-
await new Promise((
|
|
5659
|
+
await new Promise((resolve7) => {
|
|
4792
5660
|
const child = spawn3("bash", [runnerPath], {
|
|
4793
5661
|
stdio: "inherit",
|
|
4794
5662
|
env: { ...process.env, SYNKRO_CAPTURE_URL: CAPTURE_URL, SYNKRO_SCAN_URL: SCAN_URL, SYNKRO_SCAN_TURN_URL: SCAN_TURN_URL, SYNKRO_DLP_POLICY_URL: DLP_POLICY_URL, SYNKRO_TURN_VERDICTS_URL: TURN_VERDICTS_URL, SYNKRO_TURN_VERDICT_URL: TURN_VERDICT_URL, SYNKRO_MCP_EVENT_URL: MCP_EVENT_URL, SYNKRO_TAP_TOKEN: token, SYNKRO_TAP_TOKEN_FILE: JWT_PATH, SYNKRO_CD_BACKFILL: opts.backfill ? "1" : "" }
|
|
@@ -4804,7 +5672,7 @@ async function runClaudeDesktopTap(opts = {}) {
|
|
|
4804
5672
|
child.on("exit", () => {
|
|
4805
5673
|
process.off("SIGINT", forward);
|
|
4806
5674
|
process.off("SIGTERM", forward);
|
|
4807
|
-
|
|
5675
|
+
resolve7();
|
|
4808
5676
|
});
|
|
4809
5677
|
});
|
|
4810
5678
|
}
|
|
@@ -6610,10 +7478,8 @@ async function dockerInstall(opts = {}) {
|
|
|
6610
7478
|
// Pass through the batch-size lever if the operator set it. Defaults
|
|
6611
7479
|
// inside the container to 5; clamped to [1, 20] by synkro-server.ts.
|
|
6612
7480
|
...process.env.SYNKRO_MAX_BATCH_SIZE ? ["-e", `SYNKRO_MAX_BATCH_SIZE=${process.env.SYNKRO_MAX_BATCH_SIZE}`] : [],
|
|
6613
|
-
// Full verifier prompt/response tracing is explicit opt-in because it contains source code.
|
|
6614
|
-
...process.env.SYNKRO_VERIFY_TRACE === "1" ? ["-e", "SYNKRO_VERIFY_TRACE=1"] : [],
|
|
6615
7481
|
// Explicit model overrides are preserved across local install/update for
|
|
6616
|
-
// every provider and isolated lane. The server reports the resolved values
|
|
7482
|
+
// every provider and the isolated route lane. The server reports the resolved values
|
|
6617
7483
|
// in /healthz so operators can audit exactly what is spending tokens.
|
|
6618
7484
|
...[
|
|
6619
7485
|
"SYNKRO_CLAUDE_MODEL",
|
|
@@ -6647,8 +7513,8 @@ async function dockerInstall(opts = {}) {
|
|
|
6647
7513
|
"SYNKRO_TELEMETRY_QUEUE=/data/synkro-host/telemetry-pending.jsonl",
|
|
6648
7514
|
image
|
|
6649
7515
|
];
|
|
6650
|
-
const
|
|
6651
|
-
if (
|
|
7516
|
+
const run2 = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
|
|
7517
|
+
if (run2.status !== 0) {
|
|
6652
7518
|
throw new DockerInstallError(`docker run failed (image ${image})`);
|
|
6653
7519
|
}
|
|
6654
7520
|
return {
|
|
@@ -6879,7 +7745,7 @@ var init_dockerInstall = __esm({
|
|
|
6879
7745
|
HOST_PGLITE_PORT = parseInt(process.env.SYNKRO_HOST_PGLITE_PORT || "15433", 10);
|
|
6880
7746
|
CONTAINER_NAME = resolveContainerName();
|
|
6881
7747
|
defaultImageVersion = () => {
|
|
6882
|
-
if (true) return "1.
|
|
7748
|
+
if (true) return "1.9.0";
|
|
6883
7749
|
try {
|
|
6884
7750
|
const pkg = JSON.parse(readFileSync17(new URL("../../package.json", import.meta.url), "utf8"));
|
|
6885
7751
|
if (pkg.version) return pkg.version;
|
|
@@ -6911,7 +7777,7 @@ function captureClaudeSetupToken() {
|
|
|
6911
7777
|
const bin = "script";
|
|
6912
7778
|
const args2 = isMac ? ["-q", tmpFile, "claude", "setup-token"] : ["-qec", "claude setup-token", tmpFile];
|
|
6913
7779
|
const OAUTH_HINT = 'The browser approval did not return a token. This usually means claude.ai rejected the OAuth request (its "Authorization failed \u2014 Unsupported media type" page), most often because a browser extension (Grammarly, ad/script blockers, AI-assistant toolbars) stripped the request headers, or you approved on the wrong Claude account. Fix: retry in a clean incognito window with extensions disabled, and approve on the Claude account you want the cloud workers to use.';
|
|
6914
|
-
return new Promise((
|
|
7780
|
+
return new Promise((resolve7, reject) => {
|
|
6915
7781
|
const proc = nodeSpawn(bin, args2, {
|
|
6916
7782
|
stdio: "inherit",
|
|
6917
7783
|
env: { ...process.env, FORCE_COLOR: "3", COLORTERM: "truecolor", TERM: "xterm-256color" }
|
|
@@ -6978,7 +7844,7 @@ function captureClaudeSetupToken() {
|
|
|
6978
7844
|
reject(new Error(`Captured no setup token from claude setup-token output. ${reason}`));
|
|
6979
7845
|
return;
|
|
6980
7846
|
}
|
|
6981
|
-
|
|
7847
|
+
resolve7(token);
|
|
6982
7848
|
});
|
|
6983
7849
|
});
|
|
6984
7850
|
}
|
|
@@ -7004,13 +7870,13 @@ function findCodexBinary() {
|
|
|
7004
7870
|
function runCodexLogin(codexBin, codexHome) {
|
|
7005
7871
|
mkdirSync13(codexHome, { recursive: true, mode: 448 });
|
|
7006
7872
|
writeFileSync15(join17(codexHome, "config.toml"), 'cli_auth_credentials_store = "file"\n', { mode: 384 });
|
|
7007
|
-
return new Promise((
|
|
7873
|
+
return new Promise((resolve7, reject) => {
|
|
7008
7874
|
const proc = nodeSpawn2(codexBin, ["login"], {
|
|
7009
7875
|
stdio: "inherit",
|
|
7010
7876
|
env: { ...process.env, CODEX_HOME: codexHome }
|
|
7011
7877
|
});
|
|
7012
7878
|
proc.on("error", (err) => reject(new Error(`failed to spawn codex login: ${err.message}`)));
|
|
7013
|
-
proc.on("close", (code) => code === 0 ?
|
|
7879
|
+
proc.on("close", (code) => code === 0 ? resolve7() : reject(new Error(`codex login exited with code ${code}`)));
|
|
7014
7880
|
});
|
|
7015
7881
|
}
|
|
7016
7882
|
async function setupCodexCloud(gatewayUrl, bearerToken, onStatus) {
|
|
@@ -7587,7 +8453,7 @@ function isoDay(value, fallbackDay) {
|
|
|
7587
8453
|
function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), options = {}) {
|
|
7588
8454
|
const seen = options.seenStableIds ?? /* @__PURE__ */ new Set();
|
|
7589
8455
|
const rollups = /* @__PURE__ */ new Map();
|
|
7590
|
-
const
|
|
8456
|
+
const usage2 = {
|
|
7591
8457
|
input_tokens: 0,
|
|
7592
8458
|
output_tokens: 0,
|
|
7593
8459
|
cache_creation_input_tokens: 0,
|
|
@@ -7621,7 +8487,7 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
|
|
|
7621
8487
|
if (entryModel !== "<synthetic>") model = entryModel;
|
|
7622
8488
|
const day = isoDay(entry.timestamp, fallbackDay);
|
|
7623
8489
|
const key = `${day}\0${entryModel}`;
|
|
7624
|
-
const
|
|
8490
|
+
const row2 = rollups.get(key) ?? {
|
|
7625
8491
|
day,
|
|
7626
8492
|
model: entryModel,
|
|
7627
8493
|
turns: 0,
|
|
@@ -7630,23 +8496,23 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
|
|
|
7630
8496
|
cache_creation_input_tokens: 0,
|
|
7631
8497
|
cache_read_input_tokens: 0
|
|
7632
8498
|
};
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
rollups.set(key,
|
|
8499
|
+
row2.turns += 1;
|
|
8500
|
+
row2.input_tokens += counts.input_tokens;
|
|
8501
|
+
row2.output_tokens += counts.output_tokens;
|
|
8502
|
+
row2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
|
|
8503
|
+
row2.cache_read_input_tokens += counts.cache_read_input_tokens;
|
|
8504
|
+
rollups.set(key, row2);
|
|
7639
8505
|
turns += 1;
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
8506
|
+
usage2.input_tokens += counts.input_tokens;
|
|
8507
|
+
usage2.output_tokens += counts.output_tokens;
|
|
8508
|
+
usage2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
|
|
8509
|
+
usage2.cache_read_input_tokens += counts.cache_read_input_tokens;
|
|
7644
8510
|
} catch {
|
|
7645
8511
|
}
|
|
7646
8512
|
}
|
|
7647
8513
|
if (turns === 0) return null;
|
|
7648
8514
|
return {
|
|
7649
|
-
usage,
|
|
8515
|
+
usage: usage2,
|
|
7650
8516
|
model: model || "unknown",
|
|
7651
8517
|
rollups: [...rollups.values()].sort(
|
|
7652
8518
|
(a, b) => a.day.localeCompare(b.day) || a.model.localeCompare(b.model)
|
|
@@ -7712,20 +8578,20 @@ async function promptAgentSelection(detected) {
|
|
|
7712
8578
|
detected.forEach((a, i) => console.log(` ${i + 1}. ${a.name}`));
|
|
7713
8579
|
console.log(` ${detected.length + 1}. Both / all (default)`);
|
|
7714
8580
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
7715
|
-
const ask3 = () => new Promise((
|
|
8581
|
+
const ask3 = () => new Promise((resolve7) => {
|
|
7716
8582
|
rl.question(`Pick [1-${detected.length + 1}] (default: all): `, (answer) => {
|
|
7717
8583
|
const t = answer.trim().toLowerCase();
|
|
7718
8584
|
if (t === "" || t === String(detected.length + 1) || t === "both" || t === "all") {
|
|
7719
8585
|
rl.close();
|
|
7720
|
-
return
|
|
8586
|
+
return resolve7(detected);
|
|
7721
8587
|
}
|
|
7722
8588
|
const n = parseInt(t, 10);
|
|
7723
8589
|
if (Number.isInteger(n) && n >= 1 && n <= detected.length) {
|
|
7724
8590
|
rl.close();
|
|
7725
|
-
return
|
|
8591
|
+
return resolve7([detected[n - 1]]);
|
|
7726
8592
|
}
|
|
7727
8593
|
console.log("Invalid choice. Try again.");
|
|
7728
|
-
|
|
8594
|
+
resolve7(ask3());
|
|
7729
8595
|
});
|
|
7730
8596
|
});
|
|
7731
8597
|
return ask3();
|
|
@@ -7748,12 +8614,12 @@ async function promptCursorApiKey(opts) {
|
|
|
7748
8614
|
return;
|
|
7749
8615
|
}
|
|
7750
8616
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
7751
|
-
const key = await new Promise((
|
|
8617
|
+
const key = await new Promise((resolve7) => {
|
|
7752
8618
|
rl.question(
|
|
7753
8619
|
"Cursor grading needs a Cursor API key (cursor.com \u2192 Settings \u2192 API Keys).\nPaste it now, or press Enter to skip (Cursor workers stay idle until set): ",
|
|
7754
8620
|
(answer) => {
|
|
7755
8621
|
rl.close();
|
|
7756
|
-
|
|
8622
|
+
resolve7(answer.trim());
|
|
7757
8623
|
}
|
|
7758
8624
|
);
|
|
7759
8625
|
});
|
|
@@ -7768,7 +8634,7 @@ async function promptDeployLocation(current = "local") {
|
|
|
7768
8634
|
if (!process.stdin.isTTY) return current;
|
|
7769
8635
|
const other = current === "cloud" ? "local" : "cloud";
|
|
7770
8636
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
7771
|
-
return new Promise((
|
|
8637
|
+
return new Promise((resolve7) => {
|
|
7772
8638
|
rl.question(
|
|
7773
8639
|
`Where should Synkro run?
|
|
7774
8640
|
local \u2014 a grading container on this machine (Docker)
|
|
@@ -7777,7 +8643,7 @@ Each worker uses the account credentials you authorize. Choose [${current}] / ${
|
|
|
7777
8643
|
(answer) => {
|
|
7778
8644
|
rl.close();
|
|
7779
8645
|
const a = answer.trim().toLowerCase();
|
|
7780
|
-
|
|
8646
|
+
resolve7(a === "cloud" ? "cloud" : a === "local" ? "local" : current);
|
|
7781
8647
|
}
|
|
7782
8648
|
);
|
|
7783
8649
|
});
|
|
@@ -7936,7 +8802,7 @@ function writeConfigEnv(opts) {
|
|
|
7936
8802
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle2(credsPath)}`,
|
|
7937
8803
|
`SYNKRO_TIER=${shellQuoteSingle2(safeTier)}`,
|
|
7938
8804
|
`SYNKRO_INFERENCE=${shellQuoteSingle2(safeInference)}`,
|
|
7939
|
-
`SYNKRO_VERSION=${shellQuoteSingle2("1.
|
|
8805
|
+
`SYNKRO_VERSION=${shellQuoteSingle2("1.9.0")}`
|
|
7940
8806
|
];
|
|
7941
8807
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle2(safeSynkroBin)}`);
|
|
7942
8808
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle2(safeUserId)}`);
|
|
@@ -8683,7 +9549,7 @@ async function installCommand(opts = {}) {
|
|
|
8683
9549
|
await setTelemetryState({ enabled: true, remoteFlushEnabled: telemetryConsent });
|
|
8684
9550
|
emit("install", {
|
|
8685
9551
|
phase: "started",
|
|
8686
|
-
cli_version_to: "1.
|
|
9552
|
+
cli_version_to: "1.9.0",
|
|
8687
9553
|
agents_detected: agents.map((a) => a.kind),
|
|
8688
9554
|
with_github: false,
|
|
8689
9555
|
with_local_cc: false,
|
|
@@ -9544,7 +10410,7 @@ async function syncSkillFiles() {
|
|
|
9544
10410
|
function normSkillName(name) {
|
|
9545
10411
|
return name.toLowerCase().replace(/\.mdx?$/, "");
|
|
9546
10412
|
}
|
|
9547
|
-
function discoverSkillFiles(
|
|
10413
|
+
function discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames) {
|
|
9548
10414
|
const roots = [];
|
|
9549
10415
|
const add = (p) => {
|
|
9550
10416
|
try {
|
|
@@ -9554,9 +10420,9 @@ function discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNa
|
|
|
9554
10420
|
};
|
|
9555
10421
|
add(join19(homedir21(), ".claude", "skills"));
|
|
9556
10422
|
add(join19(homedir21(), ".agents", "skills"));
|
|
9557
|
-
if (
|
|
9558
|
-
add(join19(
|
|
9559
|
-
add(join19(
|
|
10423
|
+
if (repoRoot3) {
|
|
10424
|
+
add(join19(repoRoot3, ".claude", "skills"));
|
|
10425
|
+
add(join19(repoRoot3, ".agents", "skills"));
|
|
9560
10426
|
}
|
|
9561
10427
|
const out = [];
|
|
9562
10428
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -9639,7 +10505,7 @@ Found ${found.length} skill${found.length === 1 ? "" : "s"} in your Claude Code
|
|
|
9639
10505
|
async function discoverAndIngestSkills() {
|
|
9640
10506
|
try {
|
|
9641
10507
|
const sf = readFullSynkroFile();
|
|
9642
|
-
const
|
|
10508
|
+
const repoRoot3 = sf?._repoRoot || detectGitRepo2();
|
|
9643
10509
|
const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
|
|
9644
10510
|
const excludeHashes = /* @__PURE__ */ new Set();
|
|
9645
10511
|
if (sf?.skills?.length) {
|
|
@@ -9663,7 +10529,7 @@ async function discoverAndIngestSkills() {
|
|
|
9663
10529
|
}
|
|
9664
10530
|
} catch {
|
|
9665
10531
|
}
|
|
9666
|
-
const found = discoverSkillFiles(
|
|
10532
|
+
const found = discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames);
|
|
9667
10533
|
if (found.length === 0) return;
|
|
9668
10534
|
const selectable = found.filter((f) => !f.ingested);
|
|
9669
10535
|
if (selectable.length === 0) {
|
|
@@ -9699,18 +10565,18 @@ async function discoverAndIngestSkills() {
|
|
|
9699
10565
|
}
|
|
9700
10566
|
}
|
|
9701
10567
|
function resolveSynkroBinPath() {
|
|
9702
|
-
const
|
|
10568
|
+
const run2 = (cmd3) => {
|
|
9703
10569
|
try {
|
|
9704
10570
|
return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
9705
10571
|
} catch {
|
|
9706
10572
|
return "";
|
|
9707
10573
|
}
|
|
9708
10574
|
};
|
|
9709
|
-
const p =
|
|
10575
|
+
const p = run2("command -v synkro").split("\n")[0].trim();
|
|
9710
10576
|
return p && isAbsolute(p) ? p : "";
|
|
9711
10577
|
}
|
|
9712
10578
|
function ensureReachabilityGitHook() {
|
|
9713
|
-
const
|
|
10579
|
+
const run2 = (cmd3) => {
|
|
9714
10580
|
try {
|
|
9715
10581
|
return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
9716
10582
|
} catch {
|
|
@@ -9718,9 +10584,9 @@ function ensureReachabilityGitHook() {
|
|
|
9718
10584
|
}
|
|
9719
10585
|
};
|
|
9720
10586
|
try {
|
|
9721
|
-
const root =
|
|
10587
|
+
const root = run2("git rev-parse --show-toplevel");
|
|
9722
10588
|
if (!root) return null;
|
|
9723
|
-
let hooksDir =
|
|
10589
|
+
let hooksDir = run2("git config --get core.hooksPath");
|
|
9724
10590
|
hooksDir = hooksDir ? isAbsolute(hooksDir) ? hooksDir : join19(root, hooksDir) : join19(root, ".git", "hooks");
|
|
9725
10591
|
if (!existsSync22(hooksDir)) mkdirSync15(hooksDir, { recursive: true });
|
|
9726
10592
|
const hookPath = join19(hooksDir, "post-commit");
|
|
@@ -9762,18 +10628,18 @@ function ensureReachabilityGitHook() {
|
|
|
9762
10628
|
}
|
|
9763
10629
|
}
|
|
9764
10630
|
function detectGitRepo2() {
|
|
9765
|
-
const
|
|
10631
|
+
const run2 = (cmd3) => {
|
|
9766
10632
|
try {
|
|
9767
10633
|
return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
9768
10634
|
} catch {
|
|
9769
10635
|
return "";
|
|
9770
10636
|
}
|
|
9771
10637
|
};
|
|
9772
|
-
const remoteUrl =
|
|
10638
|
+
const remoteUrl = run2("git remote get-url origin");
|
|
9773
10639
|
if (remoteUrl) {
|
|
9774
10640
|
return remoteUrl.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
|
|
9775
10641
|
}
|
|
9776
|
-
const root =
|
|
10642
|
+
const root = run2("git rev-parse --show-toplevel");
|
|
9777
10643
|
return root ? root.split("/").pop() || null : null;
|
|
9778
10644
|
}
|
|
9779
10645
|
function getClaudeProjectsFolder() {
|
|
@@ -9890,13 +10756,13 @@ function extractTextContent(content) {
|
|
|
9890
10756
|
function getCodexTranscriptFiles(repo) {
|
|
9891
10757
|
const sessionsDir = join19(process.env.CODEX_HOME || join19(homedir21(), ".codex"), "sessions");
|
|
9892
10758
|
if (!existsSync22(sessionsDir)) return [];
|
|
9893
|
-
let
|
|
10759
|
+
let relative2 = [];
|
|
9894
10760
|
try {
|
|
9895
|
-
|
|
10761
|
+
relative2 = readdirSync4(sessionsDir, { recursive: true, encoding: "utf-8" });
|
|
9896
10762
|
} catch {
|
|
9897
10763
|
return [];
|
|
9898
10764
|
}
|
|
9899
|
-
return
|
|
10765
|
+
return relative2.filter((p) => p.endsWith(".jsonl")).map((p) => join19(sessionsDir, p)).filter((filePath) => {
|
|
9900
10766
|
try {
|
|
9901
10767
|
const first = readFileSync21(filePath, "utf-8").split("\n", 1)[0];
|
|
9902
10768
|
const meta = JSON.parse(first);
|
|
@@ -10971,10 +11837,10 @@ function confirmPurge() {
|
|
|
10971
11837
|
return Promise.resolve(false);
|
|
10972
11838
|
}
|
|
10973
11839
|
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
10974
|
-
return new Promise((
|
|
11840
|
+
return new Promise((resolve7) => {
|
|
10975
11841
|
rl.question(" Type 'yes' to wipe everything (anything else cancels): ", (answer) => {
|
|
10976
11842
|
rl.close();
|
|
10977
|
-
|
|
11843
|
+
resolve7(answer.trim().toLowerCase() === "yes");
|
|
10978
11844
|
});
|
|
10979
11845
|
});
|
|
10980
11846
|
}
|
|
@@ -11227,7 +12093,7 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
11227
12093
|
const port = opts.port ?? CHANNEL_PORT;
|
|
11228
12094
|
const startedAt = Date.now();
|
|
11229
12095
|
try {
|
|
11230
|
-
const result = await new Promise((
|
|
12096
|
+
const result = await new Promise((resolve7, reject) => {
|
|
11231
12097
|
const req = httpRequest({
|
|
11232
12098
|
host: CHANNEL_HOST,
|
|
11233
12099
|
port,
|
|
@@ -11253,7 +12119,7 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
11253
12119
|
reject(new LocalCCError(parsed.error));
|
|
11254
12120
|
return;
|
|
11255
12121
|
}
|
|
11256
|
-
|
|
12122
|
+
resolve7(String(parsed.result ?? ""));
|
|
11257
12123
|
} catch (err) {
|
|
11258
12124
|
reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
|
|
11259
12125
|
}
|
|
@@ -11279,14 +12145,14 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
11279
12145
|
}
|
|
11280
12146
|
}
|
|
11281
12147
|
function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
|
|
11282
|
-
return new Promise((
|
|
12148
|
+
return new Promise((resolve7) => {
|
|
11283
12149
|
const sock = connect(port, CHANNEL_HOST);
|
|
11284
12150
|
const done = (ok) => {
|
|
11285
12151
|
try {
|
|
11286
12152
|
sock.destroy();
|
|
11287
12153
|
} catch {
|
|
11288
12154
|
}
|
|
11289
|
-
|
|
12155
|
+
resolve7(ok);
|
|
11290
12156
|
};
|
|
11291
12157
|
sock.once("connect", () => done(true));
|
|
11292
12158
|
sock.once("error", () => done(false));
|
|
@@ -11318,10 +12184,10 @@ __export(grade_exports, {
|
|
|
11318
12184
|
gradeCommand: () => gradeCommand
|
|
11319
12185
|
});
|
|
11320
12186
|
async function readStdin() {
|
|
11321
|
-
return new Promise((
|
|
12187
|
+
return new Promise((resolve7, reject) => {
|
|
11322
12188
|
const chunks = [];
|
|
11323
12189
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
11324
|
-
process.stdin.on("end", () =>
|
|
12190
|
+
process.stdin.on("end", () => resolve7(Buffer.concat(chunks).toString("utf-8")));
|
|
11325
12191
|
process.stdin.on("error", reject);
|
|
11326
12192
|
});
|
|
11327
12193
|
}
|
|
@@ -11575,7 +12441,7 @@ function spawnClaudeJudge(file, claudeToken, promptHeader) {
|
|
|
11575
12441
|
Diff:
|
|
11576
12442
|
${hunks}`;
|
|
11577
12443
|
const fullPrompt = promptHeader + userMessage;
|
|
11578
|
-
return new Promise((
|
|
12444
|
+
return new Promise((resolve7) => {
|
|
11579
12445
|
const t0 = Date.now();
|
|
11580
12446
|
const proc = spawn6(
|
|
11581
12447
|
"claude",
|
|
@@ -11603,7 +12469,7 @@ ${hunks}`;
|
|
|
11603
12469
|
const latencyMs = Date.now() - t0;
|
|
11604
12470
|
if (code !== 0) {
|
|
11605
12471
|
console.warn(` claude exited ${code}: ${(stderr || stdout).slice(0, 500)}`);
|
|
11606
|
-
|
|
12472
|
+
resolve7({ findings: [], latencyMs });
|
|
11607
12473
|
return;
|
|
11608
12474
|
}
|
|
11609
12475
|
try {
|
|
@@ -11622,10 +12488,10 @@ ${hunks}`;
|
|
|
11622
12488
|
description: f.description,
|
|
11623
12489
|
fix: f.fix
|
|
11624
12490
|
}));
|
|
11625
|
-
|
|
12491
|
+
resolve7({ findings, latencyMs });
|
|
11626
12492
|
} catch (parseErr) {
|
|
11627
12493
|
console.warn(` failed to parse claude response: ${stdout.slice(0, 300)}`);
|
|
11628
|
-
|
|
12494
|
+
resolve7({ findings: [], latencyMs });
|
|
11629
12495
|
}
|
|
11630
12496
|
});
|
|
11631
12497
|
});
|
|
@@ -11674,7 +12540,7 @@ ${JSON.stringify(findings, null, 2)}
|
|
|
11674
12540
|
`;
|
|
11675
12541
|
}
|
|
11676
12542
|
function spawnOpusConsolidator(findings, claudeToken) {
|
|
11677
|
-
return new Promise((
|
|
12543
|
+
return new Promise((resolve7) => {
|
|
11678
12544
|
const prompt = buildConsolidationPrompt(findings);
|
|
11679
12545
|
const proc = spawn6(
|
|
11680
12546
|
"claude",
|
|
@@ -11701,7 +12567,7 @@ function spawnOpusConsolidator(findings, claudeToken) {
|
|
|
11701
12567
|
proc.on("close", (code) => {
|
|
11702
12568
|
if (code !== 0) {
|
|
11703
12569
|
console.warn(` opus consolidation exited ${code}: ${(stderr || stdout).slice(0, 300)}`);
|
|
11704
|
-
|
|
12570
|
+
resolve7(fallbackReview(findings));
|
|
11705
12571
|
return;
|
|
11706
12572
|
}
|
|
11707
12573
|
try {
|
|
@@ -11722,10 +12588,10 @@ function spawnOpusConsolidator(findings, claudeToken) {
|
|
|
11722
12588
|
const order = ["low", "medium", "high", "critical"];
|
|
11723
12589
|
return order.indexOf(f.severity) > order.indexOf(max) ? f.severity : max;
|
|
11724
12590
|
}, "low");
|
|
11725
|
-
|
|
12591
|
+
resolve7({ summary: review.summary || "", comments, severity: maxSeverity });
|
|
11726
12592
|
} catch {
|
|
11727
12593
|
console.warn(` failed to parse opus response, using fallback`);
|
|
11728
|
-
|
|
12594
|
+
resolve7(fallbackReview(findings));
|
|
11729
12595
|
}
|
|
11730
12596
|
});
|
|
11731
12597
|
});
|
|
@@ -12208,14 +13074,14 @@ function ensureRunning(opts = {}) {
|
|
|
12208
13074
|
return startTask(opts);
|
|
12209
13075
|
}
|
|
12210
13076
|
function probePort(host, port, timeoutMs = 500) {
|
|
12211
|
-
return new Promise((
|
|
13077
|
+
return new Promise((resolve7) => {
|
|
12212
13078
|
const sock = connect2(port, host);
|
|
12213
13079
|
const done = (ok) => {
|
|
12214
13080
|
try {
|
|
12215
13081
|
sock.destroy();
|
|
12216
13082
|
} catch {
|
|
12217
13083
|
}
|
|
12218
|
-
|
|
13084
|
+
resolve7(ok);
|
|
12219
13085
|
};
|
|
12220
13086
|
sock.once("connect", () => done(true));
|
|
12221
13087
|
sock.once("error", () => done(false));
|
|
@@ -12765,7 +13631,7 @@ function cmdLogs(rest) {
|
|
|
12765
13631
|
if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
|
|
12766
13632
|
return;
|
|
12767
13633
|
}
|
|
12768
|
-
return new Promise((
|
|
13634
|
+
return new Promise((resolve7) => {
|
|
12769
13635
|
console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
|
|
12770
13636
|
const stop = followTurns((t) => {
|
|
12771
13637
|
console.log(" " + formatTurn(t, raw));
|
|
@@ -12773,7 +13639,7 @@ function cmdLogs(rest) {
|
|
|
12773
13639
|
const onSigint = () => {
|
|
12774
13640
|
stop();
|
|
12775
13641
|
process.removeListener("SIGINT", onSigint);
|
|
12776
|
-
|
|
13642
|
+
resolve7();
|
|
12777
13643
|
};
|
|
12778
13644
|
process.on("SIGINT", onSigint);
|
|
12779
13645
|
});
|
|
@@ -13062,9 +13928,9 @@ function parseSession(file, seenStableIds) {
|
|
|
13062
13928
|
}
|
|
13063
13929
|
function ask2(q) {
|
|
13064
13930
|
const rl = createInterface4({ input: process.stdin, output: process.stdout });
|
|
13065
|
-
return new Promise((
|
|
13931
|
+
return new Promise((resolve7) => rl.question(q, (a) => {
|
|
13066
13932
|
rl.close();
|
|
13067
|
-
|
|
13933
|
+
resolve7(/^y(es)?$/i.test(a.trim()));
|
|
13068
13934
|
}));
|
|
13069
13935
|
}
|
|
13070
13936
|
async function importCommand() {
|
|
@@ -13197,8 +14063,8 @@ function canonicalize(pack) {
|
|
|
13197
14063
|
docs: pack.docs ?? []
|
|
13198
14064
|
});
|
|
13199
14065
|
}
|
|
13200
|
-
function computeDigest(
|
|
13201
|
-
return "sha256:" + crypto.createHash("sha256").update(
|
|
14066
|
+
function computeDigest(canonical2) {
|
|
14067
|
+
return "sha256:" + crypto.createHash("sha256").update(canonical2, "utf8").digest("hex");
|
|
13202
14068
|
}
|
|
13203
14069
|
function verifySignature(digest, signatureB64, publicKeyPem) {
|
|
13204
14070
|
try {
|
|
@@ -13222,10 +14088,10 @@ var init_packVerify = __esm({
|
|
|
13222
14088
|
// cli/installer/lockfile.ts
|
|
13223
14089
|
import { existsSync as existsSync30, readFileSync as readFileSync28, writeFileSync as writeFileSync20 } from "fs";
|
|
13224
14090
|
import { join as join28 } from "path";
|
|
13225
|
-
function lockPath(
|
|
13226
|
-
return join28(
|
|
14091
|
+
function lockPath(repoRoot3) {
|
|
14092
|
+
return join28(repoRoot3, LOCK_FILE);
|
|
13227
14093
|
}
|
|
13228
|
-
function writeLockfile(
|
|
14094
|
+
function writeLockfile(repoRoot3, entries) {
|
|
13229
14095
|
const sorted = [...entries].sort((a, b) => a.ref.localeCompare(b.ref));
|
|
13230
14096
|
const body = [
|
|
13231
14097
|
"# synkro.lock \u2014 generated by `synkro sync`. Commit this file.",
|
|
@@ -13241,7 +14107,7 @@ function writeLockfile(repoRoot2, entries) {
|
|
|
13241
14107
|
""
|
|
13242
14108
|
])
|
|
13243
14109
|
].join("\n");
|
|
13244
|
-
writeFileSync20(lockPath(
|
|
14110
|
+
writeFileSync20(lockPath(repoRoot3), body, "utf-8");
|
|
13245
14111
|
}
|
|
13246
14112
|
var LOCK_FILE;
|
|
13247
14113
|
var init_lockfile = __esm({
|
|
@@ -13453,51 +14319,867 @@ var init_whoami = __esm({
|
|
|
13453
14319
|
}
|
|
13454
14320
|
});
|
|
13455
14321
|
|
|
13456
|
-
// cli/commands/
|
|
13457
|
-
var
|
|
13458
|
-
__export(
|
|
13459
|
-
|
|
14322
|
+
// cli/commands/workspace.ts
|
|
14323
|
+
var workspace_exports = {};
|
|
14324
|
+
__export(workspace_exports, {
|
|
14325
|
+
workspaceCommand: () => workspaceCommand
|
|
13460
14326
|
});
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
|
|
13465
|
-
|
|
13466
|
-
|
|
13467
|
-
|
|
14327
|
+
import { existsSync as existsSync33, mkdirSync as mkdirSync19, readdirSync as readdirSync8, rmSync as rmSync5, writeFileSync as writeFileSync22 } from "fs";
|
|
14328
|
+
import { join as join31 } from "path";
|
|
14329
|
+
import { homedir as homedir31 } from "os";
|
|
14330
|
+
function markerPath(taskId) {
|
|
14331
|
+
return join31(WORKSPACE_CHOICE_DIR, `${taskId}.stay`);
|
|
14332
|
+
}
|
|
14333
|
+
function usage() {
|
|
14334
|
+
console.log(`synkro workspace \u2014 record where a task's work happens
|
|
14335
|
+
|
|
14336
|
+
Usage:
|
|
14337
|
+
synkro workspace stay <taskId> keep working in the current checkout
|
|
14338
|
+
synkro workspace clear <taskId> forget the choice (the gate asks again)
|
|
14339
|
+
synkro workspace status [taskId] show recorded choices
|
|
14340
|
+
|
|
14341
|
+
The gate asks once per task. "stay" is remembered until the choice is cleared or
|
|
14342
|
+
the active task changes.`);
|
|
14343
|
+
}
|
|
14344
|
+
async function workspaceCommand(args2) {
|
|
14345
|
+
const sub = String(args2[0] || "").trim();
|
|
14346
|
+
const taskId = String(args2[1] || "").trim();
|
|
14347
|
+
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
14348
|
+
usage();
|
|
14349
|
+
return;
|
|
13468
14350
|
}
|
|
13469
|
-
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
13473
|
-
|
|
13474
|
-
|
|
13475
|
-
|
|
14351
|
+
if (sub === "status") {
|
|
14352
|
+
let recorded = [];
|
|
14353
|
+
try {
|
|
14354
|
+
recorded = existsSync33(WORKSPACE_CHOICE_DIR) ? readdirSync8(WORKSPACE_CHOICE_DIR).filter((name) => name.endsWith(".stay")) : [];
|
|
14355
|
+
} catch {
|
|
14356
|
+
recorded = [];
|
|
14357
|
+
}
|
|
14358
|
+
if (taskId) {
|
|
14359
|
+
const on = recorded.includes(`${taskId}.stay`);
|
|
14360
|
+
console.log(`${taskId}: ${on ? "stay recorded" : "no choice recorded"}`);
|
|
14361
|
+
return;
|
|
14362
|
+
}
|
|
14363
|
+
if (recorded.length === 0) {
|
|
14364
|
+
console.log("No workspace choices recorded.");
|
|
14365
|
+
return;
|
|
14366
|
+
}
|
|
14367
|
+
console.log("Staying in the current checkout for:");
|
|
14368
|
+
for (const name of recorded.sort()) console.log(` ${name.replace(/\.stay$/, "")}`);
|
|
14369
|
+
return;
|
|
13476
14370
|
}
|
|
13477
|
-
if (
|
|
14371
|
+
if (sub !== "stay" && sub !== "clear") {
|
|
14372
|
+
console.error(`Unknown workspace subcommand: ${sub}`);
|
|
14373
|
+
usage();
|
|
14374
|
+
process.exitCode = 2;
|
|
14375
|
+
return;
|
|
14376
|
+
}
|
|
14377
|
+
if (!TASK_ID.test(taskId)) {
|
|
14378
|
+
console.error(taskId ? `Not a task id: ${taskId} (expected task_ followed by 8 characters)` : `Usage: synkro workspace ${sub} <taskId>`);
|
|
14379
|
+
process.exitCode = 2;
|
|
14380
|
+
return;
|
|
14381
|
+
}
|
|
14382
|
+
if (sub === "clear") {
|
|
14383
|
+
try {
|
|
14384
|
+
rmSync5(markerPath(taskId), { force: true });
|
|
14385
|
+
} catch {
|
|
14386
|
+
}
|
|
14387
|
+
console.log(`Cleared the workspace choice for ${taskId}.`);
|
|
14388
|
+
return;
|
|
14389
|
+
}
|
|
14390
|
+
try {
|
|
14391
|
+
mkdirSync19(WORKSPACE_CHOICE_DIR, { recursive: true });
|
|
14392
|
+
writeFileSync22(markerPath(taskId), `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
14393
|
+
`, "utf-8");
|
|
14394
|
+
} catch (error) {
|
|
14395
|
+
console.error(`Could not record the workspace choice: ${error?.message || error}`);
|
|
14396
|
+
process.exitCode = 1;
|
|
14397
|
+
return;
|
|
14398
|
+
}
|
|
14399
|
+
console.log(`Staying in the current checkout for ${taskId}. Synkro will not ask again for this task.`);
|
|
13478
14400
|
}
|
|
13479
|
-
var
|
|
13480
|
-
|
|
14401
|
+
var WORKSPACE_CHOICE_DIR, TASK_ID;
|
|
14402
|
+
var init_workspace = __esm({
|
|
14403
|
+
"cli/commands/workspace.ts"() {
|
|
13481
14404
|
"use strict";
|
|
13482
|
-
|
|
14405
|
+
WORKSPACE_CHOICE_DIR = join31(homedir31(), ".synkro", "workspace-choice");
|
|
14406
|
+
TASK_ID = /^task_[a-z0-9]{8}$/i;
|
|
13483
14407
|
}
|
|
13484
14408
|
});
|
|
13485
14409
|
|
|
13486
|
-
// cli/
|
|
13487
|
-
|
|
13488
|
-
|
|
13489
|
-
|
|
13490
|
-
|
|
13491
|
-
}
|
|
13492
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
13495
|
-
function
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
return "";
|
|
13500
|
-
}
|
|
14410
|
+
// cli/ui/tmux.ts
|
|
14411
|
+
import { execFile as execFile2, spawnSync as spawnSync11 } from "child_process";
|
|
14412
|
+
import { promisify } from "util";
|
|
14413
|
+
function runnerArgs(runner, argv) {
|
|
14414
|
+
return runner.kind === "container" ? ["docker", "exec", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
|
|
14415
|
+
}
|
|
14416
|
+
function runnerInteractiveArgs(runner, argv) {
|
|
14417
|
+
return runner.kind === "container" ? ["docker", "exec", "-it", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
|
|
14418
|
+
}
|
|
14419
|
+
async function run(runner, argv) {
|
|
14420
|
+
const [cmd3, ...args2] = runnerArgs(runner, argv);
|
|
14421
|
+
try {
|
|
14422
|
+
const { stdout, stderr } = await execFileAsync(cmd3, args2, { timeout: 8e3, maxBuffer: 1024 * 1024 });
|
|
14423
|
+
return { ok: true, stdout: String(stdout || ""), stderr: String(stderr || "") };
|
|
14424
|
+
} catch (error) {
|
|
14425
|
+
return { ok: false, stdout: String(error?.stdout || ""), stderr: String(error?.stderr || error?.message || "") };
|
|
14426
|
+
}
|
|
14427
|
+
}
|
|
14428
|
+
function runInherit(argv) {
|
|
14429
|
+
const [cmd3, ...args2] = argv;
|
|
14430
|
+
const result = spawnSync11(cmd3, args2, { stdio: "inherit" });
|
|
14431
|
+
return result.status ?? 1;
|
|
14432
|
+
}
|
|
14433
|
+
function slugify(name) {
|
|
14434
|
+
return String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "agent";
|
|
14435
|
+
}
|
|
14436
|
+
function agentSession(name) {
|
|
14437
|
+
return AGENT_PREFIX + slugify(name);
|
|
14438
|
+
}
|
|
14439
|
+
function buildSpawnAgent(opts) {
|
|
14440
|
+
const session = agentSession(opts.name);
|
|
14441
|
+
return [
|
|
14442
|
+
["tmux", "new-session", "-d", "-s", session, "-c", opts.cwd, opts.command],
|
|
14443
|
+
["tmux", "set-option", "-t", session, "status", "off"],
|
|
14444
|
+
["tmux", "set-option", "-t", session, "-q", "@synkro_harness", opts.harness],
|
|
14445
|
+
["tmux", "set-option", "-t", session, "-q", "@synkro_space", opts.space],
|
|
14446
|
+
["tmux", "set-option", "-t", session, "-q", "@synkro_backend", opts.backend],
|
|
14447
|
+
// Keep the pane visible after exit so the sidebar can render 'done'
|
|
14448
|
+
// instead of the agent silently vanishing.
|
|
14449
|
+
["tmux", "set-option", "-t", session, "remain-on-exit", "on"]
|
|
14450
|
+
];
|
|
14451
|
+
}
|
|
14452
|
+
function buildListAgents() {
|
|
14453
|
+
return ["tmux", "list-sessions", "-F", ["#{session_name}", "#{?pane_dead,dead,alive}", "#{@synkro_harness}", "#{@synkro_space}", "#{@synkro_backend}", "#{@synkro_pueue}"].join(FIELD_SEP)];
|
|
14454
|
+
}
|
|
14455
|
+
function buildCapture(session, lines = 14) {
|
|
14456
|
+
return ["tmux", "capture-pane", "-p", "-t", session, "-S", String(-lines)];
|
|
14457
|
+
}
|
|
14458
|
+
function buildKillSession(session) {
|
|
14459
|
+
return ["tmux", "kill-session", "-t", session];
|
|
14460
|
+
}
|
|
14461
|
+
function buildSetOption(session, option, value) {
|
|
14462
|
+
return ["tmux", "set-option", "-t", session, "-q", option, value];
|
|
14463
|
+
}
|
|
14464
|
+
function buildSendText(session, text) {
|
|
14465
|
+
return [
|
|
14466
|
+
["tmux", "send-keys", "-t", session, "-l", text],
|
|
14467
|
+
["tmux", "send-keys", "-t", session, "Enter"]
|
|
14468
|
+
];
|
|
14469
|
+
}
|
|
14470
|
+
function buildInterrupt(session) {
|
|
14471
|
+
return ["tmux", "send-keys", "-t", session, "Escape"];
|
|
14472
|
+
}
|
|
14473
|
+
function buildCenterAttachCommand(runner, session) {
|
|
14474
|
+
const argv = runnerInteractiveArgs(runner, ["tmux", "attach-session", "-t", session]);
|
|
14475
|
+
return "env TMUX= " + argv.map(shellQuote3).join(" ");
|
|
14476
|
+
}
|
|
14477
|
+
function shellQuote3(value) {
|
|
14478
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
|
|
14479
|
+
}
|
|
14480
|
+
function parseAgentSessions(output, backend) {
|
|
14481
|
+
return String(output || "").split("\n").map((line) => line.split(FIELD_SEP)).filter((cols) => cols[0]?.startsWith(AGENT_PREFIX)).map((cols) => ({
|
|
14482
|
+
session: cols[0],
|
|
14483
|
+
name: cols[0].slice(AGENT_PREFIX.length),
|
|
14484
|
+
dead: cols[1] === "dead",
|
|
14485
|
+
harness: cols[2] || "claude",
|
|
14486
|
+
space: cols[3] || "",
|
|
14487
|
+
backend: cols[4] || backend,
|
|
14488
|
+
pueue: cols[5] || ""
|
|
14489
|
+
}));
|
|
14490
|
+
}
|
|
14491
|
+
function parseWorktrees(porcelain) {
|
|
14492
|
+
const rows = [];
|
|
14493
|
+
let current = {};
|
|
14494
|
+
for (const line of String(porcelain || "").split("\n")) {
|
|
14495
|
+
if (line.startsWith("worktree ")) current = { path: line.slice(9).trim() };
|
|
14496
|
+
else if (line.startsWith("branch ")) current.branch = line.slice(7).replace("refs/heads/", "").trim();
|
|
14497
|
+
else if (line.trim() === "" && current.path) {
|
|
14498
|
+
rows.push({
|
|
14499
|
+
path: current.path,
|
|
14500
|
+
branch: current.branch || "detached",
|
|
14501
|
+
name: current.path.split("/").filter(Boolean).pop() || current.path
|
|
14502
|
+
});
|
|
14503
|
+
current = {};
|
|
14504
|
+
}
|
|
14505
|
+
}
|
|
14506
|
+
if (current.path) {
|
|
14507
|
+
rows.push({
|
|
14508
|
+
path: current.path,
|
|
14509
|
+
branch: current.branch || "detached",
|
|
14510
|
+
name: current.path.split("/").filter(Boolean).pop() || current.path
|
|
14511
|
+
});
|
|
14512
|
+
}
|
|
14513
|
+
return rows;
|
|
14514
|
+
}
|
|
14515
|
+
var execFileAsync, AGENT_PREFIX, UI_SESSION, CONTAINER_USER, FIELD_SEP;
|
|
14516
|
+
var init_tmux = __esm({
|
|
14517
|
+
"cli/ui/tmux.ts"() {
|
|
14518
|
+
"use strict";
|
|
14519
|
+
execFileAsync = promisify(execFile2);
|
|
14520
|
+
AGENT_PREFIX = "synkro-agent-";
|
|
14521
|
+
UI_SESSION = "synkro-ui";
|
|
14522
|
+
CONTAINER_USER = "synkro";
|
|
14523
|
+
FIELD_SEP = "|";
|
|
14524
|
+
}
|
|
14525
|
+
});
|
|
14526
|
+
|
|
14527
|
+
// cli/ui/launch.ts
|
|
14528
|
+
function welcomeCommand() {
|
|
14529
|
+
const banner = [
|
|
14530
|
+
"",
|
|
14531
|
+
" synkro ui",
|
|
14532
|
+
" governed agents, one screen",
|
|
14533
|
+
"",
|
|
14534
|
+
" enter attach selected agent",
|
|
14535
|
+
" n new agent in selected space",
|
|
14536
|
+
" c new container agent",
|
|
14537
|
+
" g/s/y consent: track / skip / stay",
|
|
14538
|
+
" T new tab q quit",
|
|
14539
|
+
""
|
|
14540
|
+
].join("\\n");
|
|
14541
|
+
return "printf " + shellQuote3(banner + "\\n") + "; tail -f /dev/null";
|
|
14542
|
+
}
|
|
14543
|
+
function sidebarCommand(bootPath, centerPane, repoCwd) {
|
|
14544
|
+
const env = [
|
|
14545
|
+
"SYNKRO_UI_CENTER=" + shellQuote3(centerPane),
|
|
14546
|
+
"SYNKRO_UI_OUTER=" + UI_SESSION,
|
|
14547
|
+
"SYNKRO_UI_BOOT=" + shellQuote3(bootPath),
|
|
14548
|
+
"SYNKRO_UI_REPO=" + shellQuote3(repoCwd)
|
|
14549
|
+
].join(" ");
|
|
14550
|
+
return "env " + env + " node " + shellQuote3(bootPath) + " ui --sidebar";
|
|
14551
|
+
}
|
|
14552
|
+
async function styleOuterSession() {
|
|
14553
|
+
const style = [
|
|
14554
|
+
["set-option", "-t", UI_SESSION, "status-position", "top"],
|
|
14555
|
+
["set-option", "-t", UI_SESSION, "status-style", "bg=colour233,fg=colour245"],
|
|
14556
|
+
["set-option", "-t", UI_SESSION, "status-left", " synkro "],
|
|
14557
|
+
["set-option", "-t", UI_SESSION, "status-left-style", "fg=colour135,bold"],
|
|
14558
|
+
["set-option", "-t", UI_SESSION, "status-right", " + (T new tab) "],
|
|
14559
|
+
["set-option", "-t", UI_SESSION, "status-right-style", "fg=colour240"],
|
|
14560
|
+
["set-option", "-t", UI_SESSION, "-w", "window-status-format", " #W "],
|
|
14561
|
+
["set-option", "-t", UI_SESSION, "-w", "window-status-current-format", "#[bg=colour135,fg=colour233,bold] #W #[default]"],
|
|
14562
|
+
["set-option", "-t", UI_SESSION, "pane-border-style", "fg=colour236"],
|
|
14563
|
+
["set-option", "-t", UI_SESSION, "pane-active-border-style", "fg=colour135"],
|
|
14564
|
+
// Tab keys without the prefix: Alt+t new tab, Alt+arrows to move.
|
|
14565
|
+
["bind-key", "-n", "M-t", "new-window"],
|
|
14566
|
+
["bind-key", "-n", "M-Right", "next-window"],
|
|
14567
|
+
["bind-key", "-n", "M-Left", "previous-window"]
|
|
14568
|
+
];
|
|
14569
|
+
for (const argv of style) await run(HOST, ["tmux", ...argv]);
|
|
14570
|
+
}
|
|
14571
|
+
async function buildTab(bootPath, repoCwd, windowTarget) {
|
|
14572
|
+
if (windowTarget === void 0) {
|
|
14573
|
+
await run(HOST, ["tmux", "new-session", "-d", "-s", UI_SESSION, "-x", "220", "-y", "55", welcomeCommand()]);
|
|
14574
|
+
windowTarget = UI_SESSION + ":0";
|
|
14575
|
+
} else {
|
|
14576
|
+
const created = await run(HOST, ["tmux", "new-window", "-t", UI_SESSION, "-P", "-F", "#{window_id}", welcomeCommand()]);
|
|
14577
|
+
windowTarget = created.stdout.trim() || windowTarget;
|
|
14578
|
+
}
|
|
14579
|
+
await run(HOST, ["tmux", "rename-window", "-t", windowTarget, "space"]);
|
|
14580
|
+
const split = await run(HOST, [
|
|
14581
|
+
"tmux",
|
|
14582
|
+
"split-window",
|
|
14583
|
+
"-hb",
|
|
14584
|
+
"-t",
|
|
14585
|
+
windowTarget,
|
|
14586
|
+
"-l",
|
|
14587
|
+
SIDEBAR_WIDTH,
|
|
14588
|
+
"-P",
|
|
14589
|
+
"-F",
|
|
14590
|
+
"#{pane_id}",
|
|
14591
|
+
"tail -f /dev/null"
|
|
14592
|
+
]);
|
|
14593
|
+
const sidebarPane = split.stdout.trim();
|
|
14594
|
+
const panes = await run(HOST, ["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_id}"]);
|
|
14595
|
+
const centerPane = panes.stdout.split("\n").map((line) => line.trim()).filter(Boolean).find((id) => id !== sidebarPane) || "";
|
|
14596
|
+
await run(HOST, ["tmux", "respawn-pane", "-k", "-t", sidebarPane, sidebarCommand(bootPath, centerPane, repoCwd)]);
|
|
14597
|
+
}
|
|
14598
|
+
async function uiSessionExists() {
|
|
14599
|
+
const result = await run(HOST, ["tmux", "has-session", "-t", UI_SESSION]);
|
|
14600
|
+
return result.ok;
|
|
14601
|
+
}
|
|
14602
|
+
async function launchUi(bootPath, repoCwd) {
|
|
14603
|
+
if (!await uiSessionExists()) {
|
|
14604
|
+
await buildTab(bootPath, repoCwd);
|
|
14605
|
+
await styleOuterSession();
|
|
14606
|
+
}
|
|
14607
|
+
return runInherit(process.env.TMUX ? ["tmux", "switch-client", "-t", UI_SESSION] : ["tmux", "attach-session", "-t", UI_SESSION]);
|
|
14608
|
+
}
|
|
14609
|
+
var SIDEBAR_WIDTH, HOST;
|
|
14610
|
+
var init_launch = __esm({
|
|
14611
|
+
"cli/ui/launch.ts"() {
|
|
14612
|
+
"use strict";
|
|
14613
|
+
init_tmux();
|
|
14614
|
+
SIDEBAR_WIDTH = "34";
|
|
14615
|
+
HOST = { kind: "host" };
|
|
14616
|
+
}
|
|
14617
|
+
});
|
|
14618
|
+
|
|
14619
|
+
// cli/ui/model.ts
|
|
14620
|
+
function detectAsk(tail2) {
|
|
14621
|
+
for (const marker of BLOCK_MARKERS) {
|
|
14622
|
+
if (marker.pattern.test(String(tail2 || ""))) return marker.ask;
|
|
14623
|
+
}
|
|
14624
|
+
return null;
|
|
14625
|
+
}
|
|
14626
|
+
function deriveStatus(input) {
|
|
14627
|
+
if (input.dead) return { status: "done" };
|
|
14628
|
+
const ask3 = detectAsk(input.tail);
|
|
14629
|
+
if (ask3) return { status: "blocked", ask: ask3 };
|
|
14630
|
+
return { status: input.changed ? "working" : "idle" };
|
|
14631
|
+
}
|
|
14632
|
+
function hashTail(tail2) {
|
|
14633
|
+
let hash = 0;
|
|
14634
|
+
const text = String(tail2 || "");
|
|
14635
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
14636
|
+
hash = (hash << 5) - hash + text.charCodeAt(index) | 0;
|
|
14637
|
+
}
|
|
14638
|
+
return String(hash);
|
|
14639
|
+
}
|
|
14640
|
+
function mapAgentToTask(spacePath, tasks) {
|
|
14641
|
+
const normalized = String(spacePath || "").replace(/\/+$/, "");
|
|
14642
|
+
if (!normalized) return void 0;
|
|
14643
|
+
const hit = tasks.find((task) => task.worktree && task.worktree.replace(/\/+$/, "") === normalized);
|
|
14644
|
+
return hit?.linear || void 0;
|
|
14645
|
+
}
|
|
14646
|
+
async function fetchConductorTasks(baseUrl) {
|
|
14647
|
+
try {
|
|
14648
|
+
const controller = new AbortController();
|
|
14649
|
+
const timer = setTimeout(() => controller.abort(), 900);
|
|
14650
|
+
const response = await fetch(baseUrl + "/api/local/conductor/repositories", { signal: controller.signal });
|
|
14651
|
+
clearTimeout(timer);
|
|
14652
|
+
if (!response.ok) return [];
|
|
14653
|
+
const payload = await response.json().catch(() => null);
|
|
14654
|
+
return Array.isArray(payload?.tasks) ? payload.tasks.map((task) => ({
|
|
14655
|
+
worktree: task?.worktree || null,
|
|
14656
|
+
linear: task?.linear?.key || null,
|
|
14657
|
+
status: String(task?.status || "")
|
|
14658
|
+
})) : [];
|
|
14659
|
+
} catch {
|
|
14660
|
+
return [];
|
|
14661
|
+
}
|
|
14662
|
+
}
|
|
14663
|
+
async function discoverHostSpaces(repoCwd) {
|
|
14664
|
+
const result = await run({ kind: "host" }, ["git", "-C", repoCwd, "worktree", "list", "--porcelain"]);
|
|
14665
|
+
if (!result.ok) return [];
|
|
14666
|
+
return parseWorktrees(result.stdout).map((row2) => ({
|
|
14667
|
+
name: row2.name,
|
|
14668
|
+
branch: row2.branch,
|
|
14669
|
+
path: row2.path,
|
|
14670
|
+
backend: "host"
|
|
14671
|
+
}));
|
|
14672
|
+
}
|
|
14673
|
+
async function discoverContainerSpaces(runner) {
|
|
14674
|
+
if (runner.kind !== "container") return [];
|
|
14675
|
+
const result = await run(runner, ["sh", "-c", "ls -1 /home/synkro/work 2>/dev/null"]);
|
|
14676
|
+
if (!result.ok) return [];
|
|
14677
|
+
return result.stdout.split("\n").map((line) => line.trim()).filter((name) => /^ui-/.test(name)).map((name) => ({
|
|
14678
|
+
name,
|
|
14679
|
+
branch: "container",
|
|
14680
|
+
path: "/home/synkro/work/" + name,
|
|
14681
|
+
backend: "container"
|
|
14682
|
+
}));
|
|
14683
|
+
}
|
|
14684
|
+
async function discoverAgents(runner, backend, previousHashes) {
|
|
14685
|
+
const listed = await run(runner, buildListAgents());
|
|
14686
|
+
const rows = listed.ok ? parseAgentSessions(listed.stdout, backend) : [];
|
|
14687
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
14688
|
+
const agents = [];
|
|
14689
|
+
for (const row2 of rows) {
|
|
14690
|
+
const capture = row2.dead ? { ok: true, stdout: "" } : await run(runner, buildCapture(row2.session));
|
|
14691
|
+
const tail2 = capture.ok ? capture.stdout : "";
|
|
14692
|
+
const nextHash = hashTail(tail2);
|
|
14693
|
+
const changed = previousHashes.has(row2.session) && previousHashes.get(row2.session) !== nextHash;
|
|
14694
|
+
hashes.set(row2.session, nextHash);
|
|
14695
|
+
const derived = deriveStatus({ dead: row2.dead, tail: tail2, changed });
|
|
14696
|
+
agents.push({
|
|
14697
|
+
name: row2.name,
|
|
14698
|
+
session: row2.session,
|
|
14699
|
+
harness: row2.harness,
|
|
14700
|
+
space: row2.space,
|
|
14701
|
+
backend: row2.backend,
|
|
14702
|
+
status: derived.status,
|
|
14703
|
+
ask: derived.ask
|
|
14704
|
+
});
|
|
14705
|
+
}
|
|
14706
|
+
return { agents, hashes };
|
|
14707
|
+
}
|
|
14708
|
+
var BLOCK_MARKERS;
|
|
14709
|
+
var init_model = __esm({
|
|
14710
|
+
"cli/ui/model.ts"() {
|
|
14711
|
+
"use strict";
|
|
14712
|
+
init_tmux();
|
|
14713
|
+
BLOCK_MARKERS = [
|
|
14714
|
+
{ pattern: /needs a tracking decision|Task tracking suggestion|tracking decision for "/i, ask: "tracking" },
|
|
14715
|
+
{ pattern: /\[synkro:task-workspace|\[synkro:scm|keep working in the current workspace/i, ask: "workspace" },
|
|
14716
|
+
{ pattern: /⛔/, ask: "tracking" }
|
|
14717
|
+
];
|
|
14718
|
+
}
|
|
14719
|
+
});
|
|
14720
|
+
|
|
14721
|
+
// cli/ui/consent.ts
|
|
14722
|
+
function actionsForAsk(ask3) {
|
|
14723
|
+
return ask3 === "workspace" ? ["stay"] : ["track", "skip"];
|
|
14724
|
+
}
|
|
14725
|
+
var CONSENT_PHRASES;
|
|
14726
|
+
var init_consent = __esm({
|
|
14727
|
+
"cli/ui/consent.ts"() {
|
|
14728
|
+
"use strict";
|
|
14729
|
+
CONSENT_PHRASES = {
|
|
14730
|
+
/** Settles the conductor tracking ask by durable decline. */
|
|
14731
|
+
skip: "skip the task tracking, continue without a task",
|
|
14732
|
+
/** Settles the task-workspace ask in place. */
|
|
14733
|
+
stay: "stay in the current worktree",
|
|
14734
|
+
/** Asks the agent to run the two-phase create flow (draft → approve). */
|
|
14735
|
+
track: "yes, track this work \u2014 draft the requirements and create the task"
|
|
14736
|
+
};
|
|
14737
|
+
}
|
|
14738
|
+
});
|
|
14739
|
+
|
|
14740
|
+
// cli/ui/render.ts
|
|
14741
|
+
function pad(text, width) {
|
|
14742
|
+
return text.length >= width ? text.slice(0, width) : text + " ".repeat(width - text.length);
|
|
14743
|
+
}
|
|
14744
|
+
function row(selected, width, content) {
|
|
14745
|
+
const body = stripForPad(" " + content, width);
|
|
14746
|
+
return selected ? STYLE.select + body + STYLE.reset : body;
|
|
14747
|
+
}
|
|
14748
|
+
function stripForPad(text, width) {
|
|
14749
|
+
let visible = 0;
|
|
14750
|
+
let out = "";
|
|
14751
|
+
let index = 0;
|
|
14752
|
+
while (index < text.length && visible < width) {
|
|
14753
|
+
if (text.startsWith(ESC, index)) {
|
|
14754
|
+
const end = text.indexOf("m", index);
|
|
14755
|
+
if (end === -1) break;
|
|
14756
|
+
out += text.slice(index, end + 1);
|
|
14757
|
+
index = end + 1;
|
|
14758
|
+
} else {
|
|
14759
|
+
out += text[index];
|
|
14760
|
+
index += 1;
|
|
14761
|
+
visible += 1;
|
|
14762
|
+
}
|
|
14763
|
+
}
|
|
14764
|
+
return out + " ".repeat(Math.max(0, width - visible));
|
|
14765
|
+
}
|
|
14766
|
+
function renderSidebar(state, width = 32, height = 40) {
|
|
14767
|
+
const lines = [];
|
|
14768
|
+
lines.push("");
|
|
14769
|
+
lines.push(" " + STYLE.header + "spaces" + STYLE.reset);
|
|
14770
|
+
lines.push("");
|
|
14771
|
+
state.spaces.forEach((space, index) => {
|
|
14772
|
+
const selected = state.section === "spaces" && index === state.spaceIndex;
|
|
14773
|
+
const badge = space.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
|
|
14774
|
+
lines.push(row(selected, width, STYLE.done + "\u25CF" + STYLE.reset + " " + badge + STYLE.bold + space.name + STYLE.reset));
|
|
14775
|
+
lines.push(row(selected, width, " " + STYLE.branch + space.branch + STYLE.reset));
|
|
14776
|
+
});
|
|
14777
|
+
if (state.spaces.length === 0) lines.push(row(false, width, STYLE.dim + "no spaces found" + STYLE.reset));
|
|
14778
|
+
lines.push("");
|
|
14779
|
+
lines.push(" " + STYLE.header + "agents" + STYLE.reset);
|
|
14780
|
+
lines.push("");
|
|
14781
|
+
state.agents.forEach((agent, index) => {
|
|
14782
|
+
const selected = state.section === "agents" && index === state.agentIndex;
|
|
14783
|
+
const dot = DOT[agent.status] || DOT.idle;
|
|
14784
|
+
const badge = agent.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
|
|
14785
|
+
const linear = agent.linear ? " " + STYLE.dim + agent.linear + STYLE.reset : "";
|
|
14786
|
+
lines.push(row(selected, width, dot + " " + badge + STYLE.bold + agent.name + STYLE.reset + linear));
|
|
14787
|
+
const statusStyle = agent.status === "blocked" ? STYLE.blocked : STYLE.dim;
|
|
14788
|
+
lines.push(row(selected, width, " " + statusStyle + agent.status + STYLE.reset + STYLE.dim + " \xB7 " + agent.harness + STYLE.reset));
|
|
14789
|
+
});
|
|
14790
|
+
if (state.agents.length === 0) lines.push(row(false, width, STYLE.dim + "no agents \u2014 n to spawn" + STYLE.reset));
|
|
14791
|
+
const selectedAgent = state.section === "agents" ? state.agents[state.agentIndex] : void 0;
|
|
14792
|
+
if (selectedAgent?.status === "blocked" && selectedAgent.ask) {
|
|
14793
|
+
lines.push("");
|
|
14794
|
+
lines.push(row(false, width, STYLE.blocked + "\u26D4 needs consent" + STYLE.reset));
|
|
14795
|
+
for (const action of actionsForAsk(selectedAgent.ask)) {
|
|
14796
|
+
const key = action === "track" ? "g" : action === "skip" ? "s" : "y";
|
|
14797
|
+
lines.push(row(false, width, STYLE.dim + " " + key + " \u2014 " + action + STYLE.reset));
|
|
14798
|
+
}
|
|
14799
|
+
}
|
|
14800
|
+
while (lines.length < height - 4) lines.push(pad("", width));
|
|
14801
|
+
lines.push(pad("", width));
|
|
14802
|
+
if (state.message) lines.push(row(false, width, STYLE.accent + state.message.slice(0, width - 2) + STYLE.reset));
|
|
14803
|
+
else lines.push(row(false, width, STYLE.dim + "n new \xB7 enter attach \xB7 x kill" + STYLE.reset));
|
|
14804
|
+
lines.push(row(false, width, STYLE.dim + "T tab \xB7 i interrupt \xB7 q quit" + STYLE.reset));
|
|
14805
|
+
lines.push(row(false, width, STYLE.dim + state.backendNote + STYLE.reset));
|
|
14806
|
+
return lines.slice(0, height).join("\n");
|
|
14807
|
+
}
|
|
14808
|
+
var ESC, STYLE, DOT;
|
|
14809
|
+
var init_render = __esm({
|
|
14810
|
+
"cli/ui/render.ts"() {
|
|
14811
|
+
"use strict";
|
|
14812
|
+
init_consent();
|
|
14813
|
+
ESC = "\x1B[";
|
|
14814
|
+
STYLE = {
|
|
14815
|
+
reset: ESC + "0m",
|
|
14816
|
+
dim: ESC + "2m",
|
|
14817
|
+
bold: ESC + "1m",
|
|
14818
|
+
header: ESC + "38;5;245m",
|
|
14819
|
+
select: ESC + "48;5;236m",
|
|
14820
|
+
working: ESC + "38;5;214m",
|
|
14821
|
+
idle: ESC + "38;5;244m",
|
|
14822
|
+
blocked: ESC + "38;5;203m",
|
|
14823
|
+
done: ESC + "38;5;114m",
|
|
14824
|
+
accent: ESC + "38;5;135m",
|
|
14825
|
+
branch: ESC + "38;5;140m"
|
|
14826
|
+
};
|
|
14827
|
+
DOT = {
|
|
14828
|
+
working: STYLE.working + "\u25CF" + STYLE.reset,
|
|
14829
|
+
idle: STYLE.idle + "\u25CB" + STYLE.reset,
|
|
14830
|
+
blocked: STYLE.blocked + "\u25CF" + STYLE.reset,
|
|
14831
|
+
done: STYLE.done + "\u25CF" + STYLE.reset
|
|
14832
|
+
};
|
|
14833
|
+
}
|
|
14834
|
+
});
|
|
14835
|
+
|
|
14836
|
+
// cli/ui/pueue.ts
|
|
14837
|
+
function buildEnsureGroup() {
|
|
14838
|
+
return ["pueue", "group", "add", PUEUE_GROUP];
|
|
14839
|
+
}
|
|
14840
|
+
function buildGroupParallel() {
|
|
14841
|
+
return ["pueue", "parallel", "-g", PUEUE_GROUP, "32"];
|
|
14842
|
+
}
|
|
14843
|
+
function buildSentinel(session) {
|
|
14844
|
+
const loop = "while tmux has-session -t " + shellQuote3(session) + " 2>/dev/null; do sleep 10; done";
|
|
14845
|
+
return ["pueue", "add", "-g", PUEUE_GROUP, "-l", session, "--", loop];
|
|
14846
|
+
}
|
|
14847
|
+
function buildRemove(taskId) {
|
|
14848
|
+
return ["pueue", "remove", taskId];
|
|
14849
|
+
}
|
|
14850
|
+
async function pueueAvailable2(runner) {
|
|
14851
|
+
const result = await run(runner, ["sh", "-c", "command -v pueue >/dev/null 2>&1 && pueue status >/dev/null 2>&1 && echo ok"]);
|
|
14852
|
+
return result.ok && result.stdout.includes("ok");
|
|
14853
|
+
}
|
|
14854
|
+
async function registerAgent(runner, session) {
|
|
14855
|
+
if (!await pueueAvailable2(runner)) return "";
|
|
14856
|
+
await run(runner, buildEnsureGroup());
|
|
14857
|
+
await run(runner, buildGroupParallel());
|
|
14858
|
+
const added = await run(runner, buildSentinel(session));
|
|
14859
|
+
const match = added.stdout.match(/id\s+(\d+)/i) || added.stderr.match(/id\s+(\d+)/i);
|
|
14860
|
+
return match ? match[1] : "";
|
|
14861
|
+
}
|
|
14862
|
+
async function releaseAgent(runner, pueueId) {
|
|
14863
|
+
if (!pueueId) return;
|
|
14864
|
+
await run(runner, buildRemove(pueueId)).catch?.(() => {
|
|
14865
|
+
});
|
|
14866
|
+
}
|
|
14867
|
+
var PUEUE_GROUP;
|
|
14868
|
+
var init_pueue2 = __esm({
|
|
14869
|
+
"cli/ui/pueue.ts"() {
|
|
14870
|
+
"use strict";
|
|
14871
|
+
init_tmux();
|
|
14872
|
+
PUEUE_GROUP = "synkro-ui";
|
|
14873
|
+
}
|
|
14874
|
+
});
|
|
14875
|
+
|
|
14876
|
+
// cli/ui/backend.ts
|
|
14877
|
+
async function detectContainerBackend() {
|
|
14878
|
+
const runner = { kind: "container", container: CONTAINER_NAME2 };
|
|
14879
|
+
const probe = await run({ kind: "host" }, [
|
|
14880
|
+
"docker",
|
|
14881
|
+
"exec",
|
|
14882
|
+
CONTAINER_NAME2,
|
|
14883
|
+
"sh",
|
|
14884
|
+
"-c",
|
|
14885
|
+
"command -v tmux >/dev/null && command -v claude >/dev/null && ls " + shellQuote3(AUTH_SEED) + " >/dev/null 2>&1 && echo ready"
|
|
14886
|
+
]);
|
|
14887
|
+
if (probe.ok && probe.stdout.includes("ready")) {
|
|
14888
|
+
return { runner, backend: "container", note: "runtime: container (" + CONTAINER_NAME2 + ")" };
|
|
14889
|
+
}
|
|
14890
|
+
return { runner: { kind: "host" }, backend: "host", note: "runtime: host (container unavailable)" };
|
|
14891
|
+
}
|
|
14892
|
+
async function provisionContainerWorkspace(runner, slug) {
|
|
14893
|
+
const dir = CONTAINER_WORK + "/ui-" + slug;
|
|
14894
|
+
await run(runner, [
|
|
14895
|
+
"sh",
|
|
14896
|
+
"-c",
|
|
14897
|
+
"mkdir -p " + shellQuote3(dir) + " && cp -n " + shellQuote3(AUTH_SEED) + " " + shellQuote3(dir + "/.claude.json") + " 2>/dev/null; true"
|
|
14898
|
+
]);
|
|
14899
|
+
return dir;
|
|
14900
|
+
}
|
|
14901
|
+
async function spawnAgent(info, request) {
|
|
14902
|
+
const slug = slugify(request.name);
|
|
14903
|
+
const session = agentSession(slug);
|
|
14904
|
+
const runner = request.backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
|
|
14905
|
+
let cwd = request.cwd;
|
|
14906
|
+
if (request.backend === "container") {
|
|
14907
|
+
cwd = request.cwd.startsWith(CONTAINER_WORK) ? request.cwd : await provisionContainerWorkspace(runner, slug);
|
|
14908
|
+
}
|
|
14909
|
+
const command = request.harness === "codex" ? "codex" : "claude";
|
|
14910
|
+
for (const argv of buildSpawnAgent({ name: slug, cwd, command, harness: request.harness, space: request.spaceName, backend: request.backend })) {
|
|
14911
|
+
const result = await run(runner, argv);
|
|
14912
|
+
if (!result.ok && argv[1] === "new-session") {
|
|
14913
|
+
return { ok: false, session, error: result.stderr.trim() || "tmux new-session failed" };
|
|
14914
|
+
}
|
|
14915
|
+
}
|
|
14916
|
+
const pueueId = await registerAgent(runner, session);
|
|
14917
|
+
if (pueueId) await run(runner, buildSetOption(session, "@synkro_pueue", pueueId));
|
|
14918
|
+
return { ok: true, session };
|
|
14919
|
+
}
|
|
14920
|
+
async function killAgent(backend, session, pueueId) {
|
|
14921
|
+
const runner = backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
|
|
14922
|
+
await run(runner, buildKillSession(session));
|
|
14923
|
+
await releaseAgent(runner, pueueId);
|
|
14924
|
+
}
|
|
14925
|
+
var CONTAINER_NAME2, CONTAINER_WORK, AUTH_SEED;
|
|
14926
|
+
var init_backend = __esm({
|
|
14927
|
+
"cli/ui/backend.ts"() {
|
|
14928
|
+
"use strict";
|
|
14929
|
+
init_tmux();
|
|
14930
|
+
init_pueue2();
|
|
14931
|
+
CONTAINER_NAME2 = "synkro-server";
|
|
14932
|
+
CONTAINER_WORK = "/home/synkro/work";
|
|
14933
|
+
AUTH_SEED = CONTAINER_WORK + "/claude-1/.claude.json";
|
|
14934
|
+
}
|
|
14935
|
+
});
|
|
14936
|
+
|
|
14937
|
+
// cli/ui/sidebar.ts
|
|
14938
|
+
function runnerFor(backend) {
|
|
14939
|
+
return backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
|
|
14940
|
+
}
|
|
14941
|
+
async function runSidebar() {
|
|
14942
|
+
const centerPane = process.env.SYNKRO_UI_CENTER || "";
|
|
14943
|
+
const outerSession = process.env.SYNKRO_UI_OUTER || "synkro-ui";
|
|
14944
|
+
const bootPath = process.env.SYNKRO_UI_BOOT || process.argv[1];
|
|
14945
|
+
const repoCwd = process.env.SYNKRO_UI_REPO || process.cwd();
|
|
14946
|
+
const info = await detectContainerBackend();
|
|
14947
|
+
const state = {
|
|
14948
|
+
spaces: [],
|
|
14949
|
+
agents: [],
|
|
14950
|
+
section: "agents",
|
|
14951
|
+
spaceIndex: 0,
|
|
14952
|
+
agentIndex: 0,
|
|
14953
|
+
backendNote: info.note,
|
|
14954
|
+
message: ""
|
|
14955
|
+
};
|
|
14956
|
+
let hashes = /* @__PURE__ */ new Map();
|
|
14957
|
+
let lastFrame = "";
|
|
14958
|
+
let spawnCounter = 1;
|
|
14959
|
+
const host = { kind: "host" };
|
|
14960
|
+
const containerRunner = { kind: "container", container: CONTAINER_NAME2 };
|
|
14961
|
+
async function refresh() {
|
|
14962
|
+
const [hostSpaces, containerSpaces, conductorTasks] = await Promise.all([
|
|
14963
|
+
discoverHostSpaces(repoCwd),
|
|
14964
|
+
info.backend === "container" ? discoverContainerSpaces(containerRunner) : Promise.resolve([]),
|
|
14965
|
+
fetchConductorTasks(CONDUCTOR_URL)
|
|
14966
|
+
]);
|
|
14967
|
+
state.spaces = [...hostSpaces, ...containerSpaces];
|
|
14968
|
+
const hostAgents = await discoverAgents(host, "host", hashes);
|
|
14969
|
+
const containerAgents = info.backend === "container" ? await discoverAgents(containerRunner, "container", hashes) : { agents: [], hashes: /* @__PURE__ */ new Map() };
|
|
14970
|
+
hashes = new Map([...hostAgents.hashes, ...containerAgents.hashes]);
|
|
14971
|
+
state.agents = [...hostAgents.agents, ...containerAgents.agents].map((agent) => ({
|
|
14972
|
+
...agent,
|
|
14973
|
+
linear: mapAgentToTask(agent.space, conductorTasks)
|
|
14974
|
+
}));
|
|
14975
|
+
state.spaceIndex = Math.min(state.spaceIndex, Math.max(0, state.spaces.length - 1));
|
|
14976
|
+
state.agentIndex = Math.min(state.agentIndex, Math.max(0, state.agents.length - 1));
|
|
14977
|
+
}
|
|
14978
|
+
function draw() {
|
|
14979
|
+
const rows = Number(process.stdout.rows || 42);
|
|
14980
|
+
const cols = Number(process.stdout.columns || 32);
|
|
14981
|
+
const frame = renderSidebar(state, cols, rows);
|
|
14982
|
+
if (frame === lastFrame) return;
|
|
14983
|
+
lastFrame = frame;
|
|
14984
|
+
process.stdout.write("\x1B[2J\x1B[H" + frame);
|
|
14985
|
+
}
|
|
14986
|
+
async function attachSelected() {
|
|
14987
|
+
const agent = state.agents[state.agentIndex];
|
|
14988
|
+
if (!agent || !centerPane) return;
|
|
14989
|
+
const command = buildCenterAttachCommand(runnerFor(agent.backend), agent.session);
|
|
14990
|
+
await run(host, ["tmux", "respawn-pane", "-k", "-t", centerPane, command]);
|
|
14991
|
+
await run(host, ["tmux", "rename-window", "-t", outerSession, agent.name]);
|
|
14992
|
+
state.message = "attached " + agent.name;
|
|
14993
|
+
}
|
|
14994
|
+
async function spawnInSelectedSpace() {
|
|
14995
|
+
const space = state.spaces[state.spaceIndex] || state.spaces[0];
|
|
14996
|
+
if (!space) {
|
|
14997
|
+
state.message = "no space selected";
|
|
14998
|
+
return;
|
|
14999
|
+
}
|
|
15000
|
+
const name = space.name + "-" + spawnCounter++;
|
|
15001
|
+
const result = await spawnAgent(info, {
|
|
15002
|
+
name,
|
|
15003
|
+
harness: "claude",
|
|
15004
|
+
spaceName: space.name,
|
|
15005
|
+
cwd: space.path,
|
|
15006
|
+
backend: space.backend === "container" && info.backend === "container" ? "container" : "host"
|
|
15007
|
+
});
|
|
15008
|
+
state.message = result.ok ? "spawned " + name : "spawn failed: " + (result.error || "").slice(0, 24);
|
|
15009
|
+
}
|
|
15010
|
+
async function spawnContainerAgent() {
|
|
15011
|
+
if (info.backend !== "container") {
|
|
15012
|
+
state.message = "container runtime unavailable";
|
|
15013
|
+
return;
|
|
15014
|
+
}
|
|
15015
|
+
const name = "box-" + spawnCounter++;
|
|
15016
|
+
const result = await spawnAgent(info, { name, harness: "claude", spaceName: name, cwd: "", backend: "container" });
|
|
15017
|
+
state.message = result.ok ? "spawned \u25A3 " + name : "spawn failed: " + (result.error || "").slice(0, 24);
|
|
15018
|
+
}
|
|
15019
|
+
async function consent(action) {
|
|
15020
|
+
const agent = state.agents[state.agentIndex];
|
|
15021
|
+
if (!agent || agent.status !== "blocked" || !agent.ask) return;
|
|
15022
|
+
if (!actionsForAsk(agent.ask).includes(action)) return;
|
|
15023
|
+
const runner = runnerFor(agent.backend);
|
|
15024
|
+
for (const argv of buildSendText(agent.session, CONSENT_PHRASES[action])) await run(runner, argv);
|
|
15025
|
+
state.message = action + " \u2192 " + agent.name;
|
|
15026
|
+
}
|
|
15027
|
+
async function newTab() {
|
|
15028
|
+
await run(host, ["node", String(bootPath), "ui", "--new-tab", outerSession]);
|
|
15029
|
+
}
|
|
15030
|
+
process.stdin.setRawMode?.(true);
|
|
15031
|
+
process.stdin.resume();
|
|
15032
|
+
process.stdin.on("data", (chunk) => {
|
|
15033
|
+
const key = chunk.toString("utf8");
|
|
15034
|
+
void (async () => {
|
|
15035
|
+
const list = state.section === "spaces" ? state.spaces : state.agents;
|
|
15036
|
+
if (key === " ") state.section = state.section === "spaces" ? "agents" : "spaces";
|
|
15037
|
+
else if (key === "j" || key === "\x1B[B") {
|
|
15038
|
+
if (state.section === "spaces") state.spaceIndex = Math.min(state.spaceIndex + 1, Math.max(0, list.length - 1));
|
|
15039
|
+
else state.agentIndex = Math.min(state.agentIndex + 1, Math.max(0, list.length - 1));
|
|
15040
|
+
} else if (key === "k" || key === "\x1B[A") {
|
|
15041
|
+
if (state.section === "spaces") state.spaceIndex = Math.max(0, state.spaceIndex - 1);
|
|
15042
|
+
else state.agentIndex = Math.max(0, state.agentIndex - 1);
|
|
15043
|
+
} else if (key === "\r") {
|
|
15044
|
+
if (state.section === "agents") await attachSelected();
|
|
15045
|
+
else state.message = "space: " + (state.spaces[state.spaceIndex]?.name || "");
|
|
15046
|
+
} else if (key === "n") await spawnInSelectedSpace();
|
|
15047
|
+
else if (key === "c") await spawnContainerAgent();
|
|
15048
|
+
else if (key === "x") {
|
|
15049
|
+
const agent = state.agents[state.agentIndex];
|
|
15050
|
+
if (agent) {
|
|
15051
|
+
await killAgent(agent.backend, agent.session, "");
|
|
15052
|
+
state.message = "killed " + agent.name;
|
|
15053
|
+
}
|
|
15054
|
+
} else if (key === "i") {
|
|
15055
|
+
const agent = state.agents[state.agentIndex];
|
|
15056
|
+
if (agent) await run(runnerFor(agent.backend), buildInterrupt(agent.session));
|
|
15057
|
+
} else if (key === "g") await consent("track");
|
|
15058
|
+
else if (key === "s") await consent("skip");
|
|
15059
|
+
else if (key === "y") await consent("stay");
|
|
15060
|
+
else if (key === "T") await newTab();
|
|
15061
|
+
else if (key === "q" || key === "") {
|
|
15062
|
+
await run(host, ["tmux", "kill-session", "-t", outerSession]);
|
|
15063
|
+
process.exit(0);
|
|
15064
|
+
}
|
|
15065
|
+
await refresh();
|
|
15066
|
+
draw();
|
|
15067
|
+
})();
|
|
15068
|
+
});
|
|
15069
|
+
await refresh();
|
|
15070
|
+
draw();
|
|
15071
|
+
setInterval(() => {
|
|
15072
|
+
void refresh().then(draw);
|
|
15073
|
+
}, POLL_MS);
|
|
15074
|
+
}
|
|
15075
|
+
var POLL_MS, CONDUCTOR_URL;
|
|
15076
|
+
var init_sidebar = __esm({
|
|
15077
|
+
"cli/ui/sidebar.ts"() {
|
|
15078
|
+
"use strict";
|
|
15079
|
+
init_model();
|
|
15080
|
+
init_render();
|
|
15081
|
+
init_consent();
|
|
15082
|
+
init_backend();
|
|
15083
|
+
init_tmux();
|
|
15084
|
+
POLL_MS = 2e3;
|
|
15085
|
+
CONDUCTOR_URL = "http://127.0.0.1:" + (process.env.SYNKRO_HOST_MCP_PORT || "18931");
|
|
15086
|
+
}
|
|
15087
|
+
});
|
|
15088
|
+
|
|
15089
|
+
// cli/commands/ui.ts
|
|
15090
|
+
var ui_exports = {};
|
|
15091
|
+
__export(ui_exports, {
|
|
15092
|
+
uiCommand: () => uiCommand
|
|
15093
|
+
});
|
|
15094
|
+
import { execSync as execSync7 } from "child_process";
|
|
15095
|
+
function repoRoot() {
|
|
15096
|
+
try {
|
|
15097
|
+
return execSync7("git rev-parse --show-toplevel", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim() || process.cwd();
|
|
15098
|
+
} catch {
|
|
15099
|
+
return process.cwd();
|
|
15100
|
+
}
|
|
15101
|
+
}
|
|
15102
|
+
function tmuxPresent() {
|
|
15103
|
+
try {
|
|
15104
|
+
execSync7("tmux -V", { stdio: ["pipe", "pipe", "pipe"] });
|
|
15105
|
+
return true;
|
|
15106
|
+
} catch {
|
|
15107
|
+
return false;
|
|
15108
|
+
}
|
|
15109
|
+
}
|
|
15110
|
+
async function uiCommand(args2) {
|
|
15111
|
+
const bootPath = String(process.argv[1] || "");
|
|
15112
|
+
if (args2.includes("--sidebar")) {
|
|
15113
|
+
await runSidebar();
|
|
15114
|
+
await new Promise(() => {
|
|
15115
|
+
});
|
|
15116
|
+
return;
|
|
15117
|
+
}
|
|
15118
|
+
if (args2.includes("--new-tab")) {
|
|
15119
|
+
await buildTab(bootPath, repoRoot(), "new");
|
|
15120
|
+
return;
|
|
15121
|
+
}
|
|
15122
|
+
if (!tmuxPresent()) {
|
|
15123
|
+
console.error("synkro ui needs tmux. Install it (brew install tmux) and rerun.");
|
|
15124
|
+
process.exitCode = 1;
|
|
15125
|
+
return;
|
|
15126
|
+
}
|
|
15127
|
+
const code = await launchUi(bootPath, repoRoot());
|
|
15128
|
+
process.exitCode = code;
|
|
15129
|
+
}
|
|
15130
|
+
var init_ui = __esm({
|
|
15131
|
+
"cli/commands/ui.ts"() {
|
|
15132
|
+
"use strict";
|
|
15133
|
+
init_launch();
|
|
15134
|
+
init_sidebar();
|
|
15135
|
+
}
|
|
15136
|
+
});
|
|
15137
|
+
|
|
15138
|
+
// cli/commands/refresh.ts
|
|
15139
|
+
var refresh_exports = {};
|
|
15140
|
+
__export(refresh_exports, {
|
|
15141
|
+
refreshCommand: () => refreshCommand
|
|
15142
|
+
});
|
|
15143
|
+
async function refreshCommand(args2 = []) {
|
|
15144
|
+
const json = args2.includes("--json");
|
|
15145
|
+
const creds = loadCredentials();
|
|
15146
|
+
if (!creds?.refresh_token) {
|
|
15147
|
+
if (json) console.log(JSON.stringify({ refreshed: false, reason: "no refresh_token" }));
|
|
15148
|
+
else console.error("Not logged in (no refresh_token) \u2014 run `synkro login`.");
|
|
15149
|
+
process.exit(1);
|
|
15150
|
+
}
|
|
15151
|
+
const ok = await refreshToken();
|
|
15152
|
+
if (json) {
|
|
15153
|
+
console.log(JSON.stringify({ refreshed: ok }));
|
|
15154
|
+
} else if (ok) {
|
|
15155
|
+
console.log("\u2713 Synkro session refreshed.");
|
|
15156
|
+
} else {
|
|
15157
|
+
console.error("Could not refresh \u2014 the refresh_token may be revoked. Run `synkro login`.");
|
|
15158
|
+
}
|
|
15159
|
+
if (!ok) process.exit(1);
|
|
15160
|
+
}
|
|
15161
|
+
var init_refresh = __esm({
|
|
15162
|
+
"cli/commands/refresh.ts"() {
|
|
15163
|
+
"use strict";
|
|
15164
|
+
init_stub();
|
|
15165
|
+
}
|
|
15166
|
+
});
|
|
15167
|
+
|
|
15168
|
+
// cli/commands/linear.ts
|
|
15169
|
+
var linear_exports = {};
|
|
15170
|
+
__export(linear_exports, {
|
|
15171
|
+
formatLinks: () => formatLinks,
|
|
15172
|
+
linearCommand: () => linearCommand
|
|
15173
|
+
});
|
|
15174
|
+
import { readFileSync as readFileSync30 } from "fs";
|
|
15175
|
+
import { homedir as homedir32 } from "os";
|
|
15176
|
+
import { join as join32 } from "path";
|
|
15177
|
+
function mcpJwt() {
|
|
15178
|
+
try {
|
|
15179
|
+
return readFileSync30(join32(SYNKRO_DIR14, ".mcp-jwt"), "utf-8").trim();
|
|
15180
|
+
} catch {
|
|
15181
|
+
return "";
|
|
15182
|
+
}
|
|
13501
15183
|
}
|
|
13502
15184
|
function formatLinks(links) {
|
|
13503
15185
|
if (!links.length) return "No Linear-linked work yet. The conductor links a ticket when you start a tracked task.";
|
|
@@ -13533,7 +15215,7 @@ var SYNKRO_DIR14, PORT2, BASE;
|
|
|
13533
15215
|
var init_linear = __esm({
|
|
13534
15216
|
"cli/commands/linear.ts"() {
|
|
13535
15217
|
"use strict";
|
|
13536
|
-
SYNKRO_DIR14 =
|
|
15218
|
+
SYNKRO_DIR14 = join32(homedir32(), ".synkro");
|
|
13537
15219
|
PORT2 = process.env.SYNKRO_MCP_PORT || "18931";
|
|
13538
15220
|
BASE = `http://127.0.0.1:${PORT2}`;
|
|
13539
15221
|
}
|
|
@@ -13682,33 +15364,33 @@ var init_cveReachability = __esm({
|
|
|
13682
15364
|
});
|
|
13683
15365
|
|
|
13684
15366
|
// cli/reachability/reachabilityScan.ts
|
|
13685
|
-
import { spawnSync as
|
|
13686
|
-
import { readFileSync as readFileSync32, writeFileSync as
|
|
13687
|
-
import { join as
|
|
13688
|
-
import { homedir as
|
|
15367
|
+
import { spawnSync as spawnSync12, execFileSync as execFileSync5 } from "child_process";
|
|
15368
|
+
import { readFileSync as readFileSync32, writeFileSync as writeFileSync23, existsSync as existsSync34, readdirSync as readdirSync9 } from "fs";
|
|
15369
|
+
import { join as join33 } from "path";
|
|
15370
|
+
import { homedir as homedir33 } from "os";
|
|
13689
15371
|
import { createRequire } from "module";
|
|
13690
|
-
function walkSourceFiles(
|
|
15372
|
+
function walkSourceFiles(repoRoot3, maxFiles = 4e3, maxBytes = 5e5) {
|
|
13691
15373
|
const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", "out", ".cache", ".synkro", ".claude", "vendor", "__tests__", "test-results"]);
|
|
13692
15374
|
const EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
13693
15375
|
const files = [];
|
|
13694
|
-
const stack = [
|
|
15376
|
+
const stack = [repoRoot3];
|
|
13695
15377
|
while (stack.length && files.length < maxFiles) {
|
|
13696
15378
|
const dir = stack.pop();
|
|
13697
15379
|
let ents;
|
|
13698
15380
|
try {
|
|
13699
|
-
ents =
|
|
15381
|
+
ents = readdirSync9(dir, { withFileTypes: true });
|
|
13700
15382
|
} catch {
|
|
13701
15383
|
continue;
|
|
13702
15384
|
}
|
|
13703
15385
|
for (const e of ents) {
|
|
13704
15386
|
if (files.length >= maxFiles) break;
|
|
13705
|
-
const full =
|
|
15387
|
+
const full = join33(dir, e.name);
|
|
13706
15388
|
if (e.isDirectory()) {
|
|
13707
15389
|
if (!SKIP.has(e.name) && !e.name.startsWith(".")) stack.push(full);
|
|
13708
15390
|
continue;
|
|
13709
15391
|
}
|
|
13710
15392
|
if (!EXT.test(e.name) || e.name.endsWith(".d.ts")) continue;
|
|
13711
|
-
const rel = full.startsWith(
|
|
15393
|
+
const rel = full.startsWith(repoRoot3 + "/") ? full.slice(repoRoot3.length + 1) : full;
|
|
13712
15394
|
try {
|
|
13713
15395
|
const content = readFileSync32(full, "utf8");
|
|
13714
15396
|
if (content.length <= maxBytes) files.push({ path: rel, content });
|
|
@@ -13726,15 +15408,15 @@ function cleanVersion(spec) {
|
|
|
13726
15408
|
const c = s.replace(/^[\^~>=<\s]+/, "");
|
|
13727
15409
|
return /^\d[\w.\-+]*$/.test(c) ? c : null;
|
|
13728
15410
|
}
|
|
13729
|
-
function gatherManifestVersions(
|
|
15411
|
+
function gatherManifestVersions(repoRoot3) {
|
|
13730
15412
|
const out = {};
|
|
13731
|
-
const dirs = [
|
|
13732
|
-
const pkgsDir =
|
|
13733
|
-
if (
|
|
15413
|
+
const dirs = [repoRoot3];
|
|
15414
|
+
const pkgsDir = join33(repoRoot3, "packages");
|
|
15415
|
+
if (existsSync34(pkgsDir)) {
|
|
13734
15416
|
try {
|
|
13735
|
-
for (const d of
|
|
13736
|
-
const pd =
|
|
13737
|
-
if (
|
|
15417
|
+
for (const d of readdirSync9(pkgsDir)) {
|
|
15418
|
+
const pd = join33(pkgsDir, d);
|
|
15419
|
+
if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
|
|
13738
15420
|
}
|
|
13739
15421
|
} catch {
|
|
13740
15422
|
}
|
|
@@ -13743,7 +15425,7 @@ function gatherManifestVersions(repoRoot2) {
|
|
|
13743
15425
|
for (const dir of dirs) {
|
|
13744
15426
|
let pkg;
|
|
13745
15427
|
try {
|
|
13746
|
-
pkg = JSON.parse(readFileSync32(
|
|
15428
|
+
pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
|
|
13747
15429
|
} catch {
|
|
13748
15430
|
continue;
|
|
13749
15431
|
}
|
|
@@ -13759,32 +15441,32 @@ function gatherManifestVersions(repoRoot2) {
|
|
|
13759
15441
|
}
|
|
13760
15442
|
return out;
|
|
13761
15443
|
}
|
|
13762
|
-
function findJelly(
|
|
15444
|
+
function findJelly(repoRoot3) {
|
|
13763
15445
|
try {
|
|
13764
15446
|
const pkgJson = require2.resolve("@cs-au-dk/jelly/package.json");
|
|
13765
15447
|
const dir = pkgJson.slice(0, pkgJson.length - "package.json".length);
|
|
13766
15448
|
const pkg = JSON.parse(readFileSync32(pkgJson, "utf8"));
|
|
13767
15449
|
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && (pkg.bin.jelly || pkg.bin[Object.keys(pkg.bin)[0]]);
|
|
13768
15450
|
if (bin) {
|
|
13769
|
-
const p =
|
|
13770
|
-
if (
|
|
15451
|
+
const p = join33(dir, bin);
|
|
15452
|
+
if (existsSync34(p)) return p;
|
|
13771
15453
|
}
|
|
13772
15454
|
} catch {
|
|
13773
15455
|
}
|
|
13774
|
-
for (const base of [
|
|
13775
|
-
const b =
|
|
13776
|
-
if (
|
|
15456
|
+
for (const base of [repoRoot3, process.cwd()]) {
|
|
15457
|
+
const b = join33(base, "node_modules", ".bin", "jelly");
|
|
15458
|
+
if (existsSync34(b)) return b;
|
|
13777
15459
|
}
|
|
13778
15460
|
return null;
|
|
13779
15461
|
}
|
|
13780
|
-
function findEntries(
|
|
13781
|
-
const dirs = [
|
|
13782
|
-
const pkgsDir =
|
|
13783
|
-
if (
|
|
15462
|
+
function findEntries(repoRoot3) {
|
|
15463
|
+
const dirs = [repoRoot3];
|
|
15464
|
+
const pkgsDir = join33(repoRoot3, "packages");
|
|
15465
|
+
if (existsSync34(pkgsDir)) {
|
|
13784
15466
|
try {
|
|
13785
|
-
for (const d of
|
|
13786
|
-
const pd =
|
|
13787
|
-
if (
|
|
15467
|
+
for (const d of readdirSync9(pkgsDir)) {
|
|
15468
|
+
const pd = join33(pkgsDir, d);
|
|
15469
|
+
if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
|
|
13788
15470
|
}
|
|
13789
15471
|
} catch {
|
|
13790
15472
|
}
|
|
@@ -13792,12 +15474,12 @@ function findEntries(repoRoot2) {
|
|
|
13792
15474
|
const entries = [];
|
|
13793
15475
|
for (const dir of dirs) {
|
|
13794
15476
|
try {
|
|
13795
|
-
const pkg = JSON.parse(readFileSync32(
|
|
15477
|
+
const pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
|
|
13796
15478
|
const cands = [pkg.source, pkg.module, pkg.main, "src/index.ts", "src/index.js", "src/main.ts", "src/server.ts", "index.ts", "index.js"];
|
|
13797
15479
|
for (const c of cands) {
|
|
13798
15480
|
if (typeof c !== "string") continue;
|
|
13799
|
-
const f =
|
|
13800
|
-
if (
|
|
15481
|
+
const f = join33(dir, c);
|
|
15482
|
+
if (existsSync34(f)) {
|
|
13801
15483
|
entries.push(f);
|
|
13802
15484
|
break;
|
|
13803
15485
|
}
|
|
@@ -13807,9 +15489,9 @@ function findEntries(repoRoot2) {
|
|
|
13807
15489
|
}
|
|
13808
15490
|
return entries.slice(0, 40);
|
|
13809
15491
|
}
|
|
13810
|
-
function currentCommit(
|
|
15492
|
+
function currentCommit(repoRoot3) {
|
|
13811
15493
|
try {
|
|
13812
|
-
return execFileSync5("git", ["rev-parse", "HEAD"], { cwd:
|
|
15494
|
+
return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
|
|
13813
15495
|
} catch {
|
|
13814
15496
|
return "";
|
|
13815
15497
|
}
|
|
@@ -13828,9 +15510,9 @@ function parseApiUsage(log) {
|
|
|
13828
15510
|
for (const k of Object.keys(map)) out[k] = { reachableApis: [...map[k]].slice(0, 60) };
|
|
13829
15511
|
return out;
|
|
13830
15512
|
}
|
|
13831
|
-
function runReachabilityScan(
|
|
13832
|
-
const commit = currentCommit(
|
|
13833
|
-
if (!opts.force && commit &&
|
|
15513
|
+
function runReachabilityScan(repoRoot3, opts = {}) {
|
|
15514
|
+
const commit = currentCommit(repoRoot3);
|
|
15515
|
+
if (!opts.force && commit && existsSync34(REACHABILITY_PATH)) {
|
|
13834
15516
|
try {
|
|
13835
15517
|
const prev = JSON.parse(readFileSync32(REACHABILITY_PATH, "utf8"));
|
|
13836
15518
|
if (prev.commit === commit) return { ok: true, cached: true, packages: Object.keys(prev.packages || {}).length };
|
|
@@ -13879,13 +15561,13 @@ function runReachabilityScan(repoRoot2, opts = {}) {
|
|
|
13879
15561
|
}
|
|
13880
15562
|
};
|
|
13881
15563
|
let tool = "ast";
|
|
13882
|
-
const jelly = findJelly(
|
|
15564
|
+
const jelly = findJelly(repoRoot3);
|
|
13883
15565
|
if (jelly) {
|
|
13884
|
-
const entries = findEntries(
|
|
15566
|
+
const entries = findEntries(repoRoot3);
|
|
13885
15567
|
if (entries.length > 0) {
|
|
13886
|
-
const r =
|
|
15568
|
+
const r = spawnSync12(
|
|
13887
15569
|
process.execPath,
|
|
13888
|
-
[jelly, "-b",
|
|
15570
|
+
[jelly, "-b", repoRoot3, "--api-usage", ...entries],
|
|
13889
15571
|
{ encoding: "utf8", timeout: opts.timeoutMs ?? 18e4, maxBuffer: 2e8 }
|
|
13890
15572
|
);
|
|
13891
15573
|
const jp = parseApiUsage((r.stdout || "") + (r.stderr || ""));
|
|
@@ -13895,7 +15577,7 @@ function runReachabilityScan(repoRoot2, opts = {}) {
|
|
|
13895
15577
|
}
|
|
13896
15578
|
}
|
|
13897
15579
|
}
|
|
13898
|
-
const astPackages = extractAllPackageUsage(walkSourceFiles(
|
|
15580
|
+
const astPackages = extractAllPackageUsage(walkSourceFiles(repoRoot3));
|
|
13899
15581
|
for (const [k, v] of Object.entries(astPackages)) {
|
|
13900
15582
|
addAll(k, v.apis);
|
|
13901
15583
|
addSites(k, v.sites);
|
|
@@ -13919,9 +15601,9 @@ function runReachabilityScan(repoRoot2, opts = {}) {
|
|
|
13919
15601
|
packages[k] = entry;
|
|
13920
15602
|
}
|
|
13921
15603
|
if (Object.keys(packages).length === 0) return { ok: false, reason: "no package usage found (no jelly output, no AST imports)" };
|
|
13922
|
-
const file = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), commit, tool, packages, versions: gatherManifestVersions(
|
|
15604
|
+
const file = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), commit, tool, packages, versions: gatherManifestVersions(repoRoot3) };
|
|
13923
15605
|
try {
|
|
13924
|
-
|
|
15606
|
+
writeFileSync23(REACHABILITY_PATH, JSON.stringify(file, null, 2));
|
|
13925
15607
|
} catch (e) {
|
|
13926
15608
|
return { ok: false, reason: "write failed: " + String(e.message || e) };
|
|
13927
15609
|
}
|
|
@@ -13933,7 +15615,7 @@ var init_reachabilityScan = __esm({
|
|
|
13933
15615
|
"use strict";
|
|
13934
15616
|
init_cveReachability();
|
|
13935
15617
|
require2 = createRequire(import.meta.url);
|
|
13936
|
-
REACHABILITY_PATH =
|
|
15618
|
+
REACHABILITY_PATH = join33(homedir33(), ".synkro", "reachability.json");
|
|
13937
15619
|
}
|
|
13938
15620
|
});
|
|
13939
15621
|
|
|
@@ -13942,13 +15624,13 @@ var reachabilityScan_exports = {};
|
|
|
13942
15624
|
__export(reachabilityScan_exports, {
|
|
13943
15625
|
reachabilityScanCommand: () => reachabilityScanCommand
|
|
13944
15626
|
});
|
|
13945
|
-
import { readFileSync as readFileSync33, existsSync as
|
|
13946
|
-
import { join as
|
|
13947
|
-
import { homedir as
|
|
15627
|
+
import { readFileSync as readFileSync33, existsSync as existsSync35 } from "fs";
|
|
15628
|
+
import { join as join34 } from "path";
|
|
15629
|
+
import { homedir as homedir34 } from "os";
|
|
13948
15630
|
import { execFileSync as execFileSync6 } from "child_process";
|
|
13949
15631
|
function readConfigEnv4() {
|
|
13950
|
-
const p =
|
|
13951
|
-
if (!
|
|
15632
|
+
const p = join34(SYNKRO_DIR15, "config.env");
|
|
15633
|
+
if (!existsSync35(p)) return {};
|
|
13952
15634
|
const out = {};
|
|
13953
15635
|
for (const line of readFileSync33(p, "utf-8").split("\n")) {
|
|
13954
15636
|
const t = line.trim();
|
|
@@ -13958,7 +15640,7 @@ function readConfigEnv4() {
|
|
|
13958
15640
|
}
|
|
13959
15641
|
return out;
|
|
13960
15642
|
}
|
|
13961
|
-
function
|
|
15643
|
+
function repoRoot2() {
|
|
13962
15644
|
try {
|
|
13963
15645
|
return execFileSync6("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
|
|
13964
15646
|
} catch {
|
|
@@ -13966,14 +15648,14 @@ function repoRoot() {
|
|
|
13966
15648
|
}
|
|
13967
15649
|
}
|
|
13968
15650
|
function repoSlug(root) {
|
|
13969
|
-
const
|
|
15651
|
+
const run2 = (a) => {
|
|
13970
15652
|
try {
|
|
13971
15653
|
return execFileSync6("git", a, { encoding: "utf-8" }).trim();
|
|
13972
15654
|
} catch {
|
|
13973
15655
|
return "";
|
|
13974
15656
|
}
|
|
13975
15657
|
};
|
|
13976
|
-
const remote =
|
|
15658
|
+
const remote = run2(["remote", "get-url", "origin"]);
|
|
13977
15659
|
if (remote) return remote.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
|
|
13978
15660
|
return root.split("/").pop() || root;
|
|
13979
15661
|
}
|
|
@@ -13982,10 +15664,10 @@ async function pushToCloud(cfg, repo) {
|
|
|
13982
15664
|
while (gwBase.endsWith("/")) gwBase = gwBase.slice(0, -1);
|
|
13983
15665
|
let jwt2 = "";
|
|
13984
15666
|
try {
|
|
13985
|
-
jwt2 = readFileSync33(
|
|
15667
|
+
jwt2 = readFileSync33(join34(SYNKRO_DIR15, ".mcp-jwt"), "utf-8").trim();
|
|
13986
15668
|
} catch {
|
|
13987
15669
|
}
|
|
13988
|
-
if (!jwt2 || !
|
|
15670
|
+
if (!jwt2 || !existsSync35(REACHABILITY_PATH)) return;
|
|
13989
15671
|
const body = readFileSync33(REACHABILITY_PATH, "utf-8");
|
|
13990
15672
|
try {
|
|
13991
15673
|
const resp = await fetch(gwBase + "/api/v1/reachability?repo=" + encodeURIComponent(repo), {
|
|
@@ -14000,7 +15682,7 @@ async function pushToCloud(cfg, repo) {
|
|
|
14000
15682
|
}
|
|
14001
15683
|
}
|
|
14002
15684
|
async function reachabilityScanCommand(args2 = []) {
|
|
14003
|
-
const root =
|
|
15685
|
+
const root = repoRoot2();
|
|
14004
15686
|
const force = args2.includes("--force");
|
|
14005
15687
|
const quiet = args2.includes("--quiet");
|
|
14006
15688
|
const res = runReachabilityScan(root, { force });
|
|
@@ -14018,7 +15700,7 @@ var init_reachabilityScan2 = __esm({
|
|
|
14018
15700
|
"cli/commands/reachabilityScan.ts"() {
|
|
14019
15701
|
"use strict";
|
|
14020
15702
|
init_reachabilityScan();
|
|
14021
|
-
SYNKRO_DIR15 =
|
|
15703
|
+
SYNKRO_DIR15 = join34(homedir34(), ".synkro");
|
|
14022
15704
|
}
|
|
14023
15705
|
});
|
|
14024
15706
|
|
|
@@ -14148,11 +15830,11 @@ var config_exports = {};
|
|
|
14148
15830
|
__export(config_exports, {
|
|
14149
15831
|
configCommand: () => configCommand
|
|
14150
15832
|
});
|
|
14151
|
-
import { readFileSync as readFileSync34, writeFileSync as
|
|
14152
|
-
import { join as
|
|
14153
|
-
import { homedir as
|
|
15833
|
+
import { readFileSync as readFileSync34, writeFileSync as writeFileSync24, existsSync as existsSync36 } from "fs";
|
|
15834
|
+
import { join as join35 } from "path";
|
|
15835
|
+
import { homedir as homedir35 } from "os";
|
|
14154
15836
|
function readConfigEnv5() {
|
|
14155
|
-
if (!
|
|
15837
|
+
if (!existsSync36(CONFIG_PATH9)) return {};
|
|
14156
15838
|
const out = {};
|
|
14157
15839
|
for (const line of readFileSync34(CONFIG_PATH9, "utf-8").split("\n")) {
|
|
14158
15840
|
const t = line.trim();
|
|
@@ -14163,7 +15845,7 @@ function readConfigEnv5() {
|
|
|
14163
15845
|
return out;
|
|
14164
15846
|
}
|
|
14165
15847
|
function updateConfigValue(key, value) {
|
|
14166
|
-
if (!
|
|
15848
|
+
if (!existsSync36(CONFIG_PATH9)) {
|
|
14167
15849
|
console.error("No config found. Run `synkro install` first.");
|
|
14168
15850
|
process.exit(1);
|
|
14169
15851
|
}
|
|
@@ -14178,7 +15860,7 @@ function updateConfigValue(key, value) {
|
|
|
14178
15860
|
return line;
|
|
14179
15861
|
});
|
|
14180
15862
|
if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
|
|
14181
|
-
|
|
15863
|
+
writeFileSync24(CONFIG_PATH9, updated.join("\n"), "utf-8");
|
|
14182
15864
|
}
|
|
14183
15865
|
function resolveInferenceMode(cfg) {
|
|
14184
15866
|
if ((cfg.SYNKRO_GRADING_MODE || "local") === "byok") return "byok";
|
|
@@ -14336,8 +16018,8 @@ var init_config = __esm({
|
|
|
14336
16018
|
"use strict";
|
|
14337
16019
|
init_stub();
|
|
14338
16020
|
init_optout();
|
|
14339
|
-
SYNKRO_DIR16 =
|
|
14340
|
-
CONFIG_PATH9 =
|
|
16021
|
+
SYNKRO_DIR16 = join35(homedir35(), ".synkro");
|
|
16022
|
+
CONFIG_PATH9 = join35(SYNKRO_DIR16, "config.env");
|
|
14341
16023
|
}
|
|
14342
16024
|
});
|
|
14343
16025
|
|
|
@@ -14401,11 +16083,11 @@ async function printTail(args2) {
|
|
|
14401
16083
|
console.log("(no events \u2014 run `synkro start` if the container is down so JSONL pending events can drain)");
|
|
14402
16084
|
return;
|
|
14403
16085
|
}
|
|
14404
|
-
for (const
|
|
14405
|
-
const session =
|
|
14406
|
-
const ts = typeof
|
|
14407
|
-
console.log(` ${ts} ${
|
|
14408
|
-
const contextStr = typeof
|
|
16086
|
+
for (const row2 of rows) {
|
|
16087
|
+
const session = row2.cc_session_id ? ` session=${String(row2.cc_session_id).slice(0, 8)}` : "";
|
|
16088
|
+
const ts = typeof row2.occurred_at === "string" ? row2.occurred_at : new Date(row2.occurred_at).toISOString();
|
|
16089
|
+
console.log(` ${ts} ${row2.event_type.padEnd(20)} ${row2.emitter}${session}`);
|
|
16090
|
+
const contextStr = typeof row2.context === "string" ? row2.context : JSON.stringify(row2.context);
|
|
14409
16091
|
console.log(` context: ${truncate2(contextStr, 200)}`);
|
|
14410
16092
|
}
|
|
14411
16093
|
}
|
|
@@ -14427,12 +16109,12 @@ async function runExport(args2) {
|
|
|
14427
16109
|
}
|
|
14428
16110
|
function confirmYesNo(question) {
|
|
14429
16111
|
if (!process.stdin.isTTY) return Promise.resolve(false);
|
|
14430
|
-
return new Promise((
|
|
16112
|
+
return new Promise((resolve7) => {
|
|
14431
16113
|
const rl = createInterface5({ input: process.stdin, output: process.stdout });
|
|
14432
16114
|
rl.question(`${question} (y/N): `, (answer) => {
|
|
14433
16115
|
rl.close();
|
|
14434
16116
|
const t = answer.trim().toLowerCase();
|
|
14435
|
-
|
|
16117
|
+
resolve7(t === "y" || t === "yes");
|
|
14436
16118
|
});
|
|
14437
16119
|
});
|
|
14438
16120
|
}
|
|
@@ -14525,15 +16207,824 @@ Usage:
|
|
|
14525
16207
|
}
|
|
14526
16208
|
});
|
|
14527
16209
|
|
|
16210
|
+
// cli/inventory/identity.ts
|
|
16211
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
16212
|
+
import { existsSync as existsSync37, mkdirSync as mkdirSync20, readFileSync as readFileSync35, renameSync as renameSync9, writeFileSync as writeFileSync25 } from "fs";
|
|
16213
|
+
import { homedir as homedir36 } from "os";
|
|
16214
|
+
import { dirname as dirname9, join as join36 } from "path";
|
|
16215
|
+
function operationalIdentityPath() {
|
|
16216
|
+
return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join36(homedir36(), ".synkro", "installation.json");
|
|
16217
|
+
}
|
|
16218
|
+
function validIdentity(value) {
|
|
16219
|
+
if (!value || typeof value !== "object") return false;
|
|
16220
|
+
const row2 = value;
|
|
16221
|
+
return typeof row2.installation_id === "string" && UUID_RE.test(row2.installation_id) && typeof row2.created_at === "string" && Number.isFinite(Date.parse(row2.created_at));
|
|
16222
|
+
}
|
|
16223
|
+
function writeIdentity(path, identity) {
|
|
16224
|
+
mkdirSync20(dirname9(path), { recursive: true, mode: 448 });
|
|
16225
|
+
const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
|
|
16226
|
+
writeFileSync25(temp, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
16227
|
+
renameSync9(temp, path);
|
|
16228
|
+
}
|
|
16229
|
+
function getOperationalInstallationIdentity(path = operationalIdentityPath()) {
|
|
16230
|
+
const prior = cached4.get(path);
|
|
16231
|
+
if (prior) return prior;
|
|
16232
|
+
if (existsSync37(path)) {
|
|
16233
|
+
try {
|
|
16234
|
+
const parsed = JSON.parse(readFileSync35(path, "utf8"));
|
|
16235
|
+
if (validIdentity(parsed)) {
|
|
16236
|
+
cached4.set(path, parsed);
|
|
16237
|
+
return parsed;
|
|
16238
|
+
}
|
|
16239
|
+
} catch {
|
|
16240
|
+
}
|
|
16241
|
+
}
|
|
16242
|
+
const identity = { installation_id: randomUUID5(), created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
16243
|
+
writeIdentity(path, identity);
|
|
16244
|
+
cached4.set(path, identity);
|
|
16245
|
+
return identity;
|
|
16246
|
+
}
|
|
16247
|
+
var UUID_RE, cached4;
|
|
16248
|
+
var init_identity2 = __esm({
|
|
16249
|
+
"cli/inventory/identity.ts"() {
|
|
16250
|
+
"use strict";
|
|
16251
|
+
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
16252
|
+
cached4 = /* @__PURE__ */ new Map();
|
|
16253
|
+
}
|
|
16254
|
+
});
|
|
16255
|
+
|
|
16256
|
+
// cli/inventory/collector.ts
|
|
16257
|
+
import { createHash as createHash5 } from "crypto";
|
|
16258
|
+
import {
|
|
16259
|
+
existsSync as existsSync38,
|
|
16260
|
+
readFileSync as readFileSync36,
|
|
16261
|
+
readdirSync as readdirSync10,
|
|
16262
|
+
statSync as statSync5
|
|
16263
|
+
} from "fs";
|
|
16264
|
+
import { arch, homedir as homedir37, hostname as hostname2, platform as platform5, release } from "os";
|
|
16265
|
+
import { basename as basename3, join as join37, relative, resolve as resolve5 } from "path";
|
|
16266
|
+
import { fileURLToPath } from "url";
|
|
16267
|
+
function sha256(value) {
|
|
16268
|
+
return createHash5("sha256").update(value).digest("hex");
|
|
16269
|
+
}
|
|
16270
|
+
function pseudonymousHostnameHash(installationId, host) {
|
|
16271
|
+
return sha256(`${installationId}:${host}`).slice(0, 32);
|
|
16272
|
+
}
|
|
16273
|
+
function cliVersion() {
|
|
16274
|
+
try {
|
|
16275
|
+
return "1.9.0";
|
|
16276
|
+
} catch {
|
|
16277
|
+
return "0.0.0";
|
|
16278
|
+
}
|
|
16279
|
+
}
|
|
16280
|
+
function readJson(path) {
|
|
16281
|
+
try {
|
|
16282
|
+
if (!existsSync38(path)) return null;
|
|
16283
|
+
const parsed = JSON.parse(readFileSync36(path, "utf8"));
|
|
16284
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
16285
|
+
} catch {
|
|
16286
|
+
return null;
|
|
16287
|
+
}
|
|
16288
|
+
}
|
|
16289
|
+
function readText(path) {
|
|
16290
|
+
try {
|
|
16291
|
+
if (!existsSync38(path)) return "";
|
|
16292
|
+
return readFileSync36(path, "utf8");
|
|
16293
|
+
} catch {
|
|
16294
|
+
return "";
|
|
16295
|
+
}
|
|
16296
|
+
}
|
|
16297
|
+
function urlOrigin(raw) {
|
|
16298
|
+
if (typeof raw !== "string") return void 0;
|
|
16299
|
+
try {
|
|
16300
|
+
const parsed = new URL(raw);
|
|
16301
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
|
|
16302
|
+
return parsed.origin;
|
|
16303
|
+
} catch {
|
|
16304
|
+
return void 0;
|
|
16305
|
+
}
|
|
16306
|
+
}
|
|
16307
|
+
function canonical(raw) {
|
|
16308
|
+
return raw.trim().toLowerCase().replace(/[\s.]+/g, "-").replace(/[^a-z0-9_:@/+-]/g, "") || "unknown";
|
|
16309
|
+
}
|
|
16310
|
+
function safePackageName(command, args2) {
|
|
16311
|
+
if (typeof command !== "string" || !command.trim()) return void 0;
|
|
16312
|
+
const runner = basename3(command.trim()).replace(/\.exe$/i, "");
|
|
16313
|
+
if (Array.isArray(args2) && ["npx", "bunx", "uvx"].includes(runner)) {
|
|
16314
|
+
const pkg = args2.find((arg) => typeof arg === "string" && !arg.startsWith("-"));
|
|
16315
|
+
if (typeof pkg === "string") {
|
|
16316
|
+
const candidate = pkg.trim();
|
|
16317
|
+
if (/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+(?:@[a-z0-9_.+~-]+)?$/i.test(candidate)) {
|
|
16318
|
+
return candidate;
|
|
16319
|
+
}
|
|
16320
|
+
return basename3(candidate);
|
|
16321
|
+
}
|
|
16322
|
+
}
|
|
16323
|
+
return runner;
|
|
16324
|
+
}
|
|
16325
|
+
function toolNames(entry) {
|
|
16326
|
+
const raw = entry.enabledTools ?? entry.enabled_tools ?? entry.allowedTools ?? entry.allowed_tools ?? entry.tools;
|
|
16327
|
+
if (!Array.isArray(raw)) return [];
|
|
16328
|
+
return [...new Set(raw.map((tool) => typeof tool === "string" ? tool : tool?.name).filter((tool) => typeof tool === "string" && !!tool.trim()))];
|
|
16329
|
+
}
|
|
16330
|
+
function mcpArtifactsFromJson(harness, config, configScope = "user") {
|
|
16331
|
+
if (!config || !config.mcpServers || typeof config.mcpServers !== "object") return [];
|
|
16332
|
+
const artifacts = [];
|
|
16333
|
+
for (const [name, raw] of Object.entries(config.mcpServers)) {
|
|
16334
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
16335
|
+
const entry = raw;
|
|
16336
|
+
const transport = typeof entry.type === "string" ? entry.type : entry.command ? "stdio" : entry.url ? "streamable-http" : "unknown";
|
|
16337
|
+
const tools = toolNames(entry);
|
|
16338
|
+
const scopeId = canonical(configScope);
|
|
16339
|
+
const id = `${canonical(name)}@${scopeId}`;
|
|
16340
|
+
const safeShape = {
|
|
16341
|
+
name,
|
|
16342
|
+
transport,
|
|
16343
|
+
origin: urlOrigin(entry.url),
|
|
16344
|
+
package_name: safePackageName(entry.command, entry.args),
|
|
16345
|
+
enabled: entry.enabled !== false && entry.disabled !== true,
|
|
16346
|
+
tools,
|
|
16347
|
+
scope: configScope
|
|
16348
|
+
};
|
|
16349
|
+
artifacts.push({
|
|
16350
|
+
harness,
|
|
16351
|
+
type: "mcp_server",
|
|
16352
|
+
canonical_id: id,
|
|
16353
|
+
display_name: String(name),
|
|
16354
|
+
enabled: safeShape.enabled,
|
|
16355
|
+
config_scope: configScope,
|
|
16356
|
+
transport: String(transport),
|
|
16357
|
+
endpoint_origin: safeShape.origin,
|
|
16358
|
+
package_name: safeShape.package_name,
|
|
16359
|
+
config_hash: sha256(JSON.stringify(safeShape)),
|
|
16360
|
+
metadata: tools.length ? { tool_names: tools } : void 0
|
|
16361
|
+
});
|
|
16362
|
+
for (const tool of tools) {
|
|
16363
|
+
artifacts.push({
|
|
16364
|
+
harness,
|
|
16365
|
+
type: "mcp_tool",
|
|
16366
|
+
canonical_id: `${id}:${canonical(tool)}`,
|
|
16367
|
+
display_name: tool,
|
|
16368
|
+
enabled: safeShape.enabled,
|
|
16369
|
+
config_scope: configScope,
|
|
16370
|
+
metadata: { source: String(name) }
|
|
16371
|
+
});
|
|
16372
|
+
}
|
|
16373
|
+
}
|
|
16374
|
+
return artifacts;
|
|
16375
|
+
}
|
|
16376
|
+
function claudeDesktopConfigCandidates(home, targetPlatform) {
|
|
16377
|
+
if (targetPlatform === "darwin") {
|
|
16378
|
+
return [join37(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")];
|
|
16379
|
+
}
|
|
16380
|
+
if (targetPlatform === "linux") {
|
|
16381
|
+
return [
|
|
16382
|
+
join37(home, ".config", "Claude", "claude_desktop_config.json"),
|
|
16383
|
+
join37(home, ".config", "claude", "claude_desktop_config.json")
|
|
16384
|
+
];
|
|
16385
|
+
}
|
|
16386
|
+
if (targetPlatform === "win32" && process.env.APPDATA) {
|
|
16387
|
+
return [join37(process.env.APPDATA, "Claude", "claude_desktop_config.json")];
|
|
16388
|
+
}
|
|
16389
|
+
return [];
|
|
16390
|
+
}
|
|
16391
|
+
function claudeManagedMcpConfigCandidates(targetPlatform) {
|
|
16392
|
+
if (targetPlatform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-mcp.json"];
|
|
16393
|
+
if (targetPlatform === "linux") return ["/etc/claude-code/managed-mcp.json"];
|
|
16394
|
+
if (targetPlatform === "win32" && process.env.ProgramFiles) {
|
|
16395
|
+
return [join37(process.env.ProgramFiles, "ClaudeCode", "managed-mcp.json")];
|
|
16396
|
+
}
|
|
16397
|
+
return [];
|
|
16398
|
+
}
|
|
16399
|
+
function discoveredProjectRoots(claudeState, currentDirectory, explicit = [], cursorRoots = []) {
|
|
16400
|
+
const roots = /* @__PURE__ */ new Set();
|
|
16401
|
+
const add = (value) => {
|
|
16402
|
+
if (typeof value !== "string" || !value.trim()) return;
|
|
16403
|
+
const path = resolve5(value);
|
|
16404
|
+
if (existsSync38(path)) roots.add(path);
|
|
16405
|
+
};
|
|
16406
|
+
add(currentDirectory);
|
|
16407
|
+
for (const path of explicit) add(path);
|
|
16408
|
+
for (const path of cursorRoots) add(path);
|
|
16409
|
+
if (claudeState?.projects && typeof claudeState.projects === "object") {
|
|
16410
|
+
for (const path of Object.keys(claudeState.projects)) add(path);
|
|
16411
|
+
}
|
|
16412
|
+
return [...roots];
|
|
16413
|
+
}
|
|
16414
|
+
function cursorWorkspaceStorageCandidates(home, targetPlatform) {
|
|
16415
|
+
if (targetPlatform === "darwin") return [join37(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")];
|
|
16416
|
+
if (targetPlatform === "linux") return [join37(home, ".config", "Cursor", "User", "workspaceStorage")];
|
|
16417
|
+
if (targetPlatform === "win32" && process.env.APPDATA) {
|
|
16418
|
+
return [join37(process.env.APPDATA, "Cursor", "User", "workspaceStorage")];
|
|
16419
|
+
}
|
|
16420
|
+
return [];
|
|
16421
|
+
}
|
|
16422
|
+
function cursorWorkspaceRoots(home, targetPlatform) {
|
|
16423
|
+
const roots = /* @__PURE__ */ new Set();
|
|
16424
|
+
for (const storage of cursorWorkspaceStorageCandidates(home, targetPlatform)) {
|
|
16425
|
+
if (!existsSync38(storage)) continue;
|
|
16426
|
+
let entries = [];
|
|
16427
|
+
try {
|
|
16428
|
+
entries = readdirSync10(storage, { withFileTypes: true });
|
|
16429
|
+
} catch {
|
|
16430
|
+
continue;
|
|
16431
|
+
}
|
|
16432
|
+
for (const entry of entries) {
|
|
16433
|
+
if (!entry.isDirectory() || entry.isSymbolicLink?.()) continue;
|
|
16434
|
+
const state = readJson(join37(storage, entry.name, "workspace.json"));
|
|
16435
|
+
const raw = state?.folder;
|
|
16436
|
+
if (typeof raw !== "string" || !raw.trim()) continue;
|
|
16437
|
+
try {
|
|
16438
|
+
const path = raw.startsWith("file:") ? fileURLToPath(raw) : raw;
|
|
16439
|
+
if (existsSync38(path)) roots.add(resolve5(path));
|
|
16440
|
+
} catch {
|
|
16441
|
+
}
|
|
16442
|
+
}
|
|
16443
|
+
}
|
|
16444
|
+
return [...roots];
|
|
16445
|
+
}
|
|
16446
|
+
function codexMcpArtifacts(content) {
|
|
16447
|
+
const artifacts = [];
|
|
16448
|
+
const sections = [...content.matchAll(/^\s*\[\s*([^\]]+)\s*\]\s*$/gm)];
|
|
16449
|
+
for (let index = 0; index < sections.length && artifacts.length < 1e3; index++) {
|
|
16450
|
+
const section = sections[index];
|
|
16451
|
+
const key = section[1].trim();
|
|
16452
|
+
const root = key.match(/^mcp_servers\s*\.\s*(?:"((?:[^"\\]|\\.)+)"|'([^']+)'|([A-Za-z0-9_-]+))$/);
|
|
16453
|
+
if (!root) continue;
|
|
16454
|
+
let name = root[1] || root[2] || root[3];
|
|
16455
|
+
if (root[1]) {
|
|
16456
|
+
try {
|
|
16457
|
+
name = JSON.parse(`"${root[1]}"`);
|
|
16458
|
+
} catch {
|
|
16459
|
+
continue;
|
|
16460
|
+
}
|
|
16461
|
+
}
|
|
16462
|
+
const start = (section.index ?? 0) + section[0].length;
|
|
16463
|
+
const end = sections[index + 1]?.index ?? content.length;
|
|
16464
|
+
const block = content.slice(start, end);
|
|
16465
|
+
const stringValue = (key2) => {
|
|
16466
|
+
const found = block.match(new RegExp(`^\\s*${key2}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, "m"));
|
|
16467
|
+
if (!found) return void 0;
|
|
16468
|
+
try {
|
|
16469
|
+
return JSON.parse(found[1]);
|
|
16470
|
+
} catch {
|
|
16471
|
+
return void 0;
|
|
16472
|
+
}
|
|
16473
|
+
};
|
|
16474
|
+
const enabled = !/^\s*enabled\s*=\s*false\s*$/m.test(block);
|
|
16475
|
+
const url = stringValue("url");
|
|
16476
|
+
const command = stringValue("command");
|
|
16477
|
+
const safeShape = { name, enabled, origin: urlOrigin(url), package_name: safePackageName(command, []) };
|
|
16478
|
+
artifacts.push({
|
|
16479
|
+
harness: "codex",
|
|
16480
|
+
type: "mcp_server",
|
|
16481
|
+
canonical_id: `${canonical(name)}@user`,
|
|
16482
|
+
display_name: name,
|
|
16483
|
+
enabled,
|
|
16484
|
+
config_scope: "user",
|
|
16485
|
+
transport: command ? "stdio" : url ? "streamable-http" : "unknown",
|
|
16486
|
+
endpoint_origin: safeShape.origin,
|
|
16487
|
+
package_name: safeShape.package_name,
|
|
16488
|
+
config_hash: sha256(JSON.stringify(safeShape))
|
|
16489
|
+
});
|
|
16490
|
+
}
|
|
16491
|
+
return artifacts;
|
|
16492
|
+
}
|
|
16493
|
+
function flattenHookEntries(value) {
|
|
16494
|
+
if (!Array.isArray(value)) return [];
|
|
16495
|
+
const out = [];
|
|
16496
|
+
for (const entry of value) {
|
|
16497
|
+
if (!entry || typeof entry !== "object") continue;
|
|
16498
|
+
const record = entry;
|
|
16499
|
+
if (typeof record.command === "string") out.push(record);
|
|
16500
|
+
if (Array.isArray(record.hooks)) out.push(...flattenHookEntries(record.hooks));
|
|
16501
|
+
}
|
|
16502
|
+
return out;
|
|
16503
|
+
}
|
|
16504
|
+
function hookArtifacts(harness, config) {
|
|
16505
|
+
if (!config?.hooks || typeof config.hooks !== "object") return [];
|
|
16506
|
+
const artifacts = [];
|
|
16507
|
+
for (const [event, rawEntries] of Object.entries(config.hooks)) {
|
|
16508
|
+
for (const entry of flattenHookEntries(rawEntries)) {
|
|
16509
|
+
const commandHash = sha256(entry.command);
|
|
16510
|
+
const managed = entry.__synkro_managed__ === true || /[\\/]\.synkro[\\/]hooks[\\/]/.test(entry.command);
|
|
16511
|
+
artifacts.push({
|
|
16512
|
+
harness,
|
|
16513
|
+
type: "hook",
|
|
16514
|
+
canonical_id: `${canonical(event)}:${commandHash.slice(0, 20)}`,
|
|
16515
|
+
display_name: `${event} \xB7 ${basename3(String(entry.command).split(/\s+/)[0] || "hook")}`,
|
|
16516
|
+
enabled: entry.enabled !== false,
|
|
16517
|
+
config_scope: "user",
|
|
16518
|
+
package_name: basename3(String(entry.command).split(/\s+/)[0] || "") || void 0,
|
|
16519
|
+
config_hash: commandHash,
|
|
16520
|
+
metadata: { managed, events: [event] }
|
|
16521
|
+
});
|
|
16522
|
+
}
|
|
16523
|
+
}
|
|
16524
|
+
return artifacts;
|
|
16525
|
+
}
|
|
16526
|
+
function parseFrontmatter(content) {
|
|
16527
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
16528
|
+
if (!match) return {};
|
|
16529
|
+
const value = (key) => match[1].match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)`, "m"))?.[1]?.trim();
|
|
16530
|
+
return { name: value("name"), version: value("version") };
|
|
16531
|
+
}
|
|
16532
|
+
function skillArtifacts(harness, root) {
|
|
16533
|
+
if (!existsSync38(root)) return [];
|
|
16534
|
+
const manifests = [];
|
|
16535
|
+
const visit = (dir) => {
|
|
16536
|
+
let entries;
|
|
16537
|
+
try {
|
|
16538
|
+
entries = readdirSync10(dir, { withFileTypes: true });
|
|
16539
|
+
} catch {
|
|
16540
|
+
return;
|
|
16541
|
+
}
|
|
16542
|
+
for (const entry of entries) {
|
|
16543
|
+
if (entry.isSymbolicLink?.()) continue;
|
|
16544
|
+
const path = join37(dir, entry.name);
|
|
16545
|
+
if (entry.isFile() && entry.name === "SKILL.md") manifests.push(path);
|
|
16546
|
+
else if (entry.isDirectory()) visit(path);
|
|
16547
|
+
}
|
|
16548
|
+
};
|
|
16549
|
+
visit(root);
|
|
16550
|
+
return manifests.map((path) => {
|
|
16551
|
+
const content = readText(path);
|
|
16552
|
+
const frontmatter = parseFrontmatter(content);
|
|
16553
|
+
const rel = relative(root, path).replaceAll("\\", "/");
|
|
16554
|
+
const name = frontmatter.name || basename3(join37(path, "..")) || "skill";
|
|
16555
|
+
return {
|
|
16556
|
+
harness,
|
|
16557
|
+
type: "skill",
|
|
16558
|
+
canonical_id: `${canonical(name)}:${sha256(rel).slice(0, 16)}`,
|
|
16559
|
+
display_name: name,
|
|
16560
|
+
version: frontmatter.version,
|
|
16561
|
+
enabled: true,
|
|
16562
|
+
config_scope: "user",
|
|
16563
|
+
config_hash: content ? sha256(content) : void 0,
|
|
16564
|
+
metadata: { source: "SKILL.md" }
|
|
16565
|
+
};
|
|
16566
|
+
});
|
|
16567
|
+
}
|
|
16568
|
+
function cursorExtensionArtifacts(root) {
|
|
16569
|
+
if (!existsSync38(root)) return [];
|
|
16570
|
+
let dirs = [];
|
|
16571
|
+
try {
|
|
16572
|
+
dirs = readdirSync10(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink());
|
|
16573
|
+
} catch {
|
|
16574
|
+
return [];
|
|
16575
|
+
}
|
|
16576
|
+
const artifacts = [];
|
|
16577
|
+
for (const dir of dirs) {
|
|
16578
|
+
const pkg = readJson(join37(root, dir.name, "package.json"));
|
|
16579
|
+
if (!pkg) continue;
|
|
16580
|
+
const publisher = typeof pkg.publisher === "string" ? pkg.publisher : void 0;
|
|
16581
|
+
const name = typeof pkg.name === "string" ? pkg.name : dir.name;
|
|
16582
|
+
const id = publisher ? `${publisher}.${name}` : name;
|
|
16583
|
+
artifacts.push({
|
|
16584
|
+
harness: "cursor",
|
|
16585
|
+
type: "extension",
|
|
16586
|
+
canonical_id: canonical(id),
|
|
16587
|
+
display_name: String(pkg.displayName || name),
|
|
16588
|
+
version: typeof pkg.version === "string" ? pkg.version : void 0,
|
|
16589
|
+
enabled: true,
|
|
16590
|
+
config_scope: "user",
|
|
16591
|
+
config_hash: sha256(JSON.stringify({ id, version: pkg.version })),
|
|
16592
|
+
metadata: publisher ? { publisher } : void 0
|
|
16593
|
+
});
|
|
16594
|
+
}
|
|
16595
|
+
return artifacts;
|
|
16596
|
+
}
|
|
16597
|
+
function deploymentMode2(home) {
|
|
16598
|
+
const raw = readText(join37(home, ".synkro", "config.env"));
|
|
16599
|
+
const value = (key) => raw.match(new RegExp(`^${key}=['"]?([^'"\\n]*)`, "m"))?.[1]?.toLowerCase();
|
|
16600
|
+
if (value("SYNKRO_GRADING_MODE") === "byok") return "byok";
|
|
16601
|
+
if (value("SYNKRO_STORAGE_MODE") === "cloud") return "cloud";
|
|
16602
|
+
return "local";
|
|
16603
|
+
}
|
|
16604
|
+
function telemetryHealth(home) {
|
|
16605
|
+
const meta = readJson(join37(home, ".synkro", "telemetry-meta.json"));
|
|
16606
|
+
const health = {};
|
|
16607
|
+
if (meta?.last_flush_ok_at && Number.isFinite(Date.parse(meta.last_flush_ok_at))) health.telemetry_last_flush_at = meta.last_flush_ok_at;
|
|
16608
|
+
if (meta?.last_flush_error) health.telemetry_last_error = "flush_failed";
|
|
16609
|
+
const queue = join37(home, ".synkro", "telemetry-pending.jsonl");
|
|
16610
|
+
try {
|
|
16611
|
+
const size = statSync5(queue).size;
|
|
16612
|
+
health.telemetry_backlog = size <= 5 * 1024 * 1024 ? readFileSync36(queue, "utf8").split("\n").filter(Boolean).length : Math.ceil(size / 1024);
|
|
16613
|
+
} catch {
|
|
16614
|
+
}
|
|
16615
|
+
return health;
|
|
16616
|
+
}
|
|
16617
|
+
function harnessSnapshot(agent) {
|
|
16618
|
+
if (agent.kind === "claude_code") {
|
|
16619
|
+
const config2 = readJson(agent.settingsPath);
|
|
16620
|
+
const coverage2 = inspectCCHooks(agent.settingsPath);
|
|
16621
|
+
return {
|
|
16622
|
+
row: {
|
|
16623
|
+
harness: "claude_code",
|
|
16624
|
+
installed: true,
|
|
16625
|
+
enabled: coverage2.installed,
|
|
16626
|
+
version: agent.version,
|
|
16627
|
+
permission_mode: config2?.permissions?.defaultMode,
|
|
16628
|
+
hook_coverage: coverage2,
|
|
16629
|
+
hook_config_hash: sha256(JSON.stringify(coverage2)),
|
|
16630
|
+
config_scope: "user"
|
|
16631
|
+
},
|
|
16632
|
+
config: config2
|
|
16633
|
+
};
|
|
16634
|
+
}
|
|
16635
|
+
if (agent.kind === "cursor") {
|
|
16636
|
+
const config2 = readJson(agent.settingsPath);
|
|
16637
|
+
const coverage2 = inspectCursorHooks(agent.settingsPath);
|
|
16638
|
+
return {
|
|
16639
|
+
row: {
|
|
16640
|
+
harness: "cursor",
|
|
16641
|
+
installed: true,
|
|
16642
|
+
enabled: coverage2.installed,
|
|
16643
|
+
version: agent.version,
|
|
16644
|
+
hook_coverage: coverage2,
|
|
16645
|
+
hook_config_hash: sha256(JSON.stringify(coverage2)),
|
|
16646
|
+
config_scope: "user"
|
|
16647
|
+
},
|
|
16648
|
+
config: config2
|
|
16649
|
+
};
|
|
16650
|
+
}
|
|
16651
|
+
const config = readJson(agent.settingsPath);
|
|
16652
|
+
const coverage = inspectCodexHooks(agent.settingsPath);
|
|
16653
|
+
const toml = readText(join37(agent.configDir, "config.toml"));
|
|
16654
|
+
const permission = toml.match(/^\s*approval_policy\s*=\s*["']([^"']+)/m)?.[1];
|
|
16655
|
+
return {
|
|
16656
|
+
row: {
|
|
16657
|
+
harness: "codex",
|
|
16658
|
+
installed: true,
|
|
16659
|
+
enabled: coverage.installed,
|
|
16660
|
+
version: agent.version,
|
|
16661
|
+
permission_mode: permission,
|
|
16662
|
+
hook_coverage: coverage,
|
|
16663
|
+
hook_config_hash: sha256(JSON.stringify(coverage)),
|
|
16664
|
+
config_scope: "user"
|
|
16665
|
+
},
|
|
16666
|
+
config
|
|
16667
|
+
};
|
|
16668
|
+
}
|
|
16669
|
+
function collectOperationalInventory(options = {}) {
|
|
16670
|
+
const home = options.homeDir ?? homedir37();
|
|
16671
|
+
const detected = options.detectedAgents ?? detectAgents();
|
|
16672
|
+
const identity = getOperationalInstallationIdentity(options.identityPath);
|
|
16673
|
+
const targetPlatform = options.platformName ?? platform5();
|
|
16674
|
+
const codexHome = options.homeDir ? join37(home, ".codex") : process.env.CODEX_HOME || join37(home, ".codex");
|
|
16675
|
+
const harnesses = [];
|
|
16676
|
+
const artifacts = [];
|
|
16677
|
+
for (const agent of detected) {
|
|
16678
|
+
const { row: row2, config } = harnessSnapshot(agent);
|
|
16679
|
+
harnesses.push(row2);
|
|
16680
|
+
artifacts.push(...hookArtifacts(row2.harness, config));
|
|
16681
|
+
}
|
|
16682
|
+
const claudeJson = readJson(join37(home, ".claude.json"));
|
|
16683
|
+
artifacts.push(...mcpArtifactsFromJson("claude_code", claudeJson));
|
|
16684
|
+
if (claudeJson?.projects && typeof claudeJson.projects === "object") {
|
|
16685
|
+
for (const [projectPath, project] of Object.entries(claudeJson.projects)) {
|
|
16686
|
+
if (!project || typeof project !== "object") continue;
|
|
16687
|
+
artifacts.push(...mcpArtifactsFromJson("claude_code", project, `local:${sha256(projectPath).slice(0, 16)}`));
|
|
16688
|
+
}
|
|
16689
|
+
}
|
|
16690
|
+
artifacts.push(...mcpArtifactsFromJson("cursor", readJson(join37(home, ".cursor", "mcp.json"))));
|
|
16691
|
+
artifacts.push(...codexMcpArtifacts(readText(join37(codexHome, "config.toml"))));
|
|
16692
|
+
const projectRoots = discoveredProjectRoots(
|
|
16693
|
+
claudeJson,
|
|
16694
|
+
options.currentDirectory ?? process.cwd(),
|
|
16695
|
+
options.projectRoots,
|
|
16696
|
+
cursorWorkspaceRoots(home, targetPlatform)
|
|
16697
|
+
);
|
|
16698
|
+
for (const projectRoot of projectRoots) {
|
|
16699
|
+
const scopeHash = sha256(projectRoot).slice(0, 16);
|
|
16700
|
+
artifacts.push(...mcpArtifactsFromJson(
|
|
16701
|
+
"claude_code",
|
|
16702
|
+
readJson(join37(projectRoot, ".mcp.json")),
|
|
16703
|
+
`project:${scopeHash}`
|
|
16704
|
+
));
|
|
16705
|
+
const cursorProjectConfig = join37(projectRoot, ".cursor", "mcp.json");
|
|
16706
|
+
if (resolve5(cursorProjectConfig) !== resolve5(join37(home, ".cursor", "mcp.json"))) {
|
|
16707
|
+
artifacts.push(...mcpArtifactsFromJson(
|
|
16708
|
+
"cursor",
|
|
16709
|
+
readJson(cursorProjectConfig),
|
|
16710
|
+
`project:${scopeHash}`
|
|
16711
|
+
));
|
|
16712
|
+
}
|
|
16713
|
+
}
|
|
16714
|
+
for (const managedPath of claudeManagedMcpConfigCandidates(targetPlatform)) {
|
|
16715
|
+
artifacts.push(...mcpArtifactsFromJson("claude_code", readJson(managedPath), "managed"));
|
|
16716
|
+
}
|
|
16717
|
+
const desktopConfigPath = claudeDesktopConfigCandidates(home, targetPlatform).find((path) => existsSync38(path));
|
|
16718
|
+
if (desktopConfigPath) {
|
|
16719
|
+
const desktopConfig = readJson(desktopConfigPath);
|
|
16720
|
+
harnesses.push({
|
|
16721
|
+
harness: "claude_desktop",
|
|
16722
|
+
installed: true,
|
|
16723
|
+
enabled: true,
|
|
16724
|
+
config_scope: "user"
|
|
16725
|
+
});
|
|
16726
|
+
artifacts.push(...mcpArtifactsFromJson("claude_desktop", desktopConfig));
|
|
16727
|
+
}
|
|
16728
|
+
const claudeSettings = readJson(join37(home, ".claude", "settings.json"));
|
|
16729
|
+
if (claudeSettings?.enabledPlugins && typeof claudeSettings.enabledPlugins === "object") {
|
|
16730
|
+
for (const [name, enabled] of Object.entries(claudeSettings.enabledPlugins)) {
|
|
16731
|
+
artifacts.push({
|
|
16732
|
+
harness: "claude_code",
|
|
16733
|
+
type: "plugin",
|
|
16734
|
+
canonical_id: canonical(name),
|
|
16735
|
+
display_name: name,
|
|
16736
|
+
enabled: enabled === true,
|
|
16737
|
+
config_scope: "user",
|
|
16738
|
+
metadata: { source: "enabledPlugins" }
|
|
16739
|
+
});
|
|
16740
|
+
}
|
|
16741
|
+
}
|
|
16742
|
+
artifacts.push(...skillArtifacts("claude_code", join37(home, ".claude", "skills")));
|
|
16743
|
+
artifacts.push(...skillArtifacts("cursor", join37(home, ".cursor", "skills")));
|
|
16744
|
+
artifacts.push(...skillArtifacts("codex", join37(codexHome, "skills")));
|
|
16745
|
+
artifacts.push(...cursorExtensionArtifacts(join37(home, ".cursor", "extensions")));
|
|
16746
|
+
const uniqueArtifacts = /* @__PURE__ */ new Map();
|
|
16747
|
+
for (const artifact of artifacts) {
|
|
16748
|
+
const key = `${artifact.harness || "global"}:${artifact.type}:${artifact.canonical_id}`;
|
|
16749
|
+
uniqueArtifacts.set(key, artifact);
|
|
16750
|
+
}
|
|
16751
|
+
const codingHarnesses = harnesses.filter((row2) => row2.harness === "claude_code" || row2.harness === "cursor" || row2.harness === "codex");
|
|
16752
|
+
const health = telemetryHealth(home) ?? {};
|
|
16753
|
+
health.scanners = {
|
|
16754
|
+
hook_runtime: codingHarnesses.length === 0 ? "unknown" : codingHarnesses.every((row2) => row2.enabled) ? "ok" : "degraded"
|
|
16755
|
+
};
|
|
16756
|
+
return {
|
|
16757
|
+
schema_version: 1,
|
|
16758
|
+
collected_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16759
|
+
installation: {
|
|
16760
|
+
install_id: identity.installation_id,
|
|
16761
|
+
hostname_hash: pseudonymousHostnameHash(identity.installation_id, hostname2()),
|
|
16762
|
+
platform: targetPlatform,
|
|
16763
|
+
os_version: release(),
|
|
16764
|
+
arch: arch(),
|
|
16765
|
+
cli_version: cliVersion(),
|
|
16766
|
+
node_version: process.version,
|
|
16767
|
+
bun_version: process.versions.bun,
|
|
16768
|
+
deployment_mode: deploymentMode2(home)
|
|
16769
|
+
},
|
|
16770
|
+
harnesses,
|
|
16771
|
+
artifacts: [...uniqueArtifacts.values()],
|
|
16772
|
+
health
|
|
16773
|
+
};
|
|
16774
|
+
}
|
|
16775
|
+
var init_collector = __esm({
|
|
16776
|
+
"cli/inventory/collector.ts"() {
|
|
16777
|
+
"use strict";
|
|
16778
|
+
init_agentDetect();
|
|
16779
|
+
init_ccHookConfig();
|
|
16780
|
+
init_cursorHookConfig();
|
|
16781
|
+
init_codexHookConfig();
|
|
16782
|
+
init_identity2();
|
|
16783
|
+
}
|
|
16784
|
+
});
|
|
16785
|
+
|
|
16786
|
+
// cli/inventory/sync.ts
|
|
16787
|
+
var sync_exports2 = {};
|
|
16788
|
+
__export(sync_exports2, {
|
|
16789
|
+
inventorySnapshotChunks: () => inventorySnapshotChunks,
|
|
16790
|
+
inventorySyncTarget: () => inventorySyncTarget,
|
|
16791
|
+
resolveInventoryGateway: () => resolveInventoryGateway,
|
|
16792
|
+
resolveLocalInventoryUrl: () => resolveLocalInventoryUrl,
|
|
16793
|
+
shouldSyncInventory: () => shouldSyncInventory,
|
|
16794
|
+
syncOperationalInventory: () => syncOperationalInventory,
|
|
16795
|
+
syncOperationalInventoryDetached: () => syncOperationalInventoryDetached
|
|
16796
|
+
});
|
|
16797
|
+
import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
|
|
16798
|
+
import { spawn as spawn8 } from "child_process";
|
|
16799
|
+
import {
|
|
16800
|
+
existsSync as existsSync39,
|
|
16801
|
+
mkdirSync as mkdirSync21,
|
|
16802
|
+
readFileSync as readFileSync37,
|
|
16803
|
+
renameSync as renameSync10,
|
|
16804
|
+
writeFileSync as writeFileSync26
|
|
16805
|
+
} from "fs";
|
|
16806
|
+
import { homedir as homedir38 } from "os";
|
|
16807
|
+
import { dirname as dirname10, join as join38 } from "path";
|
|
16808
|
+
function syncStatePath() {
|
|
16809
|
+
return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join38(homedir38(), ".synkro", "inventory-sync.json");
|
|
16810
|
+
}
|
|
16811
|
+
function readState(path = syncStatePath()) {
|
|
16812
|
+
try {
|
|
16813
|
+
const parsed = JSON.parse(readFileSync37(path, "utf8"));
|
|
16814
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
16815
|
+
} catch {
|
|
16816
|
+
return {};
|
|
16817
|
+
}
|
|
16818
|
+
}
|
|
16819
|
+
function writeState(state, path = syncStatePath()) {
|
|
16820
|
+
try {
|
|
16821
|
+
mkdirSync21(dirname10(path), { recursive: true, mode: 448 });
|
|
16822
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
16823
|
+
writeFileSync26(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
16824
|
+
renameSync10(temp, path);
|
|
16825
|
+
} catch {
|
|
16826
|
+
}
|
|
16827
|
+
}
|
|
16828
|
+
function shouldSyncInventory(state, now = Date.now(), target) {
|
|
16829
|
+
if (target && state.last_target !== target) return true;
|
|
16830
|
+
const lastOk = state.last_ok_at ? Date.parse(state.last_ok_at) : 0;
|
|
16831
|
+
if (Number.isFinite(lastOk) && lastOk > 0 && now - lastOk < SUCCESS_INTERVAL_MS) return false;
|
|
16832
|
+
const lastAttempt = state.last_attempt_at ? Date.parse(state.last_attempt_at) : 0;
|
|
16833
|
+
return !Number.isFinite(lastAttempt) || lastAttempt <= 0 || now - lastAttempt >= FAILURE_RETRY_MS;
|
|
16834
|
+
}
|
|
16835
|
+
function readConfig() {
|
|
16836
|
+
const path = join38(homedir38(), ".synkro", "config.env");
|
|
16837
|
+
const out = {};
|
|
16838
|
+
try {
|
|
16839
|
+
for (const rawLine of readFileSync37(path, "utf8").split("\n")) {
|
|
16840
|
+
const line = rawLine.trim();
|
|
16841
|
+
if (!line || line.startsWith("#")) continue;
|
|
16842
|
+
const index = line.indexOf("=");
|
|
16843
|
+
if (index <= 0) continue;
|
|
16844
|
+
const key = line.slice(0, index).trim();
|
|
16845
|
+
let value = line.slice(index + 1).trim();
|
|
16846
|
+
if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
16847
|
+
out[key] = value;
|
|
16848
|
+
}
|
|
16849
|
+
} catch {
|
|
16850
|
+
}
|
|
16851
|
+
return out;
|
|
16852
|
+
}
|
|
16853
|
+
function inventorySyncTarget(config, env = process.env) {
|
|
16854
|
+
return (config.SYNKRO_STORAGE_MODE || env.SYNKRO_STORAGE_MODE) === "cloud" ? "cloud" : "local";
|
|
16855
|
+
}
|
|
16856
|
+
function resolveLocalInventoryUrl(rawPort) {
|
|
16857
|
+
const parsed = Number.parseInt(rawPort || "", 10);
|
|
16858
|
+
const port = Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : 18931;
|
|
16859
|
+
return `http://127.0.0.1:${port}/api/local/inventory/snapshot`;
|
|
16860
|
+
}
|
|
16861
|
+
function localhost(host) {
|
|
16862
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
16863
|
+
}
|
|
16864
|
+
function resolveInventoryGateway(raw) {
|
|
16865
|
+
if (!raw) return DEFAULT_GATEWAY2;
|
|
16866
|
+
try {
|
|
16867
|
+
const url = new URL(raw);
|
|
16868
|
+
const host = url.hostname.toLowerCase();
|
|
16869
|
+
const allowedHost = localhost(host) || host === "synkro.sh" || host.endsWith(".synkro.sh");
|
|
16870
|
+
const allowedProtocol = url.protocol === "https:" || url.protocol === "http:" && localhost(host);
|
|
16871
|
+
return allowedHost && allowedProtocol ? raw.replace(/\/$/, "") : DEFAULT_GATEWAY2;
|
|
16872
|
+
} catch {
|
|
16873
|
+
return DEFAULT_GATEWAY2;
|
|
16874
|
+
}
|
|
16875
|
+
}
|
|
16876
|
+
async function loadToken() {
|
|
16877
|
+
try {
|
|
16878
|
+
const durable = readFileSync37(join38(homedir38(), ".synkro", ".mcp-jwt"), "utf8").trim();
|
|
16879
|
+
if (durable) return durable;
|
|
16880
|
+
} catch {
|
|
16881
|
+
}
|
|
16882
|
+
try {
|
|
16883
|
+
const auth = await Promise.resolve().then(() => (init_stub(), stub_exports));
|
|
16884
|
+
if (!auth.isAuthenticated()) return null;
|
|
16885
|
+
await auth.ensureValidToken();
|
|
16886
|
+
return auth.getAccessToken();
|
|
16887
|
+
} catch {
|
|
16888
|
+
return null;
|
|
16889
|
+
}
|
|
16890
|
+
}
|
|
16891
|
+
function stable(value) {
|
|
16892
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
16893
|
+
if (!value || typeof value !== "object") return value;
|
|
16894
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, stable(child)]));
|
|
16895
|
+
}
|
|
16896
|
+
function inventorySnapshotChunks(snapshot, maxBytes = INVENTORY_CHUNK_BYTES) {
|
|
16897
|
+
const { collected_at: _heartbeat, ...material } = snapshot;
|
|
16898
|
+
const payloadHash = createHash6("sha256").update(JSON.stringify(stable(material))).digest("hex");
|
|
16899
|
+
const snapshotId = randomUUID6();
|
|
16900
|
+
const base = { ...snapshot, artifacts: [] };
|
|
16901
|
+
const baseBytes = Buffer.byteLength(JSON.stringify(base));
|
|
16902
|
+
const chunks = [];
|
|
16903
|
+
let artifacts = [];
|
|
16904
|
+
let bytes = baseBytes;
|
|
16905
|
+
for (const artifact of snapshot.artifacts) {
|
|
16906
|
+
const artifactBytes = Buffer.byteLength(JSON.stringify(artifact)) + 1;
|
|
16907
|
+
if (artifacts.length > 0 && bytes + artifactBytes > maxBytes) {
|
|
16908
|
+
chunks.push({ ...snapshot, artifacts });
|
|
16909
|
+
artifacts = [];
|
|
16910
|
+
bytes = baseBytes;
|
|
16911
|
+
}
|
|
16912
|
+
artifacts.push(artifact);
|
|
16913
|
+
bytes += artifactBytes;
|
|
16914
|
+
}
|
|
16915
|
+
if (artifacts.length > 0 || chunks.length === 0) chunks.push({ ...snapshot, artifacts });
|
|
16916
|
+
return { snapshotId, payloadHash, chunks };
|
|
16917
|
+
}
|
|
16918
|
+
async function postInventoryChunks(url, headers, snapshot) {
|
|
16919
|
+
const delivery = inventorySnapshotChunks(snapshot);
|
|
16920
|
+
let response = null;
|
|
16921
|
+
for (let index = 0; index < delivery.chunks.length; index++) {
|
|
16922
|
+
response = await fetch(url, {
|
|
16923
|
+
method: "POST",
|
|
16924
|
+
headers: {
|
|
16925
|
+
...headers,
|
|
16926
|
+
"Content-Type": "application/json",
|
|
16927
|
+
"X-Synkro-Inventory-Snapshot-Id": delivery.snapshotId,
|
|
16928
|
+
"X-Synkro-Inventory-Payload-Hash": delivery.payloadHash,
|
|
16929
|
+
"X-Synkro-Inventory-Chunk-Index": String(index),
|
|
16930
|
+
"X-Synkro-Inventory-Chunk-Count": String(delivery.chunks.length),
|
|
16931
|
+
"X-Synkro-Inventory-Artifact-Count": String(snapshot.artifacts.length)
|
|
16932
|
+
},
|
|
16933
|
+
body: JSON.stringify(delivery.chunks[index]),
|
|
16934
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
|
|
16935
|
+
});
|
|
16936
|
+
if (!response.ok) return response;
|
|
16937
|
+
}
|
|
16938
|
+
return response;
|
|
16939
|
+
}
|
|
16940
|
+
async function syncOperationalInventory(options = {}) {
|
|
16941
|
+
const path = syncStatePath();
|
|
16942
|
+
const state = readState(path);
|
|
16943
|
+
const config = readConfig();
|
|
16944
|
+
const target = inventorySyncTarget(config);
|
|
16945
|
+
if (!options.force && !shouldSyncInventory(state, Date.now(), target)) return { ok: true, skipped: "throttled", target };
|
|
16946
|
+
const attemptedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
16947
|
+
writeState({ ...state, last_attempt_at: attemptedAt, last_target: target }, path);
|
|
16948
|
+
try {
|
|
16949
|
+
const snapshot = collectOperationalInventory();
|
|
16950
|
+
if (target === "local") {
|
|
16951
|
+
const response2 = await postInventoryChunks(resolveLocalInventoryUrl(
|
|
16952
|
+
process.env.SYNKRO_HOST_MCP_PORT || process.env.SYNKRO_MCP_PORT || config.SYNKRO_MCP_PORT
|
|
16953
|
+
), {}, snapshot);
|
|
16954
|
+
if (!response2.ok) {
|
|
16955
|
+
const error = `http_${response2.status}`;
|
|
16956
|
+
writeState({ ...state, last_attempt_at: attemptedAt, last_error: error, last_target: target }, path);
|
|
16957
|
+
return { ok: false, status: response2.status, error, target };
|
|
16958
|
+
}
|
|
16959
|
+
writeState({ last_attempt_at: attemptedAt, last_ok_at: (/* @__PURE__ */ new Date()).toISOString(), last_target: target }, path);
|
|
16960
|
+
return { ok: true, status: response2.status, target };
|
|
16961
|
+
}
|
|
16962
|
+
const token = await loadToken();
|
|
16963
|
+
if (!token) {
|
|
16964
|
+
writeState({ ...state, last_attempt_at: attemptedAt, last_error: "no_auth", last_target: target }, path);
|
|
16965
|
+
return { ok: false, skipped: "no_auth", target };
|
|
16966
|
+
}
|
|
16967
|
+
const gateway = resolveInventoryGateway(
|
|
16968
|
+
process.env.SYNKRO_GATEWAY_URL || config.SYNKRO_GATEWAY_URL || process.env.SYNKRO_API_URL
|
|
16969
|
+
);
|
|
16970
|
+
const response = await postInventoryChunks(
|
|
16971
|
+
`${gateway}/api/v1/cli/inventory/snapshot`,
|
|
16972
|
+
{ Authorization: `Bearer ${token}` },
|
|
16973
|
+
snapshot
|
|
16974
|
+
);
|
|
16975
|
+
if (!response.ok) {
|
|
16976
|
+
const error = `http_${response.status}`;
|
|
16977
|
+
writeState({ ...state, last_attempt_at: attemptedAt, last_error: error, last_target: target }, path);
|
|
16978
|
+
return { ok: false, status: response.status, error, target };
|
|
16979
|
+
}
|
|
16980
|
+
writeState({ last_attempt_at: attemptedAt, last_ok_at: (/* @__PURE__ */ new Date()).toISOString(), last_target: target }, path);
|
|
16981
|
+
return { ok: true, status: response.status, target };
|
|
16982
|
+
} catch {
|
|
16983
|
+
writeState({ ...state, last_attempt_at: attemptedAt, last_error: "sync_failed", last_target: target }, path);
|
|
16984
|
+
return { ok: false, error: "sync_failed", target };
|
|
16985
|
+
}
|
|
16986
|
+
}
|
|
16987
|
+
function syncOperationalInventoryDetached() {
|
|
16988
|
+
if (process.env.SYNKRO_INVENTORY_DETACHED === "1") return;
|
|
16989
|
+
const path = syncStatePath();
|
|
16990
|
+
const state = readState(path);
|
|
16991
|
+
const target = inventorySyncTarget(readConfig());
|
|
16992
|
+
if (!shouldSyncInventory(state, Date.now(), target)) return;
|
|
16993
|
+
writeState({ ...state, last_attempt_at: (/* @__PURE__ */ new Date()).toISOString(), last_target: target }, path);
|
|
16994
|
+
try {
|
|
16995
|
+
const script = process.argv[1];
|
|
16996
|
+
if (!script || !existsSync39(script)) return;
|
|
16997
|
+
const child = spawn8(process.execPath, [script, "inventory-sync", "--detached"], {
|
|
16998
|
+
detached: true,
|
|
16999
|
+
stdio: "ignore",
|
|
17000
|
+
env: { ...process.env, SYNKRO_INVENTORY_DETACHED: "1" }
|
|
17001
|
+
});
|
|
17002
|
+
child.unref();
|
|
17003
|
+
} catch {
|
|
17004
|
+
}
|
|
17005
|
+
}
|
|
17006
|
+
var DEFAULT_GATEWAY2, SUCCESS_INTERVAL_MS, FAILURE_RETRY_MS, REQUEST_TIMEOUT_MS2, INVENTORY_CHUNK_BYTES;
|
|
17007
|
+
var init_sync2 = __esm({
|
|
17008
|
+
"cli/inventory/sync.ts"() {
|
|
17009
|
+
"use strict";
|
|
17010
|
+
init_collector();
|
|
17011
|
+
DEFAULT_GATEWAY2 = "https://api.synkro.sh";
|
|
17012
|
+
SUCCESS_INTERVAL_MS = 30 * 6e4;
|
|
17013
|
+
FAILURE_RETRY_MS = 5 * 6e4;
|
|
17014
|
+
REQUEST_TIMEOUT_MS2 = 15e3;
|
|
17015
|
+
INVENTORY_CHUNK_BYTES = 15e5;
|
|
17016
|
+
}
|
|
17017
|
+
});
|
|
17018
|
+
|
|
14528
17019
|
// cli/bootstrap.js
|
|
14529
|
-
import { readFileSync as
|
|
14530
|
-
import { resolve as
|
|
17020
|
+
import { readFileSync as readFileSync38, existsSync as existsSync40 } from "fs";
|
|
17021
|
+
import { resolve as resolve6 } from "path";
|
|
14531
17022
|
var envCandidates = [
|
|
14532
|
-
|
|
17023
|
+
resolve6(process.env.HOME ?? "", ".synkro", "config.env")
|
|
14533
17024
|
];
|
|
14534
17025
|
for (const envPath of envCandidates) {
|
|
14535
|
-
if (!
|
|
14536
|
-
const envContent =
|
|
17026
|
+
if (!existsSync40(envPath)) continue;
|
|
17027
|
+
const envContent = readFileSync38(envPath, "utf-8");
|
|
14537
17028
|
for (const line of envContent.split("\n")) {
|
|
14538
17029
|
const trimmed = line.trim();
|
|
14539
17030
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -14548,9 +17039,9 @@ var args = process.argv.slice(2);
|
|
|
14548
17039
|
var cmd2 = args[0] || "";
|
|
14549
17040
|
var subArgs = args.slice(1);
|
|
14550
17041
|
var isDetachedChild = process.env.SYNKRO_TELEMETRY_DETACHED === "1";
|
|
14551
|
-
var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "version", "--version", "-v", "help", "--help", "-h", ""]);
|
|
17042
|
+
var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "inventory-sync", "version", "--version", "-v", "help", "--help", "-h", ""]);
|
|
14552
17043
|
function printVersion() {
|
|
14553
|
-
console.log("1.
|
|
17044
|
+
console.log("1.9.0");
|
|
14554
17045
|
}
|
|
14555
17046
|
function printHelp2() {
|
|
14556
17047
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|
|
@@ -14569,9 +17060,16 @@ Commands:
|
|
|
14569
17060
|
claude-desktop Monitor Claude Desktop conversations (local, macOS)
|
|
14570
17061
|
telemetry <sub> Inspect or flush local telemetry events
|
|
14571
17062
|
whoami Show resolved identity + where grading runs
|
|
17063
|
+
workspace <sub> Answer the task-workspace question (stay/clear/status)
|
|
17064
|
+
ui Governed multiplexer: spaces + agents, real sessions in tabs
|
|
14572
17065
|
refresh Refresh the login session (keeps you signed in; run on a schedule)
|
|
14573
17066
|
version Show version
|
|
14574
17067
|
|
|
17068
|
+
workspace:
|
|
17069
|
+
synkro workspace stay <taskId> keep working in the current checkout
|
|
17070
|
+
synkro workspace clear <taskId> forget the choice (Synkro asks again)
|
|
17071
|
+
synkro workspace status [taskId] show recorded choices
|
|
17072
|
+
|
|
14575
17073
|
config:
|
|
14576
17074
|
synkro config show current settings
|
|
14577
17075
|
synkro config grading <local|byok> where grading runs
|
|
@@ -14675,6 +17173,16 @@ async function main() {
|
|
|
14675
17173
|
await whoamiCommand2(subArgs);
|
|
14676
17174
|
break;
|
|
14677
17175
|
}
|
|
17176
|
+
case "workspace": {
|
|
17177
|
+
const { workspaceCommand: workspaceCommand2 } = await Promise.resolve().then(() => (init_workspace(), workspace_exports));
|
|
17178
|
+
await workspaceCommand2(subArgs);
|
|
17179
|
+
break;
|
|
17180
|
+
}
|
|
17181
|
+
case "ui": {
|
|
17182
|
+
const { uiCommand: uiCommand2 } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17183
|
+
await uiCommand2(subArgs);
|
|
17184
|
+
break;
|
|
17185
|
+
}
|
|
14678
17186
|
case "refresh": {
|
|
14679
17187
|
const { refreshCommand: refreshCommand2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
|
|
14680
17188
|
await refreshCommand2(subArgs);
|
|
@@ -14738,6 +17246,13 @@ async function main() {
|
|
|
14738
17246
|
await telemetryCommand2(subArgs);
|
|
14739
17247
|
break;
|
|
14740
17248
|
}
|
|
17249
|
+
// Internal detached worker. Operational inventory is independent of product
|
|
17250
|
+
// telemetry and always fail-open; it intentionally stays out of help output.
|
|
17251
|
+
case "inventory-sync": {
|
|
17252
|
+
const { syncOperationalInventory: syncOperationalInventory2 } = await Promise.resolve().then(() => (init_sync2(), sync_exports2));
|
|
17253
|
+
await syncOperationalInventory2({ force: subArgs.includes("--detached") || subArgs.includes("--force") });
|
|
17254
|
+
break;
|
|
17255
|
+
}
|
|
14741
17256
|
default: {
|
|
14742
17257
|
console.error(`Unknown command: ${cmd2}`);
|
|
14743
17258
|
printHelp2();
|
|
@@ -14752,6 +17267,11 @@ async function postDispatchFlush() {
|
|
|
14752
17267
|
flushDetached2();
|
|
14753
17268
|
} catch {
|
|
14754
17269
|
}
|
|
17270
|
+
try {
|
|
17271
|
+
const { syncOperationalInventoryDetached: syncOperationalInventoryDetached2 } = await Promise.resolve().then(() => (init_sync2(), sync_exports2));
|
|
17272
|
+
syncOperationalInventoryDetached2();
|
|
17273
|
+
} catch {
|
|
17274
|
+
}
|
|
14755
17275
|
}
|
|
14756
17276
|
async function shutdown(code) {
|
|
14757
17277
|
try {
|
|
@@ -14775,7 +17295,7 @@ async function shutdown(code) {
|
|
|
14775
17295
|
}, 200);
|
|
14776
17296
|
force.unref();
|
|
14777
17297
|
}
|
|
14778
|
-
main().then(() => postDispatchFlush()).then(() => shutdown(0)).catch(async (err) => {
|
|
17298
|
+
main().then(() => postDispatchFlush()).then(() => shutdown(typeof process.exitCode === "number" ? process.exitCode : 0)).catch(async (err) => {
|
|
14779
17299
|
try {
|
|
14780
17300
|
const { emit: emit2 } = await Promise.resolve().then(() => (init_telemetry(), telemetry_exports));
|
|
14781
17301
|
emit2("error", {
|