cli-relay 1.0.0
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/LICENSE +21 -0
- package/README.md +355 -0
- package/cli-relay.mjs +481 -0
- package/package.json +33 -0
- package/src/adapter-loader.mjs +138 -0
- package/src/adapters/agy.mjs +21 -0
- package/src/adapters/claude-code.mjs +18 -0
- package/src/adapters/codex.mjs +58 -0
- package/src/adapters/command-code.mjs +18 -0
- package/src/commands/doctor.mjs +59 -0
- package/src/commands/list.mjs +52 -0
- package/src/commands/pin.mjs +37 -0
- package/src/commands/pins.mjs +27 -0
- package/src/commands/reset.mjs +36 -0
- package/src/commands/unpin.mjs +32 -0
- package/src/config.mjs +84 -0
- package/src/core/adapter-env.mjs +3 -0
- package/src/core/env.mjs +7 -0
- package/src/core/errors.mjs +8 -0
- package/src/core/lock.mjs +123 -0
- package/src/core/map-store.mjs +20 -0
- package/src/core/parse-json-result.mjs +24 -0
- package/src/core/pins.mjs +24 -0
- package/src/core/thread-lookup.mjs +12 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ENV_BASE } from '../core/adapter-env.mjs';
|
|
5
|
+
|
|
6
|
+
function findRolloutFile(threadId) {
|
|
7
|
+
const root = join(homedir(), '.codex', 'sessions');
|
|
8
|
+
if (!existsSync(root)) return null;
|
|
9
|
+
try {
|
|
10
|
+
const entries = readdirSync(root, { recursive: true });
|
|
11
|
+
const match = entries.find((entry) =>
|
|
12
|
+
typeof entry === 'string' && entry.includes(threadId) && entry.endsWith('.jsonl'));
|
|
13
|
+
return match ? join(root, match) : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
name: 'codex',
|
|
21
|
+
order: 10,
|
|
22
|
+
binaryCandidates: ['codex'],
|
|
23
|
+
fresh: (prompt) => [
|
|
24
|
+
'codex', 'exec', '--skip-git-repo-check', '--sandbox', 'workspace-write',
|
|
25
|
+
'-m', 'gpt-5.6-sol', '--json', prompt,
|
|
26
|
+
],
|
|
27
|
+
// `codex exec resume` has no --sandbox flag. This is the only confirmed non-interactive
|
|
28
|
+
// path that restores the write access granted to the fresh invocation.
|
|
29
|
+
resume: (id, prompt) => [
|
|
30
|
+
'codex', 'exec', 'resume', id, '--dangerously-bypass-approvals-and-sandbox', '--json', prompt,
|
|
31
|
+
],
|
|
32
|
+
env: ENV_BASE,
|
|
33
|
+
parse(stdout) {
|
|
34
|
+
let id = null;
|
|
35
|
+
let answer = null;
|
|
36
|
+
for (const line of stdout.split('\n')) {
|
|
37
|
+
if (!line.trim()) continue;
|
|
38
|
+
let event;
|
|
39
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
40
|
+
if (event.type === 'thread.started' && event.thread_id) id = event.thread_id;
|
|
41
|
+
if (event.type === 'item.completed' &&
|
|
42
|
+
event.item?.type === 'agent_message' && event.item.text) {
|
|
43
|
+
answer = event.item.text;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { id, answer };
|
|
47
|
+
},
|
|
48
|
+
checkCompaction(id) {
|
|
49
|
+
if (!id) return false;
|
|
50
|
+
const file = findRolloutFile(id);
|
|
51
|
+
if (!file) return false;
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(file, 'utf8').includes('"context_compacted"');
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ENV_BASE } from '../core/adapter-env.mjs';
|
|
2
|
+
import { parseJsonResult } from '../core/parse-json-result.mjs';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'command-code',
|
|
6
|
+
order: 40,
|
|
7
|
+
binaryCandidates: ['command-code'],
|
|
8
|
+
fresh: (prompt) => [
|
|
9
|
+
'command-code', '-p', prompt, '-m', 'zai-org/glm-5.2', '--output-format', 'json',
|
|
10
|
+
'--trust', '--no-auto-update',
|
|
11
|
+
],
|
|
12
|
+
// Resume remains deliberately unsupported because the seed turn can disappear silently.
|
|
13
|
+
resume: null,
|
|
14
|
+
env: ENV_BASE,
|
|
15
|
+
parse: (stdout) => parseJsonResult(stdout, { id: 'sessionId', answer: 'finalText' }),
|
|
16
|
+
checkCompaction: (_id, stdout) =>
|
|
17
|
+
stdout.includes('"compaction_start"') || stdout.includes('"compaction_done"'),
|
|
18
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function realWhichCheck(binary) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const command = process.platform === 'win32' ? 'where' : 'which';
|
|
6
|
+
const child = spawn(command, [binary], { stdio: 'ignore' });
|
|
7
|
+
child.on('error', () => resolve(false));
|
|
8
|
+
child.on('exit', (code) => resolve(code === 0));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function resolveBinaryName(candidates, isAvailable = realWhichCheck) {
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
if (await isAvailable(candidate)) return candidate;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function binaryCandidates(adapter) {
|
|
20
|
+
if (adapter.binaryCandidates) return adapter.binaryCandidates;
|
|
21
|
+
const generated = adapter.fresh('');
|
|
22
|
+
return generated.length > 0 ? [generated[0]] : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function cmdDoctor(adapters, isAvailable = realWhichCheck) {
|
|
26
|
+
const checks = Object.values(adapters).map((adapter) => ({
|
|
27
|
+
adapter,
|
|
28
|
+
candidates: binaryCandidates(adapter),
|
|
29
|
+
attempted: [],
|
|
30
|
+
}));
|
|
31
|
+
const results = await Promise.allSettled(checks.map(async (check) => {
|
|
32
|
+
const resolved = await resolveBinaryName(check.candidates, async (candidate) => {
|
|
33
|
+
check.attempted.push(candidate);
|
|
34
|
+
return isAvailable(candidate);
|
|
35
|
+
});
|
|
36
|
+
return resolved;
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
40
|
+
const result = results[index];
|
|
41
|
+
const check = checks[index];
|
|
42
|
+
const tried = check.attempted.join(', ') || 'none';
|
|
43
|
+
if (result.status === 'rejected') {
|
|
44
|
+
console.log(
|
|
45
|
+
`${check.adapter.name}: unavailable (check failed; tried: ${tried}; ` +
|
|
46
|
+
`reason: ${result.reason?.message ?? String(result.reason)})`,
|
|
47
|
+
);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const resolved = result.value;
|
|
51
|
+
if (resolved) {
|
|
52
|
+
console.log(
|
|
53
|
+
`${check.adapter.name}: available (found; tried: ${tried}; resolved: ${resolved})`,
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
console.log(`${check.adapter.name}: unavailable (not found; tried: ${tried})`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { MAP_PATH } from '../config.mjs';
|
|
2
|
+
import { loadMap } from '../core/map-store.mjs';
|
|
3
|
+
|
|
4
|
+
function truncId(id, max = 24) {
|
|
5
|
+
if (id == null) return '-';
|
|
6
|
+
const value = String(id);
|
|
7
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function cmdList() {
|
|
11
|
+
const map = loadMap();
|
|
12
|
+
const names = Object.keys(map.sessions);
|
|
13
|
+
if (names.length === 0) {
|
|
14
|
+
console.log(`no threads recorded in ${MAP_PATH}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const columns = [
|
|
18
|
+
'thread', 'backend', 'confirmed', 'native_session_id', 'status', 'turns', 'created_iso',
|
|
19
|
+
'last_run_iso', 'last_exit_code', 'resume_fails', 'compacted', 'pins',
|
|
20
|
+
];
|
|
21
|
+
const rows = names.sort().map((name) => {
|
|
22
|
+
const session = map.sessions[name];
|
|
23
|
+
return {
|
|
24
|
+
thread: name,
|
|
25
|
+
backend: session.backend ?? '?',
|
|
26
|
+
confirmed: session.confirmed ? 'yes' : 'no',
|
|
27
|
+
native_session_id: truncId(session.native_session_id),
|
|
28
|
+
status: session.status ?? '-',
|
|
29
|
+
turns: session.turn_count ?? '-',
|
|
30
|
+
created_iso: session.created_iso ?? '-',
|
|
31
|
+
last_run_iso: session.last_run_iso ?? 'never',
|
|
32
|
+
last_exit_code: session.last_exit_code ?? '-',
|
|
33
|
+
resume_fails: session.consecutive_resume_failures ?? 0,
|
|
34
|
+
compacted: session.compaction_detected === true
|
|
35
|
+
? 'YES'
|
|
36
|
+
: session.compaction_detected === false ? 'no' : '-',
|
|
37
|
+
pins: session.pinned_facts?.length ?? 0,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const widths = Object.fromEntries(
|
|
41
|
+
columns.map((column) => [
|
|
42
|
+
column,
|
|
43
|
+
Math.max(column.length, ...rows.map((row) => String(row[column]).length)),
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
const formatRow = (values) => columns
|
|
47
|
+
.map((column) => String(values[column]).padEnd(widths[column]))
|
|
48
|
+
.join(' ');
|
|
49
|
+
console.log(formatRow(Object.fromEntries(columns.map((column) => [column, column]))));
|
|
50
|
+
console.log(columns.map((column) => '-'.repeat(widths[column])).join(' '));
|
|
51
|
+
for (const row of rows) console.log(formatRow(row));
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MAP_PATH, PIN_WARNING_THRESHOLD } from '../config.mjs';
|
|
2
|
+
import { withLock } from '../core/lock.mjs';
|
|
3
|
+
import { loadMap, saveMap } from '../core/map-store.mjs';
|
|
4
|
+
import { validatePinText } from '../core/pins.mjs';
|
|
5
|
+
import { withThreadSuggestions } from '../core/thread-lookup.mjs';
|
|
6
|
+
|
|
7
|
+
export async function cmdPin(thread, factText) {
|
|
8
|
+
const invalid = validatePinText(factText);
|
|
9
|
+
if (!thread || invalid) {
|
|
10
|
+
console.error(`usage: cli-relay pin <thread> "<fact>"${invalid ? ` — ${invalid}` : ''}`);
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
await withLock(() => {
|
|
14
|
+
const map = loadMap();
|
|
15
|
+
const session = map.sessions[thread];
|
|
16
|
+
if (!session) {
|
|
17
|
+
throw new Error(withThreadSuggestions(
|
|
18
|
+
`no such thread "${thread}" in ${MAP_PATH} — run fresh first, then pin`,
|
|
19
|
+
map.sessions,
|
|
20
|
+
thread,
|
|
21
|
+
));
|
|
22
|
+
}
|
|
23
|
+
session.pinned_facts = session.pinned_facts ?? [];
|
|
24
|
+
session.pinned_facts.push({ text: factText, pinned_at: new Date().toISOString() });
|
|
25
|
+
if (session.pinned_facts.length >= PIN_WARNING_THRESHOLD) {
|
|
26
|
+
console.error(
|
|
27
|
+
`warning: thread "${thread}" now has ${session.pinned_facts.length} pinned facts ` +
|
|
28
|
+
`(advisory threshold ${PIN_WARNING_THRESHOLD}) — a long, growing pin list defeats ` +
|
|
29
|
+
`its own purpose; consider consolidating into fewer, more curated facts instead of ` +
|
|
30
|
+
`appending indefinitely.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
map.sessions[thread] = session;
|
|
34
|
+
saveMap(map);
|
|
35
|
+
console.log(`pinned to "${thread}" (${session.pinned_facts.length} total): ${factText}`);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { MAP_PATH } from '../config.mjs';
|
|
2
|
+
import { loadMap } from '../core/map-store.mjs';
|
|
3
|
+
import { withThreadSuggestions } from '../core/thread-lookup.mjs';
|
|
4
|
+
|
|
5
|
+
export function cmdPins(thread) {
|
|
6
|
+
if (!thread) {
|
|
7
|
+
console.error('usage: cli-relay pins <thread>');
|
|
8
|
+
process.exit(2);
|
|
9
|
+
}
|
|
10
|
+
const map = loadMap();
|
|
11
|
+
const session = map.sessions[thread];
|
|
12
|
+
if (!session) {
|
|
13
|
+
throw new Error(withThreadSuggestions(
|
|
14
|
+
`no such thread "${thread}" in ${MAP_PATH}`,
|
|
15
|
+
map.sessions,
|
|
16
|
+
thread,
|
|
17
|
+
));
|
|
18
|
+
}
|
|
19
|
+
const pins = session.pinned_facts ?? [];
|
|
20
|
+
if (pins.length === 0) {
|
|
21
|
+
console.log(`no pinned facts for "${thread}"`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
pins.forEach((pin, index) => {
|
|
25
|
+
console.log(`${index + 1}. ${pin.text} (pinned ${pin.pinned_at})`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { MAP_PATH } from '../config.mjs';
|
|
2
|
+
import { withLock } from '../core/lock.mjs';
|
|
3
|
+
import { loadMap, saveMap } from '../core/map-store.mjs';
|
|
4
|
+
import { withThreadSuggestions } from '../core/thread-lookup.mjs';
|
|
5
|
+
|
|
6
|
+
export async function cmdReset(thread) {
|
|
7
|
+
if (!thread) {
|
|
8
|
+
console.error('usage: cli-relay reset <thread>');
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
await withLock(() => {
|
|
12
|
+
const map = loadMap();
|
|
13
|
+
const session = map.sessions[thread];
|
|
14
|
+
if (!session) {
|
|
15
|
+
throw new Error(withThreadSuggestions(
|
|
16
|
+
`no such thread "${thread}" in ${MAP_PATH} — nothing to reset`,
|
|
17
|
+
map.sessions,
|
|
18
|
+
thread,
|
|
19
|
+
));
|
|
20
|
+
}
|
|
21
|
+
if (session.pinned_facts?.length) {
|
|
22
|
+
console.error(
|
|
23
|
+
`warning: thread "${thread}" has ${session.pinned_facts.length} pinned fact(s) that ` +
|
|
24
|
+
`will be destroyed by reset — unlike a fresh restart, reset does not carry them ` +
|
|
25
|
+
`forward. Run "cli-relay pins ${thread}" first if they're worth keeping.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
delete map.sessions[thread];
|
|
29
|
+
saveMap(map);
|
|
30
|
+
const { pinned_facts: pinnedFacts, ...redacted } = session;
|
|
31
|
+
console.log(
|
|
32
|
+
`removed thread "${thread}": ${JSON.stringify(redacted)}` +
|
|
33
|
+
(pinnedFacts?.length ? ` (+ ${pinnedFacts.length} pinned fact(s), not shown)` : ''),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MAP_PATH } from '../config.mjs';
|
|
2
|
+
import { withLock } from '../core/lock.mjs';
|
|
3
|
+
import { loadMap, saveMap } from '../core/map-store.mjs';
|
|
4
|
+
import { withThreadSuggestions } from '../core/thread-lookup.mjs';
|
|
5
|
+
|
|
6
|
+
export async function cmdUnpin(thread, indexText) {
|
|
7
|
+
const index = Number(indexText);
|
|
8
|
+
if (!thread || !Number.isInteger(index) || index < 1) {
|
|
9
|
+
console.error('usage: cli-relay unpin <thread> <index> (1-based — see "cli-relay pins <thread>")');
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
await withLock(() => {
|
|
13
|
+
const map = loadMap();
|
|
14
|
+
const session = map.sessions[thread];
|
|
15
|
+
if (!session) {
|
|
16
|
+
throw new Error(withThreadSuggestions(
|
|
17
|
+
`no such thread "${thread}" in ${MAP_PATH}`,
|
|
18
|
+
map.sessions,
|
|
19
|
+
thread,
|
|
20
|
+
));
|
|
21
|
+
}
|
|
22
|
+
const pins = session.pinned_facts ?? [];
|
|
23
|
+
if (index > pins.length) {
|
|
24
|
+
throw new Error(`thread "${thread}" has only ${pins.length} pinned fact(s) — no #${index}`);
|
|
25
|
+
}
|
|
26
|
+
const [removed] = pins.splice(index - 1, 1);
|
|
27
|
+
session.pinned_facts = pins;
|
|
28
|
+
map.sessions[thread] = session;
|
|
29
|
+
saveMap(map);
|
|
30
|
+
console.log(`unpinned #${index} from "${thread}": ${removed.text}`);
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const home = homedir();
|
|
6
|
+
|
|
7
|
+
export const STATE_DIR = join(home, '.cli-relay');
|
|
8
|
+
export const CONFIG_PATH = join(STATE_DIR, 'config.json');
|
|
9
|
+
export const MAP_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export const DEFAULTS = Object.freeze({
|
|
12
|
+
MAP_PATH: join(STATE_DIR, 'sessions.json'),
|
|
13
|
+
USER_ADAPTERS_DIR: join(STATE_DIR, 'adapters'),
|
|
14
|
+
SPAWN_TIMEOUT_MS: 20 * 60_000,
|
|
15
|
+
SPAWN_KILL_GRACE_MS: 3_000,
|
|
16
|
+
LOCK_TIMEOUT_MS: 10_000,
|
|
17
|
+
LOCK_RETRY_MS: 100,
|
|
18
|
+
LOCK_STALE_GRACE_MS: 60_000,
|
|
19
|
+
RESUME_FAILURE_THRESHOLD: 3,
|
|
20
|
+
RESUME_WARNING_THRESHOLD: 10,
|
|
21
|
+
PIN_WARNING_THRESHOLD: 8,
|
|
22
|
+
MAX_PIN_LENGTH: 500,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
|
|
27
|
+
function camelCase(name) {
|
|
28
|
+
return name.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function configuredValue(raw, name) {
|
|
32
|
+
return raw[name] ?? raw[camelCase(name)] ?? DEFAULTS[name];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expandHome(value) {
|
|
36
|
+
if (value === '~') return home;
|
|
37
|
+
if (value.startsWith('~/')) return join(home, value.slice(2));
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadConfig() {
|
|
42
|
+
if (!existsSync(CONFIG_PATH)) return { ...DEFAULTS };
|
|
43
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
44
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
45
|
+
throw new Error(`${CONFIG_PATH} must contain a JSON object`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const merged = Object.fromEntries(
|
|
49
|
+
Object.keys(DEFAULTS).map((name) => [name, configuredValue(raw, name)]),
|
|
50
|
+
);
|
|
51
|
+
for (const name of ['MAP_PATH', 'USER_ADAPTERS_DIR']) {
|
|
52
|
+
if (typeof merged[name] !== 'string' || !merged[name]) {
|
|
53
|
+
throw new Error(`${CONFIG_PATH}: ${name} must be a non-empty string`);
|
|
54
|
+
}
|
|
55
|
+
merged[name] = expandHome(merged[name]);
|
|
56
|
+
}
|
|
57
|
+
for (const name of Object.keys(DEFAULTS).filter((key) => typeof DEFAULTS[key] === 'number')) {
|
|
58
|
+
if (!Number.isFinite(merged[name]) || merged[name] < 0) {
|
|
59
|
+
throw new Error(`${CONFIG_PATH}: ${name} must be a non-negative number`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return merged;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const CONFIG = Object.freeze(loadConfig());
|
|
66
|
+
|
|
67
|
+
export const {
|
|
68
|
+
MAP_PATH,
|
|
69
|
+
USER_ADAPTERS_DIR,
|
|
70
|
+
SPAWN_TIMEOUT_MS,
|
|
71
|
+
SPAWN_KILL_GRACE_MS,
|
|
72
|
+
LOCK_TIMEOUT_MS,
|
|
73
|
+
LOCK_RETRY_MS,
|
|
74
|
+
LOCK_STALE_GRACE_MS,
|
|
75
|
+
RESUME_FAILURE_THRESHOLD,
|
|
76
|
+
RESUME_WARNING_THRESHOLD,
|
|
77
|
+
PIN_WARNING_THRESHOLD,
|
|
78
|
+
MAX_PIN_LENGTH,
|
|
79
|
+
} = CONFIG;
|
|
80
|
+
|
|
81
|
+
// These must follow the configured base values. They are intentionally not independently
|
|
82
|
+
// configurable, so a map or timeout override cannot leave its related safety value behind.
|
|
83
|
+
export const LOCK_PATH = `${MAP_PATH}.lock`;
|
|
84
|
+
export const LOCK_STALE_MS = SPAWN_TIMEOUT_MS + LOCK_STALE_GRACE_MS;
|
package/src/core/env.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export class RelayError extends Error {
|
|
2
|
+
constructor(code, message, options = {}) {
|
|
3
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
4
|
+
this.name = 'RelayError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exitCode = options.exitCode ?? 1;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
LOCK_PATH, LOCK_RETRY_MS, LOCK_STALE_MS, LOCK_TIMEOUT_MS,
|
|
7
|
+
} from '../config.mjs';
|
|
8
|
+
|
|
9
|
+
const LOCK_HOLDER_FILE = 'holder.json';
|
|
10
|
+
|
|
11
|
+
function isPidAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readHolderRaw() {
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(join(LOCK_PATH, LOCK_HOLDER_FILE), 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return null; // missing or unreadable — a real signal in its own right, not an error
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reclaiming a dead lock by inspecting it and then unconditionally `rmSync`-ing the path is
|
|
29
|
+
// a TOCTOU race: two waiters can both judge the SAME dead instance eligible, and whichever
|
|
30
|
+
// runs second deletes whatever is CURRENTLY at that path — which may by then be a brand-new,
|
|
31
|
+
// legitimate holder the first reclaimer already created, letting both waiters into fn() at
|
|
32
|
+
// once (found in review, twice — first via holder.json timing, then via this exact
|
|
33
|
+
// path-based-delete gap). `renameSync` is atomic at the OS level, so at most one caller's
|
|
34
|
+
// rename of a given source path can ever succeed; the loser gets ENOENT and safely backs
|
|
35
|
+
// off having touched nothing. But atomicity alone isn't enough either: the thing a winner
|
|
36
|
+
// captures might no longer be the dead instance it originally inspected (a legitimate new
|
|
37
|
+
// holder could have appeared in the gap between that inspection and this call) — so after
|
|
38
|
+
// winning the rename, re-check the captured content against what was judged dead before
|
|
39
|
+
// destroying anything. A mismatch means we accidentally captured a live replacement; put it
|
|
40
|
+
// back for its rightful owner instead.
|
|
41
|
+
// Returns true only if this call actually took an action (rename succeeded, whether the
|
|
42
|
+
// outcome was destroying a confirmed-dead instance or restoring a live replacement it
|
|
43
|
+
// accidentally captured). False means nothing changed — the caller must fall through to
|
|
44
|
+
// the normal deadline/retry wait rather than looping without pause, which would otherwise
|
|
45
|
+
// spin at full CPU on a persistent rename failure (e.g. a permissions problem) instead of
|
|
46
|
+
// respecting LOCK_TIMEOUT_MS.
|
|
47
|
+
function reclaim(expectedHolderRaw) {
|
|
48
|
+
const claim = `${LOCK_PATH}.reclaim.${process.pid}.${Date.now()}`;
|
|
49
|
+
try {
|
|
50
|
+
renameSync(LOCK_PATH, claim);
|
|
51
|
+
} catch {
|
|
52
|
+
return false; // lost the race to someone else, or a real error (e.g. permissions) — either way, did nothing
|
|
53
|
+
}
|
|
54
|
+
let capturedHolderRaw;
|
|
55
|
+
try {
|
|
56
|
+
capturedHolderRaw = readFileSync(join(claim, LOCK_HOLDER_FILE), 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
capturedHolderRaw = null;
|
|
59
|
+
}
|
|
60
|
+
if (capturedHolderRaw === expectedHolderRaw) {
|
|
61
|
+
rmSync(claim, { recursive: true, force: true });
|
|
62
|
+
} else {
|
|
63
|
+
// Not the same instance we judged dead — a legitimate holder slipped in during our
|
|
64
|
+
// check. Restore it. If even that races (LOCK_PATH got recreated in the meantime by
|
|
65
|
+
// yet another party), there is nowhere safe to put it back; drop it rather than loop
|
|
66
|
+
// forever — this compounds three independent low-probability races at once.
|
|
67
|
+
try {
|
|
68
|
+
renameSync(claim, LOCK_PATH);
|
|
69
|
+
} catch {
|
|
70
|
+
rmSync(claim, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function withLock(fn) {
|
|
77
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(LOCK_PATH);
|
|
81
|
+
writeFileSync(
|
|
82
|
+
join(LOCK_PATH, LOCK_HOLDER_FILE),
|
|
83
|
+
JSON.stringify({ pid: process.pid, ts: Date.now() }),
|
|
84
|
+
);
|
|
85
|
+
break;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code !== 'EEXIST') throw error;
|
|
88
|
+
const holderRaw = readHolderRaw();
|
|
89
|
+
let eligible = false;
|
|
90
|
+
if (holderRaw !== null) {
|
|
91
|
+
try {
|
|
92
|
+
const holder = JSON.parse(holderRaw);
|
|
93
|
+
eligible = Date.now() - holder.ts > LOCK_STALE_MS || !isPidAlive(holder.pid);
|
|
94
|
+
} catch {
|
|
95
|
+
eligible = true; // holder.json exists but isn't valid JSON — not a state any live holder writes
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// Missing holder.json while the lock dir exists: either mid-write (a real holder's
|
|
99
|
+
// mkdir+writeFileSync are back-to-back synchronous, resolving in microseconds) or
|
|
100
|
+
// the holder died in that exact window and never will. Use the lock directory's own
|
|
101
|
+
// mtime — an objective fact any process can check on the same physical directory —
|
|
102
|
+
// rather than this waiter's own elapsed wait time, which can't tell "the original
|
|
103
|
+
// holder died" apart from "a brand-new holder's mkdir just landed."
|
|
104
|
+
try {
|
|
105
|
+
eligible = Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_TIMEOUT_MS;
|
|
106
|
+
} catch {
|
|
107
|
+
eligible = false; // the directory itself vanished already — nothing here to reclaim
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (eligible && reclaim(holderRaw)) continue;
|
|
111
|
+
if (Date.now() > deadline) {
|
|
112
|
+
throw new Error(`another cli-relay invocation holds the lock (${LOCK_PATH}) — not waiting forever`);
|
|
113
|
+
}
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return await fn();
|
|
120
|
+
} finally {
|
|
121
|
+
try { rmSync(LOCK_PATH, { recursive: true, force: true }); } catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { MAP_PATH, MAP_VERSION } from '../config.mjs';
|
|
3
|
+
|
|
4
|
+
export function loadMap() {
|
|
5
|
+
if (!existsSync(MAP_PATH)) return { version: MAP_VERSION, sessions: {} };
|
|
6
|
+
const map = JSON.parse(readFileSync(MAP_PATH, 'utf8'));
|
|
7
|
+
if (map.version !== MAP_VERSION) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`${MAP_PATH} version ${map.version} unsupported (router wants ${MAP_VERSION}) — ` +
|
|
10
|
+
'no migration, fix by hand or delete',
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return map;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveMap(map) {
|
|
17
|
+
const tmp = `${MAP_PATH}.${process.pid}.tmp`;
|
|
18
|
+
writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
|
|
19
|
+
renameSync(tmp, MAP_PATH);
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function parseJsonResult(stdout, fields) {
|
|
2
|
+
const hasFields = (value) => value && typeof value === 'object' &&
|
|
3
|
+
(fields.id in value || fields.answer in value);
|
|
4
|
+
|
|
5
|
+
const trimmed = stdout.trim();
|
|
6
|
+
try {
|
|
7
|
+
const value = JSON.parse(trimmed);
|
|
8
|
+
if (hasFields(value)) {
|
|
9
|
+
return { id: value[fields.id] ?? null, answer: value[fields.answer] ?? null };
|
|
10
|
+
}
|
|
11
|
+
} catch {
|
|
12
|
+
// Not one JSON object. Fall through to the NDJSON scan.
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const lines = trimmed.split('\n');
|
|
16
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
17
|
+
let value;
|
|
18
|
+
try { value = JSON.parse(lines[index]); } catch { continue; }
|
|
19
|
+
if (hasFields(value)) {
|
|
20
|
+
return { id: value[fields.id] ?? null, answer: value[fields.answer] ?? null };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { id: null, answer: null };
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MAX_PIN_LENGTH } from '../config.mjs';
|
|
2
|
+
|
|
3
|
+
export function validatePinText(factText) {
|
|
4
|
+
if (!factText || !factText.trim()) return 'fact text must not be empty or whitespace-only';
|
|
5
|
+
if (factText.includes('\n')) {
|
|
6
|
+
return 'fact text must be a single line — multiline facts risk breaking the pinned-block delimiter';
|
|
7
|
+
}
|
|
8
|
+
if (factText.length > MAX_PIN_LENGTH) {
|
|
9
|
+
return `fact text is ${factText.length} chars, over the ${MAX_PIN_LENGTH}-char limit — ` +
|
|
10
|
+
'keep pins short and curated, not a transcript';
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildPinnedBlock(pins) {
|
|
16
|
+
if (!pins || pins.length === 0) return '';
|
|
17
|
+
const lines = pins.map((pin, index) => `${index + 1}. ${pin.text} (pinned ${pin.pinned_at})`);
|
|
18
|
+
return (
|
|
19
|
+
`[PINNED FACTS for this thread — externally verified, override anything else in this ` +
|
|
20
|
+
`conversation's history including your own summarized memory of earlier turns. Treat any ` +
|
|
21
|
+
`contradiction between these and your own recollection as your recollection being wrong.]\n` +
|
|
22
|
+
`${lines.join('\n')}\n[END PINNED FACTS]\n\n`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function withThreadSuggestions(message, sessions, thread) {
|
|
2
|
+
if (!thread) return message;
|
|
3
|
+
const needle = thread.toLocaleLowerCase();
|
|
4
|
+
const matches = Object.keys(sessions)
|
|
5
|
+
.filter((name) => {
|
|
6
|
+
const candidate = name.toLocaleLowerCase();
|
|
7
|
+
return candidate.includes(needle) || needle.includes(candidate);
|
|
8
|
+
})
|
|
9
|
+
.sort()
|
|
10
|
+
.slice(0, 3);
|
|
11
|
+
return matches.length === 0 ? message : `${message} — did you mean: ${matches.join(', ')}?`;
|
|
12
|
+
}
|