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/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.**
|
|
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
|
|
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
|
|
40
|
+
| `arcroom flush` | waits until everything queued was sent: the last step of a CI job (exits 1 if it could not) |
|
|
41
|
+
| `arcroom sent [YYYY-MM-DD] [--all]` | what this laptop sent that day (kept 7 days), exactly as sent with `--all` |
|
|
42
|
+
| `arcroom init --everywhere` | also cover every other clone and worktree of the repos you set up (Claude Code user settings) |
|
|
43
|
+
| `arcroom uninstall [--all]` | take ArcRoom out of this project for every agent; `--all` also deletes `~/.arcroom` |
|
|
44
|
+
| `arcroom checks` / `arcroom checks approve <code>` | read and approve the done-checks in `.arcroom.json` (in CI, where there is no terminal: set `ARCROOM_APPROVE_CHECKS=<code>` in the job's environment) |
|
|
41
45
|
| `arcroom wrong [memo id] [reason]` | report a memory that was wrong |
|
|
42
46
|
| `arcroom decide "<choice>" --why ...` | record a team decision |
|
|
43
47
|
| `arcroom rule [--tag t] [--max-hits n] [--idle-days d]` | team rules for when memory expires |
|
package/dist/code.js
CHANGED
|
@@ -159,3 +159,35 @@ export function codeNoteBody(args, cwd) {
|
|
|
159
159
|
...(args.ttl_hours ? { ttl_hours: args.ttl_hours } : {}),
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
|
+
/** Another agent's claim on this file (its edits claim a file for 10 minutes), as the reason an edit waits; else undefined. */
|
|
163
|
+
export function clashNote(claims, rel, branch, now = new Date()) {
|
|
164
|
+
const c = (claims ?? []).find((c) => c?.k === rel);
|
|
165
|
+
if (!c)
|
|
166
|
+
return undefined;
|
|
167
|
+
const where = c.branch && c.branch !== branch ? `on ${c.branch}: a merge conflict if you both change it` : 'same branch';
|
|
168
|
+
return (`ArcRoom: ${short(c.by)}'s ${c.agent ?? 'agent'} is editing ${rel} right now (${minutes(c.since, now)} min, ${where}). ` +
|
|
169
|
+
'Check with them, or work on something else first. To edit it anyway, make this edit again.');
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Before this session's first edit of a file: another agent editing that same file right now, as the reason the edit
|
|
173
|
+
* waits once (the agent sees it; the same edit again goes through). One lookup per file per session; any failure is no clash.
|
|
174
|
+
* ponytail: someone who starts on the file after your first edit shows up at your next prompt (the code map), not here
|
|
175
|
+
*/
|
|
176
|
+
export async function editClash(cfg, session, cwd, file, timeoutMs = 1000) {
|
|
177
|
+
if (typeof file !== 'string' || !cwd || !cfg.apiKey)
|
|
178
|
+
return undefined;
|
|
179
|
+
const s = codeSetup(cwd);
|
|
180
|
+
if (!s || s.mode !== 'standard')
|
|
181
|
+
return undefined;
|
|
182
|
+
const rel = repoPath(s.root, file, cwd);
|
|
183
|
+
if (!rel || rel === '.')
|
|
184
|
+
return undefined;
|
|
185
|
+
const seenFile = sessionFile(session, '.clash');
|
|
186
|
+
const seen = existsSync(seenFile) ? JSON.parse(readFileSync(seenFile, 'utf8')) : [];
|
|
187
|
+
if (seen.includes(rel))
|
|
188
|
+
return undefined;
|
|
189
|
+
writeFileSync(seenFile, JSON.stringify([...seen, rel].slice(-500)));
|
|
190
|
+
const branch = currentBranch(s.root);
|
|
191
|
+
const look = await api(cfg, '/v1/code/look', { repo: repoKey(s), branch, nodes: [dirOf(rel)], session }, timeoutMs).catch(() => undefined);
|
|
192
|
+
return clashNote(look?.claims, rel, branch);
|
|
193
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -4,8 +4,10 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { blindEvent, codeSetup, repoKey, repoPath } from './blind.js';
|
|
5
5
|
import { prefetchCode } from './code.js';
|
|
6
6
|
import { EDIT_TOOLS } from './done.js';
|
|
7
|
-
import { api, ARCROOM_HOME, keyRefused, loadConfig } from './home.js';
|
|
7
|
+
import { api, ARCROOM_HOME, isOff, keyRefused, loadConfig, offRepos } from './home.js';
|
|
8
8
|
import { serveLookups } from './lookup.js';
|
|
9
|
+
import { sendLater } from './pending.js';
|
|
10
|
+
import { logSent } from './sent.js';
|
|
9
11
|
import { redact, rememberSecrets } from './redact.js';
|
|
10
12
|
import { branches, syncTree } from './tree.js';
|
|
11
13
|
const SPOOL = join(ARCROOM_HOME, 'spool.jsonl');
|
|
@@ -18,7 +20,7 @@ const BATCH = 200; // events per upload
|
|
|
18
20
|
const READ_MAX = 4 * 1024 * 1024; // bytes read per tick
|
|
19
21
|
const IDLE_MS = 3000;
|
|
20
22
|
/** Takes complete lines from the queue text: assigns each session's next seq and redacts secrets. */
|
|
21
|
-
export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
|
|
23
|
+
export function toBatch(text, seq, max = BATCH, shape = (e) => e, off = offRepos()) {
|
|
22
24
|
const next = { ...seq };
|
|
23
25
|
const events = [];
|
|
24
26
|
let consumed = 0;
|
|
@@ -37,6 +39,8 @@ export function toBatch(text, seq, max = BATCH, shape = (e) => e) {
|
|
|
37
39
|
}
|
|
38
40
|
if (e?.v !== 1)
|
|
39
41
|
continue; // raw lines from the first hook version
|
|
42
|
+
if (isOff(e.cwd, off))
|
|
43
|
+
continue; // queued before `arcroom off`: never sent
|
|
40
44
|
const n = next[e.session] ?? 0;
|
|
41
45
|
next[e.session] = n + 1;
|
|
42
46
|
rememberSecrets(e.cwd); // the second pass knows each project's .env values too
|
|
@@ -68,8 +72,27 @@ export function rotate(state, spool = SPOOL, sending = SENDING) {
|
|
|
68
72
|
}
|
|
69
73
|
return state;
|
|
70
74
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
75
|
+
/** Events grouped by the key their folder sends with (a repo can have its own key); each group is sent on its own. */
|
|
76
|
+
export function byKey(events, cfgOf) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const e of events) {
|
|
79
|
+
const cfg = cfgOf(e.cwd);
|
|
80
|
+
const g = groups.get(cfg.apiKey) ?? { cfg, events: [] };
|
|
81
|
+
g.events.push(e);
|
|
82
|
+
groups.set(cfg.apiKey, g);
|
|
83
|
+
}
|
|
84
|
+
return [...groups.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Sends the next batch from the queue. The offset only moves after the server accepted it (a resent event is ignored). */
|
|
87
|
+
async function drainOnce(def) {
|
|
88
|
+
const cfgs = new Map();
|
|
89
|
+
const cfgOf = (cwd) => {
|
|
90
|
+
if (!cwd)
|
|
91
|
+
return def;
|
|
92
|
+
if (!cfgs.has(cwd))
|
|
93
|
+
cfgs.set(cwd, loadConfig(cwd));
|
|
94
|
+
return cfgs.get(cwd);
|
|
95
|
+
};
|
|
73
96
|
const saved = readJson(STATE, { offset: 0, seq: {} });
|
|
74
97
|
const state = rotate(saved);
|
|
75
98
|
if (state !== saved)
|
|
@@ -85,19 +108,20 @@ async function drainOnce(cfg) {
|
|
|
85
108
|
const { events, consumed, seq } = toBatch(text, state.seq, BATCH, withCode);
|
|
86
109
|
if (consumed === 0)
|
|
87
110
|
return 0;
|
|
88
|
-
|
|
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(
|
|
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(
|
|
124
|
+
prefetchCode(cfgOf(String(e.cwd)), String(e.cwd), String(e.session)).catch((err) => log(`prefetch: ${err}`));
|
|
101
125
|
return events.length;
|
|
102
126
|
}
|
|
103
127
|
// Code wing (Standard): which repo, branch and folder an edit is in, cached per folder for a minute.
|
|
@@ -163,6 +187,11 @@ export async function daemon() {
|
|
|
163
187
|
sent = await drainOnce(cfg);
|
|
164
188
|
if (sent)
|
|
165
189
|
log(`sent ${sent} events`);
|
|
190
|
+
const later = await sendLater(); // agents' saves kept while the API could not be reached
|
|
191
|
+
if (later.sent)
|
|
192
|
+
log(`sent ${later.sent} saves kept while offline`);
|
|
193
|
+
for (const d of later.dropped)
|
|
194
|
+
log(`a save kept while offline was refused: ${d}`);
|
|
166
195
|
}
|
|
167
196
|
catch (err) {
|
|
168
197
|
log(String(err)); // offline or server down: events stay queued, retried next tick
|
|
@@ -195,6 +224,19 @@ export function ensureDaemon(entry) {
|
|
|
195
224
|
return;
|
|
196
225
|
spawn(process.execPath, [entry, 'daemon'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
197
226
|
}
|
|
227
|
+
/** Bytes in the queue the daemon has not sent yet (the moved-aside file's rest, then the new queue). */
|
|
228
|
+
export function unsent(state = readJson(STATE, { offset: 0, seq: {} }), spool = SPOOL, sending = SENDING) {
|
|
229
|
+
const size = (f) => (existsSync(f) ? statSync(f).size : 0);
|
|
230
|
+
return existsSync(sending) ? Math.max(0, size(sending) - state.offset) + size(spool) : Math.max(0, size(spool) - state.offset);
|
|
231
|
+
}
|
|
232
|
+
/** `arcroom flush`: the last step of a CI job. Waits until the daemon has sent everything queued; false after the timeout. */
|
|
233
|
+
export async function flush(entry, timeoutMs = 60_000) {
|
|
234
|
+
ensureDaemon(entry);
|
|
235
|
+
for (const until = Date.now() + timeoutMs; Date.now() < until; await new Promise((r) => setTimeout(r, 500)))
|
|
236
|
+
if (!unsent())
|
|
237
|
+
return true;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
198
240
|
/** Called from init: stops a running daemon so the next hook starts the updated one. */
|
|
199
241
|
export function stopDaemon() {
|
|
200
242
|
if (!isRunning())
|
package/dist/doctor.js
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import { existsSync,
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
87
|
-
|
|
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(), [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
100
|
-
|
|
118
|
+
const same = saved[f] && probe.find ? probe.find(saved[f], f) : undefined;
|
|
119
|
+
// ponytail: moved-and-edited is found by its name only; one renamed and edited at once still reads as deleted
|
|
120
|
+
const to = same ?? probe.sameName?.(f);
|
|
121
|
+
if (to && !named.has(to)) {
|
|
101
122
|
moved.set(f, to);
|
|
123
|
+
if (!same)
|
|
124
|
+
changed.add(to);
|
|
125
|
+
}
|
|
102
126
|
else if (read.has(f))
|
|
103
127
|
deleted.add(f);
|
|
104
128
|
else
|
|
@@ -130,7 +154,7 @@ export function hashFiles(cwd, paths) {
|
|
|
130
154
|
if (!rel || rel.startsWith('..'))
|
|
131
155
|
continue;
|
|
132
156
|
try {
|
|
133
|
-
out[rel] = blobHash(readFileSync(join(cwd, rel)));
|
|
157
|
+
out[rel] = blobHash(lf(readFileSync(join(cwd, rel))));
|
|
134
158
|
}
|
|
135
159
|
catch {
|
|
136
160
|
/* deleted during the turn: nothing to remember */
|
package/dist/git.js
CHANGED
|
@@ -35,3 +35,14 @@ export function git(args, opts = {}) {
|
|
|
35
35
|
const r = spawnSync(bin, [...SAFE, ...args], { encoding: 'utf8', windowsHide: true, ...opts });
|
|
36
36
|
return { status: r.status, stdout: r.stdout };
|
|
37
37
|
}
|
|
38
|
+
/** Project id shared by everyone on the repo: its git remote without protocol/credentials/.git, else the folder path. */
|
|
39
|
+
export function projectOf(cwd) {
|
|
40
|
+
const remote = git(['config', '--get', 'remote.origin.url'], { cwd }).stdout?.trim();
|
|
41
|
+
if (!remote)
|
|
42
|
+
return cwd.replaceAll('\\', '/').toLowerCase();
|
|
43
|
+
return remote
|
|
44
|
+
.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?/i, '') // https://user:token@host/... -> host/...
|
|
45
|
+
.replace(/^[^@]+@([^:]+):/, '$1/') // git@host:owner/repo -> host/owner/repo
|
|
46
|
+
.replace(/\.git$/, '')
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
}
|
package/dist/home.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { projectOf } from './git.js';
|
|
4
5
|
import { redact } from './redact.js';
|
|
5
6
|
/**
|
|
6
7
|
* ArcRoom's folder: ~/.arcroom, or ARCROOM_HOME (tests, a second setup). A repo can set environment variables for the
|
|
@@ -35,10 +36,14 @@ export function setOff(root, off, file = OFF_FILE) {
|
|
|
35
36
|
}
|
|
36
37
|
// ArcRoom API (the Oracle server behind Caddy). A local API: arcroom init --url http://localhost:8787
|
|
37
38
|
export const DEFAULT_URL = 'https://api.arcroomai.com';
|
|
38
|
-
/** ~/.arcroom/config.json written by `arcroom init
|
|
39
|
-
export function loadConfig() {
|
|
39
|
+
/** ~/.arcroom/config.json written by `arcroom init`; with `cwd`, the key of the repo it is in (its own, else the default). */
|
|
40
|
+
export function loadConfig(cwd) {
|
|
40
41
|
const file = join(ARCROOM_HOME, 'config.json');
|
|
41
|
-
|
|
42
|
+
const saved = { url: DEFAULT_URL, apiKey: '', ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
|
|
43
|
+
if (!cwd || !saved.repos || !Object.keys(saved.repos).length)
|
|
44
|
+
return saved; // no git call unless some repo has its own key
|
|
45
|
+
const own = saved.repos[projectOf(cwd)];
|
|
46
|
+
return own ? { ...saved, apiKey: own.apiKey, workspace: own.workspace } : saved;
|
|
42
47
|
}
|
|
43
48
|
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
44
49
|
/** Whether `url` points at this machine (a developer's local API). */
|
package/dist/hook.js
CHANGED
|
@@ -3,16 +3,17 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { AGENTS, fromAgent, toAgent } from './agents.js';
|
|
5
5
|
import { ensureDaemon } from './daemon.js';
|
|
6
|
-
import { noteEdit, onStop } from './done.js';
|
|
6
|
+
import { EDIT_TOOLS, noteEdit, onStop, SHELL_TOOLS } from './done.js';
|
|
7
7
|
import { toArcroomEvent } from './events.js';
|
|
8
|
-
import { noteDir } from './code.js';
|
|
8
|
+
import { editClash, noteDir } from './code.js';
|
|
9
9
|
import { fence } from './fence.js';
|
|
10
10
|
import { redact, rememberSecrets } from './redact.js';
|
|
11
|
-
import { ARCROOM_HOME, isOff, keyRefused } from './home.js';
|
|
11
|
+
import { ARCROOM_HOME, isOff, keyRefused, loadConfig } from './home.js';
|
|
12
12
|
import { lookupFast } from './lookup.js';
|
|
13
13
|
import { checkLoop, sessionFile } from './loop.js';
|
|
14
14
|
import { pack } from './pack.js';
|
|
15
15
|
import { lastRunNote, projectOf } from './session.js';
|
|
16
|
+
import { laptopHookActs } from './scope.js';
|
|
16
17
|
import { bump, MARK, signFor, toolResult } from './signs.js';
|
|
17
18
|
import { costSinceClaim, markClaim, turnUsage } from './usage.js';
|
|
18
19
|
// Claude Code runs this for every hook event, with the event JSON on stdin.
|
|
@@ -43,8 +44,14 @@ export async function hook() {
|
|
|
43
44
|
const raw = fromAgent(JSON.parse(input), agent);
|
|
44
45
|
if (isOff(raw.cwd))
|
|
45
46
|
return; // switched off for this repo by this person (arcroom off): nothing recorded or sent
|
|
46
|
-
if (
|
|
47
|
-
return; //
|
|
47
|
+
if (process.argv.includes('--user') && !laptopHookActs(raw.cwd))
|
|
48
|
+
return; // laptop-wide hook: only set-up repos, never twice
|
|
49
|
+
if (keyRefused(loadConfig(raw.cwd))) {
|
|
50
|
+
// the key was revoked or its account deleted: nothing runs until a new key (arcroom init); said once, at session start
|
|
51
|
+
if (raw.hook_event_name === 'SessionStart')
|
|
52
|
+
process.stdout.write(JSON.stringify(toAgent(agent, 'SessionStart', { systemMessage: `${MARK} · stopped: the API refused this laptop's key. Run arcroom init with a new key (web app, Settings → API keys).` }, String(raw.session_id))));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
48
55
|
rememberSecrets(raw.cwd); // this project's .env values are redacted wherever they show up
|
|
49
56
|
const name = raw.hook_event_name;
|
|
50
57
|
const session = String(raw.session_id);
|
|
@@ -58,7 +65,7 @@ export async function hook() {
|
|
|
58
65
|
const injected = (kind, text, keepText = false) => emit('context', { kind, chars: text.length, ...(keepText ? { text: text.slice(0, 2000) } : {}) });
|
|
59
66
|
// Token meter: the turn's usage goes in before its turn.end, so the report built at turn.end includes it.
|
|
60
67
|
// After each tool call too, so the website shows a long turn's tokens while it runs.
|
|
61
|
-
if (name === 'Stop' || name === 'SessionEnd' || name === '
|
|
68
|
+
if (name === 'Stop' || name === 'SessionEnd' || name === 'PreToolUse') {
|
|
62
69
|
const usage = turnUsage(session, raw.transcript_path);
|
|
63
70
|
if (usage)
|
|
64
71
|
emit('usage', usage);
|
|
@@ -149,21 +156,27 @@ export async function hook() {
|
|
|
149
156
|
}
|
|
150
157
|
if (pointer)
|
|
151
158
|
given.add(pointer.memoId);
|
|
152
|
-
// what `arcroom wrong` reports when the person says the memory just handed over was wrong
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
// what `arcroom wrong` reports when the person says the memory just handed over was wrong: every memory handed over
|
|
160
|
+
// (steps or answer, playbook, each fact and decision), so it can name the one that was wrong
|
|
161
|
+
const handed = [
|
|
162
|
+
...(pointer ? [{ memo_id: pointer.memoId, kind: pointer.kind, text: pointer.intent }] : []),
|
|
163
|
+
...(playbook ? [{ memo_id: playbook.memoId, kind: 'playbook', text: 'playbook' }] : []),
|
|
164
|
+
...(knowledge ? knowledge.items.map((i) => ({ memo_id: i.id, kind: i.kind, text: i.text })) : []),
|
|
165
|
+
];
|
|
166
|
+
if (handed.length)
|
|
167
|
+
writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: handed[0].memo_id, intent: handed[0].text, handed }));
|
|
156
168
|
if (pointer || knowledge || playbook)
|
|
157
169
|
writeFileSync(givenFile, JSON.stringify([...given]));
|
|
158
170
|
if (pointer) {
|
|
159
171
|
injected('recall', pointer.text, true);
|
|
160
172
|
// the turn's outcome credits (or heals) these steps when the turn ends
|
|
173
|
+
const match = pointer.match ? { similar: true, match: pointer.match } : {};
|
|
161
174
|
if (pointer.kind === 'steps')
|
|
162
|
-
emit('memory.replayed', { memo_id: pointer.memoId });
|
|
175
|
+
emit('memory.replayed', { memo_id: pointer.memoId, ...match });
|
|
163
176
|
else
|
|
164
|
-
emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version });
|
|
177
|
+
emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version, ...match });
|
|
165
178
|
bump(session, pointer.kind === 'steps' ? 'replayed' : 'reused');
|
|
166
|
-
say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}`);
|
|
179
|
+
say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}${pointer.match ? ` (match: ${pointer.match})` : ''}`);
|
|
167
180
|
}
|
|
168
181
|
// a hit is the lookup already done: the reminder would only send the agent to find_task for it again
|
|
169
182
|
// what teammates' agents wrote goes in one fenced block of data (fence.ts); ArcRoom's own reminder stays outside it
|
|
@@ -184,11 +197,22 @@ export async function hook() {
|
|
|
184
197
|
say(`${MARK} · blocked a repeated ${String(raw.tool_name).replace(OUR_TOOLS, '')} call (same input 3 times in a row)`);
|
|
185
198
|
bump(session, 'blocked');
|
|
186
199
|
}
|
|
187
|
-
else
|
|
188
|
-
|
|
200
|
+
else {
|
|
201
|
+
// two agents on one file: the first edit waits once, with who is on it (the same edit again goes through)
|
|
202
|
+
const clash = EDIT_TOOLS.has(raw.tool_name)
|
|
203
|
+
? await editClash(loadConfig(raw.cwd), session, raw.cwd, raw.tool_input?.file_path ?? raw.tool_input?.notebook_path).catch(() => undefined)
|
|
204
|
+
: undefined;
|
|
205
|
+
if (clash) {
|
|
206
|
+
out.hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: clash };
|
|
207
|
+
injected('deny', clash);
|
|
208
|
+
say(`${MARK} · another agent is editing this file: asked yours to check first`);
|
|
209
|
+
}
|
|
210
|
+
else
|
|
211
|
+
noteEdit(raw);
|
|
212
|
+
}
|
|
189
213
|
}
|
|
190
214
|
// a done-check the agent ran with Bash (the Stop hook asks for them; it never runs them itself)
|
|
191
|
-
if (name === 'PostToolUse' && raw.tool_name
|
|
215
|
+
if (name === 'PostToolUse' && SHELL_TOOLS.has(raw.tool_name))
|
|
192
216
|
noteEdit(raw);
|
|
193
217
|
if (name === 'PostToolUse' && String(raw.tool_name).startsWith(OUR_TOOLS)) {
|
|
194
218
|
const tool = raw.tool_name.slice(OUR_TOOLS.length);
|
|
@@ -227,14 +251,16 @@ export async function hook() {
|
|
|
227
251
|
}
|
|
228
252
|
if (project) {
|
|
229
253
|
// Session memory: start with what the last run in this project did. Slow or offline API = no note.
|
|
230
|
-
const
|
|
254
|
+
const root = git(['rev-parse', '--show-toplevel'], { cwd: raw.cwd }).stdout?.trim();
|
|
255
|
+
const note = await lastRunNote(session, project, branch, root).catch(() => undefined);
|
|
231
256
|
if (note) {
|
|
232
257
|
// the last run here may be a teammate's: its ask is their words, so it is fenced like any memory
|
|
233
258
|
out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: fence(note) };
|
|
234
259
|
injected('last_run', note, true);
|
|
235
260
|
}
|
|
236
261
|
// say what leaves the laptop, every session (events.ts keeps it to that)
|
|
237
|
-
|
|
262
|
+
const ws = loadConfig(raw.cwd).workspace;
|
|
263
|
+
say(`${MARK} · on: team memory${ws ? ` in "${ws}"` : ''} · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
|
|
238
264
|
}
|
|
239
265
|
const reply = toAgent(agent, name, out, session);
|
|
240
266
|
if (Object.keys(reply).length)
|