arcroom 0.0.2-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/dist/index.js CHANGED
@@ -2,13 +2,14 @@
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { codeSetup, forgetBlind, gitRoot, repoKey, writeSetup } from './blind.js';
5
- import { daemon } from './daemon.js';
5
+ import { daemon, flush, unsent } from './daemon.js';
6
6
  import { doctor, print } from './doctor.js';
7
7
  import { approveChecks, checksHash, isApproved, loadChecks, readApprovals } from './done.js';
8
8
  import { syncTree } from './tree.js';
9
9
  import { api, ARCROOM_HOME, loadConfig, setOff } from './home.js';
10
10
  import { hook } from './hook.js';
11
- import { init } from './init.js';
11
+ import { init, uninstall } from './init.js';
12
+ import { sentReport } from './sent.js';
12
13
  import { mcp } from './mcp.js';
13
14
  import { readStats, statusText } from './signs.js';
14
15
  import { sessionUsage, tokensOf } from './usage.js';
@@ -32,7 +33,7 @@ else if (cmd === 'doctor') {
32
33
  process.exitCode = 1;
33
34
  }
34
35
  else if (cmd === 'whoami') {
35
- const me = await api(loadConfig(), '/v1/me');
36
+ const me = await api(loadConfig(process.cwd()), '/v1/me');
36
37
  console.log(`${me.email ?? 'shared key (no person)'} in workspace "${me.workspace}" (key ${me.key_label ?? 'unlabelled'}, made ${me.key_created_at})`);
37
38
  }
