start-vibing-stacks 2.22.0 → 2.24.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 +55 -8
- package/dist/index.js +3 -1
- package/dist/migrate.d.ts +33 -11
- package/dist/migrate.js +297 -42
- 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 +445 -0
- package/stacks/_shared/hooks/final-check.ts +1 -0
- package/stacks/_shared/hooks/peers.ts +251 -0
- package/stacks/_shared/hooks/post-tool-use.ts +80 -0
- package/stacks/_shared/hooks/pre-tool-use.ts +176 -0
- package/stacks/_shared/hooks/run-hook.ts +1 -0
- package/stacks/_shared/hooks/session-start.ts +125 -0
- package/stacks/_shared/hooks/stop-validator.ts +38 -0
- package/stacks/_shared/hooks/user-prompt-submit.ts +97 -19
- package/stacks/_shared/skills/multi-instance-coordination/SKILL.md +90 -0
package/dist/setup.js
CHANGED
|
@@ -68,6 +68,10 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
68
68
|
mkdirSync(join(claudeDir, 'commands'), { recursive: true });
|
|
69
69
|
mkdirSync(join(claudeDir, 'rules'), { recursive: true });
|
|
70
70
|
mkdirSync(join(claudeDir, 'skills', 'codebase-knowledge', 'domains'), { recursive: true });
|
|
71
|
+
// Multi-instance coordination state (gitignored, runtime-managed)
|
|
72
|
+
mkdirSync(join(claudeDir, 'state', 'sessions', '_archive'), { recursive: true });
|
|
73
|
+
mkdirSync(join(claudeDir, 'state', 'inbox'), { recursive: true });
|
|
74
|
+
mkdirSync(join(claudeDir, 'state', 'file-touches', '_archive'), { recursive: true });
|
|
71
75
|
spinner.text = 'Directory structure created';
|
|
72
76
|
// 2. Copy shared agents (universal)
|
|
73
77
|
const sharedAgentsDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'agents');
|
|
@@ -79,6 +83,12 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
79
83
|
// 4. Copy shared hooks
|
|
80
84
|
const sharedHooksDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'hooks');
|
|
81
85
|
const hookCount = copyDirRecursive(sharedHooksDir, join(claudeDir, 'hooks'), options.force);
|
|
86
|
+
// 4b. Multi-instance coordination README in state dir
|
|
87
|
+
const stateReadmeSrc = join(PACKAGE_ROOT, 'stacks', '_shared', 'hooks', '_state.README.md');
|
|
88
|
+
const stateReadmeDest = join(claudeDir, 'state', 'README.md');
|
|
89
|
+
if (existsSync(stateReadmeSrc) && (!existsSync(stateReadmeDest) || options.force)) {
|
|
90
|
+
writeFileSync(stateReadmeDest, readFileSync(stateReadmeSrc, 'utf8'));
|
|
91
|
+
}
|
|
82
92
|
// 5. Copy shared config
|
|
83
93
|
const sharedConfigDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'config');
|
|
84
94
|
copyDirRecursive(sharedConfigDir, join(claudeDir, 'config'), options.force);
|
|
@@ -140,13 +150,21 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
140
150
|
spinner.text = 'Imported .cursorrules into Claude config';
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
|
-
// 11b. Ensure CLAUDE.local.md
|
|
153
|
+
// 11b. Ensure CLAUDE.local.md and .claude/state/ are gitignored
|
|
144
154
|
const gitignorePath = join(projectDir, '.gitignore');
|
|
145
155
|
if (existsSync(gitignorePath)) {
|
|
146
|
-
|
|
156
|
+
let gitignore = readFileSync(gitignorePath, 'utf8');
|
|
157
|
+
let changed = false;
|
|
147
158
|
if (!gitignore.includes('CLAUDE.local.md')) {
|
|
148
|
-
|
|
159
|
+
gitignore = gitignore.trimEnd() + '\n\n# Claude Code local preferences\nCLAUDE.local.md\n';
|
|
160
|
+
changed = true;
|
|
161
|
+
}
|
|
162
|
+
if (!gitignore.match(/^\.claude\/state\/?$/m) && !gitignore.match(/^\.claude\/?$/m)) {
|
|
163
|
+
gitignore = gitignore.trimEnd() + '\n\n# Claude Code multi-instance coordination state (runtime, per-host)\n.claude/state/\n';
|
|
164
|
+
changed = true;
|
|
149
165
|
}
|
|
166
|
+
if (changed)
|
|
167
|
+
writeFileSync(gitignorePath, gitignore);
|
|
150
168
|
}
|
|
151
169
|
// 11c. Save standards review results
|
|
152
170
|
if (config.standardsReview) {
|
|
@@ -230,6 +248,17 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
230
248
|
deny: [],
|
|
231
249
|
},
|
|
232
250
|
hooks: {
|
|
251
|
+
SessionStart: [
|
|
252
|
+
{
|
|
253
|
+
hooks: [
|
|
254
|
+
{
|
|
255
|
+
type: 'command',
|
|
256
|
+
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.ts"',
|
|
257
|
+
timeout: 10,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
233
262
|
UserPromptSubmit: [
|
|
234
263
|
{
|
|
235
264
|
matcher: '',
|
|
@@ -242,6 +271,30 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
242
271
|
],
|
|
243
272
|
},
|
|
244
273
|
],
|
|
274
|
+
PreToolUse: [
|
|
275
|
+
{
|
|
276
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
277
|
+
hooks: [
|
|
278
|
+
{
|
|
279
|
+
type: 'command',
|
|
280
|
+
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use.ts"',
|
|
281
|
+
timeout: 5,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
PostToolUse: [
|
|
287
|
+
{
|
|
288
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
289
|
+
hooks: [
|
|
290
|
+
{
|
|
291
|
+
type: 'command',
|
|
292
|
+
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use.ts"',
|
|
293
|
+
timeout: 5,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
245
298
|
Stop: [
|
|
246
299
|
{
|
|
247
300
|
hooks: [
|
|
@@ -253,6 +306,17 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
253
306
|
],
|
|
254
307
|
},
|
|
255
308
|
],
|
|
309
|
+
SessionEnd: [
|
|
310
|
+
{
|
|
311
|
+
hooks: [
|
|
312
|
+
{
|
|
313
|
+
type: 'command',
|
|
314
|
+
command: 'npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-validator.ts"',
|
|
315
|
+
timeout: 10,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
],
|
|
256
320
|
},
|
|
257
321
|
agents: {
|
|
258
322
|
'research-web': {
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: peers
|
|
3
|
+
description: Inspect or talk to other Claude Code instances running in this same project (multi-instance coordination layer).
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /peers — Multi-Instance Coordination
|
|
8
|
+
|
|
9
|
+
Use this when the user asks something like:
|
|
10
|
+
|
|
11
|
+
- "is there another Claude running in this folder?"
|
|
12
|
+
- "tell the other instance I finished the auth work"
|
|
13
|
+
- "who touched `src/x.ts` recently?"
|
|
14
|
+
- "clean up dead sessions"
|
|
15
|
+
|
|
16
|
+
Run the `peers` CLI via Bash. The script lives at `.claude/hooks/peers.ts` and is installed by `start-vibing-stacks`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
| Goal | Command |
|
|
21
|
+
|---|---|
|
|
22
|
+
| List active + idle peer sessions | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" list` |
|
|
23
|
+
| Notify another instance | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" notify <id-prefix> "message"` |
|
|
24
|
+
| See who touched what file recently | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" locks --minutes 10` |
|
|
25
|
+
| Remove stale sessions | `npx tsx "$CLAUDE_PROJECT_DIR/.claude/hooks/peers.ts" cleanup` |
|
|
26
|
+
|
|
27
|
+
The `<id-prefix>` is the 8-character short id printed by `peers list`, or any substring of the peer's title.
|
|
28
|
+
|
|
29
|
+
## Output rules
|
|
30
|
+
|
|
31
|
+
- Always quote the short id (first 8 chars), the title, and the idle time when reporting peers to the user.
|
|
32
|
+
- If `peers notify` succeeds, tell the user the message will surface in the OTHER instance at the start of its next prompt (drained by `user-prompt-submit.ts`).
|
|
33
|
+
- If `peers list` says "No peer sessions", confirm that to the user and DO NOT suggest workarounds.
|
|
34
|
+
|
|
35
|
+
## Background
|
|
36
|
+
|
|
37
|
+
State lives in `.claude/state/` (gitignored). Hooks `session-start.ts`, `user-prompt-submit.ts`, `pre-tool-use.ts`, `post-tool-use.ts` and `stop-validator.ts` keep it in sync. See `multi-instance-coordination` skill for the full protocol.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# `.claude/state/` — Multi-Instance Coordination
|
|
2
|
+
|
|
3
|
+
> This directory is **gitignored** and managed automatically by hooks. Safe to delete; it will be recreated by the next Claude session.
|
|
4
|
+
|
|
5
|
+
When two or more Claude Code instances run in the same project folder, the hooks under `.claude/hooks/` use this directory to:
|
|
6
|
+
|
|
7
|
+
1. announce themselves to each other (`sessions/<id>.json`)
|
|
8
|
+
2. record file edits as they happen (`file-touches.jsonl`)
|
|
9
|
+
3. prevent simultaneous writes to the same file (PreToolUse hook reads the touch log)
|
|
10
|
+
4. exchange messages (`inbox/<id>.jsonl` — drained at next prompt)
|
|
11
|
+
|
|
12
|
+
## Layout
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
.claude/state/
|
|
16
|
+
sessions/
|
|
17
|
+
<session-id>.json one record per active instance (heartbeat-tracked)
|
|
18
|
+
_archive/ sessions ended or stale (>30min idle)
|
|
19
|
+
inbox/
|
|
20
|
+
<session-id>.jsonl queued messages for that session
|
|
21
|
+
file-touches.jsonl append-only Edit/Write log (rotates every 1000 lines)
|
|
22
|
+
file-touches/_archive/ rotated logs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Heartbeat thresholds
|
|
26
|
+
|
|
27
|
+
| Last activity | State | Effect |
|
|
28
|
+
| ------------- | -------- | --------------------------------------------------------------------------- |
|
|
29
|
+
| < 60s | active | Counts for collision detection. PreToolUse may **block** Edit/Write. |
|
|
30
|
+
| 60s – 30min | idle | Surfaced as a warning in `systemMessage`. Edits are **not** blocked. |
|
|
31
|
+
| > 30min | stale | Auto-archived on the next sweep. |
|
|
32
|
+
| > 24h | removed | Deleted entirely. |
|
|
33
|
+
|
|
34
|
+
## Viewing peers
|
|
35
|
+
|
|
36
|
+
From inside Claude or a terminal:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/peers
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or directly:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
npx tsx .claude/hooks/peers.ts list
|
|
46
|
+
npx tsx .claude/hooks/peers.ts notify <id-prefix> "message"
|
|
47
|
+
npx tsx .claude/hooks/peers.ts locks --minutes 10
|
|
48
|
+
npx tsx .claude/hooks/peers.ts cleanup
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Safety
|
|
52
|
+
|
|
53
|
+
- All state writes are atomic (`.tmp` + `rename`).
|
|
54
|
+
- All reads are tolerant of corruption — hooks never block Claude on a malformed file.
|
|
55
|
+
- This is a single-host coordination layer. It does **not** replace git; it only prevents two live sessions from stepping on the same uncommitted edit.
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// @sv-version: 1.0.0
|
|
2
|
+
/**
|
|
3
|
+
* Multi-Instance Coordination — Shared State Library
|
|
4
|
+
*
|
|
5
|
+
* Used by session-start.ts, user-prompt-submit.ts, pre-tool-use.ts,
|
|
6
|
+
* post-tool-use.ts, stop-validator.ts and peers.ts.
|
|
7
|
+
*
|
|
8
|
+
* Layout (project-local, gitignored):
|
|
9
|
+
* .claude/state/
|
|
10
|
+
* sessions/<id>.json one record per active Claude instance
|
|
11
|
+
* sessions/_archive/ sessions ended or stale
|
|
12
|
+
* inbox/<id>.jsonl messages queued for that session
|
|
13
|
+
* file-touches.jsonl append-only edit log
|
|
14
|
+
* file-touches/_archive/ rotated logs
|
|
15
|
+
*
|
|
16
|
+
* Design rules:
|
|
17
|
+
* - All writes are atomic (.tmp + rename) where the file is read by peers.
|
|
18
|
+
* - Every read tolerates corruption (try/catch) — hooks must NEVER block Claude.
|
|
19
|
+
* - Heartbeat thresholds: <60s active, 60s-30min idle, >30min stale, >24h removed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
statSync,
|
|
31
|
+
appendFileSync,
|
|
32
|
+
openSync,
|
|
33
|
+
readSync,
|
|
34
|
+
closeSync,
|
|
35
|
+
} from 'fs';
|
|
36
|
+
import { join, basename } from 'path';
|
|
37
|
+
import { randomBytes } from 'crypto';
|
|
38
|
+
import { spawnSync } from 'child_process';
|
|
39
|
+
|
|
40
|
+
export const ACTIVE_MS = 60 * 1000;
|
|
41
|
+
export const IDLE_MS = 30 * 60 * 1000;
|
|
42
|
+
export const STALE_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
export const COLLISION_WINDOW_MS = 5 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
export const TOUCHES_ROTATE_THRESHOLD = 1000;
|
|
46
|
+
export const TOUCHES_TAIL_LINES = 200;
|
|
47
|
+
export const FILES_TOUCHED_CAP = 50;
|
|
48
|
+
|
|
49
|
+
export interface SessionRecord {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
transcriptPath?: string;
|
|
52
|
+
title: string;
|
|
53
|
+
cwd: string;
|
|
54
|
+
ppid: number;
|
|
55
|
+
gitBranch?: string;
|
|
56
|
+
startedAt: string;
|
|
57
|
+
lastSeenAt: string;
|
|
58
|
+
lastActivity: string;
|
|
59
|
+
filesTouched: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FileTouch {
|
|
63
|
+
ts: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
tool: string;
|
|
66
|
+
file: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InboxMessage {
|
|
70
|
+
ts: string;
|
|
71
|
+
fromSessionId: string;
|
|
72
|
+
fromTitle?: string;
|
|
73
|
+
message: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getProjectDir(): string {
|
|
77
|
+
return process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getStateDir(projectDir: string = getProjectDir()): string {
|
|
81
|
+
return join(projectDir, '.claude', 'state');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ensureStateDirs(stateDir: string = getStateDir()): void {
|
|
85
|
+
mkdirSync(join(stateDir, 'sessions', '_archive'), { recursive: true });
|
|
86
|
+
mkdirSync(join(stateDir, 'inbox'), { recursive: true });
|
|
87
|
+
mkdirSync(join(stateDir, 'file-touches', '_archive'), { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function nowIso(): string {
|
|
91
|
+
return new Date().toISOString();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ageMs(iso: string): number {
|
|
95
|
+
const t = Date.parse(iso);
|
|
96
|
+
if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
|
|
97
|
+
return Date.now() - t;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Atomic write: tmp + rename. Survives concurrent writers; the last rename wins. */
|
|
101
|
+
export function writeFileAtomic(target: string, contents: string): void {
|
|
102
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
103
|
+
writeFileSync(tmp, contents);
|
|
104
|
+
renameSync(tmp, target);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read JSON; returns null on any error. */
|
|
108
|
+
export function readJsonSafe<T>(path: string): T | null {
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(path)) return null;
|
|
111
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getSessionPath(stateDir: string, sessionId: string): string {
|
|
118
|
+
return join(stateDir, 'sessions', `${sessionId}.json`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function readSession(stateDir: string, sessionId: string): SessionRecord | null {
|
|
122
|
+
return readJsonSafe<SessionRecord>(getSessionPath(stateDir, sessionId));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function writeSession(stateDir: string, session: SessionRecord): void {
|
|
126
|
+
ensureStateDirs(stateDir);
|
|
127
|
+
writeFileAtomic(getSessionPath(stateDir, session.sessionId), JSON.stringify(session, null, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listSessionFiles(stateDir: string): string[] {
|
|
131
|
+
const dir = join(stateDir, 'sessions');
|
|
132
|
+
if (!existsSync(dir)) return [];
|
|
133
|
+
try {
|
|
134
|
+
return readdirSync(dir)
|
|
135
|
+
.filter(f => f.endsWith('.json'))
|
|
136
|
+
.map(f => join(dir, f));
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* List sessions excluding the caller's own. Auto-archives stale (>30min) and
|
|
144
|
+
* removes very old (>24h) records as a side effect.
|
|
145
|
+
*/
|
|
146
|
+
export function listPeerSessions(
|
|
147
|
+
stateDir: string,
|
|
148
|
+
currentSessionId: string | null
|
|
149
|
+
): SessionRecord[] {
|
|
150
|
+
const peers: SessionRecord[] = [];
|
|
151
|
+
for (const file of listSessionFiles(stateDir)) {
|
|
152
|
+
const rec = readJsonSafe<SessionRecord>(file);
|
|
153
|
+
if (!rec) continue;
|
|
154
|
+
if (rec.sessionId === currentSessionId) continue;
|
|
155
|
+
|
|
156
|
+
const age = ageMs(rec.lastSeenAt);
|
|
157
|
+
if (age > STALE_MS) {
|
|
158
|
+
try {
|
|
159
|
+
rmSync(file);
|
|
160
|
+
} catch {}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (age > IDLE_MS) {
|
|
164
|
+
archiveSessionFile(stateDir, file);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
peers.push(rec);
|
|
168
|
+
}
|
|
169
|
+
return peers;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function archiveSessionFile(stateDir: string, sessionFile: string): void {
|
|
173
|
+
try {
|
|
174
|
+
const dest = join(stateDir, 'sessions', '_archive', basename(sessionFile));
|
|
175
|
+
renameSync(sessionFile, dest);
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function archiveSession(stateDir: string, sessionId: string): void {
|
|
180
|
+
const file = getSessionPath(stateDir, sessionId);
|
|
181
|
+
if (existsSync(file)) archiveSessionFile(stateDir, file);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Update lastSeenAt + lastActivity for the current session. Idempotent. */
|
|
185
|
+
export function heartbeat(
|
|
186
|
+
stateDir: string,
|
|
187
|
+
sessionId: string,
|
|
188
|
+
activity: string,
|
|
189
|
+
patch: Partial<SessionRecord> = {}
|
|
190
|
+
): SessionRecord {
|
|
191
|
+
const existing = readSession(stateDir, sessionId);
|
|
192
|
+
const now = nowIso();
|
|
193
|
+
const merged: SessionRecord = existing
|
|
194
|
+
? { ...existing, ...patch, lastSeenAt: now, lastActivity: activity }
|
|
195
|
+
: {
|
|
196
|
+
sessionId,
|
|
197
|
+
title: patch.title || '(untitled)',
|
|
198
|
+
cwd: patch.cwd || getProjectDir(),
|
|
199
|
+
ppid: patch.ppid ?? process.ppid ?? 0,
|
|
200
|
+
gitBranch: patch.gitBranch,
|
|
201
|
+
transcriptPath: patch.transcriptPath,
|
|
202
|
+
startedAt: now,
|
|
203
|
+
lastSeenAt: now,
|
|
204
|
+
lastActivity: activity,
|
|
205
|
+
filesTouched: patch.filesTouched || [],
|
|
206
|
+
};
|
|
207
|
+
writeSession(stateDir, merged);
|
|
208
|
+
return merged;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function recordFileTouch(stateDir: string, touch: FileTouch): void {
|
|
212
|
+
ensureStateDirs(stateDir);
|
|
213
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
214
|
+
try {
|
|
215
|
+
appendFileSync(path, JSON.stringify(touch) + '\n');
|
|
216
|
+
rotateFileTouchesIfNeeded(stateDir);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function rotateFileTouchesIfNeeded(stateDir: string): void {
|
|
221
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
222
|
+
if (!existsSync(path)) return;
|
|
223
|
+
try {
|
|
224
|
+
const size = statSync(path).size;
|
|
225
|
+
if (size < 200_000) return;
|
|
226
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
227
|
+
if (lines.length < TOUCHES_ROTATE_THRESHOLD) return;
|
|
228
|
+
const archiveName = `file-touches-${Date.now()}.jsonl`;
|
|
229
|
+
const archivePath = join(stateDir, 'file-touches', '_archive', archiveName);
|
|
230
|
+
writeFileSync(archivePath, lines.slice(0, lines.length - TOUCHES_TAIL_LINES).join('\n') + '\n');
|
|
231
|
+
writeFileSync(path, lines.slice(-TOUCHES_TAIL_LINES).join('\n') + '\n');
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Read the last N lines of file-touches.jsonl (cheap tail, no full read). */
|
|
236
|
+
export function tailFileTouches(stateDir: string, lines = TOUCHES_TAIL_LINES): FileTouch[] {
|
|
237
|
+
const path = join(stateDir, 'file-touches.jsonl');
|
|
238
|
+
if (!existsSync(path)) return [];
|
|
239
|
+
try {
|
|
240
|
+
const size = statSync(path).size;
|
|
241
|
+
const readBytes = Math.min(size, 64 * 1024);
|
|
242
|
+
const fd = openSync(path, 'r');
|
|
243
|
+
const buf = Buffer.alloc(readBytes);
|
|
244
|
+
readSync(fd, buf, 0, readBytes, size - readBytes);
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
const text = buf.toString('utf8');
|
|
247
|
+
const all = text.split('\n').filter(Boolean);
|
|
248
|
+
const slice = all.slice(-lines);
|
|
249
|
+
const out: FileTouch[] = [];
|
|
250
|
+
for (const line of slice) {
|
|
251
|
+
try {
|
|
252
|
+
out.push(JSON.parse(line));
|
|
253
|
+
} catch {}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function getInboxPath(stateDir: string, sessionId: string): string {
|
|
262
|
+
return join(stateDir, 'inbox', `${sessionId}.jsonl`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function appendInbox(
|
|
266
|
+
stateDir: string,
|
|
267
|
+
targetSessionId: string,
|
|
268
|
+
msg: InboxMessage
|
|
269
|
+
): void {
|
|
270
|
+
ensureStateDirs(stateDir);
|
|
271
|
+
try {
|
|
272
|
+
appendFileSync(getInboxPath(stateDir, targetSessionId), JSON.stringify(msg) + '\n');
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Read all queued messages and remove the inbox file. */
|
|
277
|
+
export function drainInbox(stateDir: string, sessionId: string): InboxMessage[] {
|
|
278
|
+
const path = getInboxPath(stateDir, sessionId);
|
|
279
|
+
if (!existsSync(path)) return [];
|
|
280
|
+
try {
|
|
281
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
282
|
+
rmSync(path);
|
|
283
|
+
const out: InboxMessage[] = [];
|
|
284
|
+
for (const line of lines) {
|
|
285
|
+
try {
|
|
286
|
+
out.push(JSON.parse(line));
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
} catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function clearInbox(stateDir: string, sessionId: string): void {
|
|
296
|
+
const path = getInboxPath(stateDir, sessionId);
|
|
297
|
+
if (existsSync(path)) {
|
|
298
|
+
try {
|
|
299
|
+
rmSync(path);
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Read up to ~8KB of the transcript and extract a human title. */
|
|
305
|
+
export function extractTitle(transcriptPath?: string, fallbackPrompt?: string): string {
|
|
306
|
+
if (transcriptPath && existsSync(transcriptPath)) {
|
|
307
|
+
try {
|
|
308
|
+
const fd = openSync(transcriptPath, 'r');
|
|
309
|
+
const buf = Buffer.alloc(8192);
|
|
310
|
+
const bytes = readSync(fd, buf, 0, buf.length, 0);
|
|
311
|
+
closeSync(fd);
|
|
312
|
+
const text = buf.subarray(0, bytes).toString('utf8');
|
|
313
|
+
for (const line of text.split('\n')) {
|
|
314
|
+
if (!line.trim()) continue;
|
|
315
|
+
try {
|
|
316
|
+
const rec = JSON.parse(line);
|
|
317
|
+
if (typeof rec.summary === 'string' && rec.summary.trim()) {
|
|
318
|
+
return truncate(rec.summary.trim(), 80);
|
|
319
|
+
}
|
|
320
|
+
if (rec.type === 'user' && rec.message) {
|
|
321
|
+
const content =
|
|
322
|
+
typeof rec.message === 'string'
|
|
323
|
+
? rec.message
|
|
324
|
+
: typeof rec.message?.content === 'string'
|
|
325
|
+
? rec.message.content
|
|
326
|
+
: Array.isArray(rec.message?.content)
|
|
327
|
+
? rec.message.content
|
|
328
|
+
.filter((c: any) => c.type === 'text')
|
|
329
|
+
.map((c: any) => c.text)
|
|
330
|
+
.join(' ')
|
|
331
|
+
: '';
|
|
332
|
+
if (content) return truncate(content.replace(/\s+/g, ' ').trim(), 80);
|
|
333
|
+
}
|
|
334
|
+
} catch {}
|
|
335
|
+
}
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
if (fallbackPrompt) return truncate(fallbackPrompt.replace(/\s+/g, ' ').trim(), 80);
|
|
339
|
+
return '(untitled)';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function truncate(s: string, n: number): string {
|
|
343
|
+
if (s.length <= n) return s;
|
|
344
|
+
return s.slice(0, n - 1) + '…';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function shortId(sessionId: string): string {
|
|
348
|
+
return sessionId.slice(0, 8);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function classifyAge(ageMsec: number): 'active' | 'idle' | 'stale' {
|
|
352
|
+
if (ageMsec < ACTIVE_MS) return 'active';
|
|
353
|
+
if (ageMsec < IDLE_MS) return 'idle';
|
|
354
|
+
return 'stale';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Inspect tool input and return a list of file paths that will be touched.
|
|
359
|
+
* Returns paths relative to projectDir when possible, else absolute.
|
|
360
|
+
*/
|
|
361
|
+
export function extractTargetFiles(toolName: string, toolInput: any, projectDir: string): string[] {
|
|
362
|
+
if (!toolInput || typeof toolInput !== 'object') return [];
|
|
363
|
+
const out: string[] = [];
|
|
364
|
+
const push = (p: any) => {
|
|
365
|
+
if (typeof p !== 'string' || !p) return;
|
|
366
|
+
let rel = p;
|
|
367
|
+
if (p.startsWith(projectDir + '/')) rel = p.slice(projectDir.length + 1);
|
|
368
|
+
out.push(rel);
|
|
369
|
+
};
|
|
370
|
+
if (toolName === 'Edit' || toolName === 'Write') {
|
|
371
|
+
push(toolInput.file_path || toolInput.path);
|
|
372
|
+
} else if (toolName === 'MultiEdit') {
|
|
373
|
+
push(toolInput.file_path || toolInput.path);
|
|
374
|
+
} else if (toolName === 'NotebookEdit') {
|
|
375
|
+
push(toolInput.notebook_path || toolInput.target_notebook);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Read JSON from stdin with a timeout fallback. */
|
|
381
|
+
export async function readStdinJson(timeoutMs = 1500): Promise<any> {
|
|
382
|
+
return new Promise(resolve => {
|
|
383
|
+
let data = '';
|
|
384
|
+
const timer = setTimeout(() => {
|
|
385
|
+
try {
|
|
386
|
+
process.stdin.destroy();
|
|
387
|
+
} catch {}
|
|
388
|
+
try {
|
|
389
|
+
resolve(JSON.parse(data || '{}'));
|
|
390
|
+
} catch {
|
|
391
|
+
resolve({});
|
|
392
|
+
}
|
|
393
|
+
}, timeoutMs);
|
|
394
|
+
process.stdin.setEncoding('utf8');
|
|
395
|
+
process.stdin.on('data', c => {
|
|
396
|
+
data += c;
|
|
397
|
+
});
|
|
398
|
+
process.stdin.on('end', () => {
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
try {
|
|
401
|
+
resolve(JSON.parse(data || '{}'));
|
|
402
|
+
} catch {
|
|
403
|
+
resolve({});
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
process.stdin.on('error', () => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
resolve({});
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Resolve current git branch via spawnSync (no shell, no injection). */
|
|
414
|
+
export function getGitBranch(projectDir: string): string | undefined {
|
|
415
|
+
try {
|
|
416
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
417
|
+
cwd: projectDir,
|
|
418
|
+
encoding: 'utf8',
|
|
419
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
420
|
+
timeout: 2000,
|
|
421
|
+
});
|
|
422
|
+
if (r.status !== 0) return undefined;
|
|
423
|
+
const out = (r.stdout || '').toString().trim();
|
|
424
|
+
return out || undefined;
|
|
425
|
+
} catch {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format a peer session for systemMessage embedding.
|
|
432
|
+
*/
|
|
433
|
+
export function formatPeer(peer: SessionRecord): string {
|
|
434
|
+
const idle = ageMs(peer.lastSeenAt);
|
|
435
|
+
const klass = classifyAge(idle);
|
|
436
|
+
const idleSec = Math.round(idle / 1000);
|
|
437
|
+
const idleStr =
|
|
438
|
+
idleSec < 90
|
|
439
|
+
? `${idleSec}s`
|
|
440
|
+
: idleSec < 60 * 60
|
|
441
|
+
? `${Math.round(idleSec / 60)}min`
|
|
442
|
+
: `${Math.round(idleSec / 3600)}h`;
|
|
443
|
+
const branch = peer.gitBranch ? ` @${peer.gitBranch}` : '';
|
|
444
|
+
return `[${klass}] ${shortId(peer.sessionId)} "${peer.title}"${branch} idle=${idleStr}`;
|
|
445
|
+
}
|