start-vibing-stacks 2.21.0 → 2.23.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/README.md +42 -5
- package/dist/setup.js +67 -3
- package/package.json +1 -1
- package/stacks/_shared/commands/peers.md +37 -0
- package/stacks/_shared/hooks/_state.README.md +55 -0
- package/stacks/_shared/hooks/_state.ts +444 -0
- package/stacks/_shared/hooks/peers.ts +250 -0
- package/stacks/_shared/hooks/post-tool-use.ts +79 -0
- package/stacks/_shared/hooks/pre-tool-use.ts +175 -0
- package/stacks/_shared/hooks/session-start.ts +124 -0
- package/stacks/_shared/hooks/stop-validator.ts +37 -0
- package/stacks/_shared/hooks/user-prompt-submit.ts +96 -19
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +74 -1
- package/stacks/_shared/skills/multi-instance-coordination/SKILL.md +90 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +140 -2
- package/stacks/php/scripts/check-vite-manifest.mjs +309 -0
- package/stacks/php/skills/inertia-react/SKILL.md +126 -2
- package/stacks/php/stack.json +2 -1
- package/templates/CLAUDE-php.md +16 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Instance Coordination — Shared State Library
|
|
3
|
+
*
|
|
4
|
+
* Used by session-start.ts, user-prompt-submit.ts, pre-tool-use.ts,
|
|
5
|
+
* post-tool-use.ts, stop-validator.ts and peers.ts.
|
|
6
|
+
*
|
|
7
|
+
* Layout (project-local, gitignored):
|
|
8
|
+
* .claude/state/
|
|
9
|
+
* sessions/<id>.json one record per active Claude instance
|
|
10
|
+
* sessions/_archive/ sessions ended or stale
|
|
11
|
+
* inbox/<id>.jsonl messages queued for that session
|
|
12
|
+
* file-touches.jsonl append-only edit log
|
|
13
|
+
* file-touches/_archive/ rotated logs
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - All writes are atomic (.tmp + rename) where the file is read by peers.
|
|
17
|
+
* - Every read tolerates corruption (try/catch) — hooks must NEVER block Claude.
|
|
18
|
+
* - Heartbeat thresholds: <60s active, 60s-30min idle, >30min stale, >24h removed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
appendFileSync,
|
|
31
|
+
openSync,
|
|
32
|
+
readSync,
|
|
33
|
+
closeSync,
|
|
34
|
+
} from 'fs';
|
|
35
|
+
import { join, basename } from 'path';
|
|
36
|
+
import { randomBytes } from 'crypto';
|
|
37
|
+
import { spawnSync } from 'child_process';
|
|
38
|
+
|
|
39
|
+
export const ACTIVE_MS = 60 * 1000;
|
|
40
|
+
export const IDLE_MS = 30 * 60 * 1000;
|
|
41
|
+
export const STALE_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
export const COLLISION_WINDOW_MS = 5 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
export const TOUCHES_ROTATE_THRESHOLD = 1000;
|
|
45
|
+
export const TOUCHES_TAIL_LINES = 200;
|
|
46
|
+
export const FILES_TOUCHED_CAP = 50;
|
|
47
|
+
|
|
48
|
+
export interface SessionRecord {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
transcriptPath?: string;
|
|
51
|
+
title: string;
|
|
52
|
+
cwd: string;
|
|
53
|
+
ppid: number;
|
|
54
|
+
gitBranch?: string;
|
|
55
|
+
startedAt: string;
|
|
56
|
+
lastSeenAt: string;
|
|
57
|
+
lastActivity: string;
|
|
58
|
+
filesTouched: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FileTouch {
|
|
62
|
+
ts: string;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
tool: string;
|
|
65
|
+
file: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface InboxMessage {
|
|
69
|
+
ts: string;
|
|
70
|
+
fromSessionId: string;
|
|
71
|
+
fromTitle?: string;
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getProjectDir(): string {
|
|
76
|
+
return process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getStateDir(projectDir: string = getProjectDir()): string {
|
|
80
|
+
return join(projectDir, '.claude', 'state');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ensureStateDirs(stateDir: string = getStateDir()): void {
|
|
84
|
+
mkdirSync(join(stateDir, 'sessions', '_archive'), { recursive: true });
|
|
85
|
+
mkdirSync(join(stateDir, 'inbox'), { recursive: true });
|
|
86
|
+
mkdirSync(join(stateDir, 'file-touches', '_archive'), { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function nowIso(): string {
|
|
90
|
+
return new Date().toISOString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ageMs(iso: string): number {
|
|
94
|
+
const t = Date.parse(iso);
|
|
95
|
+
if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
|
|
96
|
+
return Date.now() - t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Atomic write: tmp + rename. Survives concurrent writers; the last rename wins. */
|
|
100
|
+
export function writeFileAtomic(target: string, contents: string): void {
|
|
101
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
102
|
+
writeFileSync(tmp, contents);
|
|
103
|
+
renameSync(tmp, target);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read JSON; returns null on any error. */
|
|
107
|
+
export function readJsonSafe<T>(path: string): T | null {
|
|
108
|
+
try {
|
|
109
|
+
if (!existsSync(path)) return null;
|
|
110
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getSessionPath(stateDir: string, sessionId: string): string {
|
|
117
|
+
return join(stateDir, 'sessions', `${sessionId}.json`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readSession(stateDir: string, sessionId: string): SessionRecord | null {
|
|
121
|
+
return readJsonSafe<SessionRecord>(getSessionPath(stateDir, sessionId));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function writeSession(stateDir: string, session: SessionRecord): void {
|
|
125
|
+
ensureStateDirs(stateDir);
|
|
126
|
+
writeFileAtomic(getSessionPath(stateDir, session.sessionId), JSON.stringify(session, null, 2));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function listSessionFiles(stateDir: string): string[] {
|
|
130
|
+
const dir = join(stateDir, 'sessions');
|
|
131
|
+
if (!existsSync(dir)) return [];
|
|
132
|
+
try {
|
|
133
|
+
return readdirSync(dir)
|
|
134
|
+
.filter(f => f.endsWith('.json'))
|
|
135
|
+
.map(f => join(dir, f));
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* List sessions excluding the caller's own. Auto-archives stale (>30min) and
|
|
143
|
+
* removes very old (>24h) records as a side effect.
|
|
144
|
+
*/
|
|
145
|
+
export function listPeerSessions(
|
|
146
|
+
stateDir: string,
|
|
147
|
+
currentSessionId: string | null
|
|
148
|
+
): SessionRecord[] {
|
|
149
|
+
const peers: SessionRecord[] = [];
|
|
150
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
151
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
152
|
+
if (!rec) continue;
|
|
153
|
+
if (rec.sessionId === currentSessionId) continue;
|
|
154
|
+
|
|
155
|
+
const age = ageMs(rec.lastSeenAt);
|
|
156
|
+
if (age > STALE_MS) {
|
|
157
|
+
try {
|
|
158
|
+
rmSync(file);
|
|
159
|
+
} catch {}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (age > IDLE_MS) {
|
|
163
|
+
archiveSessionFile(stateDir, file);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
peers.push(rec);
|
|
167
|
+
}
|
|
168
|
+
return peers;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function archiveSessionFile(stateDir: string, sessionFile: string): void {
|
|
172
|
+
try {
|
|
173
|
+
const dest = join(stateDir, 'sessions', '_archive', basename(sessionFile));
|
|
174
|
+
renameSync(sessionFile, dest);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function archiveSession(stateDir: string, sessionId: string): void {
|
|
179
|
+
const file = getSessionPath(stateDir, sessionId);
|
|
180
|
+
if (existsSync(file)) archiveSessionFile(stateDir, file);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Update lastSeenAt + lastActivity for the current session. Idempotent. */
|
|
184
|
+
export function heartbeat(
|
|
185
|
+
stateDir: string,
|
|
186
|
+
sessionId: string,
|
|
187
|
+
activity: string,
|
|
188
|
+
patch: Partial<SessionRecord> = {}
|
|
189
|
+
): SessionRecord {
|
|
190
|
+
const existing = readSession(stateDir, sessionId);
|
|
191
|
+
const now = nowIso();
|
|
192
|
+
const merged: SessionRecord = existing
|
|
193
|
+
? { ...existing, ...patch, lastSeenAt: now, lastActivity: activity }
|
|
194
|
+
: {
|
|
195
|
+
sessionId,
|
|
196
|
+
title: patch.title || '(untitled)',
|
|
197
|
+
cwd: patch.cwd || getProjectDir(),
|
|
198
|
+
ppid: patch.ppid ?? process.ppid ?? 0,
|
|
199
|
+
gitBranch: patch.gitBranch,
|
|
200
|
+
transcriptPath: patch.transcriptPath,
|
|
201
|
+
startedAt: now,
|
|
202
|
+
lastSeenAt: now,
|
|
203
|
+
lastActivity: activity,
|
|
204
|
+
filesTouched: patch.filesTouched || [],
|
|
205
|
+
};
|
|
206
|
+
writeSession(stateDir, merged);
|
|
207
|
+
return merged;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function recordFileTouch(stateDir: string, touch: FileTouch): void {
|
|
211
|
+
ensureStateDirs(stateDir);
|
|
212
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
213
|
+
try {
|
|
214
|
+
appendFileSync(path, JSON.stringify(touch) + '\n');
|
|
215
|
+
rotateFileTouchesIfNeeded(stateDir);
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function rotateFileTouchesIfNeeded(stateDir: string): void {
|
|
220
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
221
|
+
if (!existsSync(path)) return;
|
|
222
|
+
try {
|
|
223
|
+
const size = statSync(path).size;
|
|
224
|
+
if (size < 200_000) return;
|
|
225
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
226
|
+
if (lines.length < TOUCHES_ROTATE_THRESHOLD) return;
|
|
227
|
+
const archiveName = `file-touches-${Date.now()}.jsonl`;
|
|
228
|
+
const archivePath = join(stateDir, 'file-touches', '_archive', archiveName);
|
|
229
|
+
writeFileSync(archivePath, lines.slice(0, lines.length - TOUCHES_TAIL_LINES).join('\n') + '\n');
|
|
230
|
+
writeFileSync(path, lines.slice(-TOUCHES_TAIL_LINES).join('\n') + '\n');
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Read the last N lines of file-touches.jsonl (cheap tail, no full read). */
|
|
235
|
+
export function tailFileTouches(stateDir: string, lines = TOUCHES_TAIL_LINES): FileTouch[] {
|
|
236
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
237
|
+
if (!existsSync(path)) return [];
|
|
238
|
+
try {
|
|
239
|
+
const size = statSync(path).size;
|
|
240
|
+
const readBytes = Math.min(size, 64 * 1024);
|
|
241
|
+
const fd = openSync(path, 'r');
|
|
242
|
+
const buf = Buffer.alloc(readBytes);
|
|
243
|
+
readSync(fd, buf, 0, readBytes, size - readBytes);
|
|
244
|
+
closeSync(fd);
|
|
245
|
+
const text = buf.toString('utf8');
|
|
246
|
+
const all = text.split('\n').filter(Boolean);
|
|
247
|
+
const slice = all.slice(-lines);
|
|
248
|
+
const out: FileTouch[] = [];
|
|
249
|
+
for (const line of slice) {
|
|
250
|
+
try {
|
|
251
|
+
out.push(JSON.parse(line));
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
} catch {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function getInboxPath(stateDir: string, sessionId: string): string {
|
|
261
|
+
return join(stateDir, 'inbox', `${sessionId}.jsonl`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function appendInbox(
|
|
265
|
+
stateDir: string,
|
|
266
|
+
targetSessionId: string,
|
|
267
|
+
msg: InboxMessage
|
|
268
|
+
): void {
|
|
269
|
+
ensureStateDirs(stateDir);
|
|
270
|
+
try {
|
|
271
|
+
appendFileSync(getInboxPath(stateDir, targetSessionId), JSON.stringify(msg) + '\n');
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Read all queued messages and remove the inbox file. */
|
|
276
|
+
export function drainInbox(stateDir: string, sessionId: string): InboxMessage[] {
|
|
277
|
+
const path = getInboxPath(stateDir, sessionId);
|
|
278
|
+
if (!existsSync(path)) return [];
|
|
279
|
+
try {
|
|
280
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
281
|
+
rmSync(path);
|
|
282
|
+
const out: InboxMessage[] = [];
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
try {
|
|
285
|
+
out.push(JSON.parse(line));
|
|
286
|
+
} catch {}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function clearInbox(stateDir: string, sessionId: string): void {
|
|
295
|
+
const path = getInboxPath(stateDir, sessionId);
|
|
296
|
+
if (existsSync(path)) {
|
|
297
|
+
try {
|
|
298
|
+
rmSync(path);
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Read up to ~8KB of the transcript and extract a human title. */
|
|
304
|
+
export function extractTitle(transcriptPath?: string, fallbackPrompt?: string): string {
|
|
305
|
+
if (transcriptPath && existsSync(transcriptPath)) {
|
|
306
|
+
try {
|
|
307
|
+
const fd = openSync(transcriptPath, 'r');
|
|
308
|
+
const buf = Buffer.alloc(8192);
|
|
309
|
+
const bytes = readSync(fd, buf, 0, buf.length, 0);
|
|
310
|
+
closeSync(fd);
|
|
311
|
+
const text = buf.subarray(0, bytes).toString('utf8');
|
|
312
|
+
for (const line of text.split('\n')) {
|
|
313
|
+
if (!line.trim()) continue;
|
|
314
|
+
try {
|
|
315
|
+
const rec = JSON.parse(line);
|
|
316
|
+
if (typeof rec.summary === 'string' && rec.summary.trim()) {
|
|
317
|
+
return truncate(rec.summary.trim(), 80);
|
|
318
|
+
}
|
|
319
|
+
if (rec.type === 'user' && rec.message) {
|
|
320
|
+
const content =
|
|
321
|
+
typeof rec.message === 'string'
|
|
322
|
+
? rec.message
|
|
323
|
+
: typeof rec.message?.content === 'string'
|
|
324
|
+
? rec.message.content
|
|
325
|
+
: Array.isArray(rec.message?.content)
|
|
326
|
+
? rec.message.content
|
|
327
|
+
.filter((c: any) => c.type === 'text')
|
|
328
|
+
.map((c: any) => c.text)
|
|
329
|
+
.join(' ')
|
|
330
|
+
: '';
|
|
331
|
+
if (content) return truncate(content.replace(/\s+/g, ' ').trim(), 80);
|
|
332
|
+
}
|
|
333
|
+
} catch {}
|
|
334
|
+
}
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
if (fallbackPrompt) return truncate(fallbackPrompt.replace(/\s+/g, ' ').trim(), 80);
|
|
338
|
+
return '(untitled)';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function truncate(s: string, n: number): string {
|
|
342
|
+
if (s.length <= n) return s;
|
|
343
|
+
return s.slice(0, n - 1) + '…';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function shortId(sessionId: string): string {
|
|
347
|
+
return sessionId.slice(0, 8);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function classifyAge(ageMsec: number): 'active' | 'idle' | 'stale' {
|
|
351
|
+
if (ageMsec < ACTIVE_MS) return 'active';
|
|
352
|
+
if (ageMsec < IDLE_MS) return 'idle';
|
|
353
|
+
return 'stale';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Inspect tool input and return a list of file paths that will be touched.
|
|
358
|
+
* Returns paths relative to projectDir when possible, else absolute.
|
|
359
|
+
*/
|
|
360
|
+
export function extractTargetFiles(toolName: string, toolInput: any, projectDir: string): string[] {
|
|
361
|
+
if (!toolInput || typeof toolInput !== 'object') return [];
|
|
362
|
+
const out: string[] = [];
|
|
363
|
+
const push = (p: any) => {
|
|
364
|
+
if (typeof p !== 'string' || !p) return;
|
|
365
|
+
let rel = p;
|
|
366
|
+
if (p.startsWith(projectDir + '/')) rel = p.slice(projectDir.length + 1);
|
|
367
|
+
out.push(rel);
|
|
368
|
+
};
|
|
369
|
+
if (toolName === 'Edit' || toolName === 'Write') {
|
|
370
|
+
push(toolInput.file_path || toolInput.path);
|
|
371
|
+
} else if (toolName === 'MultiEdit') {
|
|
372
|
+
push(toolInput.file_path || toolInput.path);
|
|
373
|
+
} else if (toolName === 'NotebookEdit') {
|
|
374
|
+
push(toolInput.notebook_path || toolInput.target_notebook);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Read JSON from stdin with a timeout fallback. */
|
|
380
|
+
export async function readStdinJson(timeoutMs = 1500): Promise<any> {
|
|
381
|
+
return new Promise(resolve => {
|
|
382
|
+
let data = '';
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
try {
|
|
385
|
+
process.stdin.destroy();
|
|
386
|
+
} catch {}
|
|
387
|
+
try {
|
|
388
|
+
resolve(JSON.parse(data || '{}'));
|
|
389
|
+
} catch {
|
|
390
|
+
resolve({});
|
|
391
|
+
}
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
process.stdin.setEncoding('utf8');
|
|
394
|
+
process.stdin.on('data', c => {
|
|
395
|
+
data += c;
|
|
396
|
+
});
|
|
397
|
+
process.stdin.on('end', () => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
try {
|
|
400
|
+
resolve(JSON.parse(data || '{}'));
|
|
401
|
+
} catch {
|
|
402
|
+
resolve({});
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
process.stdin.on('error', () => {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
resolve({});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Resolve current git branch via spawnSync (no shell, no injection). */
|
|
413
|
+
export function getGitBranch(projectDir: string): string | undefined {
|
|
414
|
+
try {
|
|
415
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
416
|
+
cwd: projectDir,
|
|
417
|
+
encoding: 'utf8',
|
|
418
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
419
|
+
timeout: 2000,
|
|
420
|
+
});
|
|
421
|
+
if (r.status !== 0) return undefined;
|
|
422
|
+
const out = (r.stdout || '').toString().trim();
|
|
423
|
+
return out || undefined;
|
|
424
|
+
} catch {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Format a peer session for systemMessage embedding.
|
|
431
|
+
*/
|
|
432
|
+
export function formatPeer(peer: SessionRecord): string {
|
|
433
|
+
const idle = ageMs(peer.lastSeenAt);
|
|
434
|
+
const klass = classifyAge(idle);
|
|
435
|
+
const idleSec = Math.round(idle / 1000);
|
|
436
|
+
const idleStr =
|
|
437
|
+
idleSec < 90
|
|
438
|
+
? `${idleSec}s`
|
|
439
|
+
: idleSec < 60 * 60
|
|
440
|
+
? `${Math.round(idleSec / 60)}min`
|
|
441
|
+
: `${Math.round(idleSec / 3600)}h`;
|
|
442
|
+
const branch = peer.gitBranch ? ` @${peer.gitBranch}` : '';
|
|
443
|
+
return `[${klass}] ${shortId(peer.sessionId)} "${peer.title}"${branch} idle=${idleStr}`;
|
|
444
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `peers` — CLI for the multi-instance coordination layer.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* peers list List active + idle peer sessions.
|
|
7
|
+
* peers notify <id-prefix-or-substring> "msg"
|
|
8
|
+
* Queue a message for that peer.
|
|
9
|
+
* peers locks [--minutes 10] Show files touched in the window.
|
|
10
|
+
* peers cleanup Remove stale sessions (>1h idle).
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 success (and at least one peer for `list`)
|
|
14
|
+
* 1 argument / state error
|
|
15
|
+
* 2 no peers found (for `list`)
|
|
16
|
+
*
|
|
17
|
+
* Run:
|
|
18
|
+
* npx tsx .claude/hooks/peers.ts <command>
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'fs';
|
|
22
|
+
import { join, basename } from 'path';
|
|
23
|
+
import {
|
|
24
|
+
ACTIVE_MS,
|
|
25
|
+
IDLE_MS,
|
|
26
|
+
STALE_MS,
|
|
27
|
+
ageMs,
|
|
28
|
+
appendInbox,
|
|
29
|
+
classifyAge,
|
|
30
|
+
ensureStateDirs,
|
|
31
|
+
formatPeer,
|
|
32
|
+
getProjectDir,
|
|
33
|
+
getStateDir,
|
|
34
|
+
listSessionFiles,
|
|
35
|
+
nowIso,
|
|
36
|
+
readJsonSafe,
|
|
37
|
+
shortId,
|
|
38
|
+
tailFileTouches,
|
|
39
|
+
type SessionRecord,
|
|
40
|
+
} from './_state.js';
|
|
41
|
+
|
|
42
|
+
function parseFlag(args: string[], flag: string, fallback?: string): string | undefined {
|
|
43
|
+
const i = args.indexOf(flag);
|
|
44
|
+
if (i === -1) return fallback;
|
|
45
|
+
return args[i + 1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadSessions(stateDir: string): SessionRecord[] {
|
|
49
|
+
const out: SessionRecord[] = [];
|
|
50
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
51
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
52
|
+
if (rec) out.push(rec);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findCurrentSessionId(stateDir: string): string | null {
|
|
58
|
+
const fromEnv = process.env['CLAUDE_SESSION_ID'];
|
|
59
|
+
if (fromEnv) return fromEnv;
|
|
60
|
+
const sessions = loadSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < ACTIVE_MS);
|
|
61
|
+
if (sessions.length === 1) return sessions[0]!.sessionId;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cmdList(stateDir: string): number {
|
|
66
|
+
const all = loadSessions(stateDir);
|
|
67
|
+
const fresh = all.filter(s => ageMs(s.lastSeenAt) < IDLE_MS);
|
|
68
|
+
if (fresh.length === 0) {
|
|
69
|
+
console.log('No peer sessions in this project.');
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
fresh.sort((a, b) => ageMs(a.lastSeenAt) - ageMs(b.lastSeenAt));
|
|
73
|
+
console.log(`Sessions registered for ${getProjectDir()}:`);
|
|
74
|
+
for (const s of fresh) console.log(` ${formatPeer(s)}`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cmdNotify(stateDir: string, args: string[]): number {
|
|
79
|
+
if (args.length < 2) {
|
|
80
|
+
console.error('Usage: peers notify <id-prefix-or-title-substring> "<message>"');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const target = args[0]!;
|
|
84
|
+
const message = args.slice(1).join(' ');
|
|
85
|
+
if (!message.trim()) {
|
|
86
|
+
console.error('Empty message — nothing to send.');
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const all = loadSessions(stateDir).filter(s => ageMs(s.lastSeenAt) < STALE_MS);
|
|
91
|
+
const matches = all.filter(s => {
|
|
92
|
+
if (s.sessionId.startsWith(target)) return true;
|
|
93
|
+
if (s.title && s.title.toLowerCase().includes(target.toLowerCase())) return true;
|
|
94
|
+
return false;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
console.error(`No peer session matches "${target}". Run \`peers list\`.`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (matches.length > 1) {
|
|
103
|
+
console.error(`Ambiguous target "${target}" — ${matches.length} matches:`);
|
|
104
|
+
for (const m of matches) console.error(` - ${formatPeer(m)}`);
|
|
105
|
+
console.error('Use a longer id prefix to disambiguate.');
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const target_ = matches[0]!;
|
|
110
|
+
const fromSessionId = findCurrentSessionId(stateDir) || 'cli';
|
|
111
|
+
const fromTitle = loadSessions(stateDir).find(s => s.sessionId === fromSessionId)?.title;
|
|
112
|
+
|
|
113
|
+
appendInbox(stateDir, target_.sessionId, {
|
|
114
|
+
ts: nowIso(),
|
|
115
|
+
fromSessionId,
|
|
116
|
+
fromTitle,
|
|
117
|
+
message,
|
|
118
|
+
});
|
|
119
|
+
console.log(`Queued message for ${shortId(target_.sessionId)} "${target_.title}".`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cmdLocks(stateDir: string, args: string[]): number {
|
|
124
|
+
const minutes = Number(parseFlag(args, '--minutes', '10') || '10');
|
|
125
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
126
|
+
console.error('--minutes must be a positive number.');
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const windowMs = minutes * 60 * 1000;
|
|
130
|
+
const touches = tailFileTouches(stateDir, 1000).filter(t => ageMs(t.ts) <= windowMs);
|
|
131
|
+
if (touches.length === 0) {
|
|
132
|
+
console.log(`No file touches in the last ${minutes}min.`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sessions = loadSessions(stateDir);
|
|
137
|
+
const sessById = new Map(sessions.map(s => [s.sessionId, s]));
|
|
138
|
+
|
|
139
|
+
type Row = { file: string; sessionId: string; ts: string; tool: string };
|
|
140
|
+
const byFile = new Map<string, Row>();
|
|
141
|
+
for (const t of touches) {
|
|
142
|
+
const prev = byFile.get(t.file);
|
|
143
|
+
if (!prev || Date.parse(t.ts) > Date.parse(prev.ts)) {
|
|
144
|
+
byFile.set(t.file, { file: t.file, sessionId: t.sessionId, ts: t.ts, tool: t.tool });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rows = [...byFile.values()].sort(
|
|
149
|
+
(a, b) => Date.parse(b.ts) - Date.parse(a.ts)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
console.log(`File touches in the last ${minutes}min (most-recent first):`);
|
|
153
|
+
for (const r of rows) {
|
|
154
|
+
const sess = sessById.get(r.sessionId);
|
|
155
|
+
const klass = sess ? classifyAge(ageMs(sess.lastSeenAt)) : 'gone';
|
|
156
|
+
const who = sess ? `${shortId(sess.sessionId)} "${sess.title}"` : `${shortId(r.sessionId)}(gone)`;
|
|
157
|
+
const ageSec = Math.round(ageMs(r.ts) / 1000);
|
|
158
|
+
console.log(` ${r.file} <- ${who} [${klass}] via ${r.tool}, ${ageSec}s ago`);
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cmdCleanup(stateDir: string): number {
|
|
164
|
+
ensureStateDirs(stateDir);
|
|
165
|
+
let archived = 0;
|
|
166
|
+
let removed = 0;
|
|
167
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
168
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
169
|
+
if (!rec) continue;
|
|
170
|
+
const age = ageMs(rec.lastSeenAt);
|
|
171
|
+
if (age > STALE_MS) {
|
|
172
|
+
try {
|
|
173
|
+
rmSync(file);
|
|
174
|
+
removed++;
|
|
175
|
+
} catch {}
|
|
176
|
+
} else if (age > IDLE_MS) {
|
|
177
|
+
try {
|
|
178
|
+
const dest = join(stateDir, 'sessions', '_archive', basename(file));
|
|
179
|
+
renameSync(file, dest);
|
|
180
|
+
archived++;
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Drop empty inbox files older than 7 days.
|
|
186
|
+
const inboxDir = join(stateDir, 'inbox');
|
|
187
|
+
if (existsSync(inboxDir)) {
|
|
188
|
+
try {
|
|
189
|
+
for (const f of readdirSync(inboxDir)) {
|
|
190
|
+
const p = join(inboxDir, f);
|
|
191
|
+
try {
|
|
192
|
+
const s = statSync(p);
|
|
193
|
+
if (s.size === 0 && Date.now() - s.mtimeMs > 7 * 24 * 60 * 60 * 1000) {
|
|
194
|
+
rmSync(p);
|
|
195
|
+
} else if (s.size > 0) {
|
|
196
|
+
const lines = readFileSync(p, 'utf8').split('\n').filter(Boolean);
|
|
197
|
+
if (lines.length === 0) rmSync(p);
|
|
198
|
+
}
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(`Cleanup done. Archived: ${archived}. Removed: ${removed}.`);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function usage(): void {
|
|
209
|
+
console.log(`peers — multi-instance coordination CLI
|
|
210
|
+
|
|
211
|
+
Commands:
|
|
212
|
+
peers list
|
|
213
|
+
peers notify <id-prefix-or-title-substring> "<message>"
|
|
214
|
+
peers locks [--minutes 10]
|
|
215
|
+
peers cleanup
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function main(): void {
|
|
220
|
+
const [, , cmd, ...rest] = process.argv;
|
|
221
|
+
const stateDir = getStateDir();
|
|
222
|
+
|
|
223
|
+
switch (cmd) {
|
|
224
|
+
case 'list':
|
|
225
|
+
process.exit(cmdList(stateDir));
|
|
226
|
+
break;
|
|
227
|
+
case 'notify':
|
|
228
|
+
process.exit(cmdNotify(stateDir, rest));
|
|
229
|
+
break;
|
|
230
|
+
case 'locks':
|
|
231
|
+
process.exit(cmdLocks(stateDir, rest));
|
|
232
|
+
break;
|
|
233
|
+
case 'cleanup':
|
|
234
|
+
process.exit(cmdCleanup(stateDir));
|
|
235
|
+
break;
|
|
236
|
+
case 'help':
|
|
237
|
+
case '--help':
|
|
238
|
+
case '-h':
|
|
239
|
+
case undefined:
|
|
240
|
+
usage();
|
|
241
|
+
process.exit(0);
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
console.error(`Unknown command: ${cmd}`);
|
|
245
|
+
usage();
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main();
|