38
39
  else if (cmd === 'status') {
@@ -55,23 +56,28 @@ else if (cmd === 'rule') {
55
56
  // arcroom rule [--tag <tag>] [--max-hits N] [--idle-days D] (no limits = remove the rule)
56
57
  const opt = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
57
58
  const num = (name) => (opt(name) === undefined ? null : Number(opt(name)));
58
- const res = await api(loadConfig(), '/v1/rules', { tag: opt('--tag') ?? null, max_hits: num('--max-hits'), idle_days: num('--idle-days') });
59
+ const res = await api(loadConfig(process.cwd()), '/v1/rules', { tag: opt('--tag') ?? null, max_hits: num('--max-hits'), idle_days: num('--idle-days') });
59
60
  console.log(res.rule ? `rule set: ${JSON.stringify(res.rule)}` : 'rule removed');
60
61
  }
61
62
  else if (cmd === 'wrong' || cmd === 'share') {
62
63
  // arcroom wrong [memo id] [reason]: the memory just handed over (or the one named) was wrong
63
- // arcroom share [memo id]: share your new steps with the team before their second pass
64
+ // arcroom share <memo id>: share an answer saved as "only me" with the team (recorded steps are the team's already)
64
65
  const lastFile = join(ARCROOM_HOME, 'last-recall.json');
65
66
  const named = /^[0-9a-f-]{36}$/i.test(args[0] ?? '') ? args.shift() : undefined;
66
- const last = named ? { memo_id: named, intent: named } : existsSync(lastFile) ? JSON.parse(readFileSync(lastFile, 'utf8')) : undefined;
67
- if (!last)
67
+ const last = named ? { memo_id: named, intent: named } : cmd === 'wrong' && existsSync(lastFile) ? JSON.parse(readFileSync(lastFile, 'utf8')) : undefined;
68
+ if (!last && cmd === 'share')
69
+ console.log('steps your agents record are shared with the team already; to share an answer saved as private, pass its id: arcroom share <memo id> (see the Memory page)');
70
+ else if (!last)
68
71
  console.log('no memory was handed over yet; pass its id (see the Memory page)');
72
+ else if (!named && Array.isArray(last.handed) && last.handed.length > 1)
73
+ // several memories came in that hand-over: say which, instead of guessing
74
+ console.log(`The last hand-over had ${last.handed.length} memories. Name the wrong one:\n${last.handed.map((h) => ` arcroom wrong ${h.memo_id} ${h.kind}: ${h.text}`).join('\n')}`);
69
75
  else if (cmd === 'share') {
70
- await api(loadConfig(), '/v1/memory/share', { memo_id: last.memo_id });
76
+ await api(loadConfig(process.cwd()), '/v1/memory/share', { memo_id: last.memo_id });
71
77
  console.log(`shared with the team: ${last.intent}`);
72
78
  }
73
79
  else {
74
- const r = await api(loadConfig(), '/v1/memory/wrong', { memo_id: last.memo_id, reason: args.join(' ') || null });
80
+ const r = await api(loadConfig(process.cwd()), '/v1/memory/wrong', { memo_id: last.memo_id, reason: args.join(' ') || null });
75
81
  const what = r.deleted
76
82
  ? 'deleted (2 people said wrong)'
77
83
  : r.steps_broken
@@ -96,17 +102,35 @@ else if (cmd === 'code') {
96
102
  else if (s.mode !== 'standard')
97
103
  console.log(`Code wing is ${s.mode} for this repo${s.mode === 'off' ? ': arcroom code on turns it on' : ''}`);
98
104
  else if (sub === 'sync') {
99
- const r = await syncTree(loadConfig(), s);
105
+ const r = await syncTree(loadConfig(process.cwd()), s);
100
106
  console.log(`sent ${r.nodes} folders and files (repo ${repoKey(s)})`);
101
107
  }
102
108
  else {
103
- const repos = await api(loadConfig(), '/v1/code/tree');
109
+ const repos = await api(loadConfig(process.cwd()), '/v1/code/tree');
104
110
  const me = repos.find((x) => x.repo === repoKey(s));
105
111
  console.log(me
106
112
  ? `${me.repo}: ${me.folders} folders, ${me.files} files, ${me.editing} agent(s) editing now, default branch ${me.default_branch}`
107
113
  : 'this repo has not sent its tree yet: arcroom code sync');
108
114
  }
109
115
  }
116
+ else if (cmd === 'flush') {
117
+ // arcroom flush: the last step of a CI job, so a headless run's final events are not lost when the machine goes away
118
+ const ok = await flush(process.argv[1]);
119
+ console.log(ok ? 'everything queued was sent' : `${unsent()} bytes still queued after 60 s: see arcroom doctor`);
120
+ if (!ok)
121
+ process.exitCode = 1;
122
+ }
123
+ else if (cmd === 'sent') {
124
+ // arcroom sent [YYYY-MM-DD] [--all]: what this laptop sent that day, kept 7 days
125
+ console.log(sentReport(args));
126
+ }
127
+ else if (cmd === 'uninstall') {
128
+ // arcroom uninstall [--all]: ArcRoom out of this project for every agent; --all also deletes ~/.arcroom
129
+ const changed = uninstall(process.cwd(), args.includes('--all'));
130
+ console.log(changed.length ? `Removed ArcRoom from:\n${changed.map((c) => ` ${c}`).join('\n')}` : 'Nothing of ArcRoom was set up in this project.');
131
+ if (!args.includes('--all'))
132
+ console.log('Other projects on this laptop keep it. arcroom uninstall --all also deletes ~/.arcroom (key, queue, runtime).');
133
+ }
110
134
  else if (cmd === 'off' || cmd === 'on') {
111
135
  // arcroom off | on: ArcRoom for the repo you are in, on this laptop only (nothing traced or sent while off)
112
136
  const root = gitRoot(process.cwd()) ?? process.cwd();
@@ -123,7 +147,7 @@ else if (cmd === 'checks') {
123
147
  console.log('no doneChecks in .arcroom.json here');
124
148
  else if (args[0] === 'approve' && !process.stdin.isTTY)
125
149
  // an agent's shell has no terminal: approving stays a step the person takes themselves (N14)
126
- console.log('not approved: run arcroom checks approve yourself, in a terminal (not through an agent)');
150
+ console.log(`not approved: run arcroom checks approve yourself, in a terminal (not through an agent); in CI, set ARCROOM_APPROVE_CHECKS=${code} in the job's environment`);
127
151
  else if (args[0] === 'approve') {
128
152
  const done = approveChecks(process.cwd(), args[1] ?? '');
129
153
  console.log(done ? `approved for ${process.cwd()} (after editing files, the agent is asked to run them with its Bash tool before it stops):\n${list}` : 'not approved: run arcroom checks, read the commands, then arcroom checks approve <the code it prints>');
@@ -143,7 +167,7 @@ else if (cmd === 'decide') {
143
167
  else {
144
168
  const over = opt('--over')?.split(',').map((o) => o.trim()).filter(Boolean);
145
169
  const url = opt('--source');
146
- const r = await api(loadConfig(), '/v1/memory/remember', {
170
+ const r = await api(loadConfig(process.cwd()), '/v1/memory/remember', {
147
171
  kind: 'decision',
148
172
  text,
149
173
  why: opt('--why') ?? null,
@@ -157,4 +181,4 @@ else if (cmd === 'decide') {
157
181
  }
158
182
  }
159
183
  else
160
- console.log('usage: arcroom init [--project <id>] (asks for the key; or set ARCROOM_API_KEY) [--url <arcroom api url>] | arcroom whoami | arcroom doctor | arcroom rule [--tag t] [--max-hits n] [--idle-days d] | arcroom wrong [memo id] [reason] | arcroom share [memo id] | arcroom decide "<choice>" [--why ...] [--over "A, B"] | arcroom code [status|sync|on|off] | arcroom checks [approve] | arcroom off | arcroom on');
184
+ console.log('usage: arcroom init [--project <id>] (asks for the key; or set ARCROOM_API_KEY) [--url <arcroom api url>] | arcroom whoami | arcroom doctor | arcroom rule [--tag t] [--max-hits n] [--idle-days d] | arcroom wrong [memo id] [reason] | arcroom share [memo id] | arcroom decide "<choice>" [--why ...] [--over "A, B"] | arcroom code [status|sync|on|off] | arcroom checks [approve] | arcroom off | arcroom on | arcroom uninstall [--all] | arcroom init --everywhere | arcroom sent [YYYY-MM-DD] [--all] | arcroom flush');
package/dist/init.js CHANGED
@@ -1,26 +1,30 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { AGENTS, CURSOR_HOOKS, opencodePlugin } from './agents.js';
7
7
  import { stopDaemon } from './daemon.js';
8
- import { onPath } from './git.js';
9
- import { apiUrl, ARCROOM_HOME, DEFAULT_URL, isLocalUrl, keyRefused } from './home.js';
8
+ import { git, onPath, projectOf } from './git.js';
9
+ import { api, apiUrl, ARCROOM_HOME, DEFAULT_URL, isLocalUrl, keyRefused } from './home.js';
10
+ import { userSettings } from './scope.js';
10
11
  const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd'];
11
12
  const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
12
13
  const MARK = '.arcroom/runtime'; // identifies our hook entries in a settings file
13
14
  // node by its full path, never the word "node": an agent starting a hook inside a repo must not run a node.exe planted there
14
15
  const NODE = process.execPath;
15
16
  const nodeCmd = JSON.stringify(NODE);
17
+ // After a tool, only a shell run (a done-check passing) and ArcRoom's own tools matter, so Claude Code launches the hook
18
+ // once per tool call instead of twice: each launch is a window flash on Windows (Git Bash, no console of its own).
19
+ export const AFTER_TOOLS = 'Bash|PowerShell|mcp__arcroom__.*';
16
20
  /** Puts ArcRoom's hook on every event: replaces an earlier ArcRoom entry, keeps everyone else's hooks. */
17
- export function withArcroomHooks(settings, command, anyTool = '*') {
21
+ export function withArcroomHooks(settings, command, anyTool = '*', afterTool = anyTool) {
18
22
  const hooks = { ...settings.hooks };
19
23
  for (const event of EVENTS) {
20
24
  const others = (hooks[event] ?? []).filter((g) => !g.hooks.some((h) => h.command.includes(MARK)));
21
25
  const ours = { hooks: [{ type: 'command', command }] };
22
26
  if (TOOL_EVENTS.has(event))
23
- ours.matcher = anyTool;
27
+ ours.matcher = event === 'PostToolUse' ? afterTool : anyTool;
24
28
  hooks[event] = [...others, ours];
25
29
  }
26
30
  return { ...settings, hooks };
@@ -38,6 +42,26 @@ export function withCodexMcp(toml, entry, node = NODE) {
38
42
  const ours = `[mcp_servers.arcroom]\ncommand = ${JSON.stringify(node)}\nargs = [${JSON.stringify(entry)}, "mcp"]\n`;
39
43
  return (others ? `${others}\n\n` : '') + ours;
40
44
  }
45
+ /**
46
+ * Keeps the files init wrote out of commits, in this clone only (.git/info/exclude, never committed): they hold this
47
+ * laptop's paths, so a `git add -A` would break a teammate's setup. A file the repo already tracks is left alone.
48
+ */
49
+ export function keepOutOfGit(files, cwd) {
50
+ const root = git(['rev-parse', '--show-toplevel'], { cwd }).stdout?.trim();
51
+ const path = git(['rev-parse', '--git-path', 'info/exclude'], { cwd }).stdout?.trim();
52
+ if (!root || !path)
53
+ return [];
54
+ const exclude = isAbsolute(path) ? path : join(cwd, path);
55
+ const have = existsSync(exclude) ? readFileSync(exclude, 'utf8').split(/\r?\n/) : [];
56
+ const add = files
57
+ .map((f) => '/' + relative(root, f).replaceAll('\\', '/'))
58
+ .filter((rel) => !rel.startsWith('/..') && !have.includes(rel) && git(['ls-files', '--error-unmatch', rel.slice(1)], { cwd: root }).status !== 0);
59
+ if (!add.length)
60
+ return [];
61
+ mkdirSync(dirname(exclude), { recursive: true });
62
+ appendFileSync(exclude, (have.length && have[have.length - 1] !== '' ? '\n' : '') + add.join('\n') + '\n');
63
+ return add;
64
+ }
41
65
  const readJson = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {});
42
66
  const writeJson = (file, value) => {
43
67
  mkdirSync(dirname(file), { recursive: true });
@@ -58,6 +82,26 @@ export function initChoice(o) {
58
82
  ];
59
83
  return { key, url, notes };
60
84
  }
85
+ /**
86
+ * The config after init saves `key` in `repo`. A key for another workspace than the laptop's working default becomes this
87
+ * repo's own, and every other repo keeps sending where it did (a freelancer's two clients); otherwise it is the default.
88
+ */
89
+ export function keysAfterInit(saved, repo, key, url, workspace) {
90
+ const other = !!saved.apiKey && saved.apiKey !== key && !keyRefused({ apiKey: saved.apiKey, url }) && !!saved.workspace && !!workspace && saved.workspace !== workspace;
91
+ const repos = { ...(saved.repos ?? {}) };
92
+ if (other)
93
+ repos[repo] = { apiKey: key, workspace };
94
+ else
95
+ delete repos[repo];
96
+ const next = other ? { ...saved, url, repos } : { ...saved, apiKey: key, url, ...(workspace ? { workspace } : {}), repos };
97
+ if (!Object.keys(next.repos).length)
98
+ delete next.repos;
99
+ if (other)
100
+ console.log(`This repo sends to "${workspace}"; other repos on this laptop keep sending to "${saved.workspace}".`);
101
+ else if (saved.workspace && workspace && saved.workspace !== workspace)
102
+ console.error(`Note: this laptop's default is now "${workspace}" (was "${saved.workspace}").`);
103
+ return next;
104
+ }
61
105
  export async function init(args) {
62
106
  const arg = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
63
107
  const agent = (arg('--agent') ?? 'claude');
@@ -67,7 +111,10 @@ export async function init(args) {
67
111
  }
68
112
  const configFile = join(ARCROOM_HOME, 'config.json');
69
113
  const saved = existsSync(configFile) ? JSON.parse(readFileSync(configFile, 'utf8')) : {}; // re-running init reuses saved values
70
- const choice = initChoice({ argKey: arg('--key'), envKey: process.env.ARCROOM_API_KEY, argUrl: arg('--url'), saved });
114
+ const repo = projectOf(process.cwd());
115
+ const mine = saved.repos?.[repo]; // this repo's own key, when it has one
116
+ const forRepo = mine ? { ...saved, apiKey: mine.apiKey, workspace: mine.workspace } : saved;
117
+ const choice = initChoice({ argKey: arg('--key'), envKey: process.env.ARCROOM_API_KEY, argUrl: arg('--url'), saved: forRepo });
71
118
  let key = choice.key;
72
119
  const url = choice.url;
73
120
  if (arg('--key'))
@@ -90,9 +137,24 @@ export async function init(args) {
90
137
  console.error('That is not an ArcRoom key (it starts with ark_).');
91
138
  process.exit(1);
92
139
  }
93
- // 1. Remember the key for this machine.
140
+ // 1. Check the key with the API, then remember it for this machine: a typo or a revoked key must not end in "ArcRoom is on".
141
+ let workspace = forRepo.apiKey === key ? forRepo.workspace : undefined;
142
+ try {
143
+ const me = await api({ apiKey: key, url }, '/v1/me');
144
+ workspace = me.workspace;
145
+ console.log(`Connected to workspace "${me.workspace}"${me.email ? ` as ${me.email}` : ''}.`);
146
+ }
147
+ catch (e) {
148
+ if (/invalid api key/.test(e.message)) {
149
+ console.error('The API refused this key. Make a new one in the web app (Settings → API keys) and run arcroom init again.');
150
+ process.exit(1);
151
+ }
152
+ console.error(`Could not check the key (${e.message}); saved it anyway. Run arcroom doctor once you are online.`);
153
+ }
94
154
  mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
95
- writeFileSync(configFile, JSON.stringify({ apiKey: key, url }, null, 2), { mode: 0o600 });
155
+ const cfg = keysAfterInit(saved, repo, key, url, workspace);
156
+ cfg.setUp = [...new Set([...(cfg.setUp ?? []), repo])]; // laptop-wide hooks (--everywhere) act only in repos set up here
157
+ writeFileSync(configFile, JSON.stringify(cfg, null, 2), { mode: 0o600 });
96
158
  stopDaemon(); // the next hook event starts the updated one
97
159
  // 2. Copy the hook runtime to a stable place (npx's own copy is temporary).
98
160
  const runtime = join(ARCROOM_HOME, 'runtime');
@@ -113,7 +175,7 @@ export async function init(args) {
113
175
  }
114
176
  if (agent !== 'claude')
115
177
  return initOther(agent, entry, project);
116
- const settings = withArcroomHooks(current, `${nodeCmd} "${entry}" hook${project ? ` --project ${project}` : ''}`);
178
+ const settings = withArcroomHooks(current, `${nodeCmd} "${entry}" hook${project ? ` --project ${project}` : ''}`, '*', AFTER_TOOLS);
117
179
  // never pre-approve a server from the shared .mcp.json by name: a pull request could change what that name runs
118
180
  const approved = (current.enabledMcpjsonServers ?? []).filter((n) => n !== 'arcroom');
119
181
  if (approved.length)
@@ -121,9 +183,13 @@ export async function init(args) {
121
183
  else
122
184
  delete settings.enabledMcpjsonServers;
123
185
  // Live badge at the bottom of Claude Code ("ArcRoom · live · 2 reused"), unless the project has its own status line.
186
+ // It runs a command on every redraw, a window flash each time on Windows, so there it is only on with --status.
124
187
  const theirs = current.statusLine;
125
- if (!theirs || theirs.command?.includes(MARK))
188
+ const status = process.platform !== 'win32' || args.includes('--status');
189
+ if (status && (!theirs || theirs.command?.includes(MARK)))
126
190
  settings.statusLine = { type: 'command', command: `${nodeCmd} "${entry}" status`, padding: 0 };
191
+ else if (!status && theirs?.command?.includes(MARK))
192
+ delete settings.statusLine;
127
193
  mkdirSync(dirname(file), { recursive: true });
128
194
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
129
195
  // 4. Register the MCP server (find_task / claim_task / complete_task) for this project: in Claude Code's local scope
@@ -142,25 +208,135 @@ export async function init(args) {
142
208
  mcpConfig.mcpServers = { ...mcpConfig.mcpServers, arcroom: { command: NODE, args: [entry, 'mcp'] } };
143
209
  writeFileSync(mcpFile, JSON.stringify(mcpConfig, null, 2) + '\n');
144
210
  }
211
+ const kept = keepOutOfGit([file, ...(local ? [] : [mcpFile])], process.cwd());
212
+ // 5. --everywhere: the same hook and tools for every folder on this laptop (Claude Code's user settings and user scope),
213
+ // acting only in repos set up here, so other clones and worktrees of this repo are covered too.
214
+ const everywhere = args.includes('--everywhere');
215
+ if (everywhere) {
216
+ const user = userSettings();
217
+ const mine = existsSync(user) ? JSON.parse(readFileSync(user, 'utf8')) : {};
218
+ writeJson(user, withArcroomHooks(mine, `${nodeCmd} "${entry}" hook --user`, '*', AFTER_TOOLS));
219
+ claudeCli(['mcp', 'remove', '--scope', 'user', 'arcroom']);
220
+ claudeCli(['mcp', 'add', '--scope', 'user', 'arcroom', '--', NODE, entry, 'mcp', '--user']);
221
+ }
145
222
  console.log(`ArcRoom is on for this project.
146
223
  key ${join(ARCROOM_HOME, 'config.json')}
147
224
  hooks ${file}
148
- tools ${local ? 'Claude Code local scope (claude mcp list)' : `${mcpFile} (approve "arcroom" when Claude Code asks)`}
149
- Start a new Claude Code session here to begin capturing runs.`);
225
+ tools ${local ? 'Claude Code local scope (claude mcp list)' : `${mcpFile} (approve "arcroom" when Claude Code asks)`}${kept.length ? `\n git kept out of commits in this clone: ${kept.join(', ')}` : ''}${everywhere ? `\n laptop ${userSettings()}: every clone and worktree of a repo set up here` : ''}
226
+ Start a new Claude Code session here to begin capturing runs. Check it any time: arcroom doctor`);
150
227
  }
151
- /** `claude mcp add --scope local arcroom`: false when the claude command is not on PATH or the add failed. */
152
- function addLocalMcp(entry) {
228
+ /** Runs the claude command; null when it is not on PATH. */
229
+ function claudeCli(args) {
153
230
  const claude = onPath(process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']);
154
231
  if (!claude)
155
- return false;
232
+ return null;
156
233
  const run = (args) => claude.endsWith('.cmd') // an npm install on Windows: a batch file runs through cmd, every argument quoted
157
234
  ? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[claude, ...args].map((a) => `"${a}"`).join(' ')}"`], {
158
235
  windowsVerbatimArguments: true,
236
+ windowsHide: true,
159
237
  encoding: 'utf8',
160
238
  })
161
- : spawnSync(claude, args, { encoding: 'utf8' });
162
- run(['mcp', 'remove', '--scope', 'local', 'arcroom']); // none yet is fine
163
- return run(['mcp', 'add', '--scope', 'local', 'arcroom', '--', NODE, entry, 'mcp']).status === 0;
239
+ : spawnSync(claude, args, { encoding: 'utf8', windowsHide: true });
240
+ return run(args).status;
241
+ }
242
+ /** `claude mcp add --scope local arcroom`: false when the claude command is not on PATH or the add failed. */
243
+ function addLocalMcp(entry) {
244
+ if (claudeCli(['mcp', 'remove', '--scope', 'local', 'arcroom']) === null)
245
+ return false; // none yet is fine
246
+ return claudeCli(['mcp', 'add', '--scope', 'local', 'arcroom', '--', NODE, entry, 'mcp']) === 0;
247
+ }
248
+ /** A hooks map without ArcRoom's entries (Claude Code and Codex group them; Cursor lists commands); empty events go. */
249
+ function withoutOurHooks(hooks) {
250
+ if (!hooks)
251
+ return hooks;
252
+ const ours = (h) => String(h?.command ?? '').includes(MARK) || (h?.hooks ?? []).some((x) => String(x?.command ?? '').includes(MARK));
253
+ const next = Object.fromEntries(Object.entries(hooks).map(([event, list]) => [event, (list ?? []).filter((h) => !ours(h))]).filter(([, list]) => list.length));
254
+ return Object.keys(next).length ? next : undefined;
255
+ }
256
+ /**
257
+ * `arcroom uninstall [--all]`: takes out of this project what init put in for every agent (hooks, status line, MCP entries,
258
+ * the OpenCode plugin), keeping everyone else's settings, and stops the uploader. --all also removes ~/.arcroom (the key,
259
+ * the queue, the runtime). Returns what it changed.
260
+ */
261
+ export function uninstall(cwd, all = false, removeLocalMcp = () => claudeCli(['mcp', 'remove', '--scope', 'local', 'arcroom']), removeUserMcp = () => claudeCli(['mcp', 'remove', '--scope', 'user', 'arcroom'])) {
262
+ const changed = [];
263
+ const edit = (file, change) => {
264
+ if (!existsSync(file))
265
+ return;
266
+ const before = JSON.parse(readFileSync(file, 'utf8'));
267
+ const after = change(structuredClone(before));
268
+ if (JSON.stringify(after) === JSON.stringify(before))
269
+ return;
270
+ if (!Object.keys(after).length)
271
+ rmSync(file);
272
+ else
273
+ writeFileSync(file, JSON.stringify(after, null, 2) + '\n');
274
+ changed.push(file);
275
+ };
276
+ const ourServer = (s) => JSON.stringify(s ?? '').replaceAll('\\\\', '/').includes(MARK);
277
+ const hooksOf = (v) => {
278
+ const hooks = withoutOurHooks(v.hooks);
279
+ if (hooks)
280
+ v.hooks = hooks;
281
+ else
282
+ delete v.hooks;
283
+ if (String(v.statusLine?.command ?? '').includes(MARK))
284
+ delete v.statusLine;
285
+ return v;
286
+ };
287
+ edit(join(cwd, '.claude', 'settings.local.json'), hooksOf);
288
+ edit(join(cwd, '.codex', 'hooks.json'), hooksOf);
289
+ edit(join(cwd, '.cursor', 'hooks.json'), (v) => {
290
+ const hooks = withoutOurHooks(v.hooks);
291
+ if (hooks)
292
+ v.hooks = hooks;
293
+ else
294
+ delete v.hooks;
295
+ return Object.keys(v).length === 1 && v.version !== undefined ? {} : v;
296
+ });
297
+ for (const file of [join(cwd, '.mcp.json'), join(cwd, '.cursor', 'mcp.json')])
298
+ edit(file, (v) => {
299
+ if (ourServer(v.mcpServers?.arcroom))
300
+ delete v.mcpServers.arcroom;
301
+ if (v.mcpServers && !Object.keys(v.mcpServers).length)
302
+ delete v.mcpServers;
303
+ return v;
304
+ });
305
+ edit(join(cwd, 'opencode.json'), (v) => {
306
+ if (ourServer(v.mcp?.arcroom))
307
+ delete v.mcp.arcroom;
308
+ if (v.mcp && !Object.keys(v.mcp).length)
309
+ delete v.mcp;
310
+ return Object.keys(v).length === 1 && v.$schema ? {} : v;
311
+ });
312
+ const toml = join(cwd, '.codex', 'config.toml');
313
+ if (existsSync(toml)) {
314
+ const before = readFileSync(toml, 'utf8');
315
+ const after = before.replace(/\n?\[mcp_servers\.arcroom\][\s\S]*?(?=\n\[|$)/, '').trimEnd();
316
+ if (after !== before.trimEnd()) {
317
+ if (after)
318
+ writeFileSync(toml, after + '\n');
319
+ else
320
+ rmSync(toml);
321
+ changed.push(toml);
322
+ }
323
+ }
324
+ const plugin = join(cwd, '.opencode', 'plugins', 'arcroom.js');
325
+ if (existsSync(plugin))
326
+ rmSync(plugin), changed.push(plugin);
327
+ if (removeLocalMcp() === 0)
328
+ changed.push('Claude Code local MCP entry "arcroom"');
329
+ if (all) {
330
+ edit(userSettings(), hooksOf);
331
+ if (removeUserMcp() === 0)
332
+ changed.push('Claude Code user MCP entry "arcroom"');
333
+ }
334
+ stopDaemon();
335
+ if (all && existsSync(ARCROOM_HOME)) {
336
+ rmSync(ARCROOM_HOME, { recursive: true, force: true });
337
+ changed.push(ARCROOM_HOME);
338
+ }
339
+ return changed;
164
340
  }
165
341
  /** Steps 3 and 4 for Codex, Cursor and OpenCode: their own hook and MCP files, in this project. */
166
342
  function initOther(agent, entry, project) {
@@ -201,8 +377,10 @@ function initOther(agent, entry, project) {
201
377
  cursor: 'Restart Cursor. Cursor adds context only after a tool call, so team memory reaches the agent on its first tool use.',
202
378
  opencode: "Restart OpenCode. Experimental: per-prompt recall rides OpenCode's experimental system-prompt hook, so it may change with OpenCode.",
203
379
  };
380
+ const kept = keepOutOfGit(written, cwd);
204
381
  console.log(`ArcRoom is on for ${agent} in this project.
205
382
  key ${join(ARCROOM_HOME, 'config.json')}
206
- ${written.map((f) => ` wrote ${f}`).join('\n')}
207
- ${next[agent]}`);
383
+ ${written.map((f) => ` wrote ${f}`).join('\n')}${kept.length ? `\n git kept out of commits in this clone: ${kept.join(', ')}` : ''}
384
+ ${next[agent]}
385
+ Check it any time: arcroom doctor`);
208
386
  }
package/dist/lookup.js CHANGED
@@ -36,7 +36,7 @@ const CODE_MS = 1200; // the code map is the extra: it never holds recall up
36
36
  export async function lookup(prompt, cwd, session, given) {
37
37
  const [r, code] = await Promise.all([
38
38
  recall(prompt, cwd, projectOf(cwd), given, RECALL_MS).catch(() => undefined),
39
- codeContext(loadConfig(), cwd, session, prompt, CODE_MS).catch(() => undefined),
39
+ codeContext(loadConfig(cwd), cwd, session, prompt, CODE_MS).catch(() => undefined),
40
40
  ]);
41
41
  if (!r)
42
42
  markDown();
package/dist/loop.js CHANGED
@@ -12,9 +12,17 @@ const stable = (v) => Array.isArray(v)
12
12
  export function signature(tool, input) {
13
13
  return createHash('sha256').update(`${tool}\0${stable(input)}`).digest('hex').slice(0, 16);
14
14
  }
15
- /** True when the last LOOP_LIMIT-1 calls were all this same call. */
16
- export function isLoop(recent, sig) {
17
- return recent.length >= LOOP_LIMIT - 1 && recent.slice(-(LOOP_LIMIT - 1)).every((s) => s === sig);
15
+ // Read-only status checks an agent repeats while it waits (CI, a deploy, a container): polling, not a loop, when there
16
+ // is a wait between them. Anything else repeated is still blocked on the 3rd identical call in a row.
17
+ const POLL = /^(?:sleep \d+\s*(?:&&|;)\s*)?(?:gh (?:run|pr|workflow) (?:view|watch|list|checks|status)|gh pr checks|git (?:status|log|fetch)|kubectl (?:get|describe|rollout status)|docker (?:ps|logs)|curl\b|Start-Sleep\b)/i;
18
+ export const POLL_GAP_MS = 10_000;
19
+ export const isPoll = (tool, input) => (tool === 'Bash' || tool === 'PowerShell') && POLL.test(String(input?.command ?? '').trim());
20
+ /** True when the last LOOP_LIMIT-1 calls were all this same call, and it is not a status check polled with a wait. */
21
+ export function isLoop(recent, sig, poll = false, now = Date.now()) {
22
+ const last = recent.slice(-(LOOP_LIMIT - 1)).map((c) => (typeof c === 'string' ? { sig: c, at: 0 } : c));
23
+ if (last.length < LOOP_LIMIT - 1 || !last.every((c) => c.sig === sig))
24
+ return false;
25
+ return !(poll && now - last[last.length - 1].at >= POLL_GAP_MS);
18
26
  }
19
27
  /** Path of a per-session state file under ~/.arcroom/sessions. */
20
28
  export function sessionFile(session, suffix = '') {
@@ -27,9 +35,9 @@ export function checkLoop(session, tool, input) {
27
35
  const file = sessionFile(session);
28
36
  const recent = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
29
37
  const sig = signature(tool, input);
30
- if (isLoop(recent, sig)) {
38
+ if (isLoop(recent, sig, isPoll(tool, input))) {
31
39
  return `ArcRoom loop guard: this exact ${tool} call already ran ${LOOP_LIMIT - 1} times in a row. Don't repeat it; try a different approach.`;
32
40
  }
33
- writeFileSync(file, JSON.stringify([...recent, sig].slice(-(LOOP_LIMIT - 1))));
41
+ writeFileSync(file, JSON.stringify([...recent, { sig, at: Date.now() }].slice(-(LOOP_LIMIT - 1))));
34
42
  return undefined;
35
43
  }
package/dist/mcp.js CHANGED
@@ -7,9 +7,11 @@ import { hostname } from 'node:os';
7
7
  import { relative, resolve } from 'node:path';
8
8
  import { createInterface } from 'node:readline';
9
9
  import { api, isOff, keyRefused, loadConfig } from './home.js';
10
+ import { hooksHere, setUpHere } from './scope.js';
11
+ import { keepForLater, unreachable, WRITES } from './pending.js';
10
12
  import { codeNoteBody } from './code.js';
11
13
  import { defuse, MEMORY_NOTE } from './fence.js';
12
- import { localFile } from './fresh.js';
14
+ import { localFile, lf } from './fresh.js';
13
15
  import { ICONS } from './logo.js';
14
16
  import { withRoute } from './route.js';
15
17
  const INTENT = "The subtask in standard form 'verb object: qualifiers', lowercase, no filler words. " +
@@ -224,7 +226,7 @@ const PATHS = {
224
226
  remember: '/v1/memory/remember',
225
227
  code_note: '/v1/code/note',
226
228
  };
227
- const sha256 = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
229
+ const sha256 = (file) => createHash('sha256').update(lf(readFileSync(file))).digest('hex');
228
230
  /** {"file:<path>": sha256} for files the answer depends on; a missing file is recorded as "missing". */
229
231
  export function fileDeps(paths, cwd) {
230
232
  return Object.fromEntries(paths.map((p) => {
@@ -251,7 +253,11 @@ export async function handle(method, params, call, ctx) {
251
253
  protocolVersion: params?.protocolVersion ?? '2025-06-18',
252
254
  capabilities: { tools: {} },
253
255
  serverInfo: { name: 'arcroom', title: 'ArcRoom', version: '0.0.0', websiteUrl: 'https://arcroomai.com', icons: ICONS },
254
- instructions: 'Code changes are recorded automatically from the session; do not call these tools for them. Check find_task before a question, lookup or analysis someone could reuse (split_task when a request has several parts); claim_task, then complete_task when you do one yourself. Save a lasting fact or decision with remember; after you understand a folder, save one line about it with code_note. Run a part that has route.model with the Agent tool on that model.',
256
+ instructions: isOff(ctx.cwd)
257
+ ? 'ArcRoom is switched off for this repo (arcroom off): its tools do nothing here.'
258
+ : !hooksHere(ctx.cwd)
259
+ ? 'No ArcRoom hooks run for this client, so nothing is recorded on its own. Check find_task before a task, a question, a lookup or analysis someone could reuse (split_task when a request has several parts); claim_task, then complete_task with what you did, including code changes, when you finish one. Save a lasting fact or decision with remember; after you understand a folder, save one line about it with code_note.'
260
+ : 'Code changes are recorded automatically from the session; do not call these tools for them. Check find_task before a question, lookup or analysis someone could reuse (split_task when a request has several parts); claim_task, then complete_task when you do one yourself. Save a lasting fact or decision with remember; after you understand a folder, save one line about it with code_note. Run a part that has route.model with the Agent tool on that model.',
255
261
  };
256
262
  case 'ping':
257
263
  return {};
@@ -261,18 +267,23 @@ export async function handle(method, params, call, ctx) {
261
267
  const path = PATHS[params?.name];
262
268
  if (!path)
263
269
  throw { code: -32602, message: `unknown tool ${params?.name}` };
264
- if (keyRefused())
270
+ if (keyRefused(loadConfig(ctx.cwd)))
265
271
  return { content: [{ type: 'text', text: 'ArcRoom is stopped on this machine: its key was revoked or its account deleted. Run arcroom init with a new key.' }], isError: true };
266
272
  if (isOff(ctx.cwd))
267
273
  return { content: [{ type: 'text', text: 'ArcRoom is switched off for this repo (arcroom on to use it again).' }], isError: true };
274
+ if (process.argv.includes('--user') && !setUpHere(ctx.cwd))
275
+ return { content: [{ type: 'text', text: 'ArcRoom is not set up for this repo: run arcroom init in it once.' }], isError: true };
268
276
  // depends_on is only ever built here from real file hashes (fileDeps), never taken from the agent
269
277
  const { for: pinFor, depends_on_files, depends_on: _given, ...args } = params.arguments ?? {};
270
278
  if (params.name === 'code_note') {
279
+ const note = codeNoteBody(args, ctx.cwd);
271
280
  try {
272
- const r = await call(path, codeNoteBody(args, ctx.cwd));
281
+ const r = await call(path, note);
273
282
  return { content: [{ type: 'text', text: JSON.stringify(r) }] };
274
283
  }
275
284
  catch (err) {
285
+ if (unreachable(err))
286
+ return keptLater(path, note, ctx.cwd);
276
287
  return { content: [{ type: 'text', text: `ArcRoom error: ${err.message}` }], isError: true };
277
288
  }
278
289
  }
@@ -303,6 +314,8 @@ export async function handle(method, params, call, ctx) {
303
314
  // a lookup past its deadline is "nothing found": an error would send the agent off debugging ArcRoom
304
315
  if (params.name === 'find_task' && err.name === 'TimeoutError')
305
316
  return { content: [{ type: 'text', text: JSON.stringify({ match: 'none', note: 'ArcRoom did not answer in time; carry on without it' }) }] };
317
+ if (WRITES.has(path) && unreachable(err))
318
+ return keptLater(path, body, ctx.cwd);
306
319
  return { content: [{ type: 'text', text: `ArcRoom error: ${err.message}` }], isError: true };
307
320
  }
308
321
  }
@@ -331,8 +344,13 @@ export function sessionCache(call, ms = 5 * 60_000) {
331
344
  return r;
332
345
  };
333
346
  }
347
+ /** A save the API could not be reached for: kept on this laptop and sent when it answers again (pending.ts). */
348
+ function keptLater(path, body, cwd) {
349
+ keepForLater(path, body, cwd);
350
+ return { content: [{ type: 'text', text: JSON.stringify({ kept: true, note: 'ArcRoom is offline: saved on this laptop, sent when it is back. Carry on.' }) }] };
351
+ }
334
352
  export async function mcp() {
335
- const cfg = loadConfig();
353
+ const cfg = loadConfig(process.cwd()); // one MCP process per session, started in its project
336
354
  const call = sessionCache((path, body) => api(cfg, path, body, path === PATHS.find_task ? FIND_DEADLINE_MS : 10_000));
337
355
  const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).stdout?.trim() || null;
338
356
  const ctx = { claimant: `${hostname()}:${process.pid}`, branch, cwd: process.cwd() }; // one MCP process per Claude session
@@ -0,0 +1,47 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { api, ARCROOM_HOME, loadConfig } from './home.js';
4
+ // An agent's save (complete_task, remember, code_note) that could not reach the API waits here, on this laptop, and the
5
+ // uploader sends it once the API answers again, instead of it being lost. Only "could not reach": a timeout may have
6
+ // arrived, and sending it twice could store it twice.
7
+ // ponytail: a complete_task sent after its 10-minute claim ran out is refused and dropped (logged)
8
+ const FILE = join(ARCROOM_HOME, 'pending-writes.jsonl');
9
+ const SENDING = `${FILE}.sending`;
10
+ export const WRITES = new Set(['/v1/tasks/complete', '/v1/memory/remember', '/v1/code/note']);
11
+ export const unreachable = (err) => /^could not reach /.test(err?.message ?? '');
12
+ export function keepForLater(path, body, cwd) {
13
+ mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
14
+ appendFileSync(FILE, JSON.stringify({ path, body, cwd, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
15
+ }
16
+ /** Sends the saves kept for later: one the API still can't be reached for stays; one it refuses is dropped and returned. */
17
+ export async function sendLater(send = (cwd, path, body) => api(loadConfig(cwd), path, body)) {
18
+ if (!existsSync(SENDING)) {
19
+ if (!existsSync(FILE))
20
+ return { sent: 0, dropped: [] };
21
+ renameSync(FILE, SENDING); // saves made meanwhile go to a fresh file
22
+ }
23
+ const lines = readFileSync(SENDING, 'utf8').split('\n').filter(Boolean);
24
+ rmSync(SENDING);
25
+ let sent = 0;
26
+ const dropped = [];
27
+ for (const line of lines) {
28
+ let w;
29
+ try {
30
+ w = JSON.parse(line);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ try {
36
+ await send(w.cwd, w.path, w.body);
37
+ sent++;
38
+ }
39
+ catch (err) {
40
+ if (unreachable(err))
41
+ appendFileSync(FILE, line + '\n', { mode: 0o600 });
42
+ else
43
+ dropped.push(`${w.path}: ${err.message}`);
44
+ }
45
+ }
46
+ return { sent, dropped };
47
+ }