arcroom 0.0.3-beta → 0.0.4-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,9 +21,9 @@ npx arcroom init --agent codex # or cursor, opencode (experimental)
21
21
 
22
22
  ## What leaves your laptop
23
23
 
24
- Prompts (system reminders removed, secrets redacted, first 1,000 characters), file paths, search patterns, shell commands (first 1,000 characters), URLs and web queries, done-check results, git hashes of the files a turn touched, token counts, and what an agent saves on purpose with ArcRoom's tools. **Never file contents, tool output or edit diffs.** Secret files (`.env`, keys) are skipped.
24
+ Prompts (system reminders removed, secrets redacted, first 1,000 characters), file paths, search patterns, shell commands (first 1,000 characters), URLs and web queries, done-check results, git hashes of the files a turn touched, token counts, and what an agent saves on purpose with ArcRoom's tools. **Never file contents, tool output or edit diffs.** A path to a secret file (`.env`, keys), or a command that names one, leaves only as `[secret file]`.
25
25
 
26
- Every upload waits first in `~/.arcroom/spool.jsonl`, so you can read it before it goes. `arcroom off` stops ArcRoom for one repo on this laptop; `arcroom on` starts it again.
26
+ Every event passes through `~/.arcroom/spool.jsonl` on its way out and is sent within seconds; what was sent is kept for 7 days in `~/.arcroom/sent/`, and `arcroom sent` shows it. `arcroom off` stops ArcRoom for one repo on this laptop, including anything still queued for it; `arcroom on` starts it again.
27
27
 
28
28
  ## If the API is down
29
29
 
@@ -37,7 +37,11 @@ Hooks never block a session. Memory lookup gives up after 3 seconds, the prompt
37
37
  | `arcroom whoami` | who the key belongs to |
38
38
  | `arcroom doctor` | checks the key, the API, the hooks in this folder and the uploader, and prints the fix for each problem |
39
39
  | `arcroom off` / `arcroom on` | stop or restart ArcRoom for this repo |
40
- | `arcroom checks` / `arcroom checks approve <code>` | read and approve the done-checks in `.arcroom.json` |
40
+ | `arcroom flush` | waits until everything queued was sent: the last step of a CI job (exits 1 if it could not) |
41
+ | `arcroom sent [YYYY-MM-DD] [--all]` | what this laptop sent that day (kept 7 days), exactly as sent with `--all` |
42
+ | `arcroom init --everywhere` | also cover every other clone and worktree of the repos you set up (Claude Code user settings) |
43
+ | `arcroom uninstall [--all]` | take ArcRoom out of this project for every agent; `--all` also deletes `~/.arcroom` |
44
+ | `arcroom checks` / `arcroom checks approve <code>` | read and approve the done-checks in `.arcroom.json` (in CI, where there is no terminal: set `ARCROOM_APPROVE_CHECKS=<code>` in the job's environment) |
41
45
  | `arcroom wrong [memo id] [reason]` | report a memory that was wrong |
42
46
  | `arcroom decide "<choice>" --why ...` | record a team decision |
43
47
  | `arcroom rule [--tag t] [--max-hits n] [--idle-days d]` | team rules for when memory expires |
package/dist/code.js CHANGED
@@ -159,3 +159,35 @@ export function codeNoteBody(args, cwd) {
159
159
  ...(args.ttl_hours ? { ttl_hours: args.ttl_hours } : {}),
160
160
  };
161
161
  }
162
+ /** Another agent's claim on this file (its edits claim a file for 10 minutes), as the reason an edit waits; else undefined. */
163
+ export function clashNote(claims, rel, branch, now = new Date()) {
164
+ const c = (claims ?? []).find((c) => c?.k === rel);
165
+ if (!c)
166
+ return undefined;
167
+ const where = c.branch && c.branch !== branch ? `on ${c.branch}: a merge conflict if you both change it` : 'same branch';
168
+ return (`ArcRoom: ${short(c.by)}'s ${c.agent ?? 'agent'} is editing ${rel} right now (${minutes(c.since, now)} min, ${where}). ` +
169
+ 'Check with them, or work on something else first. To edit it anyway, make this edit again.');
170
+ }
171
+ /**
172
+ * Before this session's first edit of a file: another agent editing that same file right now, as the reason the edit
173
+ * waits once (the agent sees it; the same edit again goes through). One lookup per file per session; any failure is no clash.
174
+ * ponytail: someone who starts on the file after your first edit shows up at your next prompt (the code map), not here
175
+ */
176
+ export async function editClash(cfg, session, cwd, file, timeoutMs = 1000) {
177
+ if (typeof file !== 'string' || !cwd || !cfg.apiKey)
178
+ return undefined;
179
+ const s = codeSetup(cwd);
180
+ if (!s || s.mode !== 'standard')
181
+ return undefined;
182
+ const rel = repoPath(s.root, file, cwd);
183
+ if (!rel || rel === '.')
184
+ return undefined;
185
+ const seenFile = sessionFile(session, '.clash');
186
+ const seen = existsSync(seenFile) ? JSON.parse(readFileSync(seenFile, 'utf8')) : [];
187
+ if (seen.includes(rel))
188
+ return undefined;
189
+ writeFileSync(seenFile, JSON.stringify([...seen, rel].slice(-500)));
190
+ const branch = currentBranch(s.root);
191
+ const look = await api(cfg, '/v1/code/look', { repo: repoKey(s), branch, nodes: [dirOf(rel)], session }, timeoutMs).catch(() => undefined);
192
+ return clashNote(look?.claims, rel, branch);
193
+ }
package/dist/daemon.js CHANGED
@@ -4,8 +4,10 @@ import { join } from 'node:path';
4
4
  import { blindEvent, codeSetup, repoKey, repoPath } from './blind.js';
