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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Copyrun, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # copyrun
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A copy of you that runs your agents. Copy. Run.
4
+
5
+ Every time you allow or stop a step an agent wants to take, copyrun records the decision and your reason, on your machine. `npx copyrun copy` is the version of your judgment built from that record: it answers your agents the way you would where its own score proves it (repeating your refusals, never allowing migrations, auth, secrets, CI, dependencies or deletes alone), turns what you decide again and again into rules every agent reads (`copy rules --write`), and exports as one signed file you can take to your next job. `copy skills` counts how you solve problems with agents from the sessions on this machine (framing, decomposition, verification, recovery), and `copy compare @someone/slice` replays someone's published rules over your own decisions, or your team's.
6
+
7
+ `npx copyrun on` puts every agent acting as you on one screen at copyrun.ai/mission, whoever made it: which agent, in which project, what it's doing this second, which one is waiting for you, and a Stop. Claude Code, Codex and Cursor report through their hooks; Gemini CLI, OpenCode, OpenClaw, Aider, Goose, Amp and Copilot CLI are seen by their process; anything else reports with `npx copyrun beat`. The hooks send the agent, the folder name and the kind of step, never a path, a prompt, code or output.
8
+
9
+ When an agent reaches a step it shouldn't take alone (a migration, a change to auth, secrets or CI, a new dependency, a delete), `npx copyrun setup --ask` pauses it until you Allow or Stop from any browser, and no answer in time means Stop.
10
+
11
+ The CLI also turns a coding-agent session (Claude Code, Codex, Cursor) into a signed record that shows what the agent actually did: the prompts, the commands it ran, the files it changed and whether the tests passed. Attach it to a pull request as the receipt so reviewers can check the work, and let your agent reuse what worked for you last time.
12
+
13
+ ## Install
14
+
15
+ Requires Node.js 20 or later.
16
+
17
+ ```sh
18
+ npx copyrun --help
19
+ ```
20
+
21
+ Or install it globally:
22
+
23
+ ```sh
24
+ npm install -g copyrun
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```sh
30
+ copyrun on # every agent acting as you on one screen (copyrun.ai/mission); copyrun off; copyrun watch; copyrun beat
31
+ copyrun # the debrief: what your agent actually did in the last session here (claims vs test runs, where to look)
32
+ copyrun why <file>:<line> # which prompt wrote this line: git blame for agent code
33
+ copyrun week # your agents this week, from the sessions on this machine
34
+ copyrun share # share the latest session from this project
35
+ copyrun pr # add it to this branch's pull request
36
+ copyrun check # check the agent's claims against what it ran
37
+ copyrun resume --with codex # another agent picks up where the last session stopped
38
+ copyrun search "<task>" # find runs that did a similar task
39
+ copyrun clone <id|url> # install a run's recipe as a skill in this project
40
+ copyrun worked <id|url> # report that a cloned run worked (or: copyrun failed)
41
+ ```
42
+
43
+ Run `copyrun help <command>` for a command's options, or `copyrun help --all` for the full list.
44
+
45
+ ### Sharing a session
46
+
47
+ ```sh
48
+ copyrun share --dry-run
49
+ ```
50
+
51
+ Before anything is uploaded, copyrun redacts the session on your machine: API keys, tokens, `.env` values, credentials in URLs, email addresses, home-directory paths and your username. It shows every removal, lists anything else that might identify you or your employer (company names, internal hosts, ticket ids) and asks before publishing. A session containing a private key is never uploaded.
52
+
53
+ You choose who can see the run:
54
+
55
+ | Flag | Visibility |
56
+ | --- | --- |
57
+ | `--private` | Only you. Requires `copyrun login`. |
58
+ | `--unlisted` | Anyone with the link. |
59
+ | `--public` | Listed and searchable. Never the default. |
60
+
61
+ `--shape` shares the prompts, commands and results without file contents or diffs.
62
+
63
+ ### Pull requests
64
+
65
+ `copyrun pr` adds a short block to the pull request description with a link to the run, whether it was signed by your device, which checks the session ran and how much of the diff it wrote. The diff comparison runs locally with git. It uses the [GitHub CLI](https://cli.github.com) to edit the PR; `copyrun pr --print` prints the block instead.
66
+
67
+ Maintainers can install the [copyrun GitHub App](https://copyrun.ai/docs/github-app) to add a check to pull requests.
68
+
69
+ ### What did my agent actually do?
70
+
71
+ `copyrun` with no arguments, inside a project an agent worked in, reads the last session on this machine and prints one screen: what you asked, what the agent said, its closing claims against the session's own test, build and lint runs, where to look before you ship (files changed after the last test, tests removed, sensitive changes), the files it changed, the files no prompt names, the times you corrected it, and the tokens it used. Nothing is uploaded and no account is needed. `copyrun last <session>` does the same for any session; the exit code is 2 when a claim is contradicted.
72
+
73
+ `copyrun why <file>:<line>` is git blame for agent code: the session step that wrote the line, the prompt before it and what the agent said just before, from the sessions that ran in this repository. `copyrun why <file>` lists every stretch a session wrote. A line edited by hand since matches nothing, so "no session wrote this line" is a floor.
74
+
75
+ `copyrun week` is your agents this week: sessions per agent, how often one said "tests pass" when the last run failed, changes that never ran a test, your corrections and tokens, against the week before. Counts only, nothing uploaded.
76
+
77
+ ### Checking claims
78
+
79
+ `copyrun check` reads the latest session and flags claims that don't match what happened, such as "all tests pass" when the last test run failed. It runs entirely locally and needs no account. Add `--strict` to exit with a non-zero code for use in CI or git hooks.
80
+
81
+ ### Handing a session to another agent
82
+
83
+ `copyrun resume --with <claude|codex|cursor>` takes the latest session in this folder (or a session id, or a run you can open) and starts the other agent with a handoff: your request, your corrections as rules, the files changed, the last checks, where it stopped and where to look. It's quoted as data and redacted like a share, the agent shows a plan before changing files, and nothing is uploaded. `--print` prints the handoff instead. Very long sessions are read from their start and their last 24 MB.
84
+
85
+ ### Signing in
86
+
87
+ ```sh
88
+ copyrun login
89
+ ```
90
+
91
+ Signing in links shares to your profile and signs them with a key stored on this machine, so the run page can show it as Verified.
92
+
93
+ ## Configuration
94
+
95
+ | Variable | Description |
96
+ | --- | --- |
97
+ | `COPYRUN_API` | Server URL. Defaults to `https://copyrun.ai`. |
98
+ | `COPYRUN_HOME` | State directory. Defaults to `~/.copyrun`. |
99
+
100
+ ## Documentation
101
+
102
+ Full documentation is at [copyrun.ai/docs](https://copyrun.ai/docs). For questions and feedback, join the [community](https://copyrun.ai/community) or email hello@copyrun.ai.
103
+
104
+ ## License
105
+
106
+ [MIT](LICENSE)
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/archive-hook.ts
4
+ import { spawn } from "child_process";
5
+ import { existsSync } from "fs";
6
+ import { dirname, join as join3 } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/archive-logic.ts
10
+ var ARCHIVE_WORKER_SCRIPT = "archive-worker.mjs";
11
+ var ARCHIVE_SETTINGS_FILE = "archive.json";
12
+ var ARCHIVE_SPAWN_FILE = "archive-spawn.json";
13
+ var PENDING_DIR = "archive-pending";
14
+ var IDLE_MS = 10 * 60 * 1e3;
15
+ var SPAWN_THROTTLE_MS = 10 * 60 * 1e3;
16
+ var MAX_BODY_BYTES = 25 * 1024 * 1024;
17
+ var LOCK_STALE_MS = 15 * 60 * 1e3;
18
+ var MAX_BACKOFF_MS = 6 * 60 * 60 * 1e3;
19
+ var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
20
+ var archiveActive = (s) => !!s && s.available && s.archive.enabled;
21
+ function hookShouldRecord(s, consented) {
22
+ if (!s) return true;
23
+ if (!(archiveActive(s) && s.archive.includeAll)) return false;
24
+ return !consented || consented.includeAll;
25
+ }
26
+ function safeId(s) {
27
+ return s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown";
28
+ }
29
+ function pointerName(p, cwdKey2) {
30
+ const id = p.sessionId ? safeId(p.sessionId) : `cwd-${cwdKey2(p.cwd ?? "")}`;
31
+ return `${p.tool === "claude-code" ? "cc" : p.tool}-${id}.json`;
32
+ }
33
+ function claudeHookInput(raw) {
34
+ if (!isObj(raw)) return {};
35
+ const str = (v) => typeof v === "string" && v.length < 4096 ? v : void 0;
36
+ return { sessionId: str(raw.session_id), transcriptPath: str(raw.transcript_path), cwd: str(raw.cwd) };
37
+ }
38
+ function codexNotifyInput(raw) {
39
+ if (!isObj(raw)) return void 0;
40
+ if (raw.type !== void 0 && raw.type !== "agent-turn-complete") return void 0;
41
+ const str = (v) => typeof v === "string" && v.length < 4096 ? v : void 0;
42
+ return { sessionId: str(raw["thread-id"]) ?? str(raw["session-id"]) ?? str(raw.thread_id), cwd: str(raw.cwd) };
43
+ }
44
+ function mergePointer(prev, next, now) {
45
+ return {
46
+ v: 1,
47
+ ...prev,
48
+ ...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== void 0)),
49
+ ended: !!prev?.ended || next.ended,
50
+ firstSeen: prev?.firstSeen ?? now,
51
+ updatedAt: now
52
+ };
53
+ }
54
+
55
+ // src/archive-hook-core.ts
56
+ import { createHash as createHash2 } from "crypto";
57
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
58
+ import { join as join2 } from "path";
59
+
60
+ // src/session-seal.ts
61
+ import { createHash, createPrivateKey, sign } from "crypto";
62
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
63
+ import { join } from "path";
64
+ var SEAL_QUEUE_DIR = "seal-queue";
65
+ var SEAL_LOG_FILE = "session-seals.json";
66
+ var HOOK_MAX_BYTES = 64 * 1024 * 1024;
67
+ var SEAL_SPAWN_THROTTLE_MS = 5 * 60 * 1e3;
68
+ var sessionSealMessage = (hex) => `copyrun-session-v1:${hex}`;
69
+ function readJson(path) {
70
+ try {
71
+ return JSON.parse(readFileSync(path, "utf8"));
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+ function writeJsonAtomic(path, value) {
77
+ const tmp = `${path}.${process.pid}.tmp`;
78
+ writeFileSync(tmp, JSON.stringify(value), { mode: 384 });
79
+ renameSync(tmp, path);
80
+ }
81
+ var safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown";
82
+ function deviceOf(dir) {
83
+ const d = readJson(join(dir, "config.json"))?.device;
84
+ return d && typeof d.privateKey === "string" && typeof d.token === "string" && typeof d.api === "string" ? d : void 0;
85
+ }
86
+ function sealingEnabled(dir) {
87
+ const cfg = readJson(join(dir, "config.json"));
88
+ if (!cfg || cfg.sealSessions === false) return false;
89
+ return cfg.sealSessions === true || !!cfg.archive;
90
+ }
91
+ function fileSha256(path, max = Infinity) {
92
+ let fd;
93
+ try {
94
+ const st = statSync(path);
95
+ if (!st.isFile() || st.size > max) return void 0;
96
+ fd = openSync(path, "r");
97
+ } catch {
98
+ return void 0;
99
+ }
100
+ try {
101
+ const h = createHash("sha256");
102
+ const buf = Buffer.alloc(1 << 20);
103
+ let bytes = 0;
104
+ for (; ; ) {
105
+ const n = readSync(fd, buf, 0, buf.length, null);
106
+ if (!n) break;
107
+ h.update(buf.subarray(0, n));
108
+ bytes += n;
109
+ }
110
+ return { sha256: h.digest("hex"), bytes };
111
+ } catch {
112
+ return void 0;
113
+ } finally {
114
+ closeSync(fd);
115
+ }
116
+ }
117
+ function readSealLog(dir) {
118
+ const v = readJson(join(dir, SEAL_LOG_FILE));
119
+ return Array.isArray(v) ? v.filter((e) => e && typeof e.sha256 === "string") : [];
120
+ }
121
+ var signWith = (message, privateKeyPem) => sign(null, Buffer.from(message, "utf8"), createPrivateKey(privateKeyPem)).toString("base64");
122
+ function queueSeal(dir, r, now = Date.now()) {
123
+ const qdir = join(dir, SEAL_QUEUE_DIR);
124
+ mkdirSync(qdir, { recursive: true, mode: 448 });
125
+ const name = `${r.kind}-${safe(r.kind === "session" && r.sessionId ? r.sessionId : r.sha256)}.json`;
126
+ const at = new Date(now).toISOString();
127
+ writeJsonAtomic(join(qdir, name), { v: 1, ...r, createdAt: at, attempts: 0, nextAttemptAt: at });
128
+ return join(qdir, name);
129
+ }
130
+ function queueSessionSeal(dir, s) {
131
+ if (!s.force && !sealingEnabled(dir)) return { queued: false, reason: "disabled" };
132
+ const device = deviceOf(dir);
133
+ if (!device) return { queued: false, reason: "not-logged-in" };
134
+ const f = fileSha256(s.transcriptPath, s.maxBytes ?? HOOK_MAX_BYTES);
135
+ if (!f) return { queued: false, reason: "unreadable" };
136
+ if (readSealLog(dir).some((e) => e.kind === "session" && e.sha256 === f.sha256 && e.api === device.api)) return { queued: false, reason: "already-sealed" };
137
+ const path = queueSeal(
138
+ dir,
139
+ { 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 },
140
+ s.now
141
+ );
142
+ return { queued: true, sha256: f.sha256, bytes: f.bytes, path };
143
+ }
144
+
145
+ // src/archive-hook-core.ts
146
+ function readJson2(path) {
147
+ try {
148
+ return JSON.parse(readFileSync2(path, "utf8"));
149
+ } catch {
150
+ return void 0;
151
+ }
152
+ }
153
+ function writeJsonAtomic2(path, value) {
154
+ const tmp = `${path}.${process.pid}.tmp`;
155
+ writeFileSync2(tmp, JSON.stringify(value), { mode: 384 });
156
+ renameSync2(tmp, path);
157
+ }
158
+ var cwdKey = (cwd) => createHash2("sha256").update(cwd).digest("hex").slice(0, 16);
159
+ function runHook(event, input, o) {
160
+ const now = o.now ?? Date.now();
161
+ const cfg = readJson2(join2(o.dir, "config.json"))?.archive;
162
+ if (!cfg || typeof cfg.org !== "string" || typeof cfg.api !== "string") return { spawned: false, skipped: "not-set-up" };
163
+ const cache = readJson2(join2(o.dir, ARCHIVE_SETTINGS_FILE))?.[`${cfg.api}|${cfg.org}`];
164
+ let pointer;
165
+ let skipped;
166
+ const fields = event === "codex" ? codexNotifyInput(input) : (() => {
167
+ const c = claudeHookInput(input);
168
+ return c.sessionId || c.transcriptPath ? c : void 0;
169
+ })();
170
+ if (!fields) skipped = event === "codex" ? "bad-input" : "no-session";
171
+ else if (!hookShouldRecord(cache, cfg.scope)) skipped = "not-archived";
172
+ else {
173
+ const tool = event === "codex" ? "codex" : "claude-code";
174
+ const dir = join2(o.dir, PENDING_DIR);
175
+ mkdirSync2(dir, { recursive: true, mode: 448 });
176
+ const name = pointerName({ tool, sessionId: fields.sessionId, cwd: fields.cwd }, cwdKey);
177
+ const path = join2(dir, name);
178
+ const prev = readJson2(path);
179
+ const next = mergePointer(
180
+ prev && prev.v === 1 ? prev : void 0,
181
+ { tool, org: cfg.org, api: cfg.api, sessionId: fields.sessionId, transcriptPath: fields.transcriptPath, cwd: fields.cwd, ended: event === "session-end" },
182
+ new Date(now).toISOString()
183
+ );
184
+ writeJsonAtomic2(path, next);
185
+ pointer = path;
186
+ }
187
+ let sealed;
188
+ const transcript = fields?.transcriptPath;
189
+ if (transcript && event !== "codex") {
190
+ try {
191
+ sealed = queueSessionSeal(o.dir, { transcriptPath: transcript, sessionId: fields?.sessionId, tool: "claude-code", now });
192
+ } catch {
193
+ }
194
+ }
195
+ const spawnPath = join2(o.dir, ARCHIVE_SPAWN_FILE);
196
+ const last = readJson2(spawnPath)?.at ?? 0;
197
+ const due = event === "session-end" || !(last <= now && now - last < SPAWN_THROTTLE_MS);
198
+ let spawned = false;
199
+ if (due) {
200
+ try {
201
+ writeJsonAtomic2(spawnPath, { at: now });
202
+ } catch {
203
+ }
204
+ o.spawnWorker();
205
+ spawned = true;
206
+ }
207
+ return { pointer, spawned, ...skipped ? { skipped } : {}, ...sealed ? { seal: sealed } : {} };
208
+ }
209
+
210
+ // src/archive-hook.ts
211
+ var DIR = dirname(fileURLToPath(import.meta.url));
212
+ function readStdin() {
213
+ return new Promise((resolve) => {
214
+ if (process.stdin.isTTY) return resolve("");
215
+ let data = "";
216
+ const t = setTimeout(() => resolve(data), 500);
217
+ process.stdin.setEncoding("utf8");
218
+ process.stdin.on("data", (d) => {
219
+ data += d;
220
+ if (data.length > 1e6) process.stdin.destroy();
221
+ });
222
+ process.stdin.on("end", () => (clearTimeout(t), resolve(data)));
223
+ process.stdin.on("error", () => (clearTimeout(t), resolve(data)));
224
+ });
225
+ }
226
+ function spawnWorker() {
227
+ const worker = join3(DIR, ARCHIVE_WORKER_SCRIPT);
228
+ if (!existsSync(worker)) return;
229
+ const child = spawn(process.execPath, [worker], {
230
+ detached: true,
231
+ stdio: "ignore",
232
+ windowsHide: true,
233
+ env: { ...process.env, COPYRUN_HOME: DIR }
234
+ });
235
+ child.on("error", () => {
236
+ });
237
+ child.unref();
238
+ }
239
+ async function main() {
240
+ const event = process.argv[2];
241
+ if (event !== "stop" && event !== "session-end" && event !== "codex") return;
242
+ const raw = event === "codex" ? process.argv[3] ?? "" : await readStdin();
243
+ let input;
244
+ try {
245
+ input = JSON.parse(raw);
246
+ } catch {
247
+ input = void 0;
248
+ }
249
+ runHook(event, input, { dir: DIR, spawnWorker });
250
+ }
251
+ main().catch(() => {
252
+ }).finally(() => process.exit(0));