crewly 1.20.67 → 1.20.69

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 (84) hide show
  1. package/config/skills/agent/core/drive-read/SKILL.md +1 -10
  2. package/config/skills/agent/core/drive-search/SKILL.md +0 -9
  3. package/config/skills/agent/core/drive-upload/SKILL.md +0 -9
  4. package/config/skills/agent/core/google-connect/SKILL.md +44 -0
  5. package/config/skills/agent/core/google-connect/execute.sh +49 -0
  6. package/dist/backend/backend/src/constants.d.ts +53 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +53 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/google/google-connect-card.d.ts +64 -0
  11. package/dist/backend/backend/src/controllers/google/google-connect-card.d.ts.map +1 -0
  12. package/dist/backend/backend/src/controllers/google/google-connect-card.js +122 -0
  13. package/dist/backend/backend/src/controllers/google/google-connect-card.js.map +1 -0
  14. package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/google/google.routes.js +4 -0
  16. package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
  18. package/dist/backend/backend/src/controllers/team/team.controller.js +6 -19
  19. package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
  20. package/dist/backend/backend/src/index.d.ts +16 -0
  21. package/dist/backend/backend/src/index.d.ts.map +1 -1
  22. package/dist/backend/backend/src/index.js +32 -0
  23. package/dist/backend/backend/src/index.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts +17 -0
  25. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/agent/agent-registration.service.js +57 -9
  27. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  28. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +23 -0
  29. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +22 -0
  31. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +23 -0
  33. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js +50 -0
  35. package/dist/backend/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  36. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts +11 -0
  37. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js +13 -0
  39. package/dist/backend/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  41. package/dist/backend/backend/src/services/slack/slack-initializer.js +62 -1
  42. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  43. package/dist/backend/backend/src/services/slack/slack.service.d.ts +17 -0
  44. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/slack/slack.service.js +41 -0
  46. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  47. package/dist/backend/backend/src/services/v3/trigger-engine.service.d.ts +28 -1
  48. package/dist/backend/backend/src/services/v3/trigger-engine.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/v3/trigger-engine.service.js +66 -30
  50. package/dist/backend/backend/src/services/v3/trigger-engine.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/workflow/cron-task.service.d.ts +11 -1
  52. package/dist/backend/backend/src/services/workflow/cron-task.service.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/workflow/cron-task.service.js +68 -18
  54. package/dist/backend/backend/src/services/workflow/cron-task.service.js.map +1 -1
  55. package/dist/backend/build-info.json +2 -2
  56. package/dist/cli/backend/src/constants.d.ts +53 -0
  57. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  58. package/dist/cli/backend/src/constants.js +53 -0
  59. package/dist/cli/backend/src/constants.js.map +1 -1
  60. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts +17 -0
  61. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  62. package/dist/cli/backend/src/services/agent/agent-registration.service.js +57 -9
  63. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  64. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts +23 -0
  65. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.d.ts.map +1 -1
  66. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js +50 -0
  67. package/dist/cli/backend/src/services/messaging/sub-agent-message-queue.service.js.map +1 -1
  68. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts +162 -0
  69. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -0
  70. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js +708 -0
  71. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js.map +1 -0
  72. package/dist/cli/backend/src/services/monitoring/runtime-token-parser.service.d.ts +73 -0
  73. package/dist/cli/backend/src/services/monitoring/runtime-token-parser.service.d.ts.map +1 -0
  74. package/dist/cli/backend/src/services/monitoring/runtime-token-parser.service.js +199 -0
  75. package/dist/cli/backend/src/services/monitoring/runtime-token-parser.service.js.map +1 -0
  76. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts +11 -0
  77. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.d.ts.map +1 -1
  78. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js +13 -0
  79. package/dist/cli/backend/src/services/slack/slack-agent-dm.service.js.map +1 -1
  80. package/dist/cli/backend/src/services/slack/slack.service.d.ts +17 -0
  81. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  82. package/dist/cli/backend/src/services/slack/slack.service.js +41 -0
  83. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  84. package/package.json +1 -1
