chord-cli 0.2.0 → 0.2.2
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 +9 -8
- package/dist/clean.js +8 -4
- package/dist/cli.js +10 -11
- package/dist/db.js +48 -24
- package/dist/hooks.js +66 -60
- package/dist/launch.js +26 -54
- package/dist/mcp.js +3 -1
- package/dist/project.js +6 -7
- package/dist/pty.js +42 -8
- package/dist/serve.js +5 -7
- package/dist/tools.js +89 -40
- package/dist/trust.js +2 -2
- package/package.json +3 -2
- package/README.zh-CN.md +0 -169
package/dist/launch.js
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
// workspace with its repository worktree, and the per-launch hook/MCP configuration for each
|
|
3
3
|
// tool. Hook command strings must stay identical across launches (Codex persists trust by
|
|
4
4
|
// hashing them), so per-session values go in env.
|
|
5
|
-
import {
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import * as db from "./db.js";
|
|
11
11
|
import { ensureStateDir, resolveProject } from "./project.js";
|
|
12
|
+
import { readConfig } from "./tools.js";
|
|
12
13
|
import { claudeMcpChoices, claudeTrusts } from "./trust.js";
|
|
13
|
-
const
|
|
14
|
+
const { git } = db;
|
|
14
15
|
// realpath gives one canonical spelling (drive letter and directory case): Codex persists hook trust by hashing the command string
|
|
15
16
|
// sibling modules by absolute path: .ts when run from src, .js from the built dist
|
|
16
17
|
const here = (f) => fs.realpathSync.native(fileURLToPath(new URL(f + path.extname(fileURLToPath(import.meta.url)), import.meta.url))).split(path.sep).join('/');
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const HOOKS_TS = here('./hooks');
|
|
19
|
+
const SERVE_TS = here('./serve');
|
|
19
20
|
/** `staleWaitMs`: how long to watch a stale heartbeat before calling its agent dead (tests shorten it). */
|
|
20
21
|
export function pickName(d, tool, project, override, staleWaitMs = 2500) {
|
|
21
22
|
if (override) {
|
|
@@ -162,8 +163,8 @@ export function withNodeOnPath(env) {
|
|
|
162
163
|
return { ...env, [key]: current ? `${dir}${path.delimiter}${current}` : dir };
|
|
163
164
|
}
|
|
164
165
|
/** `$CLAUDE_PROJECT_DIR` in a repository's hook commands and MCP definitions means the repository, which for an agent
|
|
165
|
-
* is its worktree; Claude would point it at the
|
|
166
|
-
|
|
166
|
+
* is its worktree; Claude would point it at the project folder. */
|
|
167
|
+
function substituteProjectDir(value, worktree) {
|
|
167
168
|
const dir = worktree.split(path.sep).join('/');
|
|
168
169
|
const walk = (v) => {
|
|
169
170
|
if (typeof v === 'string')
|
|
@@ -178,7 +179,7 @@ export function substituteProjectDir(value, worktree) {
|
|
|
178
179
|
}
|
|
179
180
|
/** The repository's .mcp.json as an --mcp-config value, reduced to the servers the user approved in Claude Code, or
|
|
180
181
|
* null when there is nothing to pass. */
|
|
181
|
-
|
|
182
|
+
function projectMcpConfig(worktree, source) {
|
|
182
183
|
const file = path.join(worktree, '.mcp.json');
|
|
183
184
|
if (!fs.existsSync(file))
|
|
184
185
|
return null;
|
|
@@ -198,6 +199,10 @@ export function projectMcpConfig(worktree, source) {
|
|
|
198
199
|
process.stderr.write(`chord: not starting the repository's MCP server(s) ${skipped.join(', ')}: run claude in ${source.repo} once and approve them there first\n`);
|
|
199
200
|
return Object.keys(keep).length ? JSON.stringify({ mcpServers: substituteProjectDir(keep, worktree) }) : null;
|
|
200
201
|
}
|
|
202
|
+
/** CLI arguments that inject chord's hooks and MCP server into Claude Code. `projectConfig`: also load the repository's
|
|
203
|
+
* own .claude settings and .mcp.json, only when the user has trusted the repository (or the project folder) in Claude
|
|
204
|
+
* Code, because Claude's trust dialogue is what normally stands between a cloned repository's hooks and MCP servers
|
|
205
|
+
* and running them. */
|
|
201
206
|
export function claudeArgs(env, projectConfig = false) {
|
|
202
207
|
const hooks = {};
|
|
203
208
|
for (const e of EVENTS) {
|
|
@@ -215,10 +220,9 @@ export function claudeArgs(env, projectConfig = false) {
|
|
|
215
220
|
// (the briefing carries CLAUDE.md itself); the others are passed along.
|
|
216
221
|
const wt = env.CHORD_WORKTREE;
|
|
217
222
|
const addDir = wt ? ['--add-dir', wt] : [];
|
|
218
|
-
const
|
|
219
|
-
const projectMcp = source && wt ? projectMcpConfig(wt, source) : null;
|
|
223
|
+
const projectMcp = projectConfig && wt ? projectMcpConfig(wt, projectConfig) : null;
|
|
220
224
|
const mcpFiles = projectMcp ? [projectMcp] : [];
|
|
221
|
-
const settings =
|
|
225
|
+
const settings = projectConfig && wt ? mergeProjectSettings(ours, wt) : ours;
|
|
222
226
|
// --add-dir and --mcp-config take any number of values, so they must not come last: the user's own arguments
|
|
223
227
|
// follow these, and a prompt such as `chord claude "fix the tests"` would be read as another directory.
|
|
224
228
|
// --settings takes exactly one value and closes the list.
|
|
@@ -234,9 +238,7 @@ export function mergeUserSettings(args, user) {
|
|
|
234
238
|
return out;
|
|
235
239
|
}
|
|
236
240
|
const PROJECT_CONFIG_FILES = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json'];
|
|
237
|
-
|
|
238
|
-
/** chord's settings plus the repository's .claude/settings.json and settings.local.json (later files win,
|
|
239
|
-
* hook lists are concatenated so both chord's and the project's hooks run). */
|
|
241
|
+
const hasProjectConfig = (worktree) => PROJECT_CONFIG_FILES.some(f => fs.existsSync(path.join(worktree, f)));
|
|
240
242
|
/** Hook lists of `b` appended to those of `a`, so both sets run. */
|
|
241
243
|
function concatHooks(a, b) {
|
|
242
244
|
const hooks = { ...(a.hooks ?? {}) };
|
|
@@ -246,7 +248,7 @@ function concatHooks(a, b) {
|
|
|
246
248
|
}
|
|
247
249
|
/** Somebody else's settings underneath chord's: their keys fill in, chord's keys win, hook lists are concatenated,
|
|
248
250
|
* and disableAllHooks is dropped because it would switch chord itself off. */
|
|
249
|
-
|
|
251
|
+
function mergeSettings(ours, theirs) {
|
|
250
252
|
const out = { ...theirs, ...ours, hooks: concatHooks(ours, theirs) };
|
|
251
253
|
if (ours.permissions || theirs.permissions) {
|
|
252
254
|
// permission lists add up, as across Claude's own settings levels; other permission keys follow the usual precedence
|
|
@@ -259,9 +261,9 @@ export function mergeSettings(ours, theirs) {
|
|
|
259
261
|
delete out.disableAllHooks;
|
|
260
262
|
return out;
|
|
261
263
|
}
|
|
264
|
+
/** chord's settings plus the repository's .claude/settings.json and settings.local.json (later files win, hook lists
|
|
265
|
+
* are concatenated so both chord's and the project's hooks run). */
|
|
262
266
|
export function mergeProjectSettings(ours, worktree) {
|
|
263
|
-
if (!worktree)
|
|
264
|
-
return ours;
|
|
265
267
|
let theirs = {};
|
|
266
268
|
for (const name of ['settings.json', 'settings.local.json']) { // settings.local.json wins, as in Claude Code itself
|
|
267
269
|
let one;
|
|
@@ -280,10 +282,10 @@ export function mergeProjectSettings(ours, worktree) {
|
|
|
280
282
|
}
|
|
281
283
|
/** A JSON string literal is also a valid TOML basic string, quotes, backslashes and non-ASCII included. */
|
|
282
284
|
const toml = (s) => JSON.stringify(s);
|
|
283
|
-
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML.
|
|
284
|
-
|
|
285
|
-
*
|
|
286
|
-
*
|
|
285
|
+
/** CLI arguments that inject chord's hooks and MCP server into Codex. Values are TOML. `writableRoots`: directories
|
|
286
|
+
* Codex's workspace-write sandbox must also be allowed to write: a linked worktree keeps its index and refs under the
|
|
287
|
+
* main checkout's .git. shortcut: the tool now runs in the project folder, which holds that .git, so this may be
|
|
288
|
+
* redundant; drop it once a sandboxed Codex has committed from a worktree without it on Linux or macOS. */
|
|
287
289
|
export function codexArgs(env, writableRoots = []) {
|
|
288
290
|
const args = [];
|
|
289
291
|
if (writableRoots.length)
|
|
@@ -317,8 +319,9 @@ export function prepare(tool, cwd, override) {
|
|
|
317
319
|
const name = pickName(d, tool, project.dir, override);
|
|
318
320
|
const ws = workspacePath(project, name);
|
|
319
321
|
// Take the name now, before the worktree and setup take their time: a second launch under the same name a moment
|
|
320
|
-
// later must find it online.
|
|
321
|
-
|
|
322
|
+
// later must find it online. No heartbeat yet: nobody writes one until the wrapper runs, and setup or Codex's first
|
|
323
|
+
// start can take minutes, so a sweep meanwhile must judge this agent by its (live) pid alone.
|
|
324
|
+
db.addAgent(d, { name, tool, pid: process.pid, cwd: ws });
|
|
322
325
|
try {
|
|
323
326
|
const config = readConfig(project);
|
|
324
327
|
const worktree = ensureWorktree(project.repo, path.join(ws, project.repoName), name);
|
|
@@ -335,7 +338,7 @@ export function prepare(tool, cwd, override) {
|
|
|
335
338
|
if (tool === 'claude' && hasProjectConfig(worktree)) {
|
|
336
339
|
projectConfig = claudeTrusts(project.repo) || claudeTrusts(project.dir) ? { repo: project.repo } : false;
|
|
337
340
|
if (!projectConfig)
|
|
338
|
-
process.stderr.write(`chord: ${project.repo} has its own .claude settings or .mcp.json, but you have not trusted it in Claude Code, so they are not loaded for this agent. Run claude in ${project.repo} once and accept the trust prompt to change that.\n`);
|
|
341
|
+
process.stderr.write(`chord: ${project.repo} has its own .claude settings or .mcp.json, but you have not trusted it or the project folder in Claude Code, so they are not loaded for this agent. Run claude in ${project.repo} once and accept the trust prompt to change that.\n`);
|
|
339
342
|
}
|
|
340
343
|
return { name, project, cwd: ws, worktree, dbFile, env, args: tool === 'claude' ? claudeArgs(env, projectConfig) : codexArgs(env, [path.join(project.repo, '.git')]) };
|
|
341
344
|
}
|
|
@@ -344,37 +347,6 @@ export function prepare(tool, cwd, override) {
|
|
|
344
347
|
throw e;
|
|
345
348
|
}
|
|
346
349
|
}
|
|
347
|
-
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
348
|
-
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
349
|
-
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. */
|
|
350
|
-
export function readConfig(project) {
|
|
351
|
-
const file = path.join(project.dir, 'chord', 'config.json');
|
|
352
|
-
let raw;
|
|
353
|
-
try {
|
|
354
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
355
|
-
}
|
|
356
|
-
catch {
|
|
357
|
-
return {};
|
|
358
|
-
}
|
|
359
|
-
let cfg;
|
|
360
|
-
try {
|
|
361
|
-
cfg = JSON.parse(raw);
|
|
362
|
-
}
|
|
363
|
-
catch (e) {
|
|
364
|
-
throw new Error(`${file} is not valid JSON: ${e.message}`);
|
|
365
|
-
}
|
|
366
|
-
if (typeof cfg !== 'object' || cfg === null)
|
|
367
|
-
throw new Error(`${file} must hold a JSON object`);
|
|
368
|
-
const out = {};
|
|
369
|
-
for (const key of ['setup', 'test']) {
|
|
370
|
-
const v = cfg[key];
|
|
371
|
-
if (v !== undefined && typeof v !== 'string')
|
|
372
|
-
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
373
|
-
if (v)
|
|
374
|
-
out[key] = v;
|
|
375
|
-
}
|
|
376
|
-
return out;
|
|
377
|
-
}
|
|
378
350
|
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
379
351
|
export const setupMarker = (project, name) => path.join(project.stateDir, 'setup-done', name);
|
|
380
352
|
/** A failed setup aborts the launch, and the half-prepared worktree and branch go with it so the next launch starts clean. */
|
package/dist/mcp.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Minimal MCP server over stdio: newline-delimited JSON-RPC 2.0, tools only.
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { createInterface } from 'node:readline';
|
|
3
4
|
const PROTOCOL = '2025-06-18';
|
|
5
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; // src/ and dist/ both sit under the package root
|
|
4
6
|
/** Handle one JSON-RPC message. Returns the response, or undefined for notifications. */
|
|
5
7
|
export function handle(tools, req) {
|
|
6
8
|
const { id, method, params = {} } = req;
|
|
@@ -10,7 +12,7 @@ export function handle(tools, req) {
|
|
|
10
12
|
const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
11
13
|
switch (method) {
|
|
12
14
|
case 'initialize':
|
|
13
|
-
return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version:
|
|
15
|
+
return ok({ protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'chord', version: VERSION } });
|
|
14
16
|
case 'ping':
|
|
15
17
|
return ok({});
|
|
16
18
|
case 'tools/list':
|
package/dist/project.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// A chord project is the folder `chord` is run in. It contains the git repository as a child
|
|
2
2
|
// directory and a `chord/` directory for agent workspaces; chord's own state lives under ~/.chord.
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
5
4
|
import fs from 'node:fs';
|
|
6
5
|
import os from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
8
|
-
import {
|
|
7
|
+
import { git } from "./db.js";
|
|
9
8
|
/** Thrown when the folder is a repository root: chord offers to move it into a child directory. */
|
|
10
9
|
export class NeedsRestructure extends Error {
|
|
11
10
|
dir;
|
|
@@ -16,8 +15,9 @@ export class NeedsRestructure extends Error {
|
|
|
16
15
|
this.suggestedName = suggestedName;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
18
|
+
// Windows and macOS file systems ignore case: two spellings are one folder
|
|
19
|
+
const CASE_INSENSITIVE = process.platform !== 'linux';
|
|
20
|
+
const same = (a, b) => (CASE_INSENSITIVE ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
21
21
|
function repoRootOf(dir) {
|
|
22
22
|
try {
|
|
23
23
|
return fs.realpathSync.native(git(dir, 'rev-parse', '--show-toplevel'));
|
|
@@ -36,10 +36,10 @@ catch {
|
|
|
36
36
|
/** Readable like Claude Code's projects directory (separators and colons become dashes), plus a short hash so
|
|
37
37
|
* that D:\a-b and D:\a\b do not share one state directory. */
|
|
38
38
|
export function encodeProjectPath(dir) {
|
|
39
|
-
const key =
|
|
39
|
+
const key = CASE_INSENSITIVE ? dir.toLowerCase() : dir;
|
|
40
40
|
return `${dir.replace(/[\\/:]/g, '-')}-${createHash('sha1').update(key).digest('hex').slice(0, 6)}`;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
const stateDirFor = (dir) => path.join(process.env.CHORD_HOME ?? path.join(os.homedir(), '.chord'), 'projects', encodeProjectPath(dir));
|
|
43
43
|
/** A repository directory name chord can work with: a single path segment that is not chord's own directory. */
|
|
44
44
|
export function validateRepoName(name) {
|
|
45
45
|
if (!name.trim())
|
|
@@ -63,7 +63,6 @@ export function suggestedRepoName(repo) {
|
|
|
63
63
|
const own = path.basename(repo);
|
|
64
64
|
return validateRepoName(own) ? 'repo' : own;
|
|
65
65
|
}
|
|
66
|
-
/** The nearest ancestor that already is a chord project, if any. */
|
|
67
66
|
/** Child directories of `dir` that are repository roots. Looked up with stat, so a junction or symlink to a repository
|
|
68
67
|
* counts (a Dirent reports those as links, not directories). */
|
|
69
68
|
const repoChildren = (dir) => fs.readdirSync(dir).filter(n => { const p = path.join(dir, n); try {
|
package/dist/pty.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Run the agent under a pseudo-terminal: pass the user's keyboard through, and when the
|
|
2
2
|
// agent is idle with unread messages, type a nudge in as if the user had.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
6
7
|
import pty from 'node-pty';
|
|
@@ -32,6 +33,27 @@ export function resolveCommand(tool, readFile = (f) => fs.readFileSync(f, 'utf8'
|
|
|
32
33
|
throw new Error(`cannot read the script path from ${hit}`);
|
|
33
34
|
return { file: process.execPath, args: [path.join(path.dirname(hit), m[1])] };
|
|
34
35
|
}
|
|
36
|
+
/** On macOS node-pty starts the tool through its `spawn-helper` binary, which the prebuilt package ships without the
|
|
37
|
+
* execute bit (and npm no longer runs install scripts that might set it), so every launch would fail with
|
|
38
|
+
* "posix_spawnp failed". The bit is set here, or the error says how when the install directory is not writable. */
|
|
39
|
+
export function ensureSpawnHelper(root = path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json'))) {
|
|
40
|
+
for (const dir of ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`]) { // where node-pty looks
|
|
41
|
+
const helper = path.join(root, dir, 'spawn-helper');
|
|
42
|
+
if (!fs.existsSync(helper))
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
fs.accessSync(helper, fs.constants.X_OK);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
catch { /* not executable */ }
|
|
49
|
+
try {
|
|
50
|
+
fs.chmodSync(helper, 0o755);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`${helper} is not executable, so no agent can start (${e.message}); run: chmod +x "${helper}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
35
57
|
const ESC_KEY = ''; // a decoded Esc key, kept apart from escape sequences while they are stripped
|
|
36
58
|
const SOFT_NL = ''; // Shift+Enter: a newline inside the draft, not a submit
|
|
37
59
|
/**
|
|
@@ -144,8 +166,8 @@ export function typedEchoed(output, text) {
|
|
|
144
166
|
}
|
|
145
167
|
/** The Esc key as the tool reads it: in win32-input-mode (ConPTY turns a bare ESC byte into nothing, waiting for a
|
|
146
168
|
* sequence) a key-down and key-up record; elsewhere the CSI u form the tools ask for (`ESC [ > 5 u`), which a legacy
|
|
147
|
-
* reader ignores harmlessly.
|
|
148
|
-
|
|
169
|
+
* reader ignores harmlessly. Verified against Claude Code on macOS; on Linux not yet. */
|
|
170
|
+
const ESC_INPUT = process.platform === 'win32' ? '\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_' : '\x1b[27u';
|
|
149
171
|
/** Did the tool answer the exit command with its "background work is running, exit and stop it or stay?" dialogue?
|
|
150
172
|
* Claude Code asks this while a shell the agent started (a monitor it is waiting on, say) still runs. */
|
|
151
173
|
export function exitDeclined(output) {
|
|
@@ -200,14 +222,18 @@ export function nextAction(s, now) {
|
|
|
200
222
|
return 'enter'; // typed and echoed but not submitted: press Enter once more
|
|
201
223
|
return null;
|
|
202
224
|
}
|
|
203
|
-
/** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working"
|
|
225
|
+
/** Codex reports its state to the terminal as OSC 7501 ("state=idle" / "state=working", and "state=done" when a turn
|
|
226
|
+
* ends, as of 0.164); Claude Code does not. */
|
|
204
227
|
export function oscState(chunk) {
|
|
205
|
-
const m = /\x1b\]7501;state=(\w+)/g;
|
|
228
|
+
const m = /\x1b\]7501;state=(\w+)[^\x07\x1b]*(?:\x07|\x1b\\)/g; // complete reports only: "state=id" cut off by a chunk is not "busy"
|
|
206
229
|
let last = null;
|
|
207
230
|
for (const x of chunk.matchAll(m))
|
|
208
|
-
last = x[1] === 'idle' ? 'idle' : 'busy';
|
|
231
|
+
last = x[1] === 'idle' || x[1] === 'done' ? 'idle' : 'busy';
|
|
209
232
|
return last;
|
|
210
233
|
}
|
|
234
|
+
/** An OSC sequence a chunk ends in the middle of, to prepend to the next chunk; '' when the chunk ends cleanly, so a
|
|
235
|
+
* complete report is never read twice. */
|
|
236
|
+
export const oscTail = (chunk) => /\x1b\][^\x07\x1b]*$/.exec(chunk)?.[0] ?? '';
|
|
211
237
|
/** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
|
|
212
238
|
* stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
|
|
213
239
|
* it, and the next tick or terminal report puts the state right anyway. */
|
|
@@ -229,7 +255,10 @@ export function run(tool, launch, extraArgs) {
|
|
|
229
255
|
name: 'xterm-256color', cols: process.stdout.columns || 120, rows: process.stdout.rows || 40, cwd: launch.project.dir, env, // the project folder: the tool then treats it as the project (same /resume list, CLAUDE.md, trust)
|
|
230
256
|
});
|
|
231
257
|
const d = db.openDb(launch.dbFile);
|
|
232
|
-
|
|
258
|
+
// prepare registered the agent; the wrapper only takes it over (a fresh registration would drop messages that
|
|
259
|
+
// arrived during setup and a stop requested meanwhile)
|
|
260
|
+
db.setPid(d, launch.name, term.pid);
|
|
261
|
+
db.heartbeat(d, launch.name);
|
|
233
262
|
let outTail = '', echoBuf = '', awaitingEcho = false, lastOutputAt = Date.now();
|
|
234
263
|
const titles = new TitleFilter();
|
|
235
264
|
let title = titleFor(launch.name, null);
|
|
@@ -243,7 +272,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
243
272
|
const state = oscState(outTail + x);
|
|
244
273
|
if (state)
|
|
245
274
|
tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
|
|
246
|
-
outTail = x
|
|
275
|
+
outTail = oscTail(x);
|
|
247
276
|
}
|
|
248
277
|
});
|
|
249
278
|
const gate = new InputGate();
|
|
@@ -258,8 +287,13 @@ export function run(tool, launch, extraArgs) {
|
|
|
258
287
|
const onResize = () => term.resize(process.stdout.columns || 120, process.stdout.rows || 40);
|
|
259
288
|
process.stdout.on('resize', onResize);
|
|
260
289
|
Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
|
|
261
|
-
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
|
|
290
|
+
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '', lastTick = Date.now();
|
|
262
291
|
const timer = setInterval(() => tolerant('wake loop', () => {
|
|
292
|
+
// a tick long overdue means the machine slept: vouch for every wrapper before a hook sweeps the ones not yet awake
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
if (now - lastTick > db.HEARTBEAT_STALE_MS)
|
|
295
|
+
db.heartbeatAll(d);
|
|
296
|
+
lastTick = now;
|
|
263
297
|
db.heartbeat(d, launch.name);
|
|
264
298
|
const me = db.getAgent(d, launch.name);
|
|
265
299
|
const wanted = titleFor(launch.name, me?.task);
|
package/dist/serve.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// Entry point for the per-agent MCP server. Spawned by the coding agent over stdio, in the project folder; the
|
|
2
|
-
// agent's workspace comes from the CHORD_* environment,
|
|
2
|
+
// agent's workspace comes from the CHORD_* environment, never from cwd, which would make every claim path wrong.
|
|
3
3
|
import { openDb } from "./db.js";
|
|
4
4
|
import { serve } from "./mcp.js";
|
|
5
5
|
import { tools } from "./tools.js";
|
|
6
|
-
const agent = process.env
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
process.stderr.write('chord: CHORD_AGENT_ID and CHORD_DB must be set\n');
|
|
6
|
+
const { CHORD_AGENT_ID: agent, CHORD_DB: file, CHORD_WORKSPACE: cwd, CHORD_WORKTREE: worktree } = process.env;
|
|
7
|
+
if (!agent || !file || !cwd || !worktree) {
|
|
8
|
+
process.stderr.write('chord: CHORD_AGENT_ID, CHORD_DB, CHORD_WORKSPACE and CHORD_WORKTREE must be set\n');
|
|
10
9
|
process.exit(2);
|
|
11
10
|
}
|
|
12
|
-
|
|
13
|
-
serve(tools({ db: openDb(file), agent, cwd, worktree: process.env.CHORD_WORKTREE ?? cwd, project: process.env.CHORD_PROJECT }));
|
|
11
|
+
serve(tools({ db: openDb(file), agent, cwd, worktree, project: process.env.CHORD_PROJECT }));
|
package/dist/tools.js
CHANGED
|
@@ -1,40 +1,47 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as db from "./db.js";
|
|
5
|
-
|
|
5
|
+
/** path.relative, except that on macOS directory names differing only in case match, as path.relative already has them
|
|
6
|
+
* on Windows: both file systems ignore case, so /P/Repo is /P/repo, and a write must not slip past a check that way. */
|
|
7
|
+
function relativePath(from, to) {
|
|
8
|
+
if (process.platform !== 'darwin')
|
|
9
|
+
return path.relative(from, to);
|
|
10
|
+
const a = path.resolve(from).split('/').filter(Boolean), b = path.resolve(to).split('/').filter(Boolean);
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase())
|
|
13
|
+
i++;
|
|
14
|
+
return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/');
|
|
15
|
+
}
|
|
6
16
|
/** Is `file` inside `dir`? False for paths on another drive or above it. */
|
|
7
17
|
export function insideDir(dir, file) {
|
|
8
|
-
const rel =
|
|
18
|
+
const rel = relativePath(dir, path.resolve(dir, file));
|
|
9
19
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
10
20
|
}
|
|
11
21
|
/**
|
|
12
|
-
* The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
15
|
-
* repository-relative (what agents working inside the repo naturally write). Paths outside the
|
|
22
|
+
* The claim key for a path an agent names: workspace-relative with forward slashes. Absolute paths are taken as
|
|
23
|
+
* given; a path from the project folder (chord/agents/<name>/<repo>/...) is resolved there; any other relative
|
|
24
|
+
* path is repository-relative (what agents working on the repository naturally write). Paths outside the
|
|
16
25
|
* workspace are refused: chord cannot coordinate them.
|
|
17
26
|
*/
|
|
18
27
|
export function claimPath(ctx, file) {
|
|
19
|
-
const repoDir = path.relative(ctx.cwd, ctx.worktree).split(path.sep).join('/');
|
|
20
28
|
const asGiven = normFile(ctx, file);
|
|
21
29
|
let abs;
|
|
22
30
|
const fromProject = 'chord/agents/'; // the spelling from the tool's cwd, the project folder; another agent's folder then fails the check below
|
|
23
|
-
if (path.isAbsolute(file) ||
|
|
31
|
+
if (path.isAbsolute(file) || /^\.\.(\/|$)/.test(asGiven))
|
|
24
32
|
abs = path.resolve(ctx.cwd, file);
|
|
25
33
|
else if (ctx.project && asGiven.startsWith(fromProject))
|
|
26
34
|
abs = path.resolve(ctx.project, file);
|
|
27
|
-
else
|
|
28
|
-
// "rich/console.py" is the workspace path of the repository's console.py, or the repository path of a package
|
|
29
|
-
// named like the repository (rich/rich/console.py); whichever exists is meant, and both existing is ambiguous
|
|
30
|
-
const asWorkspace = path.resolve(ctx.cwd, file), asRepo = path.resolve(ctx.worktree, file);
|
|
31
|
-
const w = fs.existsSync(asWorkspace), r = fs.existsSync(asRepo);
|
|
32
|
-
if (w && r && asWorkspace !== asRepo)
|
|
33
|
-
throw new Error(`${file} is ambiguous: it could be ${normFile(ctx, asWorkspace)} or ${normFile(ctx, asRepo)}; give the path from your workspace root`);
|
|
34
|
-
abs = r && !w ? asRepo : asWorkspace;
|
|
35
|
-
}
|
|
36
|
-
else
|
|
35
|
+
else {
|
|
37
36
|
abs = path.resolve(ctx.worktree, file);
|
|
37
|
+
// From the project folder "<repo>/x" is the main checkout's x. An agent naming its own copy that way, where the
|
|
38
|
+
// repository has no such path but x exists at its root, is told the spellings that reach it rather than given a
|
|
39
|
+
// claim on a file that is not there.
|
|
40
|
+
const repoName = path.basename(ctx.worktree), inner = asGiven.slice(repoName.length + 1);
|
|
41
|
+
if (asGiven.startsWith(`${repoName}/`) && !fs.existsSync(abs) && fs.existsSync(path.resolve(ctx.worktree, inner))) {
|
|
42
|
+
throw new Error(`there is no ${asGiven} in the repository, but it has ${inner}: name files relative to the repository root ("${inner}") or from the project folder ("chord/agents/<you>/${repoName}/${inner}")`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
38
45
|
if (!insideDir(ctx.cwd, abs))
|
|
39
46
|
throw new Error(`${file} is outside your workspace; chord only coordinates files under ${ctx.cwd}`);
|
|
40
47
|
return normFile(ctx, abs);
|
|
@@ -42,7 +49,7 @@ export function claimPath(ctx, file) {
|
|
|
42
49
|
/** How long a latecomer waits for replies before it may write anyway. */
|
|
43
50
|
export const REPLY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
44
51
|
/** How long before its claim a latecomer's message to an occupant still counts as asking about that file. */
|
|
45
|
-
|
|
52
|
+
const ASK_WINDOW_MS = 2 * 60 * 1000;
|
|
46
53
|
const str = (desc) => ({ type: 'string', description: desc });
|
|
47
54
|
function req(args, key) {
|
|
48
55
|
const v = args[key];
|
|
@@ -50,9 +57,9 @@ function req(args, key) {
|
|
|
50
57
|
throw new Error(`${key} is required`);
|
|
51
58
|
return v;
|
|
52
59
|
}
|
|
53
|
-
/**
|
|
60
|
+
/** Workspace-relative path with forward slashes (repo/src/x.ts), so the same file matches across agents' worktrees. */
|
|
54
61
|
export function normFile(ctx, file) {
|
|
55
|
-
return
|
|
62
|
+
return relativePath(ctx.cwd, path.resolve(ctx.cwd, file)).split(path.sep).join('/');
|
|
56
63
|
}
|
|
57
64
|
export function peers(ctx) {
|
|
58
65
|
return db.listAgents(ctx.db).filter(a => a.name !== ctx.agent).map(a => ({
|
|
@@ -61,10 +68,11 @@ export function peers(ctx) {
|
|
|
61
68
|
}
|
|
62
69
|
// words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
|
|
63
70
|
const NOT_SYMBOLS = new Set(['a', 'an', 'and', 'or', 'the', 'of', 'in', 'to', 'for', 'with', 'new', 'its', 'all', 'function', 'functions', 'method', 'methods', 'class', 'classes', 'test', 'tests', 'block', 'blocks', 'section', 'line', 'lines', 'file', 'helper', 'helpers', 'code', 'logic', 'docs', 'docstring']);
|
|
64
|
-
|
|
71
|
+
// "Parser.parse" names Parser and parse too: whoever holds either holds part of it
|
|
72
|
+
const symbolSet = (s) => new Set((s ?? '').toLowerCase().match(/[a-z_$][\w$.]*/g)?.flatMap(w => [w, ...w.split('.')]).filter(w => w && !NOT_SYMBOLS.has(w)) ?? []);
|
|
65
73
|
/** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
|
|
66
74
|
* need to talk first. Anything less certain (no symbols on either side, or an add, delete or rename) does. */
|
|
67
|
-
|
|
75
|
+
function disjointEdits(a, b) {
|
|
68
76
|
if (a.kind !== 'modify' || b.kind !== 'modify')
|
|
69
77
|
return false;
|
|
70
78
|
const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
|
|
@@ -73,7 +81,9 @@ export function disjointEdits(a, b) {
|
|
|
73
81
|
/** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
|
|
74
82
|
* editing other parts of it. */
|
|
75
83
|
function earlierOccupants(ctx, mine) {
|
|
76
|
-
|
|
84
|
+
// by rowid, which the insert gets under the write lock, not by the timestamp read before waiting for it: two
|
|
85
|
+
// agents claiming at once would otherwise each see the other as later and neither would talk first
|
|
86
|
+
const before = (c) => c.rid < mine.rid;
|
|
77
87
|
const all = db.claimsFor(ctx.db, mine.file).filter(c => c.agent !== ctx.agent && before(c) && db.getAgent(ctx.db, c.agent)?.status !== 'offline');
|
|
78
88
|
return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
|
|
79
89
|
}
|
|
@@ -103,7 +113,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
103
113
|
const askedAt = db.firstMessageAt(ctx.db, ctx.agent, o.agent, Math.max(since, mine.created_at - ASK_WINDOW_MS));
|
|
104
114
|
if (askedAt === null)
|
|
105
115
|
notAsked.push(o.agent);
|
|
106
|
-
else if (
|
|
116
|
+
else if (db.firstMessageAt(ctx.db, o.agent, ctx.agent, since) === null && now - askedAt < REPLY_TIMEOUT_MS)
|
|
107
117
|
waiting.push({ agent: o.agent, left: REPLY_TIMEOUT_MS - (now - askedAt) });
|
|
108
118
|
}
|
|
109
119
|
if (notAsked.length) {
|
|
@@ -115,9 +125,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
115
125
|
}
|
|
116
126
|
return { ok: true };
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
// already happened into an exception
|
|
120
|
-
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', env: db.gitEnv(), stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
128
|
+
const { git } = db;
|
|
121
129
|
const gitOk = (cwd, ...args) => { try {
|
|
122
130
|
git(cwd, ...args);
|
|
123
131
|
return true;
|
|
@@ -126,7 +134,7 @@ catch {
|
|
|
126
134
|
return false;
|
|
127
135
|
} };
|
|
128
136
|
const stderrOf = (e) => (e.stderr ?? e.message).trim();
|
|
129
|
-
const sleepSync =
|
|
137
|
+
const { sleepSync } = db;
|
|
130
138
|
/** Tracked files with real changes, staged or not, ignoring line-ending differences and whatever happens inside
|
|
131
139
|
* submodules; '' when clean. */
|
|
132
140
|
export function dirtyTracked(cwd) {
|
|
@@ -148,8 +156,10 @@ function gitRetry(cwd, ...args) {
|
|
|
148
156
|
}
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
|
-
/** Run fn holding an exclusive lock file. Waits up to `waitMs
|
|
152
|
-
|
|
159
|
+
/** Run fn holding an exclusive lock file that names its holder's pid. Waits up to `waitMs`. A lock whose holder is
|
|
160
|
+
* gone is taken over at once; one older than `staleMs` as well, which must exceed the longest run of the project's
|
|
161
|
+
* test command (ten minutes) so a live holder is never robbed of its lock. */
|
|
162
|
+
export function withLock(file, fn, waitMs = 240_000, staleMs = 15 * 60_000) {
|
|
153
163
|
const t0 = Date.now();
|
|
154
164
|
for (;;) {
|
|
155
165
|
try {
|
|
@@ -160,7 +170,8 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
|
|
|
160
170
|
if (e.code !== 'EEXIST')
|
|
161
171
|
throw e;
|
|
162
172
|
try {
|
|
163
|
-
|
|
173
|
+
const holder = Number(fs.readFileSync(file, 'utf8'));
|
|
174
|
+
if ((holder > 0 && !db.pidAlive(holder)) || Date.now() - fs.statSync(file).mtimeMs > staleMs) {
|
|
164
175
|
fs.unlinkSync(file);
|
|
165
176
|
continue;
|
|
166
177
|
}
|
|
@@ -173,16 +184,50 @@ export function withLock(file, fn, waitMs = 240_000, staleMs = 180_000) {
|
|
|
173
184
|
sleepSync(150 + Math.random() * 200);
|
|
174
185
|
}
|
|
175
186
|
}
|
|
187
|
+
// only our own lock is removed: one taken over meanwhile belongs to whoever holds it now
|
|
176
188
|
try {
|
|
177
189
|
return fn();
|
|
178
190
|
}
|
|
179
191
|
finally {
|
|
180
192
|
try {
|
|
181
|
-
fs.
|
|
193
|
+
if (fs.readFileSync(file, 'utf8') === String(process.pid))
|
|
194
|
+
fs.unlinkSync(file);
|
|
182
195
|
}
|
|
183
196
|
catch { /* already gone */ }
|
|
184
197
|
}
|
|
185
198
|
}
|
|
199
|
+
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
200
|
+
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
201
|
+
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. Lives here, not in launch.ts,
|
|
202
|
+
* so the hooks (which load this module for finish's sake) do not load the launch code on every tool call. */
|
|
203
|
+
export function readConfig(project) {
|
|
204
|
+
const file = path.join(project.dir, 'chord', 'config.json');
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
let cfg;
|
|
213
|
+
try {
|
|
214
|
+
cfg = JSON.parse(raw);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
throw new Error(`${file} is not valid JSON: ${e.message}`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof cfg !== 'object' || cfg === null)
|
|
220
|
+
throw new Error(`${file} must hold a JSON object`);
|
|
221
|
+
const out = {};
|
|
222
|
+
for (const key of ['setup', 'test']) {
|
|
223
|
+
const v = cfg[key];
|
|
224
|
+
if (v !== undefined && typeof v !== 'string')
|
|
225
|
+
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
226
|
+
if (v)
|
|
227
|
+
out[key] = v;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
186
231
|
/** Rebase the agent's branch onto main and fast-forward main to it. Atomic from the agent's point of view.
|
|
187
232
|
* Finishes are serialised with a lock file: git cannot run two merges in one checkout concurrently. */
|
|
188
233
|
export function finish(ctx) {
|
|
@@ -193,9 +238,9 @@ function finishLocked(ctx) {
|
|
|
193
238
|
const wt = ctx.worktree;
|
|
194
239
|
// A rebase in progress is only ours to clean up when finish itself started it (a finish killed mid-rebase leaves
|
|
195
240
|
// the marker behind). Anyone else's rebase, perhaps with conflicts already resolved, is left alone.
|
|
196
|
-
const marker
|
|
197
|
-
for (const dir of [
|
|
198
|
-
if (!fs.existsSync(
|
|
241
|
+
const [marker, rebaseMerge, rebaseApply, indexLock] = git(wt, 'rev-parse', '--git-path', 'chord-finish', '--git-path', 'rebase-merge', '--git-path', 'rebase-apply', '--git-path', 'index.lock').split('\n').map(p => path.resolve(wt, p));
|
|
242
|
+
for (const dir of [rebaseMerge, rebaseApply]) {
|
|
243
|
+
if (!fs.existsSync(dir))
|
|
199
244
|
continue;
|
|
200
245
|
if (!fs.existsSync(marker))
|
|
201
246
|
throw new Error('a rebase is in progress in your worktree that finish did not start; run `git rebase --continue` (after resolving conflicts) or `git rebase --abort` first');
|
|
@@ -204,7 +249,6 @@ function finishLocked(ctx) {
|
|
|
204
249
|
}
|
|
205
250
|
fs.rmSync(marker, { force: true });
|
|
206
251
|
// a finish killed mid-operation can leave the worktree's own index.lock behind; one older than a minute is debris
|
|
207
|
-
const indexLock = path.resolve(wt, git(wt, 'rev-parse', '--git-path', 'index.lock'));
|
|
208
252
|
try {
|
|
209
253
|
if (Date.now() - fs.statSync(indexLock).mtimeMs > 60_000) {
|
|
210
254
|
fs.unlinkSync(indexLock);
|
|
@@ -217,6 +261,12 @@ function finishLocked(ctx) {
|
|
|
217
261
|
// such file as modified the moment it is checked out, and no agent can commit that away.
|
|
218
262
|
if (dirtyTracked(wt))
|
|
219
263
|
throw new Error('your worktree has uncommitted changes to tracked files; commit them first');
|
|
264
|
+
// Except the files this agent claimed: one it wrote and never added (git commit -a skips new files) would leave
|
|
265
|
+
// main with half the change, and the claim that protected it would be released below.
|
|
266
|
+
const claimed = db.claimsBy(ctx.db, ctx.agent).map(c => relativePath(wt, path.resolve(ctx.cwd, c.file)).split(path.sep).join('/')).filter(f => f && !f.startsWith('..'));
|
|
267
|
+
const lost = claimed.length ? git(wt, 'ls-files', '--others', '--exclude-standard', '--', ...claimed) : '';
|
|
268
|
+
if (lost)
|
|
269
|
+
throw new Error(`you claimed ${lost.split('\n').join(', ')} but never committed it: git add and commit it (or release the claim) and call finish again. Nothing was merged.`);
|
|
220
270
|
// What status still reports now differs only in line endings. git will not rebase over it, and nothing done in a
|
|
221
271
|
// worktree can clear it: the repository needs a renormalised commit, which is the user's call.
|
|
222
272
|
const phantom = git(wt, 'status', '--porcelain', '--untracked-files=no', '--ignore-submodules=dirty').split('\n').filter(Boolean).map(l => l.replace(/^[ MADRCU?!]{1,2}\s+/, ''));
|
|
@@ -269,7 +319,7 @@ function finishLocked(ctx) {
|
|
|
269
319
|
const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
|
|
270
320
|
// A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
|
|
271
321
|
// fast-forward leaves main pointing at a commit that is nowhere but inside this agent's worktree.
|
|
272
|
-
for (const line of git(root, 'diff', '--raw', main, branch).split('\n')) {
|
|
322
|
+
for (const line of git(root, '-c', 'core.quotePath=false', 'diff', '--raw', main, branch).split('\n')) { // a non-ASCII path arrives as itself, not quoted and escaped
|
|
273
323
|
const m = /^:\d{6} 160000 [0-9a-f]+ ([0-9a-f]+) [AM]\t(.+)$/.exec(line);
|
|
274
324
|
if (!m)
|
|
275
325
|
continue;
|
|
@@ -355,7 +405,6 @@ export function tools(ctx) {
|
|
|
355
405
|
db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
|
|
356
406
|
const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent);
|
|
357
407
|
const { overlap: others, elsewhere } = earlierOccupants(ctx, mine);
|
|
358
|
-
db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)} elsewhere=${elsewhere.map(o => o.agent)}`);
|
|
359
408
|
const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
|
|
360
409
|
return {
|
|
361
410
|
claimed: file,
|