syndes 0.1.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 +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config: <claudeDir>/syndes.json, with a per-project override at
|
|
3
|
+
* .claude/syndes.json.
|
|
4
|
+
*
|
|
5
|
+
* Never read from runtime/hook.mjs. The hot path pays for nothing.
|
|
6
|
+
*
|
|
7
|
+
* A malformed config falls back to defaults and reports it rather than throwing
|
|
8
|
+
* into a hook, and it is never silently rewritten over what a human hand-edited.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
import { configFile, projectConfigFile } from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
export const DEFAULTS = {
|
|
16
|
+
/** 'redacted' keeps text with secrets scrubbed | 'full' | 'metadata' (no text) */
|
|
17
|
+
privacy: 'redacted',
|
|
18
|
+
/** Extra regex sources appended to the built-in secret patterns. */
|
|
19
|
+
redactPatterns: [],
|
|
20
|
+
|
|
21
|
+
track: {
|
|
22
|
+
tools: true, // PreToolUse + PostToolUse — the expensive pair, on by default
|
|
23
|
+
prompts: true,
|
|
24
|
+
permissions: true,
|
|
25
|
+
transcript: true, // token, cost and cache numbers
|
|
26
|
+
git: true,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
coach: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
maxPerDay: 3,
|
|
32
|
+
cooldownHours: 24,
|
|
33
|
+
channels: { notification: true, terminal: true, digest: true },
|
|
34
|
+
injectContext: false, // changes Claude's own behaviour; opt-in, never a surprise
|
|
35
|
+
muted: [],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
dashboard: {
|
|
39
|
+
port: 4317,
|
|
40
|
+
idleTimeoutMinutes: 60,
|
|
41
|
+
openOnStart: true,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
retention: {
|
|
45
|
+
days: 0, // 0 = forever. Deleting a user's history is opt-in.
|
|
46
|
+
gzipAfterDays: 30,
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Gap below which two events count as continuous work, for active time. */
|
|
50
|
+
idleGapMinutes: 5,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let cached = null;
|
|
54
|
+
|
|
55
|
+
export function loadConfig(cwd = null) {
|
|
56
|
+
if (!cached) cached = merge(DEFAULTS, readJson(configFile).data ?? {});
|
|
57
|
+
if (!cwd) return cached;
|
|
58
|
+
|
|
59
|
+
const override = readJson(projectConfigFile(cwd)).data;
|
|
60
|
+
return override ? merge(cached, override) : cached;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns {{data: object|null, error: string|null, exists: boolean}} */
|
|
64
|
+
export function readJson(file) {
|
|
65
|
+
if (!existsSync(file)) return { data: null, error: null, exists: false };
|
|
66
|
+
try {
|
|
67
|
+
return { data: JSON.parse(readFileSync(file, 'utf8')), error: null, exists: true };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { data: null, error: error.message, exists: true };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveConfig(config) {
|
|
74
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
75
|
+
writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
76
|
+
cached = merge(DEFAULTS, config);
|
|
77
|
+
return cached;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read the on-disk config only, without defaults folded in — for editing. */
|
|
81
|
+
export function rawConfig() {
|
|
82
|
+
return readJson(configFile).data ?? {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getPath(object, path) {
|
|
86
|
+
return path.split('.').reduce((node, key) => (node == null ? undefined : node[key]), object);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setPath(object, path, value) {
|
|
90
|
+
const keys = path.split('.');
|
|
91
|
+
const last = keys.pop();
|
|
92
|
+
let node = object;
|
|
93
|
+
for (const key of keys) {
|
|
94
|
+
if (typeof node[key] !== 'object' || node[key] === null) node[key] = {};
|
|
95
|
+
node = node[key];
|
|
96
|
+
}
|
|
97
|
+
node[last] = value;
|
|
98
|
+
return object;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Deep merge for plain objects. Arrays replace rather than concatenate. */
|
|
102
|
+
function merge(base, override) {
|
|
103
|
+
const out = { ...base };
|
|
104
|
+
for (const [key, value] of Object.entries(override ?? {})) {
|
|
105
|
+
const existing = out[key];
|
|
106
|
+
const bothPlain =
|
|
107
|
+
value && typeof value === 'object' && !Array.isArray(value) &&
|
|
108
|
+
existing && typeof existing === 'object' && !Array.isArray(existing);
|
|
109
|
+
out[key] = bothPlain ? merge(existing, value) : value;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resetCache() {
|
|
115
|
+
cached = null;
|
|
116
|
+
}
|
package/runtime/hook.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE CRITICAL PATH. Claude Code blocks on this file, on every single tool call.
|
|
4
|
+
*
|
|
5
|
+
* Rules for this file, and only this file:
|
|
6
|
+
* • No imports from this package. Every local import is another file Node must
|
|
7
|
+
* resolve, read and parse before the user gets their tool call back. The
|
|
8
|
+
* three paths it needs are re-derived inline, on purpose.
|
|
9
|
+
* • No config read, no transcript read, no hashing, no classification.
|
|
10
|
+
* • node: builtins only, and child_process only in the branch that spawns.
|
|
11
|
+
* • Never throw. A tracker that breaks a session is worse than no tracker.
|
|
12
|
+
*
|
|
13
|
+
* Budget: Node's own start plus ~10 ms. test/hook-latency.test.mjs enforces both
|
|
14
|
+
* the budget and the no-local-import rule. A regression here is a correctness
|
|
15
|
+
* bug, not a performance nit. See DESIGN.md §1.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const runtimeDir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const claudeDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
|
|
25
|
+
const dataDir = join(claudeDir, 'syndes');
|
|
26
|
+
const spoolDir = join(dataDir, 'spool');
|
|
27
|
+
|
|
28
|
+
/** Events worth waking the worker for immediately: the user has just paused. */
|
|
29
|
+
const BREAKPOINTS = new Set([
|
|
30
|
+
'SessionStart', 'Stop', 'SubagentStop', 'SessionEnd', 'PreCompact',
|
|
31
|
+
'Notification', 'PermissionRequest',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Otherwise the worker is woken at most this often, however fast tools fire. */
|
|
35
|
+
const SPAWN_INTERVAL_MS = 2000;
|
|
36
|
+
|
|
37
|
+
main();
|
|
38
|
+
|
|
39
|
+
function main() {
|
|
40
|
+
let payload;
|
|
41
|
+
try {
|
|
42
|
+
payload = readFileSync(0, 'utf8');
|
|
43
|
+
} catch {
|
|
44
|
+
return; // no stdin: nothing happened worth recording
|
|
45
|
+
}
|
|
46
|
+
if (!payload) return;
|
|
47
|
+
|
|
48
|
+
// One existsSync is the entire cost of the off switch on the fast path.
|
|
49
|
+
if (existsSync(join(dataDir, 'tracking.off')) && stillPaused()) return;
|
|
50
|
+
|
|
51
|
+
const event = eventName(payload);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
write(payload, event);
|
|
55
|
+
} catch {
|
|
56
|
+
// A full disk or a vanished directory must not surface inside a session.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (event === 'SessionStart') printPendingCard();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (shouldSpawn(event)) spawnWorker();
|
|
63
|
+
} catch {
|
|
64
|
+
// The next hook will try again; the spool is still there.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The event name, without parsing the payload.
|
|
70
|
+
*
|
|
71
|
+
* A regex over the raw string beats JSON.parse of a tool_response that can be a
|
|
72
|
+
* megabyte of file content — and the hook has no other use for the parsed object.
|
|
73
|
+
*/
|
|
74
|
+
function eventName(payload) {
|
|
75
|
+
const match = /"hook_event_name"\s*:\s*"([A-Za-z]+)"/.exec(payload);
|
|
76
|
+
return match ? match[1] : 'Unknown';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function write(payload, event) {
|
|
80
|
+
mkdirSync(spoolDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const stamp = String(now).padStart(15, '0');
|
|
84
|
+
const unique = `${stamp}-${String(process.pid).padStart(7, '0')}-${Math.random().toString(36).slice(2, 8)}`;
|
|
85
|
+
|
|
86
|
+
const line = `${JSON.stringify({
|
|
87
|
+
t: now,
|
|
88
|
+
n: Number(process.hrtime.bigint() % 1000000n),
|
|
89
|
+
e: event,
|
|
90
|
+
p: payload,
|
|
91
|
+
})}\n`;
|
|
92
|
+
|
|
93
|
+
// Write then rename: rename is atomic on all three platforms, so the worker
|
|
94
|
+
// can never observe a half-written spool file. Discarding a torn line would
|
|
95
|
+
// be data loss, and this ledger's whole claim is that it does not lose lines.
|
|
96
|
+
const part = join(spoolDir, `${unique}.part`);
|
|
97
|
+
writeFileSync(part, line);
|
|
98
|
+
renameSync(part, join(spoolDir, `${unique}.jsonl`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shouldSpawn(event) {
|
|
102
|
+
// Never pile a second worker onto a live one; it would exit on the lock anyway
|
|
103
|
+
// and we would have paid a whole Node start to learn that.
|
|
104
|
+
if (existsSync(join(dataDir, 'writer.lock'))) return false;
|
|
105
|
+
if (BREAKPOINTS.has(event)) return true;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return Date.now() - statSync(join(dataDir, 'last-spawn')).mtimeMs > SPAWN_INTERVAL_MS;
|
|
109
|
+
} catch {
|
|
110
|
+
return true; // no marker yet
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function spawnWorker() {
|
|
115
|
+
try {
|
|
116
|
+
writeFileSync(join(dataDir, 'last-spawn'), '');
|
|
117
|
+
} catch { /* the marker is an optimisation, not a requirement */ }
|
|
118
|
+
|
|
119
|
+
const { spawn } = await import('node:child_process');
|
|
120
|
+
// process.execPath, never "node": the PATH inside a hook is not your shell's.
|
|
121
|
+
spawn(process.execPath, [join(runtimeDir, 'worker.mjs')], {
|
|
122
|
+
detached: true,
|
|
123
|
+
stdio: 'ignore',
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
}).unref();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The coach's terminal channel.
|
|
130
|
+
*
|
|
131
|
+
* The worker cannot write into the user's session, so it leaves a card here and
|
|
132
|
+
* the next SessionStart prints it. Checked on SessionStart only — the fast path
|
|
133
|
+
* for a tool call never pays for this.
|
|
134
|
+
*/
|
|
135
|
+
function printPendingCard() {
|
|
136
|
+
const card = join(dataDir, 'state', 'card.txt');
|
|
137
|
+
try {
|
|
138
|
+
if (!existsSync(card)) return;
|
|
139
|
+
process.stdout.write(readFileSync(card, 'utf8'));
|
|
140
|
+
renameSync(card, `${card}.shown`);
|
|
141
|
+
} catch { /* a card is never worth failing a session start over */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Only read when the pause file exists, which is the rare case. */
|
|
145
|
+
function stillPaused() {
|
|
146
|
+
try {
|
|
147
|
+
const raw = readFileSync(join(dataDir, 'tracking.off'), 'utf8').trim();
|
|
148
|
+
if (!raw) return true; // `off` with no expiry: paused until turned back on
|
|
149
|
+
const until = Number(JSON.parse(raw).until);
|
|
150
|
+
return !Number.isFinite(until) || Date.now() < until;
|
|
151
|
+
} catch {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL read/write, and the canonical JSON the hash chain depends on.
|
|
3
|
+
*
|
|
4
|
+
* Without canonical(), the same record hashes two different ways depending on
|
|
5
|
+
* key insertion order and `verify` becomes a coin flip.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createReadStream, appendFileSync, openSync, readSync, fstatSync, closeSync } from 'node:fs';
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import { createGunzip } from 'node:zlib';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic JSON: keys sorted at every depth, no insignificant whitespace,
|
|
14
|
+
* `undefined` dropped rather than reordered into `null`.
|
|
15
|
+
*/
|
|
16
|
+
export function canonical(value) {
|
|
17
|
+
if (value === undefined) return 'null';
|
|
18
|
+
if (value === null || typeof value !== 'object') {
|
|
19
|
+
const encoded = JSON.stringify(value);
|
|
20
|
+
return encoded === undefined ? 'null' : encoded;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
23
|
+
|
|
24
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
|
|
25
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse one line, tolerating a trailing \r and blank lines. @returns {object|null} */
|
|
29
|
+
export function parseLine(line) {
|
|
30
|
+
const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
31
|
+
if (!trimmed.trim()) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(trimmed);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stream a .jsonl or .jsonl.gz file as objects.
|
|
41
|
+
*
|
|
42
|
+
* Streaming rather than reading whole: a heavy year of use is hundreds of
|
|
43
|
+
* megabytes and the dashboard must not need it resident to answer one question.
|
|
44
|
+
*
|
|
45
|
+
* `crlfDelay: Infinity` is what makes a Windows-written file read correctly.
|
|
46
|
+
*/
|
|
47
|
+
export async function* streamRecords(file) {
|
|
48
|
+
let input = createReadStream(file);
|
|
49
|
+
if (file.endsWith('.gz')) input = input.pipe(createGunzip());
|
|
50
|
+
|
|
51
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
52
|
+
for await (const line of lines) {
|
|
53
|
+
const record = parseLine(line);
|
|
54
|
+
if (record) yield record;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Append one object as a line. The caller is responsible for holding the lock. */
|
|
59
|
+
export function appendLine(file, object) {
|
|
60
|
+
appendFileSync(file, `${JSON.stringify(object)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The last parseable line of a file, read from the tail rather than the head.
|
|
65
|
+
*
|
|
66
|
+
* Used to recover the chain tip when state/tip.json is missing or stale — which
|
|
67
|
+
* is exactly the case after a worker is killed mid-write, so it must not depend
|
|
68
|
+
* on reading a multi-megabyte segment from the front.
|
|
69
|
+
*
|
|
70
|
+
* @returns {{record: object|null, torn: boolean}} torn when the final line
|
|
71
|
+
* exists but does not parse: a half-written record, not tampering.
|
|
72
|
+
*/
|
|
73
|
+
export function lastRecordOf(file, window = 65536) {
|
|
74
|
+
let handle;
|
|
75
|
+
try {
|
|
76
|
+
handle = openSync(file, 'r');
|
|
77
|
+
const size = fstatSync(handle).size;
|
|
78
|
+
if (size === 0) return { record: null, torn: false };
|
|
79
|
+
|
|
80
|
+
const length = Math.min(window, size);
|
|
81
|
+
const buffer = Buffer.alloc(length);
|
|
82
|
+
readSync(handle, buffer, 0, length, size - length);
|
|
83
|
+
|
|
84
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
85
|
+
// A window that does not start at byte 0 may open mid-record; that first
|
|
86
|
+
// fragment is not evidence of anything and is dropped.
|
|
87
|
+
if (length < size) lines.shift();
|
|
88
|
+
|
|
89
|
+
let torn = false;
|
|
90
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
91
|
+
if (!lines[i].trim()) continue;
|
|
92
|
+
const record = parseLine(lines[i]);
|
|
93
|
+
if (record) return { record, torn };
|
|
94
|
+
torn = true;
|
|
95
|
+
}
|
|
96
|
+
return { record: null, torn };
|
|
97
|
+
} catch {
|
|
98
|
+
return { record: null, torn: false };
|
|
99
|
+
} finally {
|
|
100
|
+
if (handle !== undefined) {
|
|
101
|
+
try { closeSync(handle); } catch { /* already gone */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/runtime/lock.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writer lock. Exactly one process may append to the ledger.
|
|
3
|
+
*
|
|
4
|
+
* Implemented with mkdirSync because directory creation is atomic on macOS,
|
|
5
|
+
* Linux and Windows alike — unlike flock/fcntl, which is POSIX-only, and unlike
|
|
6
|
+
* "check then create", which is a race wearing a lock's clothes.
|
|
7
|
+
*
|
|
8
|
+
* A worker killed mid-write must not wedge the ledger forever, so a lock older
|
|
9
|
+
* than STALE_MS, or one whose owner process is gone, can be broken.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { lockDir } from './paths.mjs';
|
|
15
|
+
import { debug } from './log.mjs';
|
|
16
|
+
|
|
17
|
+
const OWNER_FILE = join(lockDir, 'owner.json');
|
|
18
|
+
const STALE_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
let held = false;
|
|
21
|
+
|
|
22
|
+
/** @returns {boolean} true when this process now owns the lock. */
|
|
23
|
+
export function acquire() {
|
|
24
|
+
if (held) return true;
|
|
25
|
+
|
|
26
|
+
if (tryCreate()) return true;
|
|
27
|
+
|
|
28
|
+
const owner = readOwner();
|
|
29
|
+
if (!isStale(owner)) return false;
|
|
30
|
+
|
|
31
|
+
debug('breaking stale lock', owner);
|
|
32
|
+
try {
|
|
33
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return tryCreate();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tryCreate() {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(lockDir, { recursive: false });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === 'EEXIST') return false;
|
|
45
|
+
// ENOENT means the parent is missing — the caller has not run ensureDataDirs.
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(OWNER_FILE, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
51
|
+
} catch {
|
|
52
|
+
// An unwritable owner file only costs us stale detection, not correctness.
|
|
53
|
+
}
|
|
54
|
+
held = true;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function release() {
|
|
59
|
+
if (!held) return;
|
|
60
|
+
held = false;
|
|
61
|
+
try {
|
|
62
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
63
|
+
} catch {
|
|
64
|
+
// The next worker's stale check will clear it.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isHeld() {
|
|
69
|
+
return existsSync(lockDir) && !isStale(readOwner());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readOwner() {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(OWNER_FILE, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A lock with no readable owner is stale — it was created by a process that died
|
|
82
|
+
* between mkdir and the owner write, and nothing else will ever clear it.
|
|
83
|
+
*/
|
|
84
|
+
function isStale(owner) {
|
|
85
|
+
if (!owner || typeof owner.pid !== 'number') return true;
|
|
86
|
+
if (Date.now() - (owner.at ?? 0) > STALE_MS) return true;
|
|
87
|
+
if (owner.pid === process.pid) return false;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
process.kill(owner.pid, 0); // signal 0 tests existence without delivering
|
|
91
|
+
return false;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// EPERM means it exists and belongs to someone else. Still alive.
|
|
94
|
+
return error.code !== 'EPERM';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { STALE_MS };
|
package/runtime/log.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug log, off unless SYNDES_DEBUG=1.
|
|
3
|
+
*
|
|
4
|
+
* Never writes to stdout or stderr from inside a hook: Claude Code reads hook
|
|
5
|
+
* stdout, and stray output there becomes model context or a visible error.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, statSync, renameSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { debugLogFile } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
const ENABLED = process.env.SYNDES_DEBUG === '1';
|
|
13
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export function debug(...parts) {
|
|
16
|
+
if (!ENABLED) return;
|
|
17
|
+
try {
|
|
18
|
+
mkdirSync(dirname(debugLogFile), { recursive: true });
|
|
19
|
+
rotateIfLarge();
|
|
20
|
+
const line = parts
|
|
21
|
+
.map((part) => (typeof part === 'string' ? part : JSON.stringify(part)))
|
|
22
|
+
.join(' ');
|
|
23
|
+
appendFileSync(debugLogFile, `${new Date().toISOString()} [${process.pid}] ${line}\n`);
|
|
24
|
+
} catch {
|
|
25
|
+
// Logging must never be the thing that breaks a session.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rotateIfLarge() {
|
|
30
|
+
try {
|
|
31
|
+
if (statSync(debugLogFile).size > MAX_BYTES) renameSync(debugLogFile, `${debugLogFile}.1`);
|
|
32
|
+
} catch {
|
|
33
|
+
// No log yet, or it vanished. Either way there is nothing to rotate.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const debugEnabled = ENABLED;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every filesystem location syndes touches.
|
|
3
|
+
*
|
|
4
|
+
* Derived from this module's URL, never from process.cwd() — hooks run with the
|
|
5
|
+
* cwd of whatever project Claude Code was launched in, not the package.
|
|
6
|
+
*
|
|
7
|
+
* `packageRoot` is the copy executing; `installDir` is the copy settings.json
|
|
8
|
+
* points at. They differ under `npx` (the npm cache, which npm prunes) and in
|
|
9
|
+
* development, which is why install copies to a stable home.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: runtime/hook.mjs deliberately does NOT import this module. It re-derives
|
|
12
|
+
* the three paths it needs inline. See DESIGN.md §1 — every local import is a
|
|
13
|
+
* file Node must resolve while the user waits.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { mkdirSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
const runtimeDir = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
/** The package this process is running from. */
|
|
24
|
+
export const packageRoot = dirname(runtimeDir);
|
|
25
|
+
|
|
26
|
+
/** Claude Code honours CLAUDE_CONFIG_DIR; we must resolve it the same way. */
|
|
27
|
+
export const claudeDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
|
|
28
|
+
|
|
29
|
+
/** True when the config directory has been redirected. An isolated install stays isolated. */
|
|
30
|
+
export const isIsolated = Boolean(process.env.CLAUDE_CONFIG_DIR?.trim());
|
|
31
|
+
|
|
32
|
+
export const settingsFile = join(claudeDir, 'settings.json');
|
|
33
|
+
export const configFile = join(claudeDir, 'syndes.json');
|
|
34
|
+
|
|
35
|
+
/** The stable home that installed hooks point at. */
|
|
36
|
+
export const installDir = join(claudeDir, 'hooks', 'syndes');
|
|
37
|
+
export const installedHookScript = join(installDir, 'runtime', 'hook.mjs');
|
|
38
|
+
export const installedCliScript = join(installDir, 'bin', 'cli.mjs');
|
|
39
|
+
export const isInstalledCopy = resolve(packageRoot) === resolve(installDir);
|
|
40
|
+
|
|
41
|
+
/** The worker, resolved within the running copy so the hook spawns its own twin. */
|
|
42
|
+
export const workerScript = join(runtimeDir, 'worker.mjs');
|
|
43
|
+
|
|
44
|
+
// ── The data root ───────────────────────────────────────────────────────────
|
|
45
|
+
// Only rollup/ and state/ are disposable. Everything else is the record.
|
|
46
|
+
|
|
47
|
+
export const dataDir = join(claudeDir, 'syndes');
|
|
48
|
+
export const logDir = join(dataDir, 'log');
|
|
49
|
+
export const sealDir = join(dataDir, 'seal');
|
|
50
|
+
export const keysDir = join(dataDir, 'keys');
|
|
51
|
+
export const spoolDir = join(dataDir, 'spool');
|
|
52
|
+
export const rollupDir = join(dataDir, 'rollup');
|
|
53
|
+
export const stateDir = join(dataDir, 'state');
|
|
54
|
+
|
|
55
|
+
export const chainKeyFile = join(keysDir, 'chain.key');
|
|
56
|
+
export const authFile = join(dataDir, 'auth.json');
|
|
57
|
+
export const lockDir = join(dataDir, 'writer.lock');
|
|
58
|
+
export const debugLogFile = join(dataDir, 'debug.log');
|
|
59
|
+
export const pauseFile = join(dataDir, 'tracking.off');
|
|
60
|
+
export const spawnMarker = join(dataDir, 'last-spawn');
|
|
61
|
+
|
|
62
|
+
export const tipFile = join(stateDir, 'tip.json');
|
|
63
|
+
export const cursorsFile = join(stateDir, 'cursors.json');
|
|
64
|
+
export const sessionsFile = join(stateDir, 'sessions.json');
|
|
65
|
+
export const coachFile = join(stateDir, 'coach.json');
|
|
66
|
+
export const projectsFile = join(stateDir, 'projects.json');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Set by the npm postinstall, cleared the first time the CLI runs.
|
|
70
|
+
*
|
|
71
|
+
* npm hides postinstall output unless --foreground-scripts, so printing the
|
|
72
|
+
* briefing there means nobody sees it. It waits for the user's next command.
|
|
73
|
+
*/
|
|
74
|
+
export const briefingPending = join(stateDir, 'briefing-pending');
|
|
75
|
+
|
|
76
|
+
export function segmentFile(day) {
|
|
77
|
+
return join(logDir, `${day}.jsonl`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sealFileFor(day) {
|
|
81
|
+
return join(sealDir, `${day}.seal.json`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function rollupFile(day) {
|
|
85
|
+
return join(rollupDir, `${day}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Per-project override, read relative to the session's working directory. */
|
|
89
|
+
export function projectConfigFile(cwd) {
|
|
90
|
+
return join(cwd, '.claude', 'syndes.json');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A path shortened for display. Only the home directory is abbreviated —
|
|
95
|
+
* rewriting claudeDir to a literal `~/.claude` would be a lie whenever
|
|
96
|
+
* CLAUDE_CONFIG_DIR points elsewhere, and printed paths exist to be copied.
|
|
97
|
+
*/
|
|
98
|
+
export function displayPath(path) {
|
|
99
|
+
const home = homedir();
|
|
100
|
+
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create the data directories. Safe to call from anywhere: `recursive` makes it
|
|
105
|
+
* a no-op once they exist, and a failure here must never be fatal.
|
|
106
|
+
*/
|
|
107
|
+
export function ensureDataDirs() {
|
|
108
|
+
try {
|
|
109
|
+
for (const dir of [logDir, sealDir, keysDir, spoolDir, rollupDir, stateDir]) {
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one module that knows what operating system this is. Everyone else asks.
|
|
3
|
+
*
|
|
4
|
+
* A probe that cannot answer returns null and the caller degrades. "Unsupported"
|
|
5
|
+
* and "broken" must never look alike — the failure mode to design out is a tool
|
|
6
|
+
* that installs six hooks on Windows, reports them green, and runs none.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join, delimiter } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const PLATFORM = process.platform;
|
|
13
|
+
export const isMac = PLATFORM === 'darwin';
|
|
14
|
+
export const isWindows = PLATFORM === 'win32';
|
|
15
|
+
export const isLinux = PLATFORM === 'linux';
|
|
16
|
+
|
|
17
|
+
const NAMES = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
|
|
18
|
+
export const platformName = NAMES[PLATFORM] ?? PLATFORM;
|
|
19
|
+
|
|
20
|
+
export const isSupportedPlatform = isMac || isWindows || isLinux;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Locate an executable on PATH.
|
|
24
|
+
*
|
|
25
|
+
* Walks PATH itself rather than shelling out: `which` does not exist on Windows,
|
|
26
|
+
* PATHEXT is what decides executability there, and a loop beats a process spawn.
|
|
27
|
+
*
|
|
28
|
+
* @returns {string|null} the absolute path, or null when it is not installed
|
|
29
|
+
*/
|
|
30
|
+
const whichCache = new Map();
|
|
31
|
+
|
|
32
|
+
export function which(binary) {
|
|
33
|
+
if (whichCache.has(binary)) return whichCache.get(binary);
|
|
34
|
+
const found = lookup(binary);
|
|
35
|
+
whichCache.set(binary, found);
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lookup(binary) {
|
|
40
|
+
const extensions = isWindows
|
|
41
|
+
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
42
|
+
: [''];
|
|
43
|
+
|
|
44
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
45
|
+
for (const extension of ['', ...extensions]) {
|
|
46
|
+
const candidate = join(dir, binary + extension);
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(candidate)) return candidate;
|
|
49
|
+
} catch {
|
|
50
|
+
// An unreadable PATH entry is not an answer about `binary`.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* argv for opening a URL or file in the platform's default handler.
|
|
59
|
+
*
|
|
60
|
+
* The empty string after `start` is required: `start` reads its first quoted
|
|
61
|
+
* argument as a window title, so `start "http://…"` opens a console window
|
|
62
|
+
* titled with the URL and no browser at all.
|
|
63
|
+
*
|
|
64
|
+
* @returns {{command: string, args: string[]}|null}
|
|
65
|
+
*/
|
|
66
|
+
export function openArgs(target) {
|
|
67
|
+
if (isMac) return { command: '/usr/bin/open', args: [target] };
|
|
68
|
+
if (isWindows) return { command: 'cmd', args: ['/c', 'start', '', target] };
|
|
69
|
+
const xdg = which('xdg-open');
|
|
70
|
+
return xdg ? { command: xdg, args: [target] } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when POSIX file modes actually mean anything here. */
|
|
74
|
+
export const honoursFileModes = !isWindows;
|