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 +7 -3
- package/dist/code.js +32 -0
- package/dist/daemon.js +51 -9
- package/dist/doctor.js +6 -18
- package/dist/done.js +21 -9
- package/dist/events.js +7 -3
- package/dist/fresh.js +32 -8
- package/dist/git.js +11 -0
- package/dist/home.js +8 -3
- package/dist/hook.js +44 -18
- package/dist/index.js +38 -14
- package/dist/init.js +197 -20
- package/dist/lookup.js +1 -1
- package/dist/loop.js +13 -5
- package/dist/mcp.js +24 -6
- package/dist/pending.js +47 -0
- package/dist/recall.js +30 -10
- package/dist/redact.js +6 -0
- package/dist/scope.js +49 -0
- package/dist/sent.js +62 -0
- package/dist/session.js +29 -21
- package/dist/tree.js +2 -1
- package/dist/usage.js +14 -0
- package/package.json +1 -1
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
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,17 +208,28 @@ 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
|
-
/**
|
|
152
|
-
function
|
|
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
|
|
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,
|
|
@@ -160,8 +237,106 @@ function addLocalMcp(entry) {
|
|
|
160
237
|
encoding: 'utf8',
|
|
161
238
|
})
|
|
162
239
|
: spawnSync(claude, args, { encoding: 'utf8', windowsHide: true });
|
|
163
|
-
run(
|
|
164
|
-
|
|
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;
|
|
165
340
|
}
|
|
166
341
|
/** Steps 3 and 4 for Codex, Cursor and OpenCode: their own hook and MCP files, in this project. */
|
|
167
342
|
function initOther(agent, entry, project) {
|
|
@@ -202,8 +377,10 @@ function initOther(agent, entry, project) {
|
|
|
202
377
|
cursor: 'Restart Cursor. Cursor adds context only after a tool call, so team memory reaches the agent on its first tool use.',
|
|
203
378
|
opencode: "Restart OpenCode. Experimental: per-prompt recall rides OpenCode's experimental system-prompt hook, so it may change with OpenCode.",
|
|
204
379
|
};
|
|
380
|
+
const kept = keepOutOfGit(written, cwd);
|
|
205
381
|
console.log(`ArcRoom is on for ${agent} in this project.
|
|
206
382
|
key ${join(ARCROOM_HOME, 'config.json')}
|
|
207
|
-
${written.map((f) => ` wrote ${f}`).join('\n')}
|
|
208
|
-
${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`);
|
|
209
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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:
|
|
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,
|
|
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
|
package/dist/pending.js
ADDED
|
@@ -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
|
+
}
|