5
5
  import { prefetchCode } from './code.js';
6
6
  import { EDIT_TOOLS } from './done.js';
7
- import { api, ARCROOM_HOME, keyRefused, loadConfig } from './home.js';
7
+ import { api, ARCROOM_HOME, isOff, keyRefused, loadConfig, offRepos } from './home.js';
8
8
  import { serveLookups } from './lookup.js';
9
+ import { sendLater } from './pending.js';
10
+ import { logSent } from './sent.js';
9
11
  import { redact, rememberSecrets } from './redact.js';
10
12
  import { branches, syncTree } from './tree.js';
11
13
  const SPOOL = join(ARCROOM_HOME, 'spool.jsonl');
@@ -18,7 +20,7 @@ const BATCH = 200; // events per upload
18
20
  const READ_MAX = 4 * 1024 * 1024; // bytes read per tick
19
21
  const IDLE_MS = 3000;
20
22
  /** Takes complete lines from the queue text: assigns each session's next seq and redacts secrets. */
21
- export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
23
+ export function toBatch(text, seq, max = BATCH, shape = (e) => e, off = offRepos()) {
22
24
  const next = { ...seq };
23
25
  const events = [];
24
26
  let consumed = 0;
@@ -37,6 +39,8 @@ export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
37
39
  }
38
40
  if (e?.v !== 1)
39
41
  continue; // raw lines from the first hook version
42
+ if (isOff(e.cwd, off))
43
+ continue; // queued before `arcroom off`: never sent
40
44
  const n = next[e.session] ?? 0;
41
45
  next[e.session] = n + 1;
42
46
  rememberSecrets(e.cwd); // the second pass knows each project's .env values too
@@ -68,8 +72,27 @@ export function rotate(state, spool = SPOOL, sending = SENDING) {
68
72
  }
69
73
  return state;
70
74
  }