@@ -0,0 +1,708 @@
1
+ import { StorageService } from '../core/storage.service.js';
2
+ import { getSessionBackendSync, createSessionBackend } from '../session/index.js';
3
+ import { LoggerService } from '../core/logger.service.js';
4
+ import { AgentHeartbeatService } from '../agent/agent-heartbeat.service.js';
5
+ import { writeFile, readFile, rename, unlink } from 'fs/promises';
6
+ import { join } from 'path';
7
+ import { existsSync } from 'fs';
8
+ import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
9
+ import { CREWLY_CONSTANTS, CONTINUATION_CONSTANTS, AGENT_IDENTITY_CONSTANTS, PTY_CONSTANTS, ACTIVITY_MONITOR_CONSTANTS, RUNTIME_TYPES } from '../../constants.js';
10
+ import { stripAnsiCodes } from '../../utils/terminal-output.utils.js';
11
+ import { PtyActivityTrackerService } from '../agent/pty-activity-tracker.service.js';
12
+ import { TokenUsageService } from '../monitoring/token-usage.service.js';
13
+ import { parseRuntimeTokens } from '../monitoring/runtime-token-parser.service.js';
14
+ import { getSettingsService } from '../settings/settings.service.js';
15
+ /**
16
+ * Activity Monitor Service - NEW ARCHITECTURE
17
+ *
18
+ * Responsibilities:
19
+ * - Track ONLY workingStatus (idle vs in_progress) based on terminal activity
20
+ * - Create and manage teamWorkingStatus.json file
21
+ * - Integrate with AgentHeartbeatService for stale agent detection
22
+ * - Remove ALL agentStatus management (now owned by AgentHeartbeatService)
23
+ *
24
+ * Key Changes:
25
+ * - No more agentStatus updates to teams.json
26
+ * - Simple activity detection: output changed = in_progress, else = idle
27
+ * - Integration with stale detection (30-minute threshold)
28
+ * - Separate teamWorkingStatus.json file for working statuses
29
+ */
30
+ export class ActivityMonitorService {
31
+ static instance;
32
+ logger;
33
+ storageService;
34
+ _sessionBackend = null;
35
+ agentHeartbeatService;
36
+ intervalId = null;
37
+ POLLING_INTERVAL = ACTIVITY_MONITOR_CONSTANTS.POLLING_INTERVAL_MS;
38
+ lastTerminalOutputs = new Map();
39
+ MAX_OUTPUT_SIZE = 512; // 512 bytes max per output
40
+ ACTIVITY_CHECK_TIMEOUT = 6000; // 6 second timeout per check
41
+ lastCleanup = Date.now();
42
+ crewlyHome;
43
+ teamWorkingStatusFile;
44
+ eventBusService = null;
45
+ /** Tracks when each session entered in_progress (epoch ms) */
46
+ busyTransitionTimestamps = new Map();
47
+ /** Tracks which sessions have had their agent:busy event emitted */
48
+ busyEventEmitted = new Set();
49
+ /** Tracks last recorded token counts per session to avoid duplicate recording */
50
+ lastRecordedTokens = new Map();
51
+ /** Cached tokenTracking setting, refreshed periodically to avoid async reads in hot path */
52
+ tokenTrackingEnabled = false;
53
+ tokenTrackingLastCheck = 0;
54
+ constructor() {
55
+ this.logger = LoggerService.getInstance().createComponentLogger('ActivityMonitor');
56
+ this.storageService = StorageService.getInstance();
57
+ this.agentHeartbeatService = AgentHeartbeatService.getInstance();
58
+ this.crewlyHome = getCrewlyHomePath();
59
+ this.teamWorkingStatusFile = join(this.crewlyHome, 'teamWorkingStatus.json');
60
+ }
61
+ /**
62
+ * Get the session backend, lazily initializing if needed.
63
+ */
64
+ async getBackend() {
65
+ if (!this._sessionBackend) {
66
+ this._sessionBackend = getSessionBackendSync();
67
+ if (!this._sessionBackend) {
68
+ this._sessionBackend = await createSessionBackend('pty');
69
+ }
70
+ }
71
+ return this._sessionBackend;
72
+ }
73
+ static getInstance() {
74
+ if (!ActivityMonitorService.instance) {
75
+ ActivityMonitorService.instance = new ActivityMonitorService();
76
+ }
77
+ return ActivityMonitorService.instance;
78
+ }
79
+ /**
80
+ * Inject the EventBusService for direct event publishing on status transitions.
81
+ *
82
+ * @param service - The EventBusService instance
83
+ */
84
+ setEventBusService(service) {
85
+ this.eventBusService = service;
86
+ }
87
+ /**
88
+ * Build an AgentEvent for a working status transition.
89
+ *
90
+ * @param type - The event type ('agent:busy' or 'agent:idle')
91
+ * @param timestamp - ISO timestamp for the event
92
+ * @param identity - Agent identity fields (team/member/session)
93
+ * @param previousValue - The previous working status
94
+ * @param newValue - The new working status
95
+ * @returns A fully-formed AgentEvent
96
+ */
97
+ buildAgentEvent(type, timestamp, identity, previousValue, newValue) {
98
+ return {
99
+ id: crypto.randomUUID(),
100
+ type,
101
+ timestamp,
102
+ teamId: identity.teamId,
103
+ teamName: identity.teamName,
104
+ memberId: identity.memberId,
105
+ memberName: identity.memberName,
106
+ sessionName: identity.sessionName,
107
+ previousValue,
108
+ newValue,
109
+ changedField: 'workingStatus',
110
+ };
111
+ }
112
+ /**
113
+ * Handle duration-gated event emission for a session status transition.
114
+ * Suppresses spurious idle→busy→idle cycles by requiring a minimum
115
+ * busy duration (MIN_BUSY_DURATION_MS) before emitting events.
116
+ *
117
+ * @param key - Unique key for tracking (e.g. 'orchestrator' or session name)
118
+ * @param newStatus - The new working status
119
+ * @param previousStatus - The previous working status
120
+ * @param statusChanged - Whether the status actually changed this poll
121
+ * @param identity - Agent identity fields for building the event
122
+ * @param now - ISO timestamp for the event
123
+ */
124
+ handleDurationGatedEmission(key, newStatus, previousStatus, statusChanged, identity, now) {
125
+ if (statusChanged && newStatus === 'in_progress') {
126
+ if (!this.busyTransitionTimestamps.has(key)) {
127
+ this.busyTransitionTimestamps.set(key, Date.now());
128
+ }
129
+ }
130
+ if (statusChanged) {
131
+ const busyStart = this.busyTransitionTimestamps.get(key);
132
+ const elapsed = busyStart != null ? Date.now() - busyStart : 0;
133
+ if (newStatus === 'in_progress') {
134
+ if (elapsed >= PTY_CONSTANTS.MIN_BUSY_DURATION_MS && !this.busyEventEmitted.has(key) && this.eventBusService) {
135
+ this.busyEventEmitted.add(key);
136
+ this.eventBusService.publish(this.buildAgentEvent('agent:busy', now, identity, previousStatus, newStatus));
137
+ }
138
+ }
139
+ else {
140
+ if (elapsed >= PTY_CONSTANTS.MIN_BUSY_DURATION_MS && this.busyEventEmitted.has(key) && this.eventBusService) {
141
+ // Generic event (back-compat for fleet dashboards / auto-claim / etc.)
142
+ this.eventBusService.publish(this.buildAgentEvent('agent:idle', now, identity, previousStatus, newStatus));
143
+ // 2026-05-20 follow-up: the in-process busy guard above (`busyEventEmitted`)
144
+ // guarantees we only reach this branch when the agent was actually
145
+ // observed busy first — so this transition is unambiguously
146
+ // post-task. Emit the precise variant so peer-watch triggers
147
+ // can filter on it without being woken by registration-idle.
148
+ this.eventBusService.publish(this.buildAgentEvent('agent:idle_after_task', now, identity, previousStatus, newStatus));
149
+ }
150
+ this.busyTransitionTimestamps.delete(key);
151
+ this.busyEventEmitted.delete(key);
152
+ }
153
+ }
154
+ else if (newStatus === 'in_progress' && !this.busyEventEmitted.has(key)) {
155
+ // Still in_progress across polls — seed timestamp if missing (service restart)
156
+ // and check if deferred busy event can now be emitted
157
+ if (!this.busyTransitionTimestamps.has(key)) {
158
+ this.busyTransitionTimestamps.set(key, Date.now());
159
+ }
160
+ const busyStart = this.busyTransitionTimestamps.get(key);
161
+ if (Date.now() - busyStart >= PTY_CONSTANTS.MIN_BUSY_DURATION_MS && this.eventBusService) {
162
+ this.busyEventEmitted.add(key);
163
+ this.eventBusService.publish(this.buildAgentEvent('agent:busy', now, identity, 'idle', 'in_progress'));
164
+ }
165
+ }
166
+ }
167
+ startPolling() {
168
+ if (this.intervalId) {
169
+ this.logger.warn('Activity monitoring already running');
170
+ return;
171
+ }
172
+ this.logger.info(`Starting activity monitoring with ${this.POLLING_INTERVAL / 1000}s intervals (workingStatus only)`);
173
+ // Run immediately first
174
+ this.performActivityCheck();
175
+ // Set up recurring polling
176
+ this.intervalId = setInterval(() => {
177
+ this.performActivityCheck();
178
+ }, this.POLLING_INTERVAL);
179
+ }
180
+ stopPolling() {
181
+ if (this.intervalId) {
182
+ clearInterval(this.intervalId);
183
+ this.intervalId = null;
184
+ this.logger.info('Activity monitoring stopped');
185
+ }
186
+ }
187
+ async performActivityCheck() {
188
+ try {
189
+ // Add timeout protection for entire activity check
190
+ await Promise.race([
191
+ this.performActivityCheckInternal(),
192
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Activity check timeout')), this.ACTIVITY_CHECK_TIMEOUT))
193
+ ]);
194
+ // Perform periodic cleanup
195
+ this.performPeriodicCleanup();
196
+ }
197
+ catch (error) {
198
+ this.logger.error('Error during activity check', {
199
+ error: error instanceof Error ? error.message : String(error)
200
+ });
201
+ }
202
+ }
203
+ /**
204
+ * NEW ARCHITECTURE: Simplified activity checking
205
+ *
206
+ * This method now:
207
+ * 1. Detects stale agents via AgentHeartbeatService (30-minute threshold)
208
+ * 2. Checks terminal activity for working status (ONLY)
209
+ * 3. Updates teamWorkingStatus.json (separate from teams.json)
210
+ * 4. Removes ALL agentStatus logic (handled by AgentHeartbeatService)
211
+ */
212
+ async performActivityCheckInternal() {
213
+ const now = new Date().toISOString();
214
+ try {
215
+ // Step 1: Detect stale agents and mark dead ones as inactive
216
+ const staleThreshold = CONTINUATION_CONSTANTS.DETECTION.STALE_THRESHOLD_MINUTES;
217
+ const staleAgents = await this.agentHeartbeatService.detectStaleAgents(staleThreshold);
218
+ if (staleAgents.length > 0) {
219
+ this.logger.debug('Detected stale agents for potential inactivity', {
220
+ staleAgents,
221
+ thresholdMinutes: staleThreshold
222
+ });
223
+ // Batch-check stale agents and mark dead ones as inactive.
224
+ // We read teams ONCE, update all dead members in memory, and save
225
+ // each modified team ONCE — instead of calling updateAgentStatus()
226
+ // per agent which does a full getTeams()+saveTeam() each time,
227
+ // flooding the libuv thread pool with I/O.
228
+ try {
229
+ const backend = await this.getBackend();
230
+ const heartbeats = await this.agentHeartbeatService.getAllAgentHeartbeats();
231
+ // Collect dead session names first
232
+ const deadSessions = new Set();
233
+ for (const agentId of staleAgents) {
234
+ let sessionName;
235
+ if (agentId === 'orchestrator') {
236
+ sessionName = heartbeats.orchestrator?.sessionName;
237
+ }
238
+ else {
239
+ sessionName = heartbeats.teamMembers[agentId]?.sessionName;
240
+ }
241
+ if (!sessionName)
242
+ continue;
243
+ // Skip orchestrator — its lifecycle is managed by OrchestratorHeartbeatMonitor,
244
+ // not the activity monitor. In-process runtimes (crewly-agent) have no PTY
245
+ // session, so sessionExists() would falsely return false.
246
+ if (agentId === AGENT_IDENTITY_CONSTANTS.ORCHESTRATOR.ID) {
247
+ this.logger.debug('Skipping orchestrator stale check (managed by OrchestratorHeartbeatMonitor)', {
248
+ sessionName,
249
+ });
250
+ continue;
251
+ }
252
+ const sessionAlive = backend.sessionExists(sessionName);
253
+ if (!sessionAlive) {
254
+ this.logger.debug('Marking stale agent as inactive (session dead)', {
255
+ agentId,
256
+ sessionName
257
+ });
258
+ deadSessions.add(sessionName);
259
+ // Update heartbeat file so detectStaleAgents stops re-detecting this agent
260
+ await this.agentHeartbeatService.updateAgentHeartbeat(sessionName, agentId, CREWLY_CONSTANTS.AGENT_STATUSES.INACTIVE);
261
+ }
262
+ }
263
+ // Batch-update team members: read teams once, save each modified team once
264
+ if (deadSessions.size > 0) {
265
+ const teams = await this.storageService.getTeams();
266
+ const modifiedTeams = new Set();
267
+ for (const team of teams) {
268
+ for (const member of team.members || []) {
269
+ if (deadSessions.has(member.sessionName)) {
270
+ member.agentStatus = CREWLY_CONSTANTS.AGENT_STATUSES.INACTIVE;
271
+ member.updatedAt = new Date().toISOString();
272
+ modifiedTeams.add(team.id);
273
+ }
274
+ }
275
+ }
276
+ // Save each modified team once (sequentially to avoid lock contention)
277
+ for (const team of teams) {
278
+ if (modifiedTeams.has(team.id)) {
279
+ await this.storageService.saveTeam(team);
280
+ }
281
+ }
282
+ }
283
+ }
284
+ catch (cleanupError) {
285
+ this.logger.error('Error during stale agent cleanup', {
286
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
287
+ });
288
+ }
289
+ }
290
+ // Step 2: Load current working status file
291
+ const workingStatusData = await this.loadTeamWorkingStatusFile();
292
+ let hasChanges = false;
293
+ // Step 3: Check orchestrator working status
294
+ const backend = await this.getBackend();
295
+ const orchestratorRunning = await Promise.race([
296
+ Promise.resolve(backend.sessionExists(CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME)),
297
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Orchestrator check timeout')), 1000))
298
+ ]).catch(() => false);
299
+ if (orchestratorRunning) {
300
+ const orchestratorOutput = await this.getTerminalOutput(CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME);
301
+ const previousOutput = this.lastTerminalOutputs.get('orchestrator') || '';
302
+ const outputChanged = orchestratorOutput !== previousOutput && orchestratorOutput.trim() !== '';
303
+ // Bridge to PtyActivityTracker so idle/heartbeat checks detect activity
304
+ // even when no WebSocket clients are connected
305
+ if (outputChanged) {
306
+ PtyActivityTrackerService.getInstance().recordActivity(CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME);
307
+ }
308
+ let newWorkingStatus = outputChanged ? 'in_progress' : 'idle';
309
+ const orchKey = 'orchestrator';
310
+ const previousStatus = workingStatusData.orchestrator.workingStatus;
311
+ // Auto-reset: if in_progress for longer than MAX_IN_PROGRESS_MS, force idle.
312
+ // Prevents workingStatus from getting stuck due to continuous terminal output
313
+ // (spinners, TUI re-renders, long-running tool calls).
314
+ if (newWorkingStatus === 'in_progress') {
315
+ let busySince = this.busyTransitionTimestamps.get(orchKey);
316
+ if (!busySince) {
317
+ // Seed timestamp when status is already in_progress but no transition was
318
+ // observed (e.g. service restart with pre-existing in_progress in the file).
319
+ // Use updatedAt from the file if available, otherwise fall back to now.
320
+ const fileUpdatedAt = Date.parse(workingStatusData.orchestrator.updatedAt);
321
+ busySince = (previousStatus === 'in_progress' && !isNaN(fileUpdatedAt)) ? fileUpdatedAt : Date.now();
322
+ this.busyTransitionTimestamps.set(orchKey, busySince);
323
+ }
324
+ if ((Date.now() - busySince) > ACTIVITY_MONITOR_CONSTANTS.MAX_IN_PROGRESS_MS) {
325
+ this.logger.info('Auto-resetting orchestrator workingStatus from in_progress to idle (exceeded max duration)', {
326
+ durationMs: Date.now() - busySince,
327
+ maxMs: ACTIVITY_MONITOR_CONSTANTS.MAX_IN_PROGRESS_MS,
328
+ });
329
+ newWorkingStatus = 'idle';
330
+ }
331
+ }
332
+ const statusChanged = previousStatus !== newWorkingStatus;
333
+ if (statusChanged) {
334
+ workingStatusData.orchestrator.workingStatus = newWorkingStatus;
335
+ workingStatusData.orchestrator.lastActivityCheck = now;
336
+ workingStatusData.orchestrator.updatedAt = now;
337
+ hasChanges = true;
338
+ this.logger.info('Orchestrator working status updated', {
339
+ sessionName: CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME,
340
+ newWorkingStatus,
341
+ outputChanged
342
+ });
343
+ }
344
+ this.handleDurationGatedEmission(orchKey, newWorkingStatus, previousStatus, statusChanged, {
345
+ teamId: 'orchestrator',
346
+ teamName: 'Orchestrator',
347
+ memberId: AGENT_IDENTITY_CONSTANTS.ORCHESTRATOR.ID,
348
+ memberName: 'Orchestrator',
349
+ sessionName: CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME,
350
+ }, now);
351
+ this.lastTerminalOutputs.set('orchestrator', orchestratorOutput);
352
+ // Track orchestrator PTY token usage (same as team members)
353
+ this.tryRecordPtyTokenUsage(CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME, orchestratorOutput, 'claude-code');
354
+ }
355
+ else {
356
+ // Orchestrator not running, set to idle
357
+ if (workingStatusData.orchestrator.workingStatus !== 'idle') {
358
+ workingStatusData.orchestrator.workingStatus = 'idle';
359
+ workingStatusData.orchestrator.lastActivityCheck = now;
360
+ workingStatusData.orchestrator.updatedAt = now;
361
+ hasChanges = true;
362
+ }
363
+ }
364
+ // Step 4: Check team member working statuses
365
+ const teams = await this.storageService.getTeams();
366
+ for (const team of teams) {
367
+ for (const member of team.members) {
368
+ if (member.sessionName) {
369
+ try {
370
+ // Check if session exists
371
+ const sessionExists = await Promise.race([
372
+ Promise.resolve(backend.sessionExists(member.sessionName)),
373
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Session check timeout')), 500))
374
+ ]).catch(() => false);
375
+ if (!sessionExists) {
376
+ // Session doesn't exist, set to idle
377
+ const memberKey = member.sessionName;
378
+ if (!workingStatusData.teamMembers[memberKey]) {
379
+ workingStatusData.teamMembers[memberKey] = {
380
+ sessionName: member.sessionName,
381
+ teamMemberId: member.id,
382
+ workingStatus: 'idle',
383
+ lastActivityCheck: now,
384
+ updatedAt: now
385
+ };
386
+ hasChanges = true;
387
+ }
388
+ else if (workingStatusData.teamMembers[memberKey].workingStatus !== 'idle') {
389
+ workingStatusData.teamMembers[memberKey].workingStatus = 'idle';
390
+ workingStatusData.teamMembers[memberKey].lastActivityCheck = now;
391
+ workingStatusData.teamMembers[memberKey].updatedAt = now;
392
+ hasChanges = true;
393
+ }
394
+ this.lastTerminalOutputs.delete(memberKey);
395
+ this.busyTransitionTimestamps.delete(memberKey);
396
+ this.busyEventEmitted.delete(memberKey);
397
+ continue;
398
+ }
399
+ // Get terminal output and check for activity
400
+ const currentOutput = await this.getTerminalOutput(member.sessionName);
401
+ const previousOutput = this.lastTerminalOutputs.get(member.sessionName) || '';
402
+ let outputChanged = currentOutput !== previousOutput && currentOutput.trim() !== '';
403
+ // Gemini CLI TUI re-renders in place, so bottom-N line comparison
404
+ // often shows no change even while the agent is actively working.
405
+ // Fall back to raw PTY activity timestamp as an additional signal.
406
+ if (!outputChanged && member.runtimeType === RUNTIME_TYPES.GEMINI_CLI) {
407
+ const tracker = PtyActivityTrackerService.getInstance();
408
+ const idleMs = tracker.getIdleTimeMs(member.sessionName);
409
+ if (idleMs > 0 && idleMs < ACTIVITY_MONITOR_CONSTANTS.POLLING_INTERVAL_MS) {
410
+ outputChanged = true;
411
+ }
412
+ }
413
+ let newWorkingStatus = outputChanged ? 'in_progress' : 'idle';
414
+ // Auto-reset: if in_progress for longer than MAX_IN_PROGRESS_MS, force idle
415
+ const memberKey = member.sessionName;
416
+ if (newWorkingStatus === 'in_progress') {
417
+ let busySince = this.busyTransitionTimestamps.get(memberKey);
418
+ if (!busySince) {
419
+ // Seed timestamp when status is already in_progress but no transition was
420
+ // observed (e.g. service restart with pre-existing in_progress in the file).
421
+ const existingEntry = workingStatusData.teamMembers[memberKey];
422
+ const fileUpdatedAt = existingEntry ? Date.parse(existingEntry.updatedAt) : NaN;
423
+ const previousMemberStatus = existingEntry?.workingStatus;
424
+ busySince = (previousMemberStatus === 'in_progress' && !isNaN(fileUpdatedAt)) ? fileUpdatedAt : Date.now();
425
+ this.busyTransitionTimestamps.set(memberKey, busySince);
426
+ }
427
+ if ((Date.now() - busySince) > ACTIVITY_MONITOR_CONSTANTS.MAX_IN_PROGRESS_MS) {
428
+ this.logger.info('Auto-resetting member workingStatus from in_progress to idle (exceeded max duration)', {
429
+ sessionName: member.sessionName,
430
+ durationMs: Date.now() - busySince,
431
+ maxMs: ACTIVITY_MONITOR_CONSTANTS.MAX_IN_PROGRESS_MS,
432
+ });
433
+ newWorkingStatus = 'idle';
434
+ }
435
+ }
436
+ // Update working status if changed
437
+ if (!workingStatusData.teamMembers[memberKey]) {
438
+ workingStatusData.teamMembers[memberKey] = {
439
+ sessionName: member.sessionName,
440
+ teamMemberId: member.id,
441
+ workingStatus: newWorkingStatus,
442
+ lastActivityCheck: now,
443
+ updatedAt: now
444
+ };
445
+ hasChanges = true;
446
+ }
447
+ else {
448
+ const previousMemberStatus = workingStatusData.teamMembers[memberKey].workingStatus;
449
+ const memberStatusChanged = previousMemberStatus !== newWorkingStatus;
450
+ if (memberStatusChanged) {
451
+ workingStatusData.teamMembers[memberKey].workingStatus = newWorkingStatus;
452
+ workingStatusData.teamMembers[memberKey].lastActivityCheck = now;
453
+ workingStatusData.teamMembers[memberKey].updatedAt = now;
454
+ hasChanges = true;
455
+ this.logger.info('Team member working status updated', {
456
+ teamId: team.id,
457
+ memberId: member.id,
458
+ memberName: member.name,
459
+ sessionName: member.sessionName,
460
+ newWorkingStatus,
461
+ outputChanged
462
+ });
463
+ }
464
+ this.handleDurationGatedEmission(memberKey, newWorkingStatus, previousMemberStatus, memberStatusChanged, {
465
+ teamId: team.id,
466
+ teamName: team.name,
467
+ memberId: member.id,
468
+ memberName: member.name,
469
+ sessionName: member.sessionName,
470
+ }, now);
471
+ }
472
+ this.lastTerminalOutputs.set(member.sessionName, currentOutput);
473
+ // Token usage tracking: parse PTY output for token data from CLI runtimes
474
+ this.tryRecordPtyTokenUsage(member.sessionName, currentOutput, member.runtimeType);
475
+ }
476
+ catch (error) {
477
+ this.logger.error('Error checking member working status', {
478
+ teamId: team.id,
479
+ memberId: member.id,
480
+ memberName: member.name,
481
+ sessionName: member.sessionName,
482
+ error: error instanceof Error ? error.message : String(error)
483
+ });
484
+ }
485
+ }
486
+ }
487
+ }
488
+ // Step 5: Save changes if any
489
+ if (hasChanges) {
490
+ workingStatusData.metadata.lastUpdated = now;
491
+ await this.saveTeamWorkingStatusFile(workingStatusData);
492
+ this.logger.debug('Updated teamWorkingStatus.json with activity changes');
493
+ }
494
+ }
495
+ catch (error) {
496
+ this.logger.error('Error during activity check', {
497
+ error: error instanceof Error ? error.message : String(error)
498
+ });
499
+ }
500
+ }
501
+ /**
502
+ * Load teamWorkingStatus.json file with proper initialization
503
+ */
504
+ async loadTeamWorkingStatusFile() {
505
+ try {
506
+ if (!existsSync(this.teamWorkingStatusFile)) {
507
+ const defaultData = this.createDefaultTeamWorkingStatusFile();
508
+ await this.saveTeamWorkingStatusFile(defaultData);
509
+ this.logger.info('Created new teamWorkingStatus.json file');
510
+ return defaultData;
511
+ }
512
+ const content = await readFile(this.teamWorkingStatusFile, 'utf-8');
513
+ const data = JSON.parse(content);
514
+ // Validate structure
515
+ if (!data.orchestrator || !data.teamMembers || !data.metadata) {
516
+ this.logger.warn('Invalid teamWorkingStatus.json structure, reinitializing');
517
+ const defaultData = this.createDefaultTeamWorkingStatusFile();
518
+ await this.saveTeamWorkingStatusFile(defaultData);
519
+ return defaultData;
520
+ }
521
+ return data;
522
+ }
523
+ catch (error) {
524
+ this.logger.error('Failed to load teamWorkingStatus.json, creating new file', {
525
+ error: error instanceof Error ? error.message : String(error)
526
+ });
527
+ const defaultData = this.createDefaultTeamWorkingStatusFile();
528
+ await this.saveTeamWorkingStatusFile(defaultData);
529
+ return defaultData;
530
+ }
531
+ }
532
+ /**
533
+ * Save teamWorkingStatus.json file with atomic write
534
+ */
535
+ async saveTeamWorkingStatusFile(data) {
536
+ const content = JSON.stringify(data, null, 2);
537
+ const tempFile = `${this.teamWorkingStatusFile}.tmp`;
538
+ try {
539
+ // Write to temp file first, then rename (atomic operation)
540
+ await writeFile(tempFile, content, 'utf-8');
541
+ await rename(tempFile, this.teamWorkingStatusFile);
542
+ }
543
+ catch (error) {
544
+ // Clean up temp file if something went wrong
545
+ try {
546
+ await unlink(tempFile);
547
+ }
548
+ catch { }
549
+ throw error;
550
+ }
551
+ }
552
+ /**
553
+ * Create default teamWorkingStatus.json structure
554
+ */
555
+ createDefaultTeamWorkingStatusFile() {
556
+ const now = new Date().toISOString();
557
+ return {
558
+ orchestrator: {
559
+ sessionName: CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME,
560
+ workingStatus: 'idle',
561
+ lastActivityCheck: now,
562
+ updatedAt: now
563
+ },
564
+ teamMembers: {},
565
+ metadata: {
566
+ lastUpdated: now,
567
+ version: '1.0.0'
568
+ }
569
+ };
570
+ }
571
+ /**
572
+ * Cleanup old terminal outputs and perform garbage collection
573
+ */
574
+ performPeriodicCleanup() {
575
+ const now = Date.now();
576
+ // Clean up every 2 minutes
577
+ if (now - this.lastCleanup > 2 * 60 * 1000) {
578
+ // Limit the size of lastTerminalOutputs Map to prevent memory leaks
579
+ if (this.lastTerminalOutputs.size > 50) {
580
+ const entries = Array.from(this.lastTerminalOutputs.entries());
581
+ this.lastTerminalOutputs.clear();
582
+ // Keep only the most recent 25 entries
583
+ const recentEntries = entries.slice(-25);
584
+ for (const [key, value] of recentEntries) {
585
+ this.lastTerminalOutputs.set(key, value);
586
+ }
587
+ }
588
+ this.lastCleanup = now;
589
+ // Force garbage collection if available
590
+ if (global.gc) {
591
+ global.gc();
592
+ this.logger.debug('Performed periodic cleanup with garbage collection', {
593
+ mapSize: this.lastTerminalOutputs.size
594
+ });
595
+ }
596
+ else {
597
+ this.logger.debug('Performed periodic cleanup', {
598
+ mapSize: this.lastTerminalOutputs.size
599
+ });
600
+ }
601
+ }
602
+ }
603
+ /**
604
+ * Attempt to parse and record token usage from PTY terminal output.
605
+ *
606
+ * Only records when token tracking is enabled in settings and when the
607
+ * parsed token counts differ from the last recorded values (to avoid
608
+ * duplicate recording from the same output being captured multiple times).
609
+ *
610
+ * @param sessionName - Agent session name
611
+ * @param output - Captured terminal output
612
+ * @param runtimeType - Runtime type hint for parser selection
613
+ */
614
+ tryRecordPtyTokenUsage(sessionName, output, runtimeType) {
615
+ // Skip non-PTY runtimes (crewly-agent records via SDK, not PTY output)
616
+ if (!runtimeType || runtimeType === 'crewly-agent')
617
+ return;
618
+ try {
619
+ // Refresh cached tokenTracking setting every 60 seconds
620
+ const now = Date.now();
621
+ if (now - this.tokenTrackingLastCheck > 60_000) {
622
+ this.tokenTrackingLastCheck = now;
623
+ getSettingsService().getSettings().then(s => {
624
+ this.tokenTrackingEnabled = s.general.tokenTracking ?? false;
625
+ }).catch(() => { });
626
+ }
627
+ if (!this.tokenTrackingEnabled)
628
+ return;
629
+ const parsed = parseRuntimeTokens(output, runtimeType);
630
+ if (!parsed)
631
+ return;
632
+ // Dedup: skip if we already recorded these exact counts for this session
633
+ const last = this.lastRecordedTokens.get(sessionName);
634
+ if (last && last.input === parsed.inputTokens && last.output === parsed.outputTokens) {
635
+ return;
636
+ }
637
+ // Record delta if we have a previous reading, otherwise record full amount
638
+ const deltaInput = last ? Math.max(0, parsed.inputTokens - last.input) : parsed.inputTokens;
639
+ const deltaOutput = last ? Math.max(0, parsed.outputTokens - last.output) : parsed.outputTokens;
640
+ if (deltaInput > 0 || deltaOutput > 0) {
641
+ const model = parsed.model ?? `${runtimeType}-default`;
642
+ TokenUsageService.getInstance().recordUsage(sessionName, sessionName, deltaInput, deltaOutput, model);
643
+ this.lastRecordedTokens.set(sessionName, {
644
+ input: parsed.inputTokens,
645
+ output: parsed.outputTokens,
646
+ });
647
+ }
648
+ }
649
+ catch {
650
+ // Non-critical — don't let token parsing errors affect activity monitoring
651
+ }
652
+ }
653
+ /**
654
+ * Get terminal output with size limits for activity detection
655
+ */
656
+ async getTerminalOutput(sessionName) {
657
+ try {
658
+ const backend = await this.getBackend();
659
+ const output = await Promise.race([
660
+ Promise.resolve(backend.captureOutput(sessionName, 5)), // 5 lines only
661
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Capture timeout')), 500))
662
+ ]);
663
+ // Strip ANSI escape codes so TUI re-renders (spinners, cursor
664
+ // repositioning) don't register as meaningful output changes.
665
+ const cleaned = stripAnsiCodes(output).trim();
666
+ // Limit output size to prevent memory issues
667
+ return cleaned.length > this.MAX_OUTPUT_SIZE
668
+ ? cleaned.substring(cleaned.length - this.MAX_OUTPUT_SIZE)
669
+ : cleaned;
670
+ }
671
+ catch (error) {
672
+ return '';
673
+ }
674
+ }
675
+ isRunning() {
676
+ return this.intervalId !== null;
677
+ }
678
+ getPollingInterval() {
679
+ return this.POLLING_INTERVAL;
680
+ }
681
+ /**
682
+ * Get current team working status data
683
+ * Useful for external services that need to check working statuses
684
+ */
685
+ async getTeamWorkingStatus() {
686
+ return await this.loadTeamWorkingStatusFile();
687
+ }
688
+ /**
689
+ * Get working status for a specific session
690
+ */
691
+ async getWorkingStatusForSession(sessionName) {
692
+ try {
693
+ const data = await this.loadTeamWorkingStatusFile();
694
+ if (sessionName === CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME) {
695
+ return data.orchestrator.workingStatus;
696
+ }
697
+ return data.teamMembers[sessionName]?.workingStatus || null;
698
+ }
699
+ catch (error) {
700
+ this.logger.error('Failed to get working status for session', {
701
+ sessionName,
702
+ error: error instanceof Error ? error.message : String(error)
703
+ });
704
+ return null;
705
+ }
706
+ }
707
+ }
708
+ //# sourceMappingURL=activity-monitor.service.js.map