copyrun 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +105 -2
- package/dist/archive-hook.js +252 -0
- package/dist/archive-worker.js +7169 -0
- package/dist/ask.js +3919 -0
- package/dist/autopilot-worker.js +226 -0
- package/dist/autopilot.js +3501 -0
- package/dist/check.js +4722 -0
- package/dist/index.js +29678 -0
- package/dist/live.js +298 -0
- package/dist/mission-watch.js +541 -0
- package/dist/mission.js +472 -0
- package/dist/nudge.js +441 -0
- package/dist/receipt.js +349 -0
- package/dist/seal-worker.js +132 -0
- package/package.json +46 -4
- package/skills/copyrun/SKILL.md +97 -0
- package/skills/copyrun-router/SKILL.md +61 -0
package/dist/nudge.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/nudge-hook.ts
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { closeSync as closeSync2, existsSync as existsSync2, fstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync as readSync2, renameSync as renameSync2, writeFileSync as writeFileSync2, writeSync } from "fs";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { dirname, join as join3 } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
// src/nudge-logic.ts
|
|
11
|
+
var NUDGE_STATE_FILE = "nudged.json";
|
|
12
|
+
var MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
var NUDGE_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
14
|
+
var NUDGE_MAX_SESSIONS = 500;
|
|
15
|
+
var MIN_TOOL_CALLS = 8;
|
|
16
|
+
var MIN_FILES_CHANGED = 1;
|
|
17
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
18
|
+
function analyzeTranscript(text) {
|
|
19
|
+
const ids = /* @__PURE__ */ new Set();
|
|
20
|
+
const files = /* @__PURE__ */ new Set();
|
|
21
|
+
let anon = 0;
|
|
22
|
+
const re = /"type"\s*:\s*"tool_use"/g;
|
|
23
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
24
|
+
const end = text.indexOf("\n", m.index);
|
|
25
|
+
const win = text.slice(m.index, Math.min(end === -1 ? text.length : end, m.index + 2e3));
|
|
26
|
+
const id = win.match(/"id"\s*:\s*"([^"\\]+)"/)?.[1];
|
|
27
|
+
if (id) {
|
|
28
|
+
if (ids.has(id)) continue;
|
|
29
|
+
ids.add(id);
|
|
30
|
+
} else anon++;
|
|
31
|
+
const name = win.match(/"name"\s*:\s*"([^"\\]+)"/)?.[1];
|
|
32
|
+
if (name && WRITE_TOOLS.has(name)) {
|
|
33
|
+
const path = win.match(/"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1];
|
|
34
|
+
files.add(path ?? `#${id ?? anon}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { toolCalls: ids.size + anon, filesChanged: files.size };
|
|
38
|
+
}
|
|
39
|
+
function isSubstantial(s, minTools = MIN_TOOL_CALLS, minFiles = MIN_FILES_CHANGED) {
|
|
40
|
+
return s.toolCalls >= minTools && s.filesChanged >= minFiles;
|
|
41
|
+
}
|
|
42
|
+
function skillsUsed(text) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const add = (n) => {
|
|
45
|
+
if (!n || !/^[A-Za-z0-9._-]{1,100}$/.test(n) || n === "." || n === "..") return;
|
|
46
|
+
const i = out.indexOf(n);
|
|
47
|
+
if (i !== -1) out.splice(i, 1);
|
|
48
|
+
out.push(n);
|
|
49
|
+
};
|
|
50
|
+
const re = /"type"\s*:\s*"tool_use"/g;
|
|
51
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
52
|
+
const end = text.indexOf("\n", m.index);
|
|
53
|
+
const win = text.slice(m.index, Math.min(end === -1 ? text.length : end, m.index + 2e3));
|
|
54
|
+
if (/"name"\s*:\s*"Skill"/.test(win)) add(win.match(/"skill"\s*:\s*"([^"\\]+)"/)?.[1]?.split(":").pop());
|
|
55
|
+
for (const p of win.matchAll(/skills(?:\/|\\\\)+([A-Za-z0-9._-]+)(?:\/|\\\\)+SKILL\.md/g)) add(p[1]);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function recipeToAsk(used, state) {
|
|
60
|
+
const asked = new Set(state.asked ?? []);
|
|
61
|
+
return [...used].reverse().find((u) => /^[2-9a-z]{6,16}$/.test(u.runId) && !asked.has(u.runId));
|
|
62
|
+
}
|
|
63
|
+
function recordAsked(state, runId, cap = NUDGE_MAX_SESSIONS) {
|
|
64
|
+
return { ...state, asked: [...(state.asked ?? []).filter((r) => r !== runId), runId].slice(-cap) };
|
|
65
|
+
}
|
|
66
|
+
function feedbackMessage(r, s) {
|
|
67
|
+
const clean = r.title.replace(/[\u0000-\u001f\u007f-\u009f----]/g, "");
|
|
68
|
+
const title = clean.length > 60 ? clean.slice(0, 59) + "\u2026" : clean;
|
|
69
|
+
return `\u{1F9EA} You used "${title}" from copyrun (${s.filesChanged} file${s.filesChanged === 1 ? "" : "s"} changed). Did it work? npx copyrun worked ${r.runId} \xB7 npx copyrun failed ${r.runId}`;
|
|
70
|
+
}
|
|
71
|
+
function preCheck(input) {
|
|
72
|
+
if (input.stopHookActive) return "stop-hook-active";
|
|
73
|
+
if (!input.enabled) return "disabled";
|
|
74
|
+
if (!input.sessionId) return "no-session";
|
|
75
|
+
if (input.state.sessions?.includes(input.sessionId)) return "already-nudged";
|
|
76
|
+
const last = input.state.last ?? 0;
|
|
77
|
+
if (last <= input.now && input.now - last < (input.cooldownMs ?? NUDGE_COOLDOWN_MS)) return "cooldown";
|
|
78
|
+
return void 0;
|
|
79
|
+
}
|
|
80
|
+
function recordNudge(state, sessionId, now, cap = NUDGE_MAX_SESSIONS) {
|
|
81
|
+
const sessions = [...(state.sessions ?? []).filter((s) => s !== sessionId), sessionId].slice(-cap);
|
|
82
|
+
return { ...state, last: now, sessions };
|
|
83
|
+
}
|
|
84
|
+
function nudgeEnabled(config) {
|
|
85
|
+
return !(config && typeof config === "object" && config.nudge === false);
|
|
86
|
+
}
|
|
87
|
+
function nudgeMessage(s) {
|
|
88
|
+
const calls = `${s.toolCalls} tool call${s.toolCalls === 1 ? "" : "s"}`;
|
|
89
|
+
const files = `${s.filesChanged} file${s.filesChanged === 1 ? "" : "s"} changed`;
|
|
90
|
+
return `\u2728 Good run (${calls}, ${files}). Share it: npx copyrun share \xB7 stop these: npx copyrun nudge off`;
|
|
91
|
+
}
|
|
92
|
+
function hookOutput(message) {
|
|
93
|
+
return JSON.stringify({ systemMessage: message });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/managed.ts
|
|
97
|
+
import { execFileSync } from "child_process";
|
|
98
|
+
import { existsSync, readFileSync } from "fs";
|
|
99
|
+
import { join } from "path";
|
|
100
|
+
var GATED_CAPABILITIES = ["shell", "install", "network", "settings"];
|
|
101
|
+
var SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
102
|
+
var HANDLE = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
103
|
+
function parseManaged(v) {
|
|
104
|
+
const problems = [];
|
|
105
|
+
const config = {};
|
|
106
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { config, problems: ["not a JSON object"] };
|
|
107
|
+
const o = v;
|
|
108
|
+
const bool = (k) => {
|
|
109
|
+
if (o[k] === void 0) return;
|
|
110
|
+
if (typeof o[k] !== "boolean") problems.push(`${k} must be true or false`);
|
|
111
|
+
else config[k] = o[k];
|
|
112
|
+
};
|
|
113
|
+
if (o.apiHost !== void 0) {
|
|
114
|
+
try {
|
|
115
|
+
const u = new URL(String(o.apiHost));
|
|
116
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
117
|
+
if (u.protocol !== "https:" && !(local && u.protocol === "http:")) problems.push("apiHost must be an https URL");
|
|
118
|
+
else config.apiHost = u.origin;
|
|
119
|
+
} catch {
|
|
120
|
+
problems.push("apiHost must be a URL");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (o.org !== void 0) {
|
|
124
|
+
const s = typeof o.org === "string" ? o.org.trim().toLowerCase() : "";
|
|
125
|
+
if (!SLUG.test(s)) problems.push("org must be an org slug");
|
|
126
|
+
else config.org = s;
|
|
127
|
+
}
|
|
128
|
+
for (const k of ["forbidPublic", "forbidAnonymous", "forbidUnlisted", "requireShape", "disableNudge", "disableLive", "disableReceipts", "disableAutopilot", "copyLink", "disableAsk", "disableMission", "disableCopy"]) bool(k);
|
|
129
|
+
if (o.autopilotOrg !== void 0) {
|
|
130
|
+
const s = typeof o.autopilotOrg === "string" ? o.autopilotOrg.trim().toLowerCase() : "";
|
|
131
|
+
if (!SLUG.test(s)) problems.push("autopilotOrg must be an org slug");
|
|
132
|
+
else config.autopilotOrg = s;
|
|
133
|
+
}
|
|
134
|
+
if (o.autopilotContent !== void 0) {
|
|
135
|
+
if (o.autopilotContent !== "full" && o.autopilotContent !== "actions" && o.autopilotContent !== "commands") problems.push('autopilotContent must be "full", "actions" or "commands"');
|
|
136
|
+
else config.autopilotContent = o.autopilotContent;
|
|
137
|
+
}
|
|
138
|
+
if (o.allowedCloneSources !== void 0) {
|
|
139
|
+
const a = o.allowedCloneSources;
|
|
140
|
+
if (a === "any" || a === "org-playbook") config.allowedCloneSources = a;
|
|
141
|
+
else if (Array.isArray(a) && a.every((x) => typeof x === "string" && HANDLE.test(x.trim().replace(/^@/, "").toLowerCase())))
|
|
142
|
+
config.allowedCloneSources = [...new Set(a.map((x) => x.trim().replace(/^@/, "").toLowerCase()))];
|
|
143
|
+
else problems.push('allowedCloneSources must be "any", "org-playbook" or a list of handles and org slugs');
|
|
144
|
+
}
|
|
145
|
+
if (o.extraRedactTerms !== void 0) {
|
|
146
|
+
if (!Array.isArray(o.extraRedactTerms) || !o.extraRedactTerms.every((t) => typeof t === "string")) problems.push("extraRedactTerms must be a list of strings");
|
|
147
|
+
else config.extraRedactTerms = [...new Set(o.extraRedactTerms.map((t) => t.trim()).filter((t) => t.length >= 2 && t.length <= 100))].slice(0, 200);
|
|
148
|
+
}
|
|
149
|
+
if (o.profile !== void 0) {
|
|
150
|
+
if (o.profile !== "finance" && o.profile !== "hipaa") problems.push('profile must be "finance" or "hipaa"');
|
|
151
|
+
else config.profile = o.profile;
|
|
152
|
+
}
|
|
153
|
+
if (o.archive !== void 0) {
|
|
154
|
+
const a = o.archive;
|
|
155
|
+
const org = a && typeof a.org === "string" ? a.org.trim().toLowerCase() : "";
|
|
156
|
+
if (!a || typeof a !== "object" || !SLUG.test(org) || a.required !== void 0 && typeof a.required !== "boolean") problems.push("archive must be { org: <slug>, required: true|false }");
|
|
157
|
+
else config.archive = { org, required: a.required === true };
|
|
158
|
+
}
|
|
159
|
+
if (o.restrictOrgs !== void 0) {
|
|
160
|
+
const list = typeof o.restrictOrgs === "string" ? o.restrictOrgs.split(",") : o.restrictOrgs;
|
|
161
|
+
if (!Array.isArray(list) || !list.every((x) => typeof x === "string" && SLUG.test(x.trim().toLowerCase()))) problems.push("restrictOrgs must be a list of org slugs");
|
|
162
|
+
else config.restrictOrgs = [...new Set(list.map((x) => x.trim().toLowerCase()))];
|
|
163
|
+
}
|
|
164
|
+
if (o.restrictMode !== void 0) {
|
|
165
|
+
if (o.restrictMode !== "block-all-uploads") problems.push('restrictMode must be "block-all-uploads"');
|
|
166
|
+
else config.restrictMode = o.restrictMode;
|
|
167
|
+
}
|
|
168
|
+
if (o.askBefore !== void 0) {
|
|
169
|
+
if (!Array.isArray(o.askBefore) || !o.askBefore.every((t) => typeof t === "string")) problems.push("askBefore must be a list of change areas and path patterns");
|
|
170
|
+
else config.askBefore = [...new Set(o.askBefore.map((t) => t.trim()).filter((t) => t && t.length <= 200))].slice(0, 50);
|
|
171
|
+
}
|
|
172
|
+
if (o.askTimeoutMinutes !== void 0) {
|
|
173
|
+
const n = o.askTimeoutMinutes;
|
|
174
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 1 || n > 15) problems.push("askTimeoutMinutes must be a number from 1 to 15");
|
|
175
|
+
else config.askTimeoutMinutes = Math.round(n);
|
|
176
|
+
}
|
|
177
|
+
if (o.askOrg !== void 0) {
|
|
178
|
+
const s = typeof o.askOrg === "string" ? o.askOrg.trim().toLowerCase() : "";
|
|
179
|
+
if (!SLUG.test(s)) problems.push("askOrg must be an org slug");
|
|
180
|
+
else config.askOrg = s;
|
|
181
|
+
}
|
|
182
|
+
if (o.allowCloneYesWith !== void 0) {
|
|
183
|
+
const l = o.allowCloneYesWith;
|
|
184
|
+
if (!Array.isArray(l) || !l.every((x) => GATED_CAPABILITIES.includes(x))) problems.push('allowCloneYesWith must list only "shell", "install", "network" and/or "settings"');
|
|
185
|
+
else config.allowCloneYesWith = [...new Set(l)];
|
|
186
|
+
}
|
|
187
|
+
return { config, problems };
|
|
188
|
+
}
|
|
189
|
+
function systemManagedPaths(platform = process.platform, env = process.env) {
|
|
190
|
+
if (platform === "darwin")
|
|
191
|
+
return [
|
|
192
|
+
{ path: "/Library/Application Support/copyrun/managed.json", kind: "json" },
|
|
193
|
+
// a configuration profile (Jamf, Kandji, Intune for Mac) lands here; it wins over the file
|
|
194
|
+
{ path: "/Library/Managed Preferences/ai.copyrun.cli.plist", kind: "plist" }
|
|
195
|
+
];
|
|
196
|
+
if (platform === "win32") return [{ path: join(env.ProgramData || env.PROGRAMDATA || "C:\\ProgramData", "copyrun", "managed.json"), kind: "json" }];
|
|
197
|
+
return [{ path: "/etc/copyrun/managed.json", kind: "json" }];
|
|
198
|
+
}
|
|
199
|
+
function plutil(p) {
|
|
200
|
+
try {
|
|
201
|
+
return execFileSync("/usr/bin/plutil", ["-convert", "json", "-o", "-", p], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
|
|
202
|
+
} catch {
|
|
203
|
+
return void 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function loadManaged(deps = {}) {
|
|
207
|
+
const env = deps.env ?? process.env;
|
|
208
|
+
const exists = deps.exists ?? existsSync;
|
|
209
|
+
const read = deps.read ?? ((p) => readFileSync(p, "utf8"));
|
|
210
|
+
const plist = deps.plistToJson ?? plutil;
|
|
211
|
+
const system = systemManagedPaths(deps.platform ?? process.platform, env).filter((f) => exists(f.path));
|
|
212
|
+
const files = system.length ? system : env.COPYRUN_MANAGED_CONFIG ? [{ path: env.COPYRUN_MANAGED_CONFIG, kind: "json" }] : [];
|
|
213
|
+
const state = { config: {}, sources: [], errors: [] };
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
let text;
|
|
216
|
+
try {
|
|
217
|
+
text = f.kind === "plist" ? plist(f.path) : read(f.path);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
state.errors.push(`${f.path}: can't be read (${e.message})`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (text === void 0) {
|
|
223
|
+
if (f.kind === "plist" && files.some((x) => x.kind === "json")) continue;
|
|
224
|
+
state.errors.push(`${f.path}: can't be read (plutil unavailable)`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
let json;
|
|
228
|
+
try {
|
|
229
|
+
json = JSON.parse(text);
|
|
230
|
+
} catch {
|
|
231
|
+
state.errors.push(`${f.path}: not valid JSON`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const { config, problems } = parseManaged(json);
|
|
235
|
+
if (problems.length) state.errors.push(`${f.path}: ${problems.join("; ")}`);
|
|
236
|
+
state.config = { ...state.config, ...config };
|
|
237
|
+
state.sources.push(f.path);
|
|
238
|
+
}
|
|
239
|
+
if (state.config.allowedCloneSources === "org-playbook" && !state.config.org) state.errors.push('allowedCloneSources "org-playbook" needs org');
|
|
240
|
+
if (state.config.autopilotOrg && state.config.org && state.config.autopilotOrg !== state.config.org) state.errors.push("autopilotOrg must be the same org as org");
|
|
241
|
+
if (state.config.askOrg && state.config.org && state.config.askOrg !== state.config.org) state.errors.push("askOrg must be the same org as org");
|
|
242
|
+
return state;
|
|
243
|
+
}
|
|
244
|
+
var cached;
|
|
245
|
+
function managed() {
|
|
246
|
+
return cached ??= loadManaged();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/session-seal.ts
|
|
250
|
+
import { createHash, createPrivateKey, sign } from "crypto";
|
|
251
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync as readFileSync2, readSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
252
|
+
import { join as join2 } from "path";
|
|
253
|
+
var SEAL_QUEUE_DIR = "seal-queue";
|
|
254
|
+
var SEAL_LOG_FILE = "session-seals.json";
|
|
255
|
+
var SEAL_WORKER_SCRIPT = "seal-worker.mjs";
|
|
256
|
+
var SEAL_SPAWN_FILE = "seal-spawn.json";
|
|
257
|
+
var HOOK_MAX_BYTES = 64 * 1024 * 1024;
|
|
258
|
+
var SEAL_SPAWN_THROTTLE_MS = 5 * 60 * 1e3;
|
|
259
|
+
var sessionSealMessage = (hex) => `copyrun-session-v1:${hex}`;
|
|
260
|
+
function readJson(path) {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
263
|
+
} catch {
|
|
264
|
+
return void 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function writeJsonAtomic(path, value) {
|
|
268
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
269
|
+
writeFileSync(tmp, JSON.stringify(value), { mode: 384 });
|
|
270
|
+
renameSync(tmp, path);
|
|
271
|
+
}
|
|
272
|
+
var safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown";
|
|
273
|
+
function deviceOf(dir) {
|
|
274
|
+
const d = readJson(join2(dir, "config.json"))?.device;
|
|
275
|
+
return d && typeof d.privateKey === "string" && typeof d.token === "string" && typeof d.api === "string" ? d : void 0;
|
|
276
|
+
}
|
|
277
|
+
function sealingEnabled(dir) {
|
|
278
|
+
const cfg = readJson(join2(dir, "config.json"));
|
|
279
|
+
if (!cfg || cfg.sealSessions === false) return false;
|
|
280
|
+
return cfg.sealSessions === true || !!cfg.archive;
|
|
281
|
+
}
|
|
282
|
+
function fileSha256(path, max = Infinity) {
|
|
283
|
+
let fd;
|
|
284
|
+
try {
|
|
285
|
+
const st = statSync(path);
|
|
286
|
+
if (!st.isFile() || st.size > max) return void 0;
|
|
287
|
+
fd = openSync(path, "r");
|
|
288
|
+
} catch {
|
|
289
|
+
return void 0;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const h = createHash("sha256");
|
|
293
|
+
const buf = Buffer.alloc(1 << 20);
|
|
294
|
+
let bytes = 0;
|
|
295
|
+
for (; ; ) {
|
|
296
|
+
const n = readSync(fd, buf, 0, buf.length, null);
|
|
297
|
+
if (!n) break;
|
|
298
|
+
h.update(buf.subarray(0, n));
|
|
299
|
+
bytes += n;
|
|
300
|
+
}
|
|
301
|
+
return { sha256: h.digest("hex"), bytes };
|
|
302
|
+
} catch {
|
|
303
|
+
return void 0;
|
|
304
|
+
} finally {
|
|
305
|
+
closeSync(fd);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function readSealLog(dir) {
|
|
309
|
+
const v = readJson(join2(dir, SEAL_LOG_FILE));
|
|
310
|
+
return Array.isArray(v) ? v.filter((e) => e && typeof e.sha256 === "string") : [];
|
|
311
|
+
}
|
|
312
|
+
var signWith = (message, privateKeyPem) => sign(null, Buffer.from(message, "utf8"), createPrivateKey(privateKeyPem)).toString("base64");
|
|
313
|
+
function queueSeal(dir, r, now = Date.now()) {
|
|
314
|
+
const qdir = join2(dir, SEAL_QUEUE_DIR);
|
|
315
|
+
mkdirSync(qdir, { recursive: true, mode: 448 });
|
|
316
|
+
const name = `${r.kind}-${safe(r.kind === "session" && r.sessionId ? r.sessionId : r.sha256)}.json`;
|
|
317
|
+
const at = new Date(now).toISOString();
|
|
318
|
+
writeJsonAtomic(join2(qdir, name), { v: 1, ...r, createdAt: at, attempts: 0, nextAttemptAt: at });
|
|
319
|
+
return join2(qdir, name);
|
|
320
|
+
}
|
|
321
|
+
function queueSessionSeal(dir, s) {
|
|
322
|
+
if (!s.force && !sealingEnabled(dir)) return { queued: false, reason: "disabled" };
|
|
323
|
+
const device = deviceOf(dir);
|
|
324
|
+
if (!device) return { queued: false, reason: "not-logged-in" };
|
|
325
|
+
const f = fileSha256(s.transcriptPath, s.maxBytes ?? HOOK_MAX_BYTES);
|
|
326
|
+
if (!f) return { queued: false, reason: "unreadable" };
|
|
327
|
+
if (readSealLog(dir).some((e) => e.kind === "session" && e.sha256 === f.sha256 && e.api === device.api)) return { queued: false, reason: "already-sealed" };
|
|
328
|
+
const path = queueSeal(
|
|
329
|
+
dir,
|
|
330
|
+
{ kind: "session", sha256: f.sha256, bytes: f.bytes, sessionId: s.sessionId, tool: s.tool, signature: signWith(sessionSealMessage(f.sha256), device.privateKey), publicKey: device.publicKey, api: device.api },
|
|
331
|
+
s.now
|
|
332
|
+
);
|
|
333
|
+
return { queued: true, sha256: f.sha256, bytes: f.bytes, path };
|
|
334
|
+
}
|
|
335
|
+
function sealSpawnDue(dir, now, always = false) {
|
|
336
|
+
const path = join2(dir, SEAL_SPAWN_FILE);
|
|
337
|
+
const last = readJson(path)?.at ?? 0;
|
|
338
|
+
if (!always && last <= now && now - last < SEAL_SPAWN_THROTTLE_MS) return false;
|
|
339
|
+
try {
|
|
340
|
+
writeJsonAtomic(path, { at: now });
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/nudge-hook.ts
|
|
347
|
+
var DIR = dirname(fileURLToPath(import.meta.url));
|
|
348
|
+
function readJson2(path) {
|
|
349
|
+
try {
|
|
350
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
351
|
+
} catch {
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function readTail(path, max) {
|
|
356
|
+
const fd = openSync2(path, "r");
|
|
357
|
+
try {
|
|
358
|
+
const size = fstatSync(fd).size;
|
|
359
|
+
const len = Math.min(size, max);
|
|
360
|
+
const buf = Buffer.alloc(len);
|
|
361
|
+
readSync2(fd, buf, 0, len, size - len);
|
|
362
|
+
return buf.toString("utf8");
|
|
363
|
+
} finally {
|
|
364
|
+
closeSync2(fd);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function readStdin() {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
let data = "";
|
|
370
|
+
const t = setTimeout(() => resolve(data), 1500);
|
|
371
|
+
process.stdin.setEncoding("utf8");
|
|
372
|
+
process.stdin.on("data", (d) => {
|
|
373
|
+
data += d;
|
|
374
|
+
if (data.length > 1e6) process.stdin.destroy();
|
|
375
|
+
});
|
|
376
|
+
process.stdin.on("end", () => (clearTimeout(t), resolve(data)));
|
|
377
|
+
process.stdin.on("error", () => (clearTimeout(t), resolve(data)));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function sealSession(transcriptPath, sessionId) {
|
|
381
|
+
if (!transcriptPath) return;
|
|
382
|
+
try {
|
|
383
|
+
const r = queueSessionSeal(DIR, { transcriptPath, sessionId, tool: "claude-code" });
|
|
384
|
+
const worker = join3(DIR, SEAL_WORKER_SCRIPT);
|
|
385
|
+
if (!r.queued || !existsSync2(worker) || !sealSpawnDue(DIR, Date.now())) return;
|
|
386
|
+
const child = spawn(process.execPath, [worker], { detached: true, stdio: "ignore", windowsHide: true, env: { ...process.env, COPYRUN_HOME: DIR } });
|
|
387
|
+
child.on("error", () => {
|
|
388
|
+
});
|
|
389
|
+
child.unref();
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function originsOf(names, cwd) {
|
|
394
|
+
const claude = process.env.CLAUDE_CONFIG_DIR ?? join3(homedir(), ".claude");
|
|
395
|
+
const roots = [join3(claude, "skills"), ...cwd ? [join3(cwd, ".claude", "skills")] : []];
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const name of names) {
|
|
398
|
+
for (const root of roots) {
|
|
399
|
+
const o = readJson2(join3(root, name, ".origin"));
|
|
400
|
+
if (o && typeof o.runId === "string") {
|
|
401
|
+
out.push({ runId: o.runId, title: typeof o.title === "string" ? o.title : name });
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
async function main() {
|
|
409
|
+
const input = JSON.parse(await readStdin());
|
|
410
|
+
sealSession(input.transcript_path, input.session_id);
|
|
411
|
+
const statePath = join3(DIR, NUDGE_STATE_FILE);
|
|
412
|
+
let state = readJson2(statePath) ?? {};
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
const skip = preCheck({
|
|
415
|
+
sessionId: input.session_id,
|
|
416
|
+
stopHookActive: input.stop_hook_active === true,
|
|
417
|
+
// managed settings (docs/mdm.md) can turn it off for the whole machine
|
|
418
|
+
enabled: nudgeEnabled(readJson2(join3(DIR, "config.json"))) && !managed().config.disableNudge,
|
|
419
|
+
state,
|
|
420
|
+
now
|
|
421
|
+
});
|
|
422
|
+
if (skip && skip !== "already-nudged" && skip !== "cooldown" || !input.transcript_path) return;
|
|
423
|
+
const text = readTail(input.transcript_path, MAX_TRANSCRIPT_BYTES);
|
|
424
|
+
const stats = analyzeTranscript(text);
|
|
425
|
+
const ask = stats.filesChanged > 0 && !process.env.COPYRUN_REPLAY ? recipeToAsk(originsOf(skillsUsed(text), input.cwd), state) : void 0;
|
|
426
|
+
let message;
|
|
427
|
+
if (ask) {
|
|
428
|
+
state = recordAsked(state, ask.runId);
|
|
429
|
+
message = feedbackMessage(ask, stats);
|
|
430
|
+
} else if (!skip && isSubstantial(stats)) {
|
|
431
|
+
state = recordNudge(state, input.session_id, now);
|
|
432
|
+
message = nudgeMessage(stats);
|
|
433
|
+
}
|
|
434
|
+
if (!message) return;
|
|
435
|
+
const tmp = `${statePath}.${process.pid}.tmp`;
|
|
436
|
+
writeFileSync2(tmp, JSON.stringify(state), { mode: 384 });
|
|
437
|
+
renameSync2(tmp, statePath);
|
|
438
|
+
writeSync(1, hookOutput(message) + "\n");
|
|
439
|
+
}
|
|
440
|
+
main().catch(() => {
|
|
441
|
+
}).finally(() => process.exit(0));
|