71
- /** Sends the next batch from the queue. The offset only moves after the server accepted it. */
72
- async function drainOnce(cfg) {
75
+ /** Events grouped by the key their folder sends with (a repo can have its own key); each group is sent on its own. */
76
+ export function byKey(events, cfgOf) {
77
+ const groups = new Map();
78
+ for (const e of events) {
79
+ const cfg = cfgOf(e.cwd);
80
+ const g = groups.get(cfg.apiKey) ?? { cfg, events: [] };
81
+ g.events.push(e);
82
+ groups.set(cfg.apiKey, g);
83
+ }
84
+ return [...groups.values()];
85
+ }
86
+ /** Sends the next batch from the queue. The offset only moves after the server accepted it (a resent event is ignored). */
87
+ async function drainOnce(def) {
88
+ const cfgs = new Map();
89
+ const cfgOf = (cwd) => {
90
+ if (!cwd)
91
+ return def;
92
+ if (!cfgs.has(cwd))
93
+ cfgs.set(cwd, loadConfig(cwd));
94
+ return cfgs.get(cwd);
95
+ };
73
96
  const saved = readJson(STATE, { offset: 0, seq: {} });
74
97
  const state = rotate(saved);
75
98
  if (state !== saved)
@@ -85,19 +108,20 @@ async function drainOnce(cfg) {
85
108
  const { events, consumed, seq } = toBatch(text, state.seq, BATCH, withCode);
86
109
  if (consumed === 0)
87
110
  return 0;
88
- if (events.length) {
89
- const result = await api(cfg, '/v1/ingest', { events }, 30_000);
111
+ for (const g of byKey(events, cfgOf)) {
112
+ const result = await api(g.cfg, '/v1/ingest', { events: g.events }, 30_000);
90
113
  if (result.rejected)
91
- log(`server rejected ${result.rejected} of ${events.length} events`);
114
+ log(`server rejected ${result.rejected} of ${g.events.length} events`);
115
+ logSent(g.events); // what left the laptop, as sent: arcroom sent
92
116
  }
93
117
  writeFileSync(STATE, JSON.stringify({ offset: state.offset + Buffer.byteLength(text.slice(0, consumed)), seq }));
94
118
  // a turn ended or a session started in a repo: send what changed in its tree (after the events, so claims land first)
95
119
  for (const cwd of new Set(events.filter((e) => e.type === 'turn.end' || e.type === 'run.start').map((e) => e.cwd).filter(Boolean)))
96
- await syncRepo(cfg, String(cwd)).catch((err) => log(`tree sync: ${err}`));
120
+ await syncRepo(cfgOf(String(cwd)), String(cwd)).catch((err) => log(`tree sync: ${err}`));
97
121
  // a session started: its first prompt's code map, fetched now (not awaited: the queue keeps moving)
98
122
  for (const e of events)
99
123
  if (e.type === 'run.start' && e.cwd)
100
- prefetchCode(cfg, String(e.cwd), String(e.session)).catch((err) => log(`prefetch: ${err}`));
124
+ prefetchCode(cfgOf(String(e.cwd)), String(e.cwd), String(e.session)).catch((err) => log(`prefetch: ${err}`));
101
125
  return events.length;
102
126
  }
103
127
  // Code wing (Standard): which repo, branch and folder an edit is in, cached per folder for a minute.
@@ -163,6 +187,11 @@ export async function daemon() {
163
187
  sent = await drainOnce(cfg);
164
188
  if (sent)
165
189
  log(`sent ${sent} events`);
190
+ const later = await sendLater(); // agents' saves kept while the API could not be reached
191
+ if (later.sent)
192
+ log(`sent ${later.sent} saves kept while offline`);
193
+ for (const d of later.dropped)
194
+ log(`a save kept while offline was refused: ${d}`);
166
195
  }
167
196
  catch (err) {
168
197
  log(String(err)); // offline or server down: events stay queued, retried next tick
@@ -195,6 +224,19 @@ export function ensureDaemon(entry) {
195
224
  return;
196
225
  spawn(process.execPath, [entry, 'daemon'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
197
226
  }
227
+ /** Bytes in the queue the daemon has not sent yet (the moved-aside file's rest, then the new queue). */
228
+ export function unsent(state = readJson(STATE, { offset: 0, seq: {} }), spool = SPOOL, sending = SENDING) {
229
+ const size = (f) => (existsSync(f) ? statSync(f).size : 0);
230
+ return existsSync(sending) ? Math.max(0, size(sending) - state.offset) + size(spool) : Math.max(0, size(spool) - state.offset);
231
+ }
232
+ /** `arcroom flush`: the last step of a CI job. Waits until the daemon has sent everything queued; false after the timeout. */
233
+ export async function flush(entry, timeoutMs = 60_000) {
234
+ ensureDaemon(entry);
235
+ for (const until = Date.now() + timeoutMs; Date.now() < until; await new Promise((r) => setTimeout(r, 500)))
236
+ if (!unsent())
237
+ return true;
238
+ return false;
239
+ }
198
240
  /** Called from init: stops a running daemon so the next hook starts the updated one. */
199
241
  export function stopDaemon() {
200
242
  if (!isRunning())
package/dist/doctor.js CHANGED
@@ -1,26 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { isRunning } from './daemon.js';
4
4
  import { isApproved, loadChecks, readApprovals } from './done.js';
5
5
  import { api, apiUrl, ARCROOM_HOME, isOff, keyRefused, loadConfig } from './home.js';
6
- // where `arcroom init` wires each agent in a project
7
- const AGENT_FILES = [
8
- ['Claude Code', join('.claude', 'settings.local.json')],
9
- ['Codex', join('.codex', 'hooks.json')],
10
- ['Cursor', join('.cursor', 'hooks.json')],
11
- ['OpenCode', join('.opencode', 'plugins', 'arcroom.js')],
12
- ];
13
- // the runtime under ~/.arcroom, or under ARCROOM_HOME when that is set
14
- const runtimePath = join(ARCROOM_HOME, 'runtime').replaceAll('\\', '/');
15
- const ours = (file) => {
16
- if (!existsSync(file))
17
- return false;
18
- const text = readFileSync(file, 'utf8').replaceAll('\\\\', '/').replaceAll('\\', '/');
19
- return text.includes('.arcroom/runtime') || text.includes(runtimePath);
20
- };
6
+ import { AGENT_FILES, laptopWide, ours } from './scope.js';
21
7
  export async function doctor(cwd = process.cwd()) {
22
8
  const checks = [];
23
- const cfg = loadConfig();
9
+ const cfg = loadConfig(cwd);
24
10
  if (!cfg.apiKey)
25
11
  return [{ ok: false, what: 'no key on this laptop', fix: 'arcroom init (key: web app, Settings → API keys)' }];
26
12
  checks.push({ ok: true, what: `key ${cfg.apiKey.slice(0, 8)}… in ${join(ARCROOM_HOME, 'config.json')}` });
@@ -49,7 +35,9 @@ export async function doctor(cwd = process.cwd()) {
49
35
  const wired = AGENT_FILES.filter(([, f]) => ours(join(cwd, f))).map(([agent]) => agent);
50
36
  checks.push(wired.length
51
37
  ? { ok: true, what: `this folder is set up for ${wired.join(', ')}` }
52
- : { ok: false, what: 'no agent in this folder runs ArcRoom hooks', fix: 'arcroom init (or arcroom init --agent codex|cursor|opencode) here' });
38
+ : laptopWide(cwd)
39
+ ? { ok: true, what: 'laptop-wide hooks cover this repo (arcroom init --everywhere)' }
40
+ : { ok: false, what: 'no agent in this folder runs ArcRoom hooks', fix: 'arcroom init (or arcroom init --agent codex|cursor|opencode) here' });
53
41
  if (isOff(cwd))
54
42
  checks.push({ ok: 'warn', what: 'ArcRoom is switched off for this repo on this laptop', fix: 'arcroom on' });
55
43
  const spool = join(ARCROOM_HOME, 'spool.jsonl');
package/dist/done.js CHANGED
@@ -2,8 +2,10 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { hashFiles } from './fresh.js';
5
+ import { git } from './git.js';
5
6
  import { ARCROOM_HOME } from './home.js';
6
7
  import { sessionFile } from './loop.js';
8
+ import { projectOf } from './session.js';
7
9
  // Finish line: on Stop, if this turn edited files, the project's done-checks (.arcroom.json "doneChecks") must have passed
8
10
  // since the last edit. The hook never runs them itself: it blocks the stop and asks the agent to run them with its own
9
11
  // Bash tool, so the agent's permission prompt shows every run (a test file or package script the agent just edited can
@@ -12,6 +14,8 @@ import { sessionFile } from './loop.js';
12
14
  // Checks are asked for only once the person approved these exact commands for this folder (`arcroom checks approve`):
13
15
  // a cloned repo, a pull request or an agent's edit that adds or changes them asks for nothing until then.
14
16
  export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
17
+ /** The tools a done-check can run in: Bash, and PowerShell on Windows. */
18
+ export const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
15
19
  export const MAX_BLOCKS = 2;
16
20
  const MAX_PASSED = 50;
17
21
  const FILE_TOOLS = new Set(['Read', ...EDIT_TOOLS]);
@@ -22,15 +26,16 @@ const succeeded = (r) => typeof r === 'string'
22
26
  : !(r && typeof r === 'object' && (r.interrupted || r.is_error || r.isError || [r.exit_code, r.exitCode, r.returncode].some((c) => typeof c === 'number' && c !== 0)));
23
27
  /** True when this Bash command ran the check: as written, or last after `cd … &&` / `;`. */
24
28
  export const ranCheck = (command, check) => {
25
- const c = check.trim();
26
- return command === c || command.endsWith(`&& ${c}`) || command.endsWith(`; ${c}`);
29
+ const one = (s) => s.trim().replace(/\s+/g, ' '); // extra spaces don't make it a different command
30
+ const [cmd, c] = [one(command), one(check)];
31
+ return cmd === c || cmd.endsWith(`&& ${c}`) || cmd.endsWith(`; ${c}`);
27
32
  };
28
33
  /**
29
34
  * Records an edit when this tool call changes files, every file the turn reads or writes (for their hashes), and every
30
35
  * Bash command that succeeded since the last edit (a done-check the agent ran).
31
36
  */
32
37
  export function track(state, raw) {
33
- if (raw.hook_event_name === 'PostToolUse' && raw.tool_name === 'Bash') {
38
+ if (raw.hook_event_name === 'PostToolUse' && SHELL_TOOLS.has(raw.tool_name)) {
34
39
  const command = String(raw.tool_input?.command ?? '').trim();
35
40
  const passed = state.passed ?? [];
36
41
  if (!command || command.length > 500 || passed.includes(command) || passed.length >= MAX_PASSED || !succeeded(raw.tool_response))
@@ -62,11 +67,13 @@ export function decide(pending, blocks) {
62
67
  return blocks < MAX_BLOCKS ? 'block' : 'partial';
63
68
  }
64
69
  export function blockReason(pending, cwd) {
65
- return (`ArcRoom done-checks: before you stop, run ${pending.length > 1 ? 'each of these' : 'this'} with your Bash tool from ${cwd}, ` +
70
+ return (`ArcRoom done-checks: before you stop, run ${pending.length > 1 ? 'each of these' : 'this'} with your shell tool (Bash or PowerShell) from ${cwd}, ` +
66
71
  `exactly as written, and fix anything that fails:\n${pending.map((c) => `- \`${c}\``).join('\n')}`);
67
72
  }
73
+ /** The repo's top folder: .arcroom.json lives there, so the checks hold in every subfolder and worktree. */
74
+ const rootOf = (cwd) => git(['rev-parse', '--show-toplevel'], { cwd }).stdout?.trim() || cwd;
68
75
  export function loadChecks(cwd) {
69
- const file = join(cwd, '.arcroom.json');
76
+ const file = join(rootOf(cwd), '.arcroom.json');
70
77
  if (!existsSync(file))
71
78
  return [];
72
79
  const checks = JSON.parse(readFileSync(file, 'utf8')).doneChecks;
@@ -83,21 +90,26 @@ export function readApprovals() {
83
90
  return {};
84
91
  }
85
92
  }
86
- /** True only when these exact commands were approved for this folder. */
87
- export const isApproved = (cwd, checks, approvals) => approvals[resolve(cwd)] === checksHash(checks);
93
+ /** Approvals are per repo, so every clone, worktree and subfolder of it on this laptop shares one. */
94
+ const repoOf = (cwd) => projectOf(rootOf(cwd));
95
+ /** True only when these exact commands were approved for this repo (or, before approvals were per repo, this folder).
96
+ * In CI there is no terminal to approve in: ARCROOM_APPROVE_CHECKS=<the code arcroom checks prints>, set in the job's own
97
+ * environment (an agent cannot set it for the hook), approves the same exact commands. */
98
+ export const isApproved = (cwd, checks, approvals, ci = process.env.ARCROOM_APPROVE_CHECKS) => [repoOf(cwd), resolve(cwd)].some((k) => approvals[k] === checksHash(checks)) ||
99
+ (!!ci && ci.length >= 8 && checksHash(checks).startsWith(ci));
88
100
  /** `arcroom checks approve <code>`: the person approves the folder's done-checks they were shown (`code` = the first
89
101
  * 8 characters of their hash, printed by `arcroom checks`); null when the file no longer holds those checks. */
90
102
  export function approveChecks(cwd, code) {
91
103
  const checks = loadChecks(cwd);
92
104
  if (code.length < 8 || !checksHash(checks).startsWith(code))
93
105
  return null;
94
- const approvals = { ...readApprovals(), [resolve(cwd)]: checksHash(checks) };
106
+ const approvals = { ...readApprovals(), [repoOf(cwd)]: checksHash(checks) };
95
107
  mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
96
108
  writeFileSync(APPROVED, JSON.stringify(approvals, null, 2) + '\n', { mode: 0o600 });
97
109
  return checks;
98
110
  }
99
111
  const read = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { edited: false, blocks: 0 });
100
- /** PreToolUse / PostToolUse: remember what this turn edited, and the Bash commands that passed after that. */
112
+ /** PreToolUse / PostToolUse: remember what this turn edited, and the shell commands that passed after that. */
101
113
  export function noteEdit(raw) {
102
114
  const file = sessionFile(String(raw.session_id), '.done');
103
115
  const state = read(file);
package/dist/events.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { hostname } from 'node:os';
2
- import { redact, redactText } from './redact.js';
2
+ import { redact, redactText, sensitiveFile } from './redact.js';
3
3
  const MAX = 4000; // ponytail: fixed per-field cap so one huge tool output can't bloat the queue; make configurable if reports need full outputs
4
4
  /** Keeps small values as they are; replaces big ones with a preview and their size, redacted before the cut. */
5
5
  export function clip(value) {
@@ -18,7 +18,9 @@ const KEEP = ['file_path', 'notebook_path', 'path', 'pattern', 'command', 'url',
18
18
  const SHORT = 1000; // prompts and commands; the server reads the first 300 (prompt) and 200 (command) chars
19
19
  // redacted before the cut, so a secret the cut splits is still found
20
20
  const short = (v) => (typeof v === 'string' ? redactText(v).slice(0, SHORT) : v);
21
- const metadata = (input) => Object.fromEntries(KEEP.filter((k) => input && typeof input === 'object' && input[k] !== undefined).map((k) => [k, k === 'command' ? short(input[k]) : input[k]]));
21
+ // a path to a secret file (.env, keys) or a command that names one leaves only as "[secret file]"
22
+ const kept = (k, v) => (sensitiveFile(v) ? '[secret file]' : k === 'command' ? short(v) : v);
23
+ const metadata = (input) => Object.fromEntries(KEEP.filter((k) => input && typeof input === 'object' && input[k] !== undefined).map((k) => [k, kept(k, input[k])]));
22
24
  // what Claude Code adds to a prompt (<system-reminder> blocks) is not what the person typed
23
25
  const typed = (prompt) => short(String(prompt ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim());
24
26
  /** Maps one Claude Code hook payload to an ArcRoom run event. */
@@ -37,7 +39,9 @@ export function toArcroomEvent(raw, at = new Date()) {
37
39
  return { ...base, type: 'prompt', data: { prompt: typed(raw.prompt) } };
38
40
  case 'PreToolUse': {
39
41
  const ours = String(raw.tool_name).startsWith(OURS);
40
- return { ...base, type: 'tool.start', data: { id: raw.tool_use_id, tool: raw.tool_name, input: clip(ours ? raw.tool_input : metadata(raw.tool_input)) } };
42
+ // a call made inside a subagent says which one, so its steps are not read as the main agent's
43
+ const subagent = raw.agent_id ? { subagent: String(raw.agent_type ?? raw.agent_id).slice(0, 80) } : {};
44
+ return { ...base, type: 'tool.start', data: { id: raw.tool_use_id, tool: raw.tool_name, input: clip(ours ? raw.tool_input : metadata(raw.tool_input)), ...subagent } };
41
45
  }
42
46
  case 'PostToolUse':
43
47
  return { ...base, type: 'tool.end', data: { id: raw.tool_use_id, tool: raw.tool_name, ...(String(raw.tool_name).startsWith(OURS) ? { output: clip(raw.tool_response) } : {}) } };
package/dist/fresh.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { git } from './git.js';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { readFileSync, realpathSync, statSync } from 'node:fs';
4
- import { extname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  // Is a stored memory still true for this checkout? Steps keep the git blob hash of each file they touched (taken on
6
6
  // the laptop at the end of the turn; hashes only, no content). Before handing steps over, each file is looked up here:
7
7
  // same hash → as it was
8
8
  // other hash → changed since: kept, flagged so the agent checks before trusting it
9
- // missing → renamed if a file here has the saved hash (followed to its new path); otherwise deleted (dropped)
9
+ // missing → renamed if a file here has the saved hash (followed to its new path), or moved and edited if exactly
10
+ // one file here has its name (followed, flagged as changed); otherwise deleted (dropped)
10
11
  // when the task read it, or created by the task when it only wrote it (still a target)
11
12
  // More than half the files it read deleted: the steps are stale and nothing is handed over. All local, no network.
12
13
  /** Git's blob id for a file's bytes (the same id `git hash-object` prints), so it works with or without git. */
13
14
  export const blobHash = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
14
- const MAX_SCAN = 2000; // ponytail: rename search hashes candidate files; index blob ids instead if repos get huge
15
+ /** A text file's bytes with Windows line ends as LF, so a Windows and a Mac checkout of it hash the same; a binary file
16
+ * (one with a NUL byte) stays as it is. Every hash saved with steps or answers is taken over these bytes. */
17
+ export const lf = (bytes) => (bytes.includes(0) ? bytes : Buffer.from(bytes.toString('latin1').replaceAll('\r\n', '\n'), 'latin1'));
18
+ const MAX_SCAN = 2000; // files hashed when git's index has no match (no git, untracked files, CRLF committed)
15
19
  const MAX_READ = 5_000_000;
16
20
  /**
17
21
  * A path from stored memory (anyone with a workspace key can write it) as a file inside `cwd` that may be read, or
@@ -40,24 +44,39 @@ export function checkout(cwd) {
40
44
  const hash = (p) => {
41
45
  const file = localFile(cwd, p);
42
46
  try {
43
- return file ? blobHash(readFileSync(file)) : undefined;
47
+ return file ? blobHash(lf(readFileSync(file))) : undefined;
44
48
  }
45
49
  catch {
46
50
  return undefined;
47
51
  }
48
52
  };
53
+ // git's index: every tracked file with its blob id, in one call and without opening a file, at any repo size
54
+ let index;
55
+ const tracked = () => (index ??= new Map((git(['ls-files', '-s', '-z'], { cwd, timeout: 5000 }).stdout ?? '').split('\0').flatMap((e) => {
56
+ const m = /^\d+ ([0-9a-f]{40,64}) \d\t([\s\S]+)$/.exec(e);
57
+ return m ? [[m[2], m[1]]] : [];
58
+ })));
49
59
  let files;
50
60
  const list = () => {
51
61
  if (files)
52
62
  return files;
53
- const r = git(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, timeout: 1500 });
63
+ const r = git(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, timeout: 5000 });
54
64
  files = r.status === 0 ? (r.stdout ?? '').split('\n').filter(Boolean).slice(0, MAX_SCAN) : [];
55
65
  return files;
56
66
  };
57
67
  return {
58
68
  exists: (p) => !!localFile(cwd, p),
59
69
  hash,
70
+ sameName: (like) => {
71
+ const name = basename(like);
72
+ const hits = [...tracked().keys()].filter((p) => basename(p) === name && !!localFile(cwd, p));
73
+ return hits.length === 1 ? hits[0] : undefined;
74
+ },
60
75
  find: (want, like) => {
76
+ // a tracked file git already knows by this id, still the same on disk
77
+ for (const [p, id] of tracked())
78
+ if (id === want && hash(p) === want)
79
+ return p;
61
80
  // same extension first (a move keeps it), then everything; only files that exist and are not huge
62
81
  const ext = extname(like);
63
82
  const pool = [...list().filter((f) => extname(f) === ext), ...list().filter((f) => extname(f) !== ext)];
@@ -96,9 +115,14 @@ export function freshen(steps, saved = {}, probe) {
96
115
  changed.add(f);
97
116
  continue;
98
117
  }
99
- const to = saved[f] && probe.find ? probe.find(saved[f], f) : undefined;
100
- if (to && !named.has(to))
118
+ const same = saved[f] && probe.find ? probe.find(saved[f], f) : undefined;
119
+ // ponytail: moved-and-edited is found by its name only; one renamed and edited at once still reads as deleted
120
+ const to = same ?? probe.sameName?.(f);
121
+ if (to && !named.has(to)) {
101
122
  moved.set(f, to);
123
+ if (!same)
124
+ changed.add(to);
125
+ }
102
126
  else if (read.has(f))
103
127
  deleted.add(f);
104
128
  else
@@ -130,7 +154,7 @@ export function hashFiles(cwd, paths) {
130
154
  if (!rel || rel.startsWith('..'))
131
155
  continue;
132
156
  try {
133
- out[rel] = blobHash(readFileSync(join(cwd, rel)));
157
+ out[rel] = blobHash(lf(readFileSync(join(cwd, rel))));
134
158
  }
135
159
  catch {
136
160
  /* deleted during the turn: nothing to remember */
package/dist/git.js CHANGED
@@ -35,3 +35,14 @@ export function git(args, opts = {}) {
35
35
  const r = spawnSync(bin, [...SAFE, ...args], { encoding: 'utf8', windowsHide: true, ...opts });
36
36
  return { status: r.status, stdout: r.stdout };
37
37
  }
38
+ /** Project id shared by everyone on the repo: its git remote without protocol/credentials/.git, else the folder path. */
39
+ export function projectOf(cwd) {
40
+ const remote = git(['config', '--get', 'remote.origin.url'], { cwd }).stdout?.trim();
41
+ if (!remote)
42
+ return cwd.replaceAll('\\', '/').toLowerCase();
43
+ return remote
44
+ .replace(/^[a-z+]+:\/\/(?:[^@/]+@)?/i, '') // https://user:token@host/... -> host/...
45
+ .replace(/^[^@]+@([^:]+):/, '$1/') // git@host:owner/repo -> host/owner/repo
46
+ .replace(/\.git$/, '')
47
+ .toLowerCase();
48
+ }
package/dist/home.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, relative } from 'node:path';
4
+ import { projectOf } from './git.js';
4
5
  import { redact } from './redact.js';
5
6
  /**
6
7
  * ArcRoom's folder: ~/.arcroom, or ARCROOM_HOME (tests, a second setup). A repo can set environment variables for the
@@ -35,10 +36,14 @@ export function setOff(root, off, file = OFF_FILE) {
35
36
  }
36
37
  // ArcRoom API (the Oracle server behind Caddy). A local API: arcroom init --url http://localhost:8787
37
38
  export const DEFAULT_URL = 'https://api.arcroomai.com';
38
- /** ~/.arcroom/config.json written by `arcroom init`. */
39
- export function loadConfig() {
39
+ /** ~/.arcroom/config.json written by `arcroom init`; with `cwd`, the key of the repo it is in (its own, else the default). */
40
+ export function loadConfig(cwd) {
40
41
  const file = join(ARCROOM_HOME, 'config.json');
41
- return { url: DEFAULT_URL, apiKey: '', ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
42
+ const saved = { url: DEFAULT_URL, apiKey: '', ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
43
+ if (!cwd || !saved.repos || !Object.keys(saved.repos).length)
44
+ return saved; // no git call unless some repo has its own key
45
+ const own = saved.repos[projectOf(cwd)];
46
+ return own ? { ...saved, apiKey: own.apiKey, workspace: own.workspace } : saved;
42
47
  }
43
48
  const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
44
49
  /** Whether `url` points at this machine (a developer's local API). */
package/dist/hook.js CHANGED
@@ -3,16 +3,17 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
3
3
  import { join } from 'node:path';
4
4
  import { AGENTS, fromAgent, toAgent } from './agents.js';
5
5
  import { ensureDaemon } from './daemon.js';
6
- import { noteEdit, onStop } from './done.js';
6
+ import { EDIT_TOOLS, noteEdit, onStop, SHELL_TOOLS } from './done.js';
7
7
  import { toArcroomEvent } from './events.js';
8
- import { noteDir } from './code.js';
8
+ import { editClash, noteDir } from './code.js';
9
9
  import { fence } from './fence.js';
10
10
  import { redact, rememberSecrets } from './redact.js';
11
- import { ARCROOM_HOME, isOff, keyRefused } from './home.js';
11
+ import { ARCROOM_HOME, isOff, keyRefused, loadConfig } from './home.js';
12
12
  import { lookupFast } from './lookup.js';
13
13
  import { checkLoop, sessionFile } from './loop.js';
14
14
  import { pack } from './pack.js';
15
15
  import { lastRunNote, projectOf } from './session.js';
16
+ import { laptopHookActs } from './scope.js';
16
17
  import { bump, MARK, signFor, toolResult } from './signs.js';
17
18
  import { costSinceClaim, markClaim, turnUsage } from './usage.js';
18
19
  // Claude Code runs this for every hook event, with the event JSON on stdin.
@@ -43,8 +44,14 @@ export async function hook() {
43
44
  const raw = fromAgent(JSON.parse(input), agent);
44
45
  if (isOff(raw.cwd))
45
46
  return; // switched off for this repo by this person (arcroom off): nothing recorded or sent
46
- if (keyRefused())
47
- return; // the key was revoked or its account deleted: nothing runs until a new key (arcroom init)
47
+ if (process.argv.includes('--user') && !laptopHookActs(raw.cwd))
48
+ return; // laptop-wide hook: only set-up repos, never twice
49
+ if (keyRefused(loadConfig(raw.cwd))) {
50
+ // the key was revoked or its account deleted: nothing runs until a new key (arcroom init); said once, at session start
51
+ if (raw.hook_event_name === 'SessionStart')
52
+ process.stdout.write(JSON.stringify(toAgent(agent, 'SessionStart', { systemMessage: `${MARK} · stopped: the API refused this laptop's key. Run arcroom init with a new key (web app, Settings → API keys).` }, String(raw.session_id))));
53
+ return;
54
+ }
48
55
  rememberSecrets(raw.cwd); // this project's .env values are redacted wherever they show up
49
56
  const name = raw.hook_event_name;
50
57
  const session = String(raw.session_id);
@@ -58,7 +65,7 @@ export async function hook() {
58
65
  const injected = (kind, text, keepText = false) => emit('context', { kind, chars: text.length, ...(keepText ? { text: text.slice(0, 2000) } : {}) });
59
66
  // Token meter: the turn's usage goes in before its turn.end, so the report built at turn.end includes it.
60
67
  // After each tool call too, so the website shows a long turn's tokens while it runs.
61
- if (name === 'Stop' || name === 'SessionEnd' || name === 'PostToolUse') {
68
+ if (name === 'Stop' || name === 'SessionEnd' || name === 'PreToolUse') {
62
69
  const usage = turnUsage(session, raw.transcript_path);
63
70
  if (usage)
64
71
  emit('usage', usage);
@@ -149,21 +156,27 @@ export async function hook() {
149
156
  }
150
157
  if (pointer)
151
158
  given.add(pointer.memoId);
152
- // what `arcroom wrong` reports when the person says the memory just handed over was wrong
153
- const last = pointer ?? (playbook && { memoId: playbook.memoId, intent: 'playbook' });
154
- if (last)
155
- writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: last.memoId, intent: last.intent }));
159
+ // what `arcroom wrong` reports when the person says the memory just handed over was wrong: every memory handed over
160
+ // (steps or answer, playbook, each fact and decision), so it can name the one that was wrong
161
+ const handed = [
162
+ ...(pointer ? [{ memo_id: pointer.memoId, kind: pointer.kind, text: pointer.intent }] : []),
163
+ ...(playbook ? [{ memo_id: playbook.memoId, kind: 'playbook', text: 'playbook' }] : []),
164
+ ...(knowledge ? knowledge.items.map((i) => ({ memo_id: i.id, kind: i.kind, text: i.text })) : []),
165
+ ];
166
+ if (handed.length)
167
+ writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: handed[0].memo_id, intent: handed[0].text, handed }));
156
168
  if (pointer || knowledge || playbook)
157
169
  writeFileSync(givenFile, JSON.stringify([...given]));
158
170
  if (pointer) {
159
171
  injected('recall', pointer.text, true);
160
172
  // the turn's outcome credits (or heals) these steps when the turn ends
173
+ const match = pointer.match ? { similar: true, match: pointer.match } : {};
161
174
  if (pointer.kind === 'steps')
162
- emit('memory.replayed', { memo_id: pointer.memoId });
175
+ emit('memory.replayed', { memo_id: pointer.memoId, ...match });
163
176
  else
164
- emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version });
177
+ emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version, ...match });
165
178
  bump(session, pointer.kind === 'steps' ? 'replayed' : 'reused');
166
- say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}`);
179
+ say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}${pointer.match ? ` (match: ${pointer.match})` : ''}`);
167
180
  }
168
181
  // a hit is the lookup already done: the reminder would only send the agent to find_task for it again
169
182
  // what teammates' agents wrote goes in one fenced block of data (fence.ts); ArcRoom's own reminder stays outside it
@@ -184,11 +197,22 @@ export async function hook() {
184
197
  say(`${MARK} · blocked a repeated ${String(raw.tool_name).replace(OUR_TOOLS, '')} call (same input 3 times in a row)`);
185
198
  bump(session, 'blocked');
186
199
  }
187
- else
188
- noteEdit(raw);
200
+ else {
201
+ // two agents on one file: the first edit waits once, with who is on it (the same edit again goes through)
202
+ const clash = EDIT_TOOLS.has(raw.tool_name)
203
+ ? await editClash(loadConfig(raw.cwd), session, raw.cwd, raw.tool_input?.file_path ?? raw.tool_input?.notebook_path).catch(() => undefined)
204
+ : undefined;
205
+ if (clash) {
206
+ out.hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: clash };
207
+ injected('deny', clash);
208
+ say(`${MARK} · another agent is editing this file: asked yours to check first`);
209
+ }
210
+ else
211
+ noteEdit(raw);
212
+ }
189
213
  }
