gru-ai 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/.claude/skills/brainstorm/SKILL.md +340 -0
- package/.claude/skills/code-review-excellence/SKILL.md +198 -0
- package/.claude/skills/directive/SKILL.md +121 -0
- package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
- package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
- package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
- package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
- package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
- package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
- package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
- package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
- package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
- package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
- package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
- package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
- package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
- package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
- package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
- package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
- package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
- package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
- package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
- package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
- package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
- package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
- package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
- package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
- package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
- package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
- package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
- package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
- package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
- package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
- package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
- package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
- package/.claude/skills/frontend-design/SKILL.md +42 -0
- package/.claude/skills/gruai-agents/SKILL.md +161 -0
- package/.claude/skills/gruai-config/SKILL.md +61 -0
- package/.claude/skills/healthcheck/SKILL.md +216 -0
- package/.claude/skills/report/SKILL.md +380 -0
- package/.claude/skills/scout/SKILL.md +452 -0
- package/.claude/skills/seo-audit/SKILL.md +107 -0
- package/.claude/skills/walkthrough/SKILL.md +274 -0
- package/.claude/skills/webapp-testing/SKILL.md +96 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/cli/templates/CLAUDE.md.template +57 -0
- package/cli/templates/agent-roles/backend.md +47 -0
- package/cli/templates/agent-roles/cmo.md +52 -0
- package/cli/templates/agent-roles/content.md +48 -0
- package/cli/templates/agent-roles/coo.md +66 -0
- package/cli/templates/agent-roles/cpo.md +52 -0
- package/cli/templates/agent-roles/cto.md +63 -0
- package/cli/templates/agent-roles/data.md +46 -0
- package/cli/templates/agent-roles/design.md +46 -0
- package/cli/templates/agent-roles/frontend.md +47 -0
- package/cli/templates/agent-roles/fullstack.md +47 -0
- package/cli/templates/agent-roles/qa.md +46 -0
- package/cli/templates/backlog.json.template +3 -0
- package/cli/templates/directive.json.template +9 -0
- package/cli/templates/directive.md.template +23 -0
- package/cli/templates/goals-index.md +21 -0
- package/cli/templates/gruai.config.json.template +12 -0
- package/cli/templates/lessons.md +16 -0
- package/cli/templates/vision.md +35 -0
- package/cli/templates/welcome-directive/directive.json +9 -0
- package/cli/templates/welcome-directive/directive.md +53 -0
- package/dist/assets/GamePage-C5XQQOQH.js +49 -0
- package/dist/assets/README.md +17 -0
- package/dist/assets/characters/char_0.png +0 -0
- package/dist/assets/characters/char_1.png +0 -0
- package/dist/assets/characters/char_10.png +0 -0
- package/dist/assets/characters/char_11.png +0 -0
- package/dist/assets/characters/char_2.png +0 -0
- package/dist/assets/characters/char_3.png +0 -0
- package/dist/assets/characters/char_4.png +0 -0
- package/dist/assets/characters/char_5.png +0 -0
- package/dist/assets/characters/char_6.png +0 -0
- package/dist/assets/characters/char_7.png +0 -0
- package/dist/assets/characters/char_8.png +0 -0
- package/dist/assets/characters/char_9.png +0 -0
- package/dist/assets/index-CnTPDqpP.js +12 -0
- package/dist/assets/index-gR5q7ikB.css +1 -0
- package/dist/assets/office/furniture.png +0 -0
- package/dist/assets/office/room-builder.png +0 -0
- package/dist/index.html +16 -0
- package/dist-server/scripts/intelligence-trends.d.ts +100 -0
- package/dist-server/scripts/intelligence-trends.js +365 -0
- package/dist-server/server/actions/cleanup.d.ts +4 -0
- package/dist-server/server/actions/cleanup.js +30 -0
- package/dist-server/server/actions/send-input.d.ts +6 -0
- package/dist-server/server/actions/send-input.js +147 -0
- package/dist-server/server/actions/terminal.d.ts +4 -0
- package/dist-server/server/actions/terminal.js +427 -0
- package/dist-server/server/config.d.ts +9 -0
- package/dist-server/server/config.js +217 -0
- package/dist-server/server/db.d.ts +7 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/hooks/event-receiver.d.ts +11 -0
- package/dist-server/server/hooks/event-receiver.js +36 -0
- package/dist-server/server/index.d.ts +1 -0
- package/dist-server/server/index.js +552 -0
- package/dist-server/server/notifications/macos.d.ts +5 -0
- package/dist-server/server/notifications/macos.js +22 -0
- package/dist-server/server/notifications/notifier.d.ts +17 -0
- package/dist-server/server/notifications/notifier.js +110 -0
- package/dist-server/server/parsers/process-discovery.d.ts +39 -0
- package/dist-server/server/parsers/process-discovery.js +776 -0
- package/dist-server/server/parsers/session-scanner.d.ts +56 -0
- package/dist-server/server/parsers/session-scanner.js +390 -0
- package/dist-server/server/parsers/session-state.d.ts +68 -0
- package/dist-server/server/parsers/session-state.js +696 -0
- package/dist-server/server/parsers/session-state.test.d.ts +1 -0
- package/dist-server/server/parsers/session-state.test.js +950 -0
- package/dist-server/server/parsers/task-parser.d.ts +10 -0
- package/dist-server/server/parsers/task-parser.js +97 -0
- package/dist-server/server/parsers/team-parser.d.ts +3 -0
- package/dist-server/server/parsers/team-parser.js +67 -0
- package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
- package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
- package/dist-server/server/platform/claude-code.d.ts +34 -0
- package/dist-server/server/platform/claude-code.js +94 -0
- package/dist-server/server/platform/index.d.ts +5 -0
- package/dist-server/server/platform/index.js +1 -0
- package/dist-server/server/platform/types.d.ts +190 -0
- package/dist-server/server/platform/types.js +9 -0
- package/dist-server/server/state/aggregator.d.ts +42 -0
- package/dist-server/server/state/aggregator.js +1080 -0
- package/dist-server/server/state/work-item-types.d.ts +555 -0
- package/dist-server/server/state/work-item-types.js +168 -0
- package/dist-server/server/types.d.ts +237 -0
- package/dist-server/server/types.js +1 -0
- package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
- package/dist-server/server/watchers/claude-watcher.js +130 -0
- package/dist-server/server/watchers/context-watcher.d.ts +22 -0
- package/dist-server/server/watchers/context-watcher.js +125 -0
- package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
- package/dist-server/server/watchers/directive-watcher.js +497 -0
- package/dist-server/server/watchers/session-watcher.d.ts +18 -0
- package/dist-server/server/watchers/session-watcher.js +126 -0
- package/dist-server/server/watchers/state-watcher.d.ts +36 -0
- package/dist-server/server/watchers/state-watcher.js +369 -0
- package/package.json +68 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectLabel, extractInitialPrompt, extractLatestPrompt, cleanPromptText, isSystemContent, extractAgentIdentityFromFile, resolveAgentFromParent, resolveAgentFromSetting } from './session-scanner.js';
|
|
4
|
+
// --- Constants ---
|
|
5
|
+
const TAIL_SIZE = 65536;
|
|
6
|
+
const ACTIVE_WINDOW_MS = 300_000;
|
|
7
|
+
const TASKS_UUID_RE = /\.claude\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
8
|
+
// Tools that always require user action regardless of permission mode
|
|
9
|
+
const INPUT_REQUIRING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']);
|
|
10
|
+
// --- In-memory state ---
|
|
11
|
+
const fileStates = new Map();
|
|
12
|
+
// --- Public API ---
|
|
13
|
+
export function getFileState(filePath, stateMap) {
|
|
14
|
+
return (stateMap ?? fileStates).get(filePath);
|
|
15
|
+
}
|
|
16
|
+
export function getAllFileStates(stateMap) {
|
|
17
|
+
return stateMap ?? fileStates;
|
|
18
|
+
}
|
|
19
|
+
export function removeFileState(filePath, stateMap) {
|
|
20
|
+
(stateMap ?? fileStates).delete(filePath);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get or bootstrap state for a file. If not in the map, does a cold-start bootstrap.
|
|
24
|
+
*/
|
|
25
|
+
export function getOrBootstrap(filePath, stateMap) {
|
|
26
|
+
const map = stateMap ?? fileStates;
|
|
27
|
+
const existing = map.get(filePath);
|
|
28
|
+
if (existing)
|
|
29
|
+
return existing;
|
|
30
|
+
return bootstrapFromTail(filePath, map);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Cold start: read last 64KB, feed through state machine, set byteOffset = fileSize.
|
|
34
|
+
*/
|
|
35
|
+
export function bootstrapFromTail(filePath, stateMap) {
|
|
36
|
+
const map = stateMap ?? fileStates;
|
|
37
|
+
let fd = null;
|
|
38
|
+
try {
|
|
39
|
+
fd = fs.openSync(filePath, 'r');
|
|
40
|
+
const stat = fs.fstatSync(fd);
|
|
41
|
+
if (stat.size === 0) {
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const readSize = Math.min(TAIL_SIZE, stat.size);
|
|
46
|
+
const buffer = Buffer.allocUnsafe(readSize);
|
|
47
|
+
fs.readSync(fd, buffer, 0, readSize, stat.size - readSize);
|
|
48
|
+
fs.closeSync(fd);
|
|
49
|
+
fd = null;
|
|
50
|
+
const content = buffer.toString('utf-8');
|
|
51
|
+
const lines = content.split('\n');
|
|
52
|
+
// Discard first line (likely partial from mid-file read) unless we read the whole file
|
|
53
|
+
const startIdx = stat.size > TAIL_SIZE ? 1 : 0;
|
|
54
|
+
const state = {
|
|
55
|
+
byteOffset: stat.size,
|
|
56
|
+
mtimeMs: stat.mtimeMs,
|
|
57
|
+
fileSize: stat.size,
|
|
58
|
+
machineState: 'done',
|
|
59
|
+
toolUseCount: 0,
|
|
60
|
+
toolResultCount: 0,
|
|
61
|
+
pendingInputTool: false,
|
|
62
|
+
lastActivityAt: new Date(stat.mtimeMs).toISOString(),
|
|
63
|
+
messageCount: 0,
|
|
64
|
+
};
|
|
65
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i].trim();
|
|
67
|
+
if (!line)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const entry = JSON.parse(line);
|
|
71
|
+
processEntry(state, entry);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// skip malformed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Extract prompts (separate I/O reads for head + expanded tail)
|
|
78
|
+
state.initialPrompt = extractInitialPrompt(filePath);
|
|
79
|
+
state.latestPrompt = extractLatestPrompt(filePath);
|
|
80
|
+
// Extract named agent identity from initial prompt
|
|
81
|
+
const identity = extractAgentIdentityFromFile(filePath);
|
|
82
|
+
if (identity) {
|
|
83
|
+
state.agentName = identity.name;
|
|
84
|
+
state.agentRole = identity.role;
|
|
85
|
+
}
|
|
86
|
+
// Fallback: for subagent files without detected identity, cross-reference
|
|
87
|
+
// the parent session's Agent tool calls to find the subagent_type
|
|
88
|
+
if (!state.agentName) {
|
|
89
|
+
const subagentsIdx = filePath.indexOf('/subagents/');
|
|
90
|
+
if (subagentsIdx !== -1) {
|
|
91
|
+
// Extract parent session directory and derive parent JSONL path
|
|
92
|
+
const parentDir = filePath.slice(0, subagentsIdx);
|
|
93
|
+
const parentSessionId = path.basename(parentDir);
|
|
94
|
+
const parentJsonl = path.join(path.dirname(parentDir), `${parentSessionId}.jsonl`);
|
|
95
|
+
// Extract child agentId from filename (agent-{id}.jsonl)
|
|
96
|
+
const childFilename = path.basename(filePath);
|
|
97
|
+
const childAgentId = childFilename.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
98
|
+
const parentIdentity = resolveAgentFromParent(parentJsonl, childAgentId);
|
|
99
|
+
if (parentIdentity) {
|
|
100
|
+
state.agentName = parentIdentity.name;
|
|
101
|
+
state.agentRole = parentIdentity.role;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
map.set(filePath, state);
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
if (fd !== null) {
|
|
110
|
+
try {
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
catch { /* ignore */ }
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Main entry: read new bytes from file, feed through state machine, return updated state.
|
|
120
|
+
* Returns null if no new data.
|
|
121
|
+
*/
|
|
122
|
+
export function processFileUpdate(filePath, stateMap) {
|
|
123
|
+
const map = stateMap ?? fileStates;
|
|
124
|
+
const state = map.get(filePath);
|
|
125
|
+
// New file — bootstrap
|
|
126
|
+
if (!state) {
|
|
127
|
+
return bootstrapFromTail(filePath, map);
|
|
128
|
+
}
|
|
129
|
+
let stat;
|
|
130
|
+
try {
|
|
131
|
+
stat = fs.statSync(filePath);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// File gone
|
|
135
|
+
map.delete(filePath);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
// File truncated (e.g., recreated) — re-bootstrap
|
|
139
|
+
if (stat.size < state.byteOffset) {
|
|
140
|
+
map.delete(filePath);
|
|
141
|
+
return bootstrapFromTail(filePath, map);
|
|
142
|
+
}
|
|
143
|
+
// No new data
|
|
144
|
+
if (stat.size === state.byteOffset) {
|
|
145
|
+
// Update mtime even if no new bytes (touch)
|
|
146
|
+
state.mtimeMs = stat.mtimeMs;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
// Read new bytes
|
|
150
|
+
const entries = readNewEntries(filePath, state.byteOffset, stat.size);
|
|
151
|
+
if (!entries)
|
|
152
|
+
return null;
|
|
153
|
+
// Feed through state machine
|
|
154
|
+
for (const entry of entries.parsed) {
|
|
155
|
+
processEntry(state, entry);
|
|
156
|
+
}
|
|
157
|
+
state.byteOffset = entries.newOffset;
|
|
158
|
+
state.mtimeMs = stat.mtimeMs;
|
|
159
|
+
state.fileSize = stat.size;
|
|
160
|
+
// Re-extract latestPrompt on change (might have new user message)
|
|
161
|
+
state.latestPrompt = extractLatestPrompt(filePath);
|
|
162
|
+
// Retry agent identity detection if still unknown (common for subagents
|
|
163
|
+
// where the first update fires before the user prompt is written)
|
|
164
|
+
if (!state.agentName) {
|
|
165
|
+
const identity = extractAgentIdentityFromFile(filePath);
|
|
166
|
+
if (identity) {
|
|
167
|
+
state.agentName = identity.name;
|
|
168
|
+
state.agentRole = identity.role;
|
|
169
|
+
}
|
|
170
|
+
// Fallback: cross-reference parent session for subagent_type
|
|
171
|
+
if (!state.agentName) {
|
|
172
|
+
const subagentsIdx = filePath.indexOf('/subagents/');
|
|
173
|
+
if (subagentsIdx !== -1) {
|
|
174
|
+
const parentDir = filePath.slice(0, subagentsIdx);
|
|
175
|
+
const parentSessionId = path.basename(parentDir);
|
|
176
|
+
const parentJsonl = path.join(path.dirname(parentDir), `${parentSessionId}.jsonl`);
|
|
177
|
+
const childFilename = path.basename(filePath);
|
|
178
|
+
const childAgentId = childFilename.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
179
|
+
const parentIdentity = resolveAgentFromParent(parentJsonl, childAgentId);
|
|
180
|
+
if (parentIdentity) {
|
|
181
|
+
state.agentName = parentIdentity.name;
|
|
182
|
+
state.agentRole = parentIdentity.role;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return state;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Initialize all file states from disk. Called once at startup.
|
|
191
|
+
* Only fully parses sessions modified within RECENT_THRESHOLD_MS.
|
|
192
|
+
* Older sessions get a lightweight stub (idle, mtime only) and are
|
|
193
|
+
* fully parsed on-demand if they receive a file-change event.
|
|
194
|
+
*/
|
|
195
|
+
const RECENT_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
196
|
+
export function initializeAllFileStates(claudeHome, projectFilter, stateMap) {
|
|
197
|
+
const map = stateMap ?? fileStates;
|
|
198
|
+
const discovered = discoverSessionFiles(claudeHome, projectFilter);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
let fullCount = 0;
|
|
201
|
+
let stubCount = 0;
|
|
202
|
+
for (const [filePath] of discovered) {
|
|
203
|
+
let mtime;
|
|
204
|
+
try {
|
|
205
|
+
mtime = fs.statSync(filePath).mtimeMs;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (now - mtime < RECENT_THRESHOLD_MS) {
|
|
211
|
+
// Recent file — full parse
|
|
212
|
+
bootstrapFromTail(filePath, map);
|
|
213
|
+
fullCount++;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// Old file — lightweight stub (skip expensive JSONL parsing)
|
|
217
|
+
const stat = fs.statSync(filePath);
|
|
218
|
+
const stub = {
|
|
219
|
+
byteOffset: stat.size,
|
|
220
|
+
mtimeMs: stat.mtimeMs,
|
|
221
|
+
fileSize: stat.size,
|
|
222
|
+
machineState: 'done',
|
|
223
|
+
toolUseCount: 0,
|
|
224
|
+
toolResultCount: 0,
|
|
225
|
+
pendingInputTool: false,
|
|
226
|
+
lastActivityAt: new Date(stat.mtimeMs).toISOString(),
|
|
227
|
+
messageCount: 0,
|
|
228
|
+
};
|
|
229
|
+
// Quick agent name extraction from head (cheap — reads first few KB only)
|
|
230
|
+
const identity = extractAgentIdentityFromFile(filePath);
|
|
231
|
+
if (identity) {
|
|
232
|
+
stub.agentName = identity.name;
|
|
233
|
+
stub.agentRole = identity.role;
|
|
234
|
+
}
|
|
235
|
+
map.set(filePath, stub);
|
|
236
|
+
stubCount++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
console.log(`[session-state] Bootstrapped ${fullCount} recent + ${stubCount} stubs (${discovered.size} total)`);
|
|
240
|
+
return discovered;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Recursively collect agent-*.jsonl files from a subagents/ directory.
|
|
244
|
+
* Handles nested subagents (sub-sub-agents spawned by subagents).
|
|
245
|
+
*/
|
|
246
|
+
function collectSubagentFiles(dir, parentSessionId, project, projectDir, result) {
|
|
247
|
+
let entries;
|
|
248
|
+
try {
|
|
249
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.startsWith('agent-')) {
|
|
256
|
+
if (entry.name.startsWith('agent-acompact-'))
|
|
257
|
+
continue;
|
|
258
|
+
const agentId = entry.name.replace(/^agent-/, '').replace(/\.jsonl$/, '');
|
|
259
|
+
const filePath = path.join(dir, entry.name);
|
|
260
|
+
result.set(filePath, {
|
|
261
|
+
filePath,
|
|
262
|
+
sessionId: `${parentSessionId}:${agentId}`,
|
|
263
|
+
project,
|
|
264
|
+
projectDir,
|
|
265
|
+
isSubagent: true,
|
|
266
|
+
parentSessionId,
|
|
267
|
+
agentId,
|
|
268
|
+
});
|
|
269
|
+
// Check for nested subagents under {agent-id}/subagents/
|
|
270
|
+
const nestedSubagentsDir = path.join(dir, '..', agentId, 'subagents');
|
|
271
|
+
collectSubagentFiles(nestedSubagentsDir, parentSessionId, project, projectDir, result);
|
|
272
|
+
}
|
|
273
|
+
// Also recurse into any subdirectories that contain subagents/
|
|
274
|
+
if (entry.isDirectory() && entry.name === 'subagents') {
|
|
275
|
+
collectSubagentFiles(path.join(dir, entry.name), parentSessionId, project, projectDir, result);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Discover .jsonl session files under ~/.claude/projects/.
|
|
281
|
+
* When projectFilter is provided, only scan that single project directory
|
|
282
|
+
* instead of iterating all directories.
|
|
283
|
+
*/
|
|
284
|
+
export function discoverSessionFiles(claudeHome, projectFilter) {
|
|
285
|
+
const projectsDir = path.join(claudeHome, 'projects');
|
|
286
|
+
const result = new Map();
|
|
287
|
+
let projectDirs;
|
|
288
|
+
try {
|
|
289
|
+
if (projectFilter) {
|
|
290
|
+
// Scoped: only scan the single matching project directory
|
|
291
|
+
const filtered = path.join(projectsDir, projectFilter);
|
|
292
|
+
if (fs.existsSync(filtered) && fs.statSync(filtered).isDirectory()) {
|
|
293
|
+
projectDirs = [projectFilter];
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
console.warn(`[session-state] Project filter directory not found: ${filtered}`);
|
|
297
|
+
projectDirs = [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
projectDirs = fs.readdirSync(projectsDir).filter((d) => {
|
|
302
|
+
try {
|
|
303
|
+
return fs.statSync(path.join(projectsDir, d)).isDirectory();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
for (const projectDir of projectDirs) {
|
|
315
|
+
const projectPath = path.join(projectsDir, projectDir);
|
|
316
|
+
const label = projectLabel(projectDir);
|
|
317
|
+
let entries;
|
|
318
|
+
try {
|
|
319
|
+
entries = fs.readdirSync(projectPath, { withFileTypes: true });
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// Top-level .jsonl files (parent sessions)
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
327
|
+
continue;
|
|
328
|
+
const sessionId = entry.name.replace('.jsonl', '');
|
|
329
|
+
const filePath = path.join(projectPath, entry.name);
|
|
330
|
+
result.set(filePath, {
|
|
331
|
+
filePath,
|
|
332
|
+
sessionId,
|
|
333
|
+
project: label,
|
|
334
|
+
projectDir,
|
|
335
|
+
isSubagent: false,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// Subagent .jsonl files under {uuid}/subagents/ (recursive for nested subagents)
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
if (!entry.isDirectory())
|
|
341
|
+
continue;
|
|
342
|
+
const parentSessionId = entry.name;
|
|
343
|
+
const subagentsDir = path.join(projectPath, entry.name, 'subagents');
|
|
344
|
+
collectSubagentFiles(subagentsDir, parentSessionId, label, projectDir, result);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Map machine state to SessionActivity for backward compatibility.
|
|
351
|
+
*/
|
|
352
|
+
export function toSessionActivity(state) {
|
|
353
|
+
const sessionId = state.sessionId;
|
|
354
|
+
if (!sessionId)
|
|
355
|
+
return null;
|
|
356
|
+
const ageMs = Date.now() - state.mtimeMs;
|
|
357
|
+
const active = ageMs < ACTIVE_WINDOW_MS;
|
|
358
|
+
const thinking = state.machineState === 'working' &&
|
|
359
|
+
!state.lastToolName &&
|
|
360
|
+
state.toolUseCount === state.toolResultCount;
|
|
361
|
+
return {
|
|
362
|
+
sessionId,
|
|
363
|
+
tool: state.lastToolName,
|
|
364
|
+
detail: state.lastToolDetail,
|
|
365
|
+
thinking,
|
|
366
|
+
model: state.model,
|
|
367
|
+
lastSeen: state.lastActivityAt,
|
|
368
|
+
active,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Map machine state to LastEntryType for backward compatibility with deriveSessionStatus.
|
|
373
|
+
*/
|
|
374
|
+
export function machineStateToLastEntryType(state) {
|
|
375
|
+
switch (state.machineState) {
|
|
376
|
+
case 'working':
|
|
377
|
+
return 'assistant-tool'; // always → 'working' status (no more 'thinking')
|
|
378
|
+
case 'needs_input':
|
|
379
|
+
return 'assistant-question';
|
|
380
|
+
case 'done':
|
|
381
|
+
return 'assistant-text';
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// --- Internal helpers ---
|
|
385
|
+
/**
|
|
386
|
+
* Classify a JSONL entry into an event type.
|
|
387
|
+
*/
|
|
388
|
+
function classifyEntry(entry) {
|
|
389
|
+
if (!entry.type)
|
|
390
|
+
return 'SKIP';
|
|
391
|
+
// Skip noise entries
|
|
392
|
+
if (entry.type === 'progress' || entry.type === 'queue-operation' ||
|
|
393
|
+
entry.type === 'file-history-snapshot') {
|
|
394
|
+
return 'SKIP';
|
|
395
|
+
}
|
|
396
|
+
// System entry with turn_duration → turn end
|
|
397
|
+
if (entry.type === 'system') {
|
|
398
|
+
if (entry.subtype === 'turn_duration')
|
|
399
|
+
return 'TURN_END';
|
|
400
|
+
return 'SKIP';
|
|
401
|
+
}
|
|
402
|
+
if (entry.type === 'user') {
|
|
403
|
+
const content = entry.message?.content;
|
|
404
|
+
if (Array.isArray(content) && content.some((c) => c.type === 'tool_result')) {
|
|
405
|
+
return 'TOOL_RESULT';
|
|
406
|
+
}
|
|
407
|
+
return 'USER_PROMPT';
|
|
408
|
+
}
|
|
409
|
+
if (entry.type === 'assistant') {
|
|
410
|
+
const content = entry.message?.content;
|
|
411
|
+
if (Array.isArray(content) && content.some((c) => c.type === 'tool_use')) {
|
|
412
|
+
return 'ASSISTANT_TOOL_USE';
|
|
413
|
+
}
|
|
414
|
+
return 'ASSISTANT_TEXT';
|
|
415
|
+
}
|
|
416
|
+
return 'SKIP';
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Process a single entry through the state machine (mutates state in place).
|
|
420
|
+
*/
|
|
421
|
+
function processEntry(state, entry) {
|
|
422
|
+
// Accumulate metadata from every entry
|
|
423
|
+
if (entry.sessionId && !state.sessionId)
|
|
424
|
+
state.sessionId = entry.sessionId;
|
|
425
|
+
if (entry.message?.model)
|
|
426
|
+
state.model = entry.message.model;
|
|
427
|
+
if (entry.cwd)
|
|
428
|
+
state.cwd = entry.cwd;
|
|
429
|
+
if (entry.gitBranch)
|
|
430
|
+
state.gitBranch = entry.gitBranch;
|
|
431
|
+
if (entry.version)
|
|
432
|
+
state.version = entry.version;
|
|
433
|
+
if (entry.slug)
|
|
434
|
+
state.slug = entry.slug;
|
|
435
|
+
if (entry.timestamp)
|
|
436
|
+
state.lastActivityAt = entry.timestamp;
|
|
437
|
+
// CLI-spawned agents: extract agent identity from agentSetting field
|
|
438
|
+
if (entry.type === 'agent-setting' && entry.agentSetting && !state.agentName) {
|
|
439
|
+
const identity = resolveAgentFromSetting(entry.agentSetting);
|
|
440
|
+
if (identity) {
|
|
441
|
+
state.agentName = identity.name;
|
|
442
|
+
state.agentRole = identity.role;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Extract tasksId from entries that reference tasks directories
|
|
446
|
+
const entryStr = JSON.stringify(entry);
|
|
447
|
+
const tasksMatch = TASKS_UUID_RE.exec(entryStr);
|
|
448
|
+
if (tasksMatch)
|
|
449
|
+
state.tasksId = tasksMatch[1];
|
|
450
|
+
const event = classifyEntry(entry);
|
|
451
|
+
if (event === 'SKIP')
|
|
452
|
+
return;
|
|
453
|
+
state.messageCount++;
|
|
454
|
+
switch (event) {
|
|
455
|
+
case 'USER_PROMPT': {
|
|
456
|
+
// Extract prompt text from user message
|
|
457
|
+
const promptContent = entry.message?.content;
|
|
458
|
+
let promptText;
|
|
459
|
+
if (Array.isArray(promptContent)) {
|
|
460
|
+
for (const block of promptContent) {
|
|
461
|
+
if (block.type === 'tool_result')
|
|
462
|
+
continue;
|
|
463
|
+
const text = block.text ?? (typeof block.content === 'string' ? block.content : undefined);
|
|
464
|
+
if (typeof text === 'string' && text.trim() && !isSystemContent(text)) {
|
|
465
|
+
promptText = text;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (promptText) {
|
|
471
|
+
const cleaned = cleanPromptText(promptText);
|
|
472
|
+
if (cleaned) {
|
|
473
|
+
state.latestPrompt = cleaned;
|
|
474
|
+
if (!state.initialPrompt)
|
|
475
|
+
state.initialPrompt = cleaned;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// New user turn — reset tool counts
|
|
479
|
+
state.toolUseCount = 0;
|
|
480
|
+
state.toolResultCount = 0;
|
|
481
|
+
state.pendingInputTool = false;
|
|
482
|
+
state.lastToolName = undefined;
|
|
483
|
+
state.lastToolDetail = undefined;
|
|
484
|
+
state.machineState = 'working';
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
case 'ASSISTANT_TOOL_USE': {
|
|
488
|
+
const content = entry.message?.content;
|
|
489
|
+
if (Array.isArray(content)) {
|
|
490
|
+
const toolBlocks = content.filter((c) => c.type === 'tool_use');
|
|
491
|
+
state.toolUseCount += toolBlocks.length;
|
|
492
|
+
// Track the last tool and check for input-requiring tools
|
|
493
|
+
for (const block of toolBlocks) {
|
|
494
|
+
if (block.name) {
|
|
495
|
+
state.lastToolName = block.name;
|
|
496
|
+
state.lastToolDetail = extractDetail(block.name, block.input);
|
|
497
|
+
if (INPUT_REQUIRING_TOOLS.has(block.name)) {
|
|
498
|
+
state.pendingInputTool = true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Input-requiring tools (AskUserQuestion, ExitPlanMode) → needs_input immediately
|
|
504
|
+
state.machineState = state.pendingInputTool ? 'needs_input' : 'working';
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
case 'TOOL_RESULT': {
|
|
508
|
+
const content = entry.message?.content;
|
|
509
|
+
if (Array.isArray(content)) {
|
|
510
|
+
state.toolResultCount += content.filter((c) => c.type === 'tool_result').length;
|
|
511
|
+
}
|
|
512
|
+
// If all tools resolved, clear pending input tool flag
|
|
513
|
+
if (state.toolUseCount <= state.toolResultCount) {
|
|
514
|
+
state.pendingInputTool = false;
|
|
515
|
+
}
|
|
516
|
+
// Always stay working — turn isn't done until TURN_END
|
|
517
|
+
state.machineState = 'working';
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case 'ASSISTANT_TEXT': {
|
|
521
|
+
// Input-requiring tool still pending → needs_input
|
|
522
|
+
if (state.pendingInputTool) {
|
|
523
|
+
state.machineState = 'needs_input';
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
// Check if last text block looks like it's waiting for user input
|
|
527
|
+
const content = entry.message?.content;
|
|
528
|
+
if (Array.isArray(content)) {
|
|
529
|
+
const lastText = [...content].reverse().find((c) => c.type === 'text' && c.text);
|
|
530
|
+
if (lastText?.text && looksLikeWaitingForInput(lastText.text)) {
|
|
531
|
+
state.machineState = 'needs_input';
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// If tools are still running, stay working
|
|
536
|
+
if (state.toolUseCount > state.toolResultCount) {
|
|
537
|
+
state.machineState = 'working';
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
// All tools resolved and Claude sent text — turn is done
|
|
541
|
+
state.machineState = 'done';
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case 'TURN_END':
|
|
545
|
+
// Turn is over — preserve needs_input if already detected (e.g. question in text)
|
|
546
|
+
if (state.pendingInputTool || state.machineState === 'needs_input') {
|
|
547
|
+
state.machineState = 'needs_input';
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
state.machineState = 'done';
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Read new entries from a file starting at fromOffset.
|
|
556
|
+
*/
|
|
557
|
+
function readNewEntries(filePath, fromOffset, toSize) {
|
|
558
|
+
let fd = null;
|
|
559
|
+
try {
|
|
560
|
+
fd = fs.openSync(filePath, 'r');
|
|
561
|
+
const readSize = toSize - fromOffset;
|
|
562
|
+
const buffer = Buffer.allocUnsafe(readSize);
|
|
563
|
+
fs.readSync(fd, buffer, 0, readSize, fromOffset);
|
|
564
|
+
fs.closeSync(fd);
|
|
565
|
+
fd = null;
|
|
566
|
+
const content = buffer.toString('utf-8');
|
|
567
|
+
const lines = content.split('\n');
|
|
568
|
+
// If content doesn't end with \n, last line is partial — exclude it
|
|
569
|
+
let newOffset;
|
|
570
|
+
let linesToParse;
|
|
571
|
+
if (!content.endsWith('\n')) {
|
|
572
|
+
linesToParse = lines.slice(0, -1);
|
|
573
|
+
// Calculate offset: exclude the partial last line
|
|
574
|
+
const partialLineLength = Buffer.byteLength(lines[lines.length - 1], 'utf-8');
|
|
575
|
+
newOffset = toSize - partialLineLength;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
linesToParse = lines;
|
|
579
|
+
newOffset = toSize;
|
|
580
|
+
}
|
|
581
|
+
const parsed = [];
|
|
582
|
+
for (const line of linesToParse) {
|
|
583
|
+
const trimmed = line.trim();
|
|
584
|
+
if (!trimmed)
|
|
585
|
+
continue;
|
|
586
|
+
try {
|
|
587
|
+
parsed.push(JSON.parse(trimmed));
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// skip malformed
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { parsed, newOffset };
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
if (fd !== null) {
|
|
597
|
+
try {
|
|
598
|
+
fs.closeSync(fd);
|
|
599
|
+
}
|
|
600
|
+
catch { /* ignore */ }
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Detect if assistant text looks like it's waiting for user input.
|
|
607
|
+
*
|
|
608
|
+
* Uses multiple heuristics beyond just trailing '?':
|
|
609
|
+
* 1. Last sentence/paragraph ends with '?' (anywhere, not just very end)
|
|
610
|
+
* 2. Text contains explicit choice/question patterns
|
|
611
|
+
* 3. Text ends with a prompt-like pattern (colon after a question phrase)
|
|
612
|
+
*/
|
|
613
|
+
function looksLikeWaitingForInput(text) {
|
|
614
|
+
const trimmed = text.trimEnd();
|
|
615
|
+
if (!trimmed)
|
|
616
|
+
return false;
|
|
617
|
+
// 1. Check if there's a question mark near the end of the text.
|
|
618
|
+
// Look at the last 500 chars to find the final sentence boundary.
|
|
619
|
+
const tail = trimmed.slice(-500);
|
|
620
|
+
// Find the last question mark, ignoring those inside code blocks or URLs
|
|
621
|
+
const lastQ = tail.lastIndexOf('?');
|
|
622
|
+
if (lastQ !== -1) {
|
|
623
|
+
// Make sure there's no substantial content AFTER the '?' that would
|
|
624
|
+
// indicate the question was rhetorical or mid-paragraph.
|
|
625
|
+
const afterQ = tail.slice(lastQ + 1).trim();
|
|
626
|
+
// Allow trailing markdown, whitespace, closing parens/brackets, or short annotations
|
|
627
|
+
if (afterQ.length <= 2 || /^[)\]}>*_`~\s]+$/.test(afterQ)) {
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
// If the text after '?' is just a sources/attribution line, still a question
|
|
631
|
+
if (/^(\n\s*)+sources?:/i.test(afterQ)) {
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// 2. Explicit choice/input patterns at end of text
|
|
636
|
+
const lastLines = tail.split('\n').filter(l => l.trim()).slice(-3).join(' ').toLowerCase();
|
|
637
|
+
const waitingPatterns = [
|
|
638
|
+
/\bwould you (?:like|prefer|want)\b/,
|
|
639
|
+
/\bshould (?:i|we)\b/,
|
|
640
|
+
/\bdo you (?:want|prefer|need)\b/,
|
|
641
|
+
/\bplease (?:choose|select|pick|confirm|specify|provide|let me know)\b/,
|
|
642
|
+
/\blet me know\b/,
|
|
643
|
+
/\bwhat (?:do you|would you|should)\b/,
|
|
644
|
+
/\bwhich (?:one|option|approach)\b/,
|
|
645
|
+
/\bchoose (?:one|an option|from)\b/,
|
|
646
|
+
/\bselect (?:one|an option|from)\b/,
|
|
647
|
+
/\bhow (?:would you like|should|do you want)\b/,
|
|
648
|
+
/\bready to (?:proceed|continue|start)\b/,
|
|
649
|
+
/\bshall (?:i|we)\b/,
|
|
650
|
+
/\bcan you (?:confirm|clarify|provide|specify)\b/,
|
|
651
|
+
];
|
|
652
|
+
for (const pattern of waitingPatterns) {
|
|
653
|
+
if (pattern.test(lastLines))
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Extract human-readable detail from a tool invocation.
|
|
660
|
+
*/
|
|
661
|
+
function extractDetail(toolName, input) {
|
|
662
|
+
if (!input)
|
|
663
|
+
return toolName;
|
|
664
|
+
switch (toolName) {
|
|
665
|
+
case 'Read':
|
|
666
|
+
case 'Edit':
|
|
667
|
+
case 'Write': {
|
|
668
|
+
const filePath = input['file_path'];
|
|
669
|
+
if (typeof filePath === 'string')
|
|
670
|
+
return path.basename(filePath);
|
|
671
|
+
return toolName;
|
|
672
|
+
}
|
|
673
|
+
case 'Bash': {
|
|
674
|
+
const command = input['command'];
|
|
675
|
+
if (typeof command === 'string')
|
|
676
|
+
return command.slice(0, 40);
|
|
677
|
+
return 'bash';
|
|
678
|
+
}
|
|
679
|
+
case 'Grep': {
|
|
680
|
+
const pattern = input['pattern'];
|
|
681
|
+
if (typeof pattern === 'string')
|
|
682
|
+
return pattern;
|
|
683
|
+
return 'grep';
|
|
684
|
+
}
|
|
685
|
+
case 'Task':
|
|
686
|
+
return 'Spawned agent';
|
|
687
|
+
case 'AskUserQuestion':
|
|
688
|
+
return 'Waiting for answer';
|
|
689
|
+
case 'ExitPlanMode':
|
|
690
|
+
return 'Plan ready for review';
|
|
691
|
+
case 'EnterPlanMode':
|
|
692
|
+
return 'Requesting plan mode';
|
|
693
|
+
default:
|
|
694
|
+
return toolName;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|