arcroom 0.0.3-beta → 0.0.5-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
@@ -1,10 +1,9 @@
1
- import { git } from './git.js';
1
+ import { git, repoPath } from './git.js';
2
2
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { codeSetup, repoKey, repoPath } from './blind.js';
5
4
  import { api } from './home.js';
6
5
  import { sessionFile } from './loop.js';
7
- import { readTree } from './tree.js';
6
+ import { codeSetup, readTree, repoKey } from './tree.js';
8
7
  // Code wing at prompt time: before the agent starts, the Room tells it what it knows about the folders this request
9
8
  // is about: a summary still true for the folder's current hash, or "changed since its summary", who is editing what
10
9
  // right now (on this branch or another), notes other agents left, and what teammates changed since the last prompt.
@@ -159,3 +158,35 @@ export function codeNoteBody(args, cwd) {
159
158
  ...(args.ttl_hours ? { ttl_hours: args.ttl_hours } : {}),
160
159
  };
161
160
  }
161
+ /** Another agent's claim on this file (its edits claim a file for 10 minutes), as the reason an edit waits; else undefined. */
162
+ export function clashNote(claims, rel, branch, now = new Date()) {
163
+ const c = (claims ?? []).find((c) => c?.k === rel);
164
+ if (!c)
165
+ return undefined;
166
+ const where = c.branch && c.branch !== branch ? `on ${c.branch}: a merge conflict if you both change it` : 'same branch';
167
+ return (`ArcRoom: ${short(c.by)}'s ${c.agent ?? 'agent'} is editing ${rel} right now (${minutes(c.since, now)} min, ${where}). ` +
168
+ 'Check with them, or work on something else first. To edit it anyway, make this edit again.');
169
+ }
170
+ /**
171
+ * Before this session's first edit of a file: another agent editing that same file right now, as the reason the edit
172
+ * waits once (the agent sees it; the same edit again goes through). One lookup per file per session; any failure is no clash.
173
+ * ponytail: someone who starts on the file after your first edit shows up at your next prompt (the code map), not here
174
+ */
175
+ export async function editClash(cfg, session, cwd, file, timeoutMs = 1000) {
176
+ if (typeof file !== 'string' || !cwd || !cfg.apiKey)
177
+ return undefined;
178
+ const s = codeSetup(cwd);
179
+ if (!s || s.mode !== 'standard')
180
+ return undefined;
181
+ const rel = repoPath(s.root, file, cwd);
182
+ if (!rel || rel === '.')
183
+ return undefined;
184
+ const seenFile = sessionFile(session, '.clash');
185
+ const seen = existsSync(seenFile) ? JSON.parse(readFileSync(seenFile, 'utf8')) : [];
186
+ if (seen.includes(rel))
187
+ return undefined;
188
+ writeFileSync(seenFile, JSON.stringify([...seen, rel].slice(-500)));
189
+ const branch = currentBranch(s.root);
190
+ const look = await api(cfg, '/v1/code/look', { repo: repoKey(s), branch, nodes: [dirOf(rel)], session }, timeoutMs).catch(() => undefined);
191
+ return clashNote(look?.claims, rel, branch);
192
+ }
package/dist/daemon.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { blindEvent, codeSetup, repoKey, repoPath } from './blind.js';
4
+ import { repoPath } from './git.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
- import { branches, syncTree } from './tree.js';
12
+ import { branches, codeSetup, repoKey, syncTree } from './tree.js';
11
13
  const SPOOL = join(ARCROOM_HOME, 'spool.jsonl');
12
14
  const SENDING = join(ARCROOM_HOME, 'spool.sending.jsonl');
13
15
  const STATE = join(ARCROOM_HOME, 'daemon-state.json');
@@ -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,22 +108,23 @@ 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
- // Code wing (Standard): which repo, branch and folder an edit is in, cached per folder for a minute.
127
+ // Code wing: which repo, branch and folder an edit is in, cached per folder for a minute.
104
128
  const setups = new Map();