190
214
  // a done-check the agent ran with Bash (the Stop hook asks for them; it never runs them itself)
191
- if (name === 'PostToolUse' && raw.tool_name === 'Bash')
215
+ if (name === 'PostToolUse' && SHELL_TOOLS.has(raw.tool_name))
192
216
  noteEdit(raw);
193
217
  if (name === 'PostToolUse' && String(raw.tool_name).startsWith(OUR_TOOLS)) {
194
218
  const tool = raw.tool_name.slice(OUR_TOOLS.length);
@@ -227,14 +251,16 @@ export async function hook() {
227
251
  }
228
252
  if (project) {
229
253
  // Session memory: start with what the last run in this project did. Slow or offline API = no note.
230
- const note = await lastRunNote(session, project).catch(() => undefined);
254
+ const root = git(['rev-parse', '--show-toplevel'], { cwd: raw.cwd }).stdout?.trim();
255
+ const note = await lastRunNote(session, project, branch, root).catch(() => undefined);
231
256
  if (note) {
232
257
  // the last run here may be a teammate's: its ask is their words, so it is fenced like any memory
233
258
  out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: fence(note) };
234
259
  injected('last_run', note, true);
235
260
  }
236
261
  // say what leaves the laptop, every session (events.ts keeps it to that)
237
- say(`${MARK} · on: team memory · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
262
+ const ws = loadConfig(raw.cwd).workspace;
263
+ say(`${MARK} · on: team memory${ws ? ` in "${ws}"` : ''} · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
238
264
  }
239
265
  const reply = toAgent(agent, name, out, session);
240
266
  if (Object.keys(reply).length)