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,1080 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { parseAllTeams } from '../parsers/team-parser.js';
|
|
7
|
+
import { parseAllTeamTasks, parseAllTasks } from '../parsers/task-parser.js';
|
|
8
|
+
import { initializeAllFileStates as initializeAllFileStatesRaw, discoverSessionFiles as discoverSessionFilesRaw, getAllFileStates as getAllFileStatesRaw, getOrBootstrap as getOrBootstrapRaw, removeFileState as removeFileStateRaw, machineStateToLastEntryType, toSessionActivity, } from '../parsers/session-state.js';
|
|
9
|
+
import { projectDirFromPath } from '../parsers/session-scanner.js';
|
|
10
|
+
import { discoverClaudePanes } from '../parsers/process-discovery.js';
|
|
11
|
+
import { getRecentEvents } from '../db.js';
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const FIVE_MINUTES_MS = 5 * 60 * 1000;
|
|
14
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
15
|
+
function deriveSessionStatus(ageMs, lastEntryType, eventInfo) {
|
|
16
|
+
// Hook events override if recent (<5min)
|
|
17
|
+
if (eventInfo) {
|
|
18
|
+
const eventAge = Date.now() - new Date(eventInfo.timestamp).getTime();
|
|
19
|
+
if (eventAge < FIVE_MINUTES_MS) {
|
|
20
|
+
return eventInfo.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Time tiers
|
|
24
|
+
if (ageMs < FIVE_MINUTES_MS) {
|
|
25
|
+
switch (lastEntryType) {
|
|
26
|
+
case 'user': return 'working';
|
|
27
|
+
case 'assistant-tool': return 'working';
|
|
28
|
+
case 'assistant-question': return 'waiting-input';
|
|
29
|
+
case 'assistant-text': return 'done';
|
|
30
|
+
default: return 'idle';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// If the agent's last message was final text output, it's done regardless of age
|
|
34
|
+
if (lastEntryType === 'assistant-text') {
|
|
35
|
+
return 'done';
|
|
36
|
+
}
|
|
37
|
+
if (ageMs < ONE_HOUR_MS) {
|
|
38
|
+
return 'paused';
|
|
39
|
+
}
|
|
40
|
+
return 'idle';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Bidirectional status propagation between parent and subagent sessions.
|
|
44
|
+
* Runs AFTER normal status derivation — only upgrades statuses, never downgrades.
|
|
45
|
+
*
|
|
46
|
+
* 1. Parent → Subagent: If parent is "working", subagents with stale status
|
|
47
|
+
* (idle/paused) get upgraded to "working" (they're active inside the parent).
|
|
48
|
+
* 2. Subagent → Parent: If any subagent has "error" or "waiting-input"/"waiting-approval",
|
|
49
|
+
* set subagentAttention on the parent. Also collect active subagent names.
|
|
50
|
+
* 3. Reverse propagation: If parent is "idle" but has subagents with recent activity
|
|
51
|
+
* (< 5 min), upgrade the parent to "working".
|
|
52
|
+
*/
|
|
53
|
+
function propagateSubagentStatuses(sessions) {
|
|
54
|
+
// Build parent → children lookup
|
|
55
|
+
const childrenByParent = new Map();
|
|
56
|
+
const sessionById = new Map();
|
|
57
|
+
for (const s of sessions) {
|
|
58
|
+
sessionById.set(s.id, s);
|
|
59
|
+
if (s.isSubagent && s.parentSessionId) {
|
|
60
|
+
const children = childrenByParent.get(s.parentSessionId);
|
|
61
|
+
if (children) {
|
|
62
|
+
children.push(s);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
childrenByParent.set(s.parentSessionId, [s]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const session of sessions) {
|
|
70
|
+
if (session.isSubagent)
|
|
71
|
+
continue; // Only process parent sessions
|
|
72
|
+
const children = childrenByParent.get(session.id);
|
|
73
|
+
if (!children || children.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
// --- Reverse propagation: subagent activity can upgrade parent ---
|
|
76
|
+
if (session.status === 'idle' || session.status === 'paused') {
|
|
77
|
+
const hasRecentSubagent = children.some(child => {
|
|
78
|
+
const childAge = Date.now() - new Date(child.lastActivity).getTime();
|
|
79
|
+
return childAge < FIVE_MINUTES_MS;
|
|
80
|
+
});
|
|
81
|
+
if (hasRecentSubagent) {
|
|
82
|
+
session.status = 'working';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// --- Forward propagation: parent working → subagents working ---
|
|
86
|
+
const activeSubagentNames = [];
|
|
87
|
+
let hasAttention = false;
|
|
88
|
+
for (const child of children) {
|
|
89
|
+
// If parent is working, upgrade RECENT subagents only (within 5 min)
|
|
90
|
+
if (session.status === 'working') {
|
|
91
|
+
const childAge = Date.now() - new Date(child.lastActivity).getTime();
|
|
92
|
+
if ((child.status === 'idle' || child.status === 'paused') && childAge < FIVE_MINUTES_MS) {
|
|
93
|
+
child.status = 'working';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Track which subagents are actively working
|
|
97
|
+
if (child.status === 'working' && child.agentName) {
|
|
98
|
+
activeSubagentNames.push(child.agentName);
|
|
99
|
+
}
|
|
100
|
+
// Surface subagent attention states on parent
|
|
101
|
+
if (child.status === 'error' || child.status === 'waiting-input' || child.status === 'waiting-approval') {
|
|
102
|
+
hasAttention = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
session.subagentAttention = hasAttention || undefined;
|
|
106
|
+
session.activeSubagentNames = activeSubagentNames.length > 0 ? activeSubagentNames : undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export class Aggregator extends EventEmitter {
|
|
110
|
+
state;
|
|
111
|
+
config;
|
|
112
|
+
staleTimer = null;
|
|
113
|
+
discoveryTimer = null;
|
|
114
|
+
paneMapping = { byTasksDir: new Map(), byPid: new Map(), bySessionId: new Map(), byPaneTitle: new Map(), panePrompts: new Map(), byItermSession: new Map(), orphanItermSessions: [] };
|
|
115
|
+
discoveredFiles = new Map();
|
|
116
|
+
workState = { features: null, backlogs: null, conductor: null, index: null };
|
|
117
|
+
projectFilter;
|
|
118
|
+
adapter;
|
|
119
|
+
constructor(config, adapter) {
|
|
120
|
+
super();
|
|
121
|
+
this.config = config;
|
|
122
|
+
this.adapter = adapter ?? null;
|
|
123
|
+
this.projectFilter = projectDirFromPath(process.cwd());
|
|
124
|
+
this.state = {
|
|
125
|
+
teams: [],
|
|
126
|
+
sessions: [],
|
|
127
|
+
projects: [],
|
|
128
|
+
tasksByTeam: {},
|
|
129
|
+
tasksBySession: {},
|
|
130
|
+
events: [],
|
|
131
|
+
sessionActivities: {},
|
|
132
|
+
directiveState: null,
|
|
133
|
+
directiveHistory: [],
|
|
134
|
+
activeDirectives: [],
|
|
135
|
+
lastUpdated: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
getState() {
|
|
139
|
+
return this.state;
|
|
140
|
+
}
|
|
141
|
+
getActiveSessions() {
|
|
142
|
+
// Any session that's not idle/done — includes working, paused, waiting-input, waiting-approval, error
|
|
143
|
+
// "paused" means < 1hr old, user may still be thinking/reading
|
|
144
|
+
// Only "idle" (> 1hr) is safe to assume abandoned
|
|
145
|
+
return this.state.sessions.filter(s => s.status !== 'idle' && s.status !== 'done');
|
|
146
|
+
}
|
|
147
|
+
initialize() {
|
|
148
|
+
console.log('[aggregator] Initializing state from filesystem...');
|
|
149
|
+
const teams = parseAllTeams(this.config.claudeHome);
|
|
150
|
+
const teamNameSet = new Set(teams.map((t) => t.name));
|
|
151
|
+
const { byTeam: tasksByTeam, bySession: tasksBySession } = parseAllTasks(this.config.claudeHome, teamNameSet);
|
|
152
|
+
const events = getRecentEvents(200);
|
|
153
|
+
// Bootstrap all session file states (incremental parser) — scoped to this project
|
|
154
|
+
console.log(`[aggregator] Session scope: ${this.projectFilter}`);
|
|
155
|
+
this.discoveredFiles = this.adapter
|
|
156
|
+
? this.adapter.initializeAllFileStates(this.projectFilter)
|
|
157
|
+
: initializeAllFileStatesRaw(this.config.claudeHome, this.projectFilter);
|
|
158
|
+
// Build sessions from file states + hook events
|
|
159
|
+
const { sessions, projects } = this.buildSessionsFromFileStates(events);
|
|
160
|
+
for (const team of teams) {
|
|
161
|
+
if (team.leadSessionId) {
|
|
162
|
+
const session = sessions.find((s) => s.id === team.leadSessionId);
|
|
163
|
+
if (session) {
|
|
164
|
+
session.feature = `lead:${team.name}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const sessionActivities = this.buildSessionActivities();
|
|
169
|
+
this.state = {
|
|
170
|
+
teams,
|
|
171
|
+
sessions,
|
|
172
|
+
projects,
|
|
173
|
+
tasksByTeam,
|
|
174
|
+
tasksBySession,
|
|
175
|
+
events,
|
|
176
|
+
sessionActivities,
|
|
177
|
+
directiveState: null,
|
|
178
|
+
directiveHistory: [],
|
|
179
|
+
activeDirectives: [],
|
|
180
|
+
lastUpdated: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
const totalTasks = Object.values(tasksByTeam).reduce((sum, t) => sum + t.length, 0);
|
|
183
|
+
const activeSessions = sessions.filter((s) => s.status === 'working').length;
|
|
184
|
+
console.log(`[aggregator] Initialized: ${teams.length} teams, ${totalTasks} tasks, ${events.length} events, ${sessions.length} sessions (${activeSessions} active), ${projects.length} projects`);
|
|
185
|
+
this.refreshProcessDiscovery();
|
|
186
|
+
this.discoveryTimer = setInterval(() => this.refreshProcessDiscovery(), 30_000);
|
|
187
|
+
this.detectStaleness();
|
|
188
|
+
this.staleTimer = setInterval(() => this.detectStaleness(), 60_000);
|
|
189
|
+
}
|
|
190
|
+
refreshTeams() {
|
|
191
|
+
this.state.teams = parseAllTeams(this.config.claudeHome);
|
|
192
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
193
|
+
this.emitChange('teams_updated');
|
|
194
|
+
}
|
|
195
|
+
refreshTasks(teamName) {
|
|
196
|
+
if (teamName) {
|
|
197
|
+
const tasks = parseAllTeamTasks(this.config.claudeHome, [teamName]);
|
|
198
|
+
this.state.tasksByTeam[teamName] = tasks[teamName] ?? [];
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
const teamNameSet = new Set(this.state.teams.map((t) => t.name));
|
|
202
|
+
const { byTeam, bySession } = parseAllTasks(this.config.claudeHome, teamNameSet);
|
|
203
|
+
this.state.tasksByTeam = byTeam;
|
|
204
|
+
this.state.tasksBySession = bySession;
|
|
205
|
+
}
|
|
206
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
207
|
+
this.emitChange('tasks_updated');
|
|
208
|
+
}
|
|
209
|
+
refreshAll() {
|
|
210
|
+
this.refreshTeams();
|
|
211
|
+
this.refreshTasks();
|
|
212
|
+
this.refreshSessions();
|
|
213
|
+
}
|
|
214
|
+
updateDirectiveState(directiveState, directiveHistory, activeDirectives) {
|
|
215
|
+
this.state.directiveState = directiveState;
|
|
216
|
+
if (directiveHistory !== undefined) {
|
|
217
|
+
this.state.directiveHistory = directiveHistory;
|
|
218
|
+
}
|
|
219
|
+
if (activeDirectives !== undefined) {
|
|
220
|
+
this.state.activeDirectives = activeDirectives;
|
|
221
|
+
}
|
|
222
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
223
|
+
this.emitChange('directive_updated');
|
|
224
|
+
}
|
|
225
|
+
updateWorkState(workState) {
|
|
226
|
+
this.workState = workState;
|
|
227
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
228
|
+
this.emitChange('state_updated');
|
|
229
|
+
}
|
|
230
|
+
getWorkState() {
|
|
231
|
+
return this.workState;
|
|
232
|
+
}
|
|
233
|
+
getWorkItems(filters) {
|
|
234
|
+
const items = [];
|
|
235
|
+
// Collect all items
|
|
236
|
+
if (this.workState.features) {
|
|
237
|
+
items.push(...this.workState.features.features);
|
|
238
|
+
}
|
|
239
|
+
if (this.workState.backlogs) {
|
|
240
|
+
items.push(...this.workState.backlogs.items);
|
|
241
|
+
}
|
|
242
|
+
if (this.workState.conductor) {
|
|
243
|
+
// Guard each array with ?? [] — conductor.json is read via plain JSON.parse
|
|
244
|
+
// cast, so individual fields may be missing/undefined if the file is partial.
|
|
245
|
+
items.push(...(this.workState.conductor.directives ?? []));
|
|
246
|
+
items.push(...(this.workState.conductor.reports ?? []));
|
|
247
|
+
items.push(...(this.workState.conductor.discussions ?? []));
|
|
248
|
+
items.push(...(this.workState.conductor.research ?? []));
|
|
249
|
+
}
|
|
250
|
+
if (!filters)
|
|
251
|
+
return items;
|
|
252
|
+
return items.filter(item => {
|
|
253
|
+
if (filters.type && item.type !== filters.type)
|
|
254
|
+
return false;
|
|
255
|
+
if (filters.status && item.status !== filters.status)
|
|
256
|
+
return false;
|
|
257
|
+
if (filters.category && item.category !== filters.category)
|
|
258
|
+
return false;
|
|
259
|
+
if (filters.q) {
|
|
260
|
+
const q = filters.q.toLowerCase();
|
|
261
|
+
const searchable = `${item.title} ${item.id} ${item.category ?? ''}`.toLowerCase();
|
|
262
|
+
if (!searchable.includes(q))
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
refreshSessions() {
|
|
269
|
+
const newDiscovered = this.adapter
|
|
270
|
+
? this.adapter.discoverSessionFiles(this.projectFilter)
|
|
271
|
+
: discoverSessionFilesRaw(this.config.claudeHome, this.projectFilter);
|
|
272
|
+
for (const [filePath] of newDiscovered) {
|
|
273
|
+
if (!this.discoveredFiles.has(filePath)) {
|
|
274
|
+
if (this.adapter) {
|
|
275
|
+
this.adapter.getOrBootstrap(filePath);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
getOrBootstrapRaw(filePath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const [filePath] of this.discoveredFiles) {
|
|
283
|
+
if (!newDiscovered.has(filePath)) {
|
|
284
|
+
if (this.adapter) {
|
|
285
|
+
this.adapter.removeFileState(filePath);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
removeFileStateRaw(filePath);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.discoveredFiles = newDiscovered;
|
|
293
|
+
const { sessions, projects } = this.buildSessionsFromFileStates(this.state.events);
|
|
294
|
+
for (const team of this.state.teams) {
|
|
295
|
+
if (team.leadSessionId) {
|
|
296
|
+
const session = sessions.find((s) => s.id === team.leadSessionId);
|
|
297
|
+
if (session) {
|
|
298
|
+
session.feature = `lead:${team.name}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Carry over paneIds from previous sessions to preserve stable assignments
|
|
303
|
+
const prevPaneIds = new Map();
|
|
304
|
+
for (const s of this.state.sessions) {
|
|
305
|
+
if (s.paneId)
|
|
306
|
+
prevPaneIds.set(s.id, s.paneId);
|
|
307
|
+
}
|
|
308
|
+
for (const s of sessions) {
|
|
309
|
+
const prevPane = prevPaneIds.get(s.id);
|
|
310
|
+
if (prevPane)
|
|
311
|
+
s.paneId = prevPane;
|
|
312
|
+
}
|
|
313
|
+
this.state.sessions = sessions;
|
|
314
|
+
this.state.projects = projects;
|
|
315
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
316
|
+
this.applyPaneMappings();
|
|
317
|
+
this.emitChange('sessions_updated');
|
|
318
|
+
this.emitChange('projects_updated');
|
|
319
|
+
}
|
|
320
|
+
rederiveSessionStatuses() {
|
|
321
|
+
const fileStates = this.adapter ? this.adapter.getAllFileStates() : getAllFileStatesRaw();
|
|
322
|
+
let statusChanged = false;
|
|
323
|
+
let activityChanged = false;
|
|
324
|
+
// Build sessionId → filePath lookup
|
|
325
|
+
const sessionToFilePath = new Map();
|
|
326
|
+
for (const [fp, discovered] of this.discoveredFiles) {
|
|
327
|
+
sessionToFilePath.set(discovered.sessionId, fp);
|
|
328
|
+
}
|
|
329
|
+
// Build set of session IDs with live processes (from pane mapping)
|
|
330
|
+
const liveSessionIds = new Set();
|
|
331
|
+
for (const [sessionId] of this.paneMapping.bySessionId) {
|
|
332
|
+
liveSessionIds.add(sessionId);
|
|
333
|
+
}
|
|
334
|
+
// Also check byTasksDir which maps tasksId → paneId
|
|
335
|
+
for (const session of this.state.sessions) {
|
|
336
|
+
if (session.tasksId && this.paneMapping.byTasksDir.has(session.tasksId)) {
|
|
337
|
+
liveSessionIds.add(session.id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const session of this.state.sessions) {
|
|
341
|
+
const fp = sessionToFilePath.get(session.id);
|
|
342
|
+
const fileState = fp ? fileStates.get(fp) : undefined;
|
|
343
|
+
// Re-derive status
|
|
344
|
+
const lastEntryType = fileState
|
|
345
|
+
? machineStateToLastEntryType(fileState)
|
|
346
|
+
: 'unknown';
|
|
347
|
+
const ageMs = fileState
|
|
348
|
+
? Date.now() - fileState.mtimeMs
|
|
349
|
+
: Date.now() - new Date(session.lastActivity).getTime();
|
|
350
|
+
const eventInfo = this.getLatestEventInfo(session.id);
|
|
351
|
+
let newStatus = deriveSessionStatus(ageMs, lastEntryType, eventInfo);
|
|
352
|
+
// Process-aware override: if a live claude process exists for this session,
|
|
353
|
+
// it shouldn't be idle — upgrade to at least 'paused' (running but quiet)
|
|
354
|
+
if (liveSessionIds.has(session.id) && (newStatus === 'idle')) {
|
|
355
|
+
newStatus = 'paused';
|
|
356
|
+
}
|
|
357
|
+
if (newStatus !== session.status) {
|
|
358
|
+
session.status = newStatus;
|
|
359
|
+
statusChanged = true;
|
|
360
|
+
}
|
|
361
|
+
// Re-derive activity (clear stale active flags)
|
|
362
|
+
if (fileState) {
|
|
363
|
+
const activity = toSessionActivity(fileState);
|
|
364
|
+
if (activity) {
|
|
365
|
+
const existing = this.state.sessionActivities[session.id];
|
|
366
|
+
if (existing?.active !== activity.active) {
|
|
367
|
+
this.state.sessionActivities[session.id] = activity;
|
|
368
|
+
activityChanged = true;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Re-run propagation after individual status re-derivation
|
|
374
|
+
propagateSubagentStatuses(this.state.sessions);
|
|
375
|
+
if (statusChanged) {
|
|
376
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
377
|
+
this.emitChange('sessions_updated');
|
|
378
|
+
}
|
|
379
|
+
if (activityChanged) {
|
|
380
|
+
this.emitChange('session_activities_updated');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
updateSessionFromFileState(filePath, fileState) {
|
|
384
|
+
const discovered = this.discoveredFiles.get(filePath);
|
|
385
|
+
if (!discovered)
|
|
386
|
+
return;
|
|
387
|
+
const sessionId = discovered.sessionId;
|
|
388
|
+
const activity = toSessionActivity(fileState);
|
|
389
|
+
if (activity) {
|
|
390
|
+
this.state.sessionActivities[sessionId] = activity;
|
|
391
|
+
}
|
|
392
|
+
const existing = this.state.sessions.find((s) => s.id === sessionId);
|
|
393
|
+
if (existing) {
|
|
394
|
+
const lastEntryType = machineStateToLastEntryType(fileState);
|
|
395
|
+
const ageMs = Date.now() - fileState.mtimeMs;
|
|
396
|
+
const eventInfo = this.getLatestEventInfo(sessionId);
|
|
397
|
+
existing.status = deriveSessionStatus(ageMs, lastEntryType, eventInfo);
|
|
398
|
+
if (fileState.model)
|
|
399
|
+
existing.model = fileState.model;
|
|
400
|
+
if (fileState.cwd)
|
|
401
|
+
existing.cwd = fileState.cwd;
|
|
402
|
+
if (fileState.gitBranch)
|
|
403
|
+
existing.gitBranch = fileState.gitBranch;
|
|
404
|
+
if (fileState.version)
|
|
405
|
+
existing.version = fileState.version;
|
|
406
|
+
if (fileState.slug)
|
|
407
|
+
existing.slug = fileState.slug;
|
|
408
|
+
if (fileState.tasksId)
|
|
409
|
+
existing.tasksId = fileState.tasksId;
|
|
410
|
+
if (fileState.latestPrompt)
|
|
411
|
+
existing.latestPrompt = fileState.latestPrompt;
|
|
412
|
+
if (fileState.agentName)
|
|
413
|
+
existing.agentName = fileState.agentName;
|
|
414
|
+
if (fileState.agentRole)
|
|
415
|
+
existing.agentRole = fileState.agentRole;
|
|
416
|
+
existing.lastActivity = new Date(fileState.mtimeMs).toISOString();
|
|
417
|
+
existing.fileSize = fileState.fileSize;
|
|
418
|
+
}
|
|
419
|
+
// Re-run propagation since this session's status may affect parent/children
|
|
420
|
+
propagateSubagentStatuses(this.state.sessions);
|
|
421
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
422
|
+
this.emitChange('sessions_updated');
|
|
423
|
+
this.emitChange('session_activities_updated');
|
|
424
|
+
}
|
|
425
|
+
addEvent(event) {
|
|
426
|
+
this.state.events.unshift(event);
|
|
427
|
+
if (this.state.events.length > 200) {
|
|
428
|
+
this.state.events = this.state.events.slice(0, 200);
|
|
429
|
+
}
|
|
430
|
+
this.updateSessionFromEvent(event);
|
|
431
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
432
|
+
this.emitChange('event_added');
|
|
433
|
+
}
|
|
434
|
+
refreshProcessDiscovery() {
|
|
435
|
+
discoverClaudePanes().then((mapping) => {
|
|
436
|
+
this.paneMapping = mapping;
|
|
437
|
+
if (mapping.byItermSession.size > 0) {
|
|
438
|
+
console.log(`[aggregator] iTerm2 PIDs: ${[...mapping.byItermSession.entries()].map(([pid, info]) => `${pid}→${info.itermId.slice(0, 8)}(${info.name.slice(0, 30)})`).join(', ')}`);
|
|
439
|
+
const itermTasksDirs = [...mapping.byTasksDir.entries()].filter(([, v]) => v.startsWith('iterm:'));
|
|
440
|
+
const itermSessionIds = [...mapping.bySessionId.entries()].filter(([, v]) => v.startsWith('iterm:'));
|
|
441
|
+
console.log(`[aggregator] iTerm2 in byTasksDir: ${itermTasksDirs.length}, bySessionId: ${itermSessionIds.length}`);
|
|
442
|
+
if (itermSessionIds.length > 0)
|
|
443
|
+
console.log(`[aggregator] iTerm2 bySessionId: ${itermSessionIds.map(([k, v]) => `${k.slice(0, 12)}→${v}`).join(', ')}`);
|
|
444
|
+
}
|
|
445
|
+
this.applyPaneMappings();
|
|
446
|
+
}).catch((err) => {
|
|
447
|
+
console.error('[aggregator] Process discovery error:', err);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
applyPaneMappings() {
|
|
451
|
+
const teamPaneSessionIds = new Set();
|
|
452
|
+
for (const team of this.state.teams) {
|
|
453
|
+
for (const member of team.members) {
|
|
454
|
+
if (member.agentId && member.tmuxPaneId) {
|
|
455
|
+
teamPaneSessionIds.add(member.agentId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const sessionToTasksDir = new Map();
|
|
460
|
+
for (const [dirName] of Object.entries(this.state.tasksBySession)) {
|
|
461
|
+
sessionToTasksDir.set(dirName, dirName);
|
|
462
|
+
}
|
|
463
|
+
const statusPriority = {
|
|
464
|
+
'working': 0, 'waiting-approval': 0, 'waiting-input': 0, 'error': 0,
|
|
465
|
+
'done': 1, 'paused': 1,
|
|
466
|
+
'idle': 2,
|
|
467
|
+
};
|
|
468
|
+
const sortedSessions = [...this.state.sessions].sort((a, b) => {
|
|
469
|
+
const pa = statusPriority[a.status] ?? 3;
|
|
470
|
+
const pb = statusPriority[b.status] ?? 3;
|
|
471
|
+
if (pa !== pb)
|
|
472
|
+
return pa - pb;
|
|
473
|
+
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
|
|
474
|
+
});
|
|
475
|
+
const assignedPaneIds = new Set();
|
|
476
|
+
const validItermIds = new Set([...this.paneMapping.byItermSession.values()].map((info) => `iterm:${info.itermId}`));
|
|
477
|
+
for (const session of this.state.sessions) {
|
|
478
|
+
if (session.paneId?.startsWith('iterm:') && validItermIds.has(session.paneId)) {
|
|
479
|
+
assignedPaneIds.add(session.paneId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
483
|
+
const hasLikelyPane = (s) => {
|
|
484
|
+
if (s.status !== 'idle')
|
|
485
|
+
return true;
|
|
486
|
+
const age = Date.now() - new Date(s.lastActivity).getTime();
|
|
487
|
+
return age < THIRTY_DAYS_MS;
|
|
488
|
+
};
|
|
489
|
+
let changed = false;
|
|
490
|
+
for (const session of sortedSessions) {
|
|
491
|
+
if (teamPaneSessionIds.has(session.id))
|
|
492
|
+
continue;
|
|
493
|
+
if (session.isSubagent)
|
|
494
|
+
continue;
|
|
495
|
+
if (!hasLikelyPane(session)) {
|
|
496
|
+
if (session.paneId) {
|
|
497
|
+
session.paneId = undefined;
|
|
498
|
+
changed = true;
|
|
499
|
+
}
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
let paneId = session.tasksId
|
|
503
|
+
? this.paneMapping.byTasksDir.get(session.tasksId)
|
|
504
|
+
: undefined;
|
|
505
|
+
if (!paneId) {
|
|
506
|
+
paneId = this.paneMapping.byTasksDir.get(session.id);
|
|
507
|
+
}
|
|
508
|
+
if (!paneId) {
|
|
509
|
+
const baseId = session.parentSessionId ?? session.id;
|
|
510
|
+
paneId = this.paneMapping.bySessionId.get(baseId);
|
|
511
|
+
}
|
|
512
|
+
if (paneId && !assignedPaneIds.has(paneId)) {
|
|
513
|
+
assignedPaneIds.add(paneId);
|
|
514
|
+
if (session.paneId !== paneId) {
|
|
515
|
+
session.paneId = paneId;
|
|
516
|
+
changed = true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (session.paneId && !session.paneId.startsWith('iterm:')) {
|
|
520
|
+
session.paneId = undefined;
|
|
521
|
+
changed = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Second pass: fuzzy title matching
|
|
525
|
+
if (this.paneMapping.byPaneTitle.size > 0) {
|
|
526
|
+
for (const session of sortedSessions) {
|
|
527
|
+
if (session.paneId || teamPaneSessionIds.has(session.id) || session.isSubagent)
|
|
528
|
+
continue;
|
|
529
|
+
if (!hasLikelyPane(session))
|
|
530
|
+
continue;
|
|
531
|
+
const textParts = [];
|
|
532
|
+
if (session.initialPrompt)
|
|
533
|
+
textParts.push(session.initialPrompt);
|
|
534
|
+
if (session.latestPrompt)
|
|
535
|
+
textParts.push(session.latestPrompt);
|
|
536
|
+
if (session.slug)
|
|
537
|
+
textParts.push(session.slug.replace(/-/g, ' '));
|
|
538
|
+
if (textParts.length === 0)
|
|
539
|
+
continue;
|
|
540
|
+
const promptLower = textParts.join(' ').toLowerCase();
|
|
541
|
+
const promptWords = promptLower.split(/\s+/).filter((w) => w.length > 2);
|
|
542
|
+
if (promptWords.length === 0)
|
|
543
|
+
continue;
|
|
544
|
+
let bestPaneId;
|
|
545
|
+
let bestScore = 0;
|
|
546
|
+
for (const [title, titlePaneId] of this.paneMapping.byPaneTitle) {
|
|
547
|
+
if (assignedPaneIds.has(titlePaneId))
|
|
548
|
+
continue;
|
|
549
|
+
const titleWords = title.split(/\s+/).filter((w) => w.length > 2);
|
|
550
|
+
if (titleWords.length === 0)
|
|
551
|
+
continue;
|
|
552
|
+
const overlap = titleWords.filter((tw) => promptWords.some((pw) => pw.includes(tw) || tw.includes(pw))).length;
|
|
553
|
+
const ratio = overlap / titleWords.length;
|
|
554
|
+
if (promptLower.includes(title) || title.includes(promptLower)) {
|
|
555
|
+
const score = title.length + 100;
|
|
556
|
+
if (score > bestScore) {
|
|
557
|
+
bestScore = score;
|
|
558
|
+
bestPaneId = titlePaneId;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (overlap >= 1 && ratio > 0.5 && overlap > bestScore) {
|
|
562
|
+
bestScore = overlap;
|
|
563
|
+
bestPaneId = titlePaneId;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (bestPaneId) {
|
|
567
|
+
session.paneId = bestPaneId;
|
|
568
|
+
assignedPaneIds.add(bestPaneId);
|
|
569
|
+
changed = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Third pass: content-based matching
|
|
574
|
+
if (this.paneMapping.panePrompts.size > 0) {
|
|
575
|
+
const candidates = [];
|
|
576
|
+
for (const session of sortedSessions) {
|
|
577
|
+
if (session.paneId || teamPaneSessionIds.has(session.id) || session.isSubagent)
|
|
578
|
+
continue;
|
|
579
|
+
if (!hasLikelyPane(session))
|
|
580
|
+
continue;
|
|
581
|
+
const sessionTexts = [];
|
|
582
|
+
if (session.latestPrompt)
|
|
583
|
+
sessionTexts.push(session.latestPrompt.replace(/\.{3}$/, '').toLowerCase());
|
|
584
|
+
if (session.initialPrompt)
|
|
585
|
+
sessionTexts.push(session.initialPrompt.replace(/\.{3}$/, '').toLowerCase());
|
|
586
|
+
if (sessionTexts.length === 0)
|
|
587
|
+
continue;
|
|
588
|
+
for (const [paneId, panePromptList] of this.paneMapping.panePrompts) {
|
|
589
|
+
if (assignedPaneIds.has(paneId))
|
|
590
|
+
continue;
|
|
591
|
+
let score = 0;
|
|
592
|
+
for (const panePrompt of panePromptList) {
|
|
593
|
+
const paneLower = panePrompt.toLowerCase();
|
|
594
|
+
for (const sessionText of sessionTexts) {
|
|
595
|
+
if (paneLower.includes(sessionText) || sessionText.includes(paneLower)) {
|
|
596
|
+
const matchLen = Math.min(paneLower.length, sessionText.length);
|
|
597
|
+
score = Math.max(score, matchLen + 100);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
const paneWords = paneLower.split(/\s+/).filter((w) => w.length > 2);
|
|
601
|
+
const sessWords = sessionText.split(/\s+/).filter((w) => w.length > 2);
|
|
602
|
+
if (paneWords.length === 0 || sessWords.length === 0)
|
|
603
|
+
continue;
|
|
604
|
+
const overlap = paneWords.filter((pw) => sessWords.some((sw) => pw.includes(sw) || sw.includes(pw))).length;
|
|
605
|
+
const ratio = overlap / Math.max(paneWords.length, sessWords.length);
|
|
606
|
+
if (overlap >= 2 && ratio > 0.4) {
|
|
607
|
+
score = Math.max(score, overlap);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (score > 0) {
|
|
613
|
+
candidates.push({ sessionId: session.id, paneId, score });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
618
|
+
const assignedSessionIds = new Set();
|
|
619
|
+
for (const { sessionId, paneId } of candidates) {
|
|
620
|
+
if (assignedSessionIds.has(sessionId) || assignedPaneIds.has(paneId))
|
|
621
|
+
continue;
|
|
622
|
+
const session = this.state.sessions.find((s) => s.id === sessionId);
|
|
623
|
+
if (!session)
|
|
624
|
+
continue;
|
|
625
|
+
session.paneId = paneId;
|
|
626
|
+
assignedPaneIds.add(paneId);
|
|
627
|
+
assignedSessionIds.add(sessionId);
|
|
628
|
+
changed = true;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Pass 3.5: iTerm2 native session matching
|
|
632
|
+
if (this.paneMapping.byItermSession.size > 0) {
|
|
633
|
+
const itermEntries = [...this.paneMapping.byItermSession.entries()];
|
|
634
|
+
const matchedItermPids = new Set();
|
|
635
|
+
for (const [pid, info] of itermEntries) {
|
|
636
|
+
const itermPaneId = `iterm:${info.itermId}`;
|
|
637
|
+
if (assignedPaneIds.has(itermPaneId)) {
|
|
638
|
+
matchedItermPids.add(pid);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const remainingIterm = itermEntries.filter(([pid]) => !matchedItermPids.has(pid));
|
|
642
|
+
if (remainingIterm.length > 0) {
|
|
643
|
+
// Strategy A1: Exact session ID match (from JSONL file modification in discovery)
|
|
644
|
+
for (const [pid, info] of remainingIterm) {
|
|
645
|
+
if (matchedItermPids.has(pid) || !info.sessionId)
|
|
646
|
+
continue;
|
|
647
|
+
const session = this.state.sessions.find(s => s.id === info.sessionId && !s.paneId && !s.isSubagent);
|
|
648
|
+
if (session) {
|
|
649
|
+
const itermPaneId = `iterm:${info.itermId}`;
|
|
650
|
+
session.paneId = itermPaneId;
|
|
651
|
+
assignedPaneIds.add(itermPaneId);
|
|
652
|
+
matchedItermPids.add(pid);
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Strategy A2: Fallback to cwd matching for remaining unmatched iTerm PIDs
|
|
657
|
+
for (const [pid, info] of remainingIterm) {
|
|
658
|
+
if (matchedItermPids.has(pid) || !info.cwd)
|
|
659
|
+
continue;
|
|
660
|
+
const cwdCandidates = sortedSessions.filter(s => !s.paneId && !teamPaneSessionIds.has(s.id) && !s.isSubagent &&
|
|
661
|
+
hasLikelyPane(s) && s.cwd === info.cwd);
|
|
662
|
+
if (cwdCandidates.length > 0) {
|
|
663
|
+
const session = cwdCandidates[0];
|
|
664
|
+
const itermPaneId = `iterm:${info.itermId}`;
|
|
665
|
+
session.paneId = itermPaneId;
|
|
666
|
+
assignedPaneIds.add(itermPaneId);
|
|
667
|
+
matchedItermPids.add(pid);
|
|
668
|
+
changed = true;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const session of sortedSessions) {
|
|
672
|
+
if (session.paneId || teamPaneSessionIds.has(session.id) || session.isSubagent)
|
|
673
|
+
continue;
|
|
674
|
+
if (!hasLikelyPane(session))
|
|
675
|
+
continue;
|
|
676
|
+
const textParts = [];
|
|
677
|
+
if (session.initialPrompt)
|
|
678
|
+
textParts.push(session.initialPrompt);
|
|
679
|
+
if (session.latestPrompt)
|
|
680
|
+
textParts.push(session.latestPrompt);
|
|
681
|
+
if (session.slug)
|
|
682
|
+
textParts.push(session.slug.replace(/-/g, ' '));
|
|
683
|
+
if (textParts.length === 0)
|
|
684
|
+
continue;
|
|
685
|
+
const sessionText = textParts.join(' ').toLowerCase();
|
|
686
|
+
const sessionWords = sessionText.split(/\s+/).filter((w) => w.length > 2);
|
|
687
|
+
if (sessionWords.length === 0)
|
|
688
|
+
continue;
|
|
689
|
+
let bestPid;
|
|
690
|
+
let bestScore = 0;
|
|
691
|
+
for (const [pid, info] of remainingIterm) {
|
|
692
|
+
if (matchedItermPids.has(pid))
|
|
693
|
+
continue;
|
|
694
|
+
if (!info.name)
|
|
695
|
+
continue;
|
|
696
|
+
const nameLower = info.name.toLowerCase();
|
|
697
|
+
const nameWords = nameLower.split(/\s+/).filter((w) => w.length > 2);
|
|
698
|
+
if (sessionText.includes(nameLower) || nameLower.includes(sessionText)) {
|
|
699
|
+
const score = nameLower.length + 100;
|
|
700
|
+
if (score > bestScore) {
|
|
701
|
+
bestScore = score;
|
|
702
|
+
bestPid = pid;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (nameWords.length > 0) {
|
|
706
|
+
const overlap = nameWords.filter((nw) => sessionWords.some((sw) => sw.includes(nw) || nw.includes(sw))).length;
|
|
707
|
+
const ratio = overlap / nameWords.length;
|
|
708
|
+
if (overlap >= 1 && ratio > 0.5 && overlap > bestScore) {
|
|
709
|
+
bestScore = overlap;
|
|
710
|
+
bestPid = pid;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (bestPid !== undefined) {
|
|
715
|
+
const info = this.paneMapping.byItermSession.get(bestPid);
|
|
716
|
+
const itermPaneId = `iterm:${info.itermId}`;
|
|
717
|
+
session.paneId = itermPaneId;
|
|
718
|
+
assignedPaneIds.add(itermPaneId);
|
|
719
|
+
matchedItermPids.add(bestPid);
|
|
720
|
+
changed = true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const unmatchedActiveSessions = sortedSessions.filter((s) => !s.paneId && !teamPaneSessionIds.has(s.id) && !s.isSubagent &&
|
|
724
|
+
hasLikelyPane(s));
|
|
725
|
+
const unmatchedIterm = remainingIterm.filter(([pid]) => !matchedItermPids.has(pid));
|
|
726
|
+
if (unmatchedActiveSessions.length === 1 && unmatchedIterm.length === 1) {
|
|
727
|
+
const [session] = unmatchedActiveSessions;
|
|
728
|
+
const [[pid, info]] = unmatchedIterm;
|
|
729
|
+
const itermPaneId = `iterm:${info.itermId}`;
|
|
730
|
+
session.paneId = itermPaneId;
|
|
731
|
+
assignedPaneIds.add(itermPaneId);
|
|
732
|
+
matchedItermPids.add(pid);
|
|
733
|
+
changed = true;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// Pass 3.6: Orphan iTerm matching (sessions where claude has exited)
|
|
738
|
+
if (this.paneMapping.orphanItermSessions.length > 0) {
|
|
739
|
+
for (const orphan of this.paneMapping.orphanItermSessions) {
|
|
740
|
+
const itermPaneId = `iterm:${orphan.itermId}`;
|
|
741
|
+
if (assignedPaneIds.has(itermPaneId))
|
|
742
|
+
continue;
|
|
743
|
+
// Strategy A: Try candidate session IDs in order (most recent first)
|
|
744
|
+
if (orphan.candidateSessionIds.length > 0) {
|
|
745
|
+
for (const candidateId of orphan.candidateSessionIds) {
|
|
746
|
+
const session = sortedSessions.find(s => s.id === candidateId && !s.paneId && !s.isSubagent);
|
|
747
|
+
if (session) {
|
|
748
|
+
session.paneId = itermPaneId;
|
|
749
|
+
assignedPaneIds.add(itermPaneId);
|
|
750
|
+
changed = true;
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (assignedPaneIds.has(itermPaneId))
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
// Strategy B: CWD matching (only if exactly 1 candidate)
|
|
758
|
+
if (orphan.cwd) {
|
|
759
|
+
const cwdCandidates = sortedSessions.filter(s => !s.paneId && !teamPaneSessionIds.has(s.id) && !s.isSubagent &&
|
|
760
|
+
hasLikelyPane(s) && s.cwd === orphan.cwd);
|
|
761
|
+
if (cwdCandidates.length === 1) {
|
|
762
|
+
const session = cwdCandidates[0];
|
|
763
|
+
session.paneId = itermPaneId;
|
|
764
|
+
assignedPaneIds.add(itermPaneId);
|
|
765
|
+
changed = true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// Pass 3.9: Active session reclamation — if an active session has no pane but
|
|
771
|
+
// matches scrollback content in a pane currently assigned to an idle session,
|
|
772
|
+
// the active session steals the pane. This handles the common case of resuming
|
|
773
|
+
// a different session in the same tmux pane (stale title match).
|
|
774
|
+
if (this.paneMapping.panePrompts.size > 0) {
|
|
775
|
+
const activeSessions = sortedSessions.filter((s) => !s.paneId && !teamPaneSessionIds.has(s.id) && !s.isSubagent &&
|
|
776
|
+
hasLikelyPane(s) &&
|
|
777
|
+
(s.status === 'working' || s.status === 'waiting-input' || s.status === 'waiting-approval' || s.status === 'error'));
|
|
778
|
+
for (const session of activeSessions) {
|
|
779
|
+
const sessionTexts = [];
|
|
780
|
+
if (session.latestPrompt)
|
|
781
|
+
sessionTexts.push(session.latestPrompt.replace(/\.{3}$/, '').toLowerCase());
|
|
782
|
+
if (session.initialPrompt)
|
|
783
|
+
sessionTexts.push(session.initialPrompt.replace(/\.{3}$/, '').toLowerCase());
|
|
784
|
+
if (sessionTexts.length === 0)
|
|
785
|
+
continue;
|
|
786
|
+
let bestPaneId;
|
|
787
|
+
let bestScore = 0;
|
|
788
|
+
for (const [paneId, panePromptList] of this.paneMapping.panePrompts) {
|
|
789
|
+
// Only consider panes assigned to idle sessions
|
|
790
|
+
const currentOwner = this.state.sessions.find((s) => s.paneId === paneId && !s.isSubagent);
|
|
791
|
+
if (!currentOwner || currentOwner.status !== 'idle')
|
|
792
|
+
continue;
|
|
793
|
+
for (const panePrompt of panePromptList) {
|
|
794
|
+
const paneLower = panePrompt.toLowerCase();
|
|
795
|
+
for (const sessionText of sessionTexts) {
|
|
796
|
+
if (paneLower.includes(sessionText) || sessionText.includes(paneLower)) {
|
|
797
|
+
const matchLen = Math.min(paneLower.length, sessionText.length);
|
|
798
|
+
const score = matchLen + 100;
|
|
799
|
+
if (score > bestScore) {
|
|
800
|
+
bestScore = score;
|
|
801
|
+
bestPaneId = paneId;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (bestPaneId && bestScore > 100) {
|
|
808
|
+
// Steal the pane from the idle session
|
|
809
|
+
const oldOwner = this.state.sessions.find((s) => s.paneId === bestPaneId && !s.isSubagent);
|
|
810
|
+
if (oldOwner) {
|
|
811
|
+
oldOwner.paneId = undefined;
|
|
812
|
+
}
|
|
813
|
+
session.paneId = bestPaneId;
|
|
814
|
+
changed = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// Fourth pass: propagate to subagents
|
|
819
|
+
for (const session of this.state.sessions) {
|
|
820
|
+
if (!session.isSubagent || !session.parentSessionId)
|
|
821
|
+
continue;
|
|
822
|
+
const parent = this.state.sessions.find((s) => s.id === session.parentSessionId);
|
|
823
|
+
if (parent?.paneId && session.paneId !== parent.paneId) {
|
|
824
|
+
session.paneId = parent.paneId;
|
|
825
|
+
changed = true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// Fifth pass: derive terminalApp from paneId format
|
|
829
|
+
for (const session of this.state.sessions) {
|
|
830
|
+
let newTerminalApp;
|
|
831
|
+
if (!session.paneId) {
|
|
832
|
+
newTerminalApp = undefined;
|
|
833
|
+
}
|
|
834
|
+
else if (/^%\d+$/.test(session.paneId)) {
|
|
835
|
+
newTerminalApp = 'tmux';
|
|
836
|
+
}
|
|
837
|
+
else if (session.paneId.startsWith('iterm:')) {
|
|
838
|
+
newTerminalApp = 'iterm2';
|
|
839
|
+
}
|
|
840
|
+
else if (session.paneId.startsWith('warp:')) {
|
|
841
|
+
newTerminalApp = 'warp';
|
|
842
|
+
}
|
|
843
|
+
else if (session.paneId.startsWith('terminal:')) {
|
|
844
|
+
newTerminalApp = 'terminal';
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
newTerminalApp = 'unknown';
|
|
848
|
+
}
|
|
849
|
+
if (session.terminalApp !== newTerminalApp) {
|
|
850
|
+
session.terminalApp = newTerminalApp;
|
|
851
|
+
changed = true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (changed) {
|
|
855
|
+
this.state.lastUpdated = new Date().toISOString();
|
|
856
|
+
this.emitChange('sessions_updated');
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
detectStaleness() {
|
|
860
|
+
// Re-derive session statuses based on current time tiers
|
|
861
|
+
this.rederiveSessionStatuses();
|
|
862
|
+
this.getLivePaneIds().then((livePanes) => {
|
|
863
|
+
let changed = false;
|
|
864
|
+
for (const team of this.state.teams) {
|
|
865
|
+
const allInactive = team.members.length > 0 && team.members.every((m) => !m.isActive);
|
|
866
|
+
const configPath = path.join(this.config.claudeHome, 'teams', team.name, 'config.json');
|
|
867
|
+
let configStale = false;
|
|
868
|
+
try {
|
|
869
|
+
const stat = fs.statSync(configPath);
|
|
870
|
+
configStale = Date.now() - stat.mtimeMs > 2 * 60 * 60 * 1000;
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
configStale = true;
|
|
874
|
+
}
|
|
875
|
+
const memberSessionIds = new Set();
|
|
876
|
+
if (team.leadSessionId) {
|
|
877
|
+
memberSessionIds.add(team.leadSessionId);
|
|
878
|
+
const leadSession = this.state.sessions.find((s) => s.id === team.leadSessionId);
|
|
879
|
+
if (leadSession) {
|
|
880
|
+
for (const subId of leadSession.subagentIds) {
|
|
881
|
+
memberSessionIds.add(subId);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
886
|
+
const hasRecentEvents = this.state.events.some((e) => memberSessionIds.has(e.sessionId) && new Date(e.timestamp).getTime() > twoHoursAgo);
|
|
887
|
+
const allPanesGone = team.members.length > 0 && team.members.every((m) => !m.tmuxPaneId || !livePanes.has(m.tmuxPaneId));
|
|
888
|
+
const stale = (allInactive || allPanesGone) && configStale && !hasRecentEvents;
|
|
889
|
+
if (team.stale !== stale) {
|
|
890
|
+
team.stale = stale;
|
|
891
|
+
changed = true;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (changed) {
|
|
895
|
+
this.emitChange('teams_updated');
|
|
896
|
+
}
|
|
897
|
+
}).catch((err) => {
|
|
898
|
+
console.error('[aggregator] Error in stale detection:', err);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
destroy() {
|
|
902
|
+
if (this.staleTimer) {
|
|
903
|
+
clearInterval(this.staleTimer);
|
|
904
|
+
this.staleTimer = null;
|
|
905
|
+
}
|
|
906
|
+
if (this.discoveryTimer) {
|
|
907
|
+
clearInterval(this.discoveryTimer);
|
|
908
|
+
this.discoveryTimer = null;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// --- Private helpers ---
|
|
912
|
+
buildSessionsFromFileStates(events) {
|
|
913
|
+
const fileStates = this.adapter ? this.adapter.getAllFileStates() : getAllFileStatesRaw();
|
|
914
|
+
const eventStatusMap = new Map();
|
|
915
|
+
for (const event of events) {
|
|
916
|
+
const existing = eventStatusMap.get(event.sessionId);
|
|
917
|
+
if (!existing || event.timestamp > existing.timestamp) {
|
|
918
|
+
eventStatusMap.set(event.sessionId, {
|
|
919
|
+
status: this.statusFromEventType(event.type),
|
|
920
|
+
timestamp: event.timestamp,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const parentSubagentMap = new Map();
|
|
925
|
+
const sessions = [];
|
|
926
|
+
for (const [filePath, discovered] of this.discoveredFiles) {
|
|
927
|
+
const state = fileStates.get(filePath);
|
|
928
|
+
let lastActivity;
|
|
929
|
+
let fileSize;
|
|
930
|
+
if (state) {
|
|
931
|
+
lastActivity = new Date(state.mtimeMs).toISOString();
|
|
932
|
+
fileSize = state.fileSize;
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
try {
|
|
936
|
+
const stat = fs.statSync(filePath);
|
|
937
|
+
lastActivity = stat.mtime.toISOString();
|
|
938
|
+
fileSize = stat.size;
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const lastEntryType = state
|
|
945
|
+
? machineStateToLastEntryType(state)
|
|
946
|
+
: 'unknown';
|
|
947
|
+
const eventInfo = eventStatusMap.get(discovered.sessionId);
|
|
948
|
+
const ageMs = Date.now() - new Date(lastActivity).getTime();
|
|
949
|
+
const status = deriveSessionStatus(ageMs, lastEntryType, eventInfo);
|
|
950
|
+
sessions.push({
|
|
951
|
+
id: discovered.sessionId,
|
|
952
|
+
project: discovered.project,
|
|
953
|
+
projectDir: discovered.projectDir,
|
|
954
|
+
status,
|
|
955
|
+
lastActivity,
|
|
956
|
+
model: state?.model,
|
|
957
|
+
cwd: state?.cwd,
|
|
958
|
+
gitBranch: state?.gitBranch,
|
|
959
|
+
version: state?.version,
|
|
960
|
+
slug: state?.slug,
|
|
961
|
+
initialPrompt: state?.initialPrompt,
|
|
962
|
+
latestPrompt: state?.latestPrompt,
|
|
963
|
+
tasksId: state?.tasksId,
|
|
964
|
+
isSubagent: discovered.isSubagent,
|
|
965
|
+
parentSessionId: discovered.parentSessionId,
|
|
966
|
+
agentId: discovered.agentId,
|
|
967
|
+
agentName: state?.agentName,
|
|
968
|
+
agentRole: state?.agentRole,
|
|
969
|
+
subagentIds: [],
|
|
970
|
+
fileSize,
|
|
971
|
+
});
|
|
972
|
+
if (discovered.isSubagent && discovered.parentSessionId && discovered.agentId) {
|
|
973
|
+
const existing = parentSubagentMap.get(discovered.parentSessionId);
|
|
974
|
+
if (existing) {
|
|
975
|
+
existing.push(discovered.agentId);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
parentSubagentMap.set(discovered.parentSessionId, [discovered.agentId]);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
else if (!discovered.isSubagent) {
|
|
982
|
+
if (!parentSubagentMap.has(discovered.sessionId)) {
|
|
983
|
+
parentSubagentMap.set(discovered.sessionId, []);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
for (const session of sessions) {
|
|
988
|
+
if (!session.isSubagent) {
|
|
989
|
+
session.subagentIds = parentSubagentMap.get(session.id) ?? [];
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Propagate statuses between parent and subagent sessions
|
|
993
|
+
propagateSubagentStatuses(sessions);
|
|
994
|
+
const projectMap = new Map();
|
|
995
|
+
for (const session of sessions) {
|
|
996
|
+
if (session.isSubagent)
|
|
997
|
+
continue;
|
|
998
|
+
let group = projectMap.get(session.projectDir);
|
|
999
|
+
if (!group) {
|
|
1000
|
+
group = { name: session.project, dirName: session.projectDir, sessions: [] };
|
|
1001
|
+
projectMap.set(session.projectDir, group);
|
|
1002
|
+
}
|
|
1003
|
+
group.sessions.push(session);
|
|
1004
|
+
}
|
|
1005
|
+
return { sessions, projects: Array.from(projectMap.values()) };
|
|
1006
|
+
}
|
|
1007
|
+
buildSessionActivities() {
|
|
1008
|
+
const result = {};
|
|
1009
|
+
const fileStates = this.adapter ? this.adapter.getAllFileStates() : getAllFileStatesRaw();
|
|
1010
|
+
for (const [, state] of fileStates) {
|
|
1011
|
+
const activity = toSessionActivity(state);
|
|
1012
|
+
if (activity && activity.active) {
|
|
1013
|
+
result[activity.sessionId] = activity;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return result;
|
|
1017
|
+
}
|
|
1018
|
+
getLatestEventInfo(sessionId) {
|
|
1019
|
+
for (const event of this.state.events) {
|
|
1020
|
+
if (event.sessionId === sessionId) {
|
|
1021
|
+
return {
|
|
1022
|
+
status: this.statusFromEventType(event.type),
|
|
1023
|
+
timestamp: event.timestamp,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
updateSessionFromEvent(event) {
|
|
1030
|
+
const existing = this.state.sessions.find((s) => s.id === event.sessionId);
|
|
1031
|
+
if (existing) {
|
|
1032
|
+
existing.status = this.statusFromEventType(event.type);
|
|
1033
|
+
existing.lastActivity = event.timestamp;
|
|
1034
|
+
if (event.project)
|
|
1035
|
+
existing.project = event.project;
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
this.state.sessions.push({
|
|
1039
|
+
id: event.sessionId,
|
|
1040
|
+
project: event.project ?? 'unknown',
|
|
1041
|
+
projectDir: '',
|
|
1042
|
+
status: this.statusFromEventType(event.type),
|
|
1043
|
+
lastActivity: event.timestamp,
|
|
1044
|
+
isSubagent: false,
|
|
1045
|
+
subagentIds: [],
|
|
1046
|
+
fileSize: 0,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
this.emitChange('sessions_updated');
|
|
1050
|
+
}
|
|
1051
|
+
statusFromEventType(type) {
|
|
1052
|
+
switch (type) {
|
|
1053
|
+
case 'stop':
|
|
1054
|
+
case 'teammate_idle':
|
|
1055
|
+
case 'subagent_stop':
|
|
1056
|
+
return 'idle';
|
|
1057
|
+
case 'permission_prompt':
|
|
1058
|
+
return 'waiting-approval';
|
|
1059
|
+
case 'idle_prompt':
|
|
1060
|
+
case 'elicitation_dialog':
|
|
1061
|
+
return 'waiting-input';
|
|
1062
|
+
case 'error':
|
|
1063
|
+
return 'error';
|
|
1064
|
+
default:
|
|
1065
|
+
return 'working';
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async getLivePaneIds() {
|
|
1069
|
+
try {
|
|
1070
|
+
const { stdout } = await execFileAsync('tmux', ['list-panes', '-a', '-F', '#{pane_id}']);
|
|
1071
|
+
return new Set(stdout.trim().split('\n').filter(Boolean));
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
return new Set();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
emitChange(type) {
|
|
1078
|
+
this.emit('change', type);
|
|
1079
|
+
}
|
|
1080
|
+
}
|