105
129
  function setupFor(cwd) {
106
130
  const hit = setups.get(cwd);
@@ -122,14 +146,12 @@ export function codeTag(e, s, branch) {
122
146
  const dir = node.includes('/') ? node.slice(0, node.lastIndexOf('/')) : '.';
123
147
  return { ...e, data: { ...e.data, code: { repo: repoKey(s), branch, node, dir, agent: e.data.agent ?? 'claude' } } };
124
148
  }
125
- /** Each event as it may leave this laptop: a Blind repo's with paths scrambled and contents dropped, an edit tagged. */
149
+ /** Each event as it leaves this laptop: an edit tagged with where it is. */
126
150
  export function withCode(e, setup = setupFor) {
127
- if (typeof e.cwd !== 'string')
151
+ if (typeof e.cwd !== 'string' || e.type !== 'tool.start')
128
152
  return e;
129
153
  const { s, branch } = setup(e.cwd);
130
- if (s?.mode === 'blind')
131
- return blindEvent(e, s);
132
- return e.type === 'tool.start' ? codeTag(e, s, branch) : e;
154
+ return codeTag(e, s, branch);
133
155
  }
134
156
  const lastSync = new Map();
135
157
  async function syncRepo(cfg, cwd) {
@@ -163,6 +185,11 @@ export async function daemon() {
163
185
  sent = await drainOnce(cfg);
164
186
  if (sent)
165
187
  log(`sent ${sent} events`);
188
+ const later = await sendLater(); // agents' saves kept while the API could not be reached
189
+ if (later.sent)
190
+ log(`sent ${later.sent} saves kept while offline`);
191
+ for (const d of later.dropped)
192
+ log(`a save kept while offline was refused: ${d}`);
166
193
  }
167
194
  catch (err) {
168
195
  log(String(err)); // offline or server down: events stay queued, retried next tick
@@ -195,6 +222,19 @@ export function ensureDaemon(entry) {
195
222
  return;
196
223
  spawn(process.execPath, [entry, 'daemon'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
197
224
  }
225
+ /** Bytes in the queue the daemon has not sent yet (the moved-aside file's rest, then the new queue). */
226
+ export function unsent(state = readJson(STATE, { offset: 0, seq: {} }), spool = SPOOL, sending = SENDING) {
227
+ const size = (f) => (existsSync(f) ? statSync(f).size : 0);
228
+ return existsSync(sending) ? Math.max(0, size(sending) - state.offset) + size(spool) : Math.max(0, size(spool) - state.offset);
229
+ }
230
+ /** `arcroom flush`: the last step of a CI job. Waits until the daemon has sent everything queued; false after the timeout. */
231
+ export async function flush(entry, timeoutMs = 60_000) {
232
+ ensureDaemon(entry);
233
+ for (const until = Date.now() + timeoutMs; Date.now() < until; await new Promise((r) => setTimeout(r, 500)))
234
+ if (!unsent())
235
+ return true;
236
+ return false;
237
+ }
198
238
  /** Called from init: stops a running daemon so the next hook starts the updated one. */
199
239
  export function stopDaemon() {
200
240
  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
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { delimiter, isAbsolute, join } from 'node:path';
3
+ import { delimiter, isAbsolute, join, relative } from 'node:path';
4
4
  // Every git call ArcRoom makes runs inside a repo it did not write, often with no one watching (hooks, the daemon).
5
5
  // Two ways such a repo could run its own code through those calls, both closed here:
6
6
  // - Windows starts a git.exe sitting in the working folder before the one on PATH: git is looked up on PATH once,
@@ -35,3 +35,24 @@ 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
+ }
49
+ /** The repo's top folder, or undefined outside a git repo. */
50
+ export const gitRoot = (cwd) => {
51
+ const r = git(['rev-parse', '--show-toplevel'], { cwd, timeout: 3000 });
52
+ return r.status === 0 ? r.stdout?.trim() : undefined;
53
+ };
54
+ /** A path (absolute, or relative to `from`) as a repo path: "src/billing/tax.ts", or undefined outside the repo. */
55
+ export function repoPath(root, p, from = root) {
56
+ const rel = relative(root, isAbsolute(p) ? p : join(from, p)).replaceAll('\\', '/');
57
+ return rel.startsWith('..') || isAbsolute(rel) ? undefined : rel || '.';
58
+ }
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). */