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.
Files changed (143) hide show
  1. package/.claude/skills/brainstorm/SKILL.md +340 -0
  2. package/.claude/skills/code-review-excellence/SKILL.md +198 -0
  3. package/.claude/skills/directive/SKILL.md +121 -0
  4. package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
  5. package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
  6. package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
  7. package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
  8. package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
  9. package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
  10. package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
  11. package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
  12. package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
  13. package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
  14. package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
  15. package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
  16. package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
  17. package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
  18. package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
  19. package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
  20. package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
  21. package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
  22. package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
  23. package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
  24. package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
  25. package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
  26. package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
  27. package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
  28. package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
  29. package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
  30. package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
  31. package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
  32. package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
  33. package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
  34. package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
  35. package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
  36. package/.claude/skills/frontend-design/SKILL.md +42 -0
  37. package/.claude/skills/gruai-agents/SKILL.md +161 -0
  38. package/.claude/skills/gruai-config/SKILL.md +61 -0
  39. package/.claude/skills/healthcheck/SKILL.md +216 -0
  40. package/.claude/skills/report/SKILL.md +380 -0
  41. package/.claude/skills/scout/SKILL.md +452 -0
  42. package/.claude/skills/seo-audit/SKILL.md +107 -0
  43. package/.claude/skills/walkthrough/SKILL.md +274 -0
  44. package/.claude/skills/webapp-testing/SKILL.md +96 -0
  45. package/LICENSE +21 -0
  46. package/README.md +206 -0
  47. package/cli/templates/CLAUDE.md.template +57 -0
  48. package/cli/templates/agent-roles/backend.md +47 -0
  49. package/cli/templates/agent-roles/cmo.md +52 -0
  50. package/cli/templates/agent-roles/content.md +48 -0
  51. package/cli/templates/agent-roles/coo.md +66 -0
  52. package/cli/templates/agent-roles/cpo.md +52 -0
  53. package/cli/templates/agent-roles/cto.md +63 -0
  54. package/cli/templates/agent-roles/data.md +46 -0
  55. package/cli/templates/agent-roles/design.md +46 -0
  56. package/cli/templates/agent-roles/frontend.md +47 -0
  57. package/cli/templates/agent-roles/fullstack.md +47 -0
  58. package/cli/templates/agent-roles/qa.md +46 -0
  59. package/cli/templates/backlog.json.template +3 -0
  60. package/cli/templates/directive.json.template +9 -0
  61. package/cli/templates/directive.md.template +23 -0
  62. package/cli/templates/goals-index.md +21 -0
  63. package/cli/templates/gruai.config.json.template +12 -0
  64. package/cli/templates/lessons.md +16 -0
  65. package/cli/templates/vision.md +35 -0
  66. package/cli/templates/welcome-directive/directive.json +9 -0
  67. package/cli/templates/welcome-directive/directive.md +53 -0
  68. package/dist/assets/GamePage-C5XQQOQH.js +49 -0
  69. package/dist/assets/README.md +17 -0
  70. package/dist/assets/characters/char_0.png +0 -0
  71. package/dist/assets/characters/char_1.png +0 -0
  72. package/dist/assets/characters/char_10.png +0 -0
  73. package/dist/assets/characters/char_11.png +0 -0
  74. package/dist/assets/characters/char_2.png +0 -0
  75. package/dist/assets/characters/char_3.png +0 -0
  76. package/dist/assets/characters/char_4.png +0 -0
  77. package/dist/assets/characters/char_5.png +0 -0
  78. package/dist/assets/characters/char_6.png +0 -0
  79. package/dist/assets/characters/char_7.png +0 -0
  80. package/dist/assets/characters/char_8.png +0 -0
  81. package/dist/assets/characters/char_9.png +0 -0
  82. package/dist/assets/index-CnTPDqpP.js +12 -0
  83. package/dist/assets/index-gR5q7ikB.css +1 -0
  84. package/dist/assets/office/furniture.png +0 -0
  85. package/dist/assets/office/room-builder.png +0 -0
  86. package/dist/index.html +16 -0
  87. package/dist-server/scripts/intelligence-trends.d.ts +100 -0
  88. package/dist-server/scripts/intelligence-trends.js +365 -0
  89. package/dist-server/server/actions/cleanup.d.ts +4 -0
  90. package/dist-server/server/actions/cleanup.js +30 -0
  91. package/dist-server/server/actions/send-input.d.ts +6 -0
  92. package/dist-server/server/actions/send-input.js +147 -0
  93. package/dist-server/server/actions/terminal.d.ts +4 -0
  94. package/dist-server/server/actions/terminal.js +427 -0
  95. package/dist-server/server/config.d.ts +9 -0
  96. package/dist-server/server/config.js +217 -0
  97. package/dist-server/server/db.d.ts +7 -0
  98. package/dist-server/server/db.js +79 -0
  99. package/dist-server/server/hooks/event-receiver.d.ts +11 -0
  100. package/dist-server/server/hooks/event-receiver.js +36 -0
  101. package/dist-server/server/index.d.ts +1 -0
  102. package/dist-server/server/index.js +552 -0
  103. package/dist-server/server/notifications/macos.d.ts +5 -0
  104. package/dist-server/server/notifications/macos.js +22 -0
  105. package/dist-server/server/notifications/notifier.d.ts +17 -0
  106. package/dist-server/server/notifications/notifier.js +110 -0
  107. package/dist-server/server/parsers/process-discovery.d.ts +39 -0
  108. package/dist-server/server/parsers/process-discovery.js +776 -0
  109. package/dist-server/server/parsers/session-scanner.d.ts +56 -0
  110. package/dist-server/server/parsers/session-scanner.js +390 -0
  111. package/dist-server/server/parsers/session-state.d.ts +68 -0
  112. package/dist-server/server/parsers/session-state.js +696 -0
  113. package/dist-server/server/parsers/session-state.test.d.ts +1 -0
  114. package/dist-server/server/parsers/session-state.test.js +950 -0
  115. package/dist-server/server/parsers/task-parser.d.ts +10 -0
  116. package/dist-server/server/parsers/task-parser.js +97 -0
  117. package/dist-server/server/parsers/team-parser.d.ts +3 -0
  118. package/dist-server/server/parsers/team-parser.js +67 -0
  119. package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
  120. package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
  121. package/dist-server/server/platform/claude-code.d.ts +34 -0
  122. package/dist-server/server/platform/claude-code.js +94 -0
  123. package/dist-server/server/platform/index.d.ts +5 -0
  124. package/dist-server/server/platform/index.js +1 -0
  125. package/dist-server/server/platform/types.d.ts +190 -0
  126. package/dist-server/server/platform/types.js +9 -0
  127. package/dist-server/server/state/aggregator.d.ts +42 -0
  128. package/dist-server/server/state/aggregator.js +1080 -0
  129. package/dist-server/server/state/work-item-types.d.ts +555 -0
  130. package/dist-server/server/state/work-item-types.js +168 -0
  131. package/dist-server/server/types.d.ts +237 -0
  132. package/dist-server/server/types.js +1 -0
  133. package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
  134. package/dist-server/server/watchers/claude-watcher.js +130 -0
  135. package/dist-server/server/watchers/context-watcher.d.ts +22 -0
  136. package/dist-server/server/watchers/context-watcher.js +125 -0
  137. package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
  138. package/dist-server/server/watchers/directive-watcher.js +497 -0
  139. package/dist-server/server/watchers/session-watcher.d.ts +18 -0
  140. package/dist-server/server/watchers/session-watcher.js +126 -0
  141. package/dist-server/server/watchers/state-watcher.d.ts +36 -0
  142. package/dist-server/server/watchers/state-watcher.js +369 -0
  143. 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
+ }