@fonz/tgcc 0.6.17 → 0.6.18

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/dist/bridge.js CHANGED
@@ -6,13 +6,16 @@ import pino from 'pino';
6
6
  import { resolveUserConfig, resolveRepoPath, updateConfig, isValidRepoName, findRepoOwner } from './config.js';
7
7
  import { CCProcess, generateMcpConfig } from './cc-process.js';
8
8
  import { createTextMessage, createImageMessage, createDocumentMessage, } from './cc-protocol.js';
9
- import { StreamAccumulator, SubAgentTracker, escapeHtml } from './streaming.js';
9
+ import { StreamAccumulator, SubAgentTracker, escapeHtml, formatSystemMessage } from './streaming.js';
10
10
  import { TelegramBot } from './telegram.js';
11
11
  import { InlineKeyboard } from 'grammy';
12
12
  import { McpBridgeServer } from './mcp-bridge.js';
13
13
  import { SessionStore, discoverCCSessions, } from './session.js';
14
14
  import { CtlServer, } from './ctl-server.js';
15
15
  import { ProcessRegistry } from './process-registry.js';
16
+ import { EventBuffer } from './event-buffer.js';
17
+ import { HighSignalDetector } from './high-signal.js';
18
+ import { randomUUID } from 'node:crypto';
16
19
  // ── Message Batcher ──
17
20
  class MessageBatcher {
18
21
  pending = [];
@@ -100,11 +103,18 @@ export class Bridge extends EventEmitter {
100
103
  config;
101
104
  agents = new Map();
102
105
  processRegistry = new ProcessRegistry();
103
- sessionModelOverrides = new Map(); // "agentId:userId" → model (from /model cmd, not persisted)
104
106
  mcpServer;
105
107
  ctlServer;
106
108
  sessionStore;
107
109
  logger;
110
+ // High-signal event detection
111
+ highSignalDetector;
112
+ // Supervisor protocol
113
+ supervisorWrite = null;
114
+ supervisorAgentId = null;
115
+ supervisorSubscriptions = new Set(); // "agentId:sessionId" or "agentId:*"
116
+ suppressExitForProcess = new Set(); // sessionIds where takeover suppresses exit event
117
+ supervisorPendingRequests = new Map();
108
118
  constructor(config, logger) {
109
119
  super();
110
120
  this.config = config;
@@ -112,6 +122,18 @@ export class Bridge extends EventEmitter {
112
122
  this.sessionStore = new SessionStore(config.global.stateFile, this.logger);
113
123
  this.mcpServer = new McpBridgeServer((req) => this.handleMcpToolRequest(req), this.logger);
114
124
  this.ctlServer = new CtlServer(this, this.logger);
125
+ this.highSignalDetector = new HighSignalDetector({
126
+ emitSupervisorEvent: (event) => {
127
+ if (this.isSupervisorSubscribed(event.agentId, this.agents.get(event.agentId)?.ccProcess?.sessionId ?? null)) {
128
+ this.sendToSupervisor(event);
129
+ }
130
+ },
131
+ pushEventBuffer: (agentId, line) => {
132
+ const agent = this.agents.get(agentId);
133
+ if (agent)
134
+ agent.eventBuffer.push(line);
135
+ },
136
+ });
115
137
  }
116
138
  // ── Startup ──
117
139
  async start() {
@@ -120,20 +142,37 @@ export class Bridge extends EventEmitter {
120
142
  await this.startAgent(agentId, agentConfig);
121
143
  }
122
144
  this.logger.info({ agents: Object.keys(this.config.agents) }, 'Bridge started');
145
+ // Emit bridge_started event to supervisor
146
+ this.sendToSupervisor({
147
+ type: 'event',
148
+ event: 'bridge_started',
149
+ agents: Object.keys(this.config.agents),
150
+ uptime: 0,
151
+ });
123
152
  }
124
153
  async startAgent(agentId, agentConfig) {
125
154
  this.logger.info({ agentId }, 'Starting agent');
126
155
  const tgBot = new TelegramBot(agentId, agentConfig, this.config.global.mediaDir, (msg) => this.handleTelegramMessage(agentId, msg), (cmd) => this.handleSlashCommand(agentId, cmd), this.logger, (query) => this.handleCallbackQuery(agentId, query));
156
+ // Resolve initial repo and model from config + persisted state
157
+ const agentState = this.sessionStore.getAgent(agentId);
158
+ const configDefaults = agentConfig.defaults;
127
159
  const instance = {
128
160
  id: agentId,
129
161
  config: agentConfig,
130
162
  tgBot,
131
- processes: new Map(),
132
- accumulators: new Map(),
133
- subAgentTrackers: new Map(),
134
- batchers: new Map(),
163
+ ephemeral: false,
164
+ repo: agentState.repo || configDefaults.repo,
165
+ model: agentState.model || configDefaults.model,
166
+ ccProcess: null,
167
+ accumulator: null,
168
+ subAgentTracker: null,
169
+ batcher: null,
135
170
  pendingPermissions: new Map(),
136
- typingIntervals: new Map(),
171
+ typingInterval: null,
172
+ typingChatId: null,
173
+ pendingSessionId: null,
174
+ destroyTimer: null,
175
+ eventBuffer: new EventBuffer(),
137
176
  };
138
177
  this.agents.set(agentId, instance);
139
178
  await tgBot.start();
@@ -174,67 +213,62 @@ export class Bridge extends EventEmitter {
174
213
  const agent = this.agents.get(agentId);
175
214
  if (!agent)
176
215
  return;
177
- this.logger.info({ agentId }, 'Stopping agent');
178
- // Stop bot
179
- await agent.tgBot.stop();
180
- // Unsubscribe all clients for this agent from the registry
181
- for (const [userId] of agent.processes) {
182
- const chatId = Number(userId); // In TG, private chat ID === user ID
183
- const clientRef = { agentId, userId, chatId };
184
- this.processRegistry.unsubscribe(clientRef);
216
+ this.logger.info({ agentId, ephemeral: agent.ephemeral }, 'Stopping agent');
217
+ // Stop bot (persistent agents only)
218
+ if (agent.tgBot)
219
+ await agent.tgBot.stop();
220
+ // Clear auto-destroy timer (ephemeral agents)
221
+ if (agent.destroyTimer) {
222
+ clearTimeout(agent.destroyTimer);
223
+ agent.destroyTimer = null;
185
224
  }
186
- // Kill all CC processes and wait for them to exit
187
- const processExitPromises = [];
188
- for (const [, proc] of agent.processes) {
189
- // Create a promise that resolves when the process exits or times out
225
+ // Kill CC process if active
226
+ if (agent.ccProcess) {
227
+ const proc = agent.ccProcess;
190
228
  const exitPromise = new Promise((resolve) => {
191
229
  const onExit = () => {
192
230
  proc.off('exit', onExit);
193
231
  resolve();
194
232
  };
195
233
  proc.on('exit', onExit);
196
- // Timeout after 3 seconds if process doesn't exit
197
234
  const timeoutId = setTimeout(() => {
198
235
  proc.off('exit', onExit);
199
236
  resolve();
200
237
  }, 3000);
201
- // Clear timeout if process exits before timeout
202
238
  proc.on('exit', () => clearTimeout(timeoutId));
203
239
  });
204
- processExitPromises.push(exitPromise);
205
240
  proc.destroy();
241
+ await exitPromise;
242
+ // Unsubscribe from registry
243
+ const clientRef = { agentId, userId: agentId, chatId: 0 };
244
+ this.processRegistry.unsubscribe(clientRef);
206
245
  }
207
- // Wait for all processes to exit (or timeout)
208
- await Promise.all(processExitPromises);
209
- // Cancel batchers
210
- for (const [, batcher] of agent.batchers) {
211
- batcher.cancel();
212
- batcher.destroy();
213
- }
214
- // Close MCP sockets
215
- for (const userId of agent.processes.keys()) {
216
- const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
217
- this.mcpServer.close(socketPath);
246
+ // Cancel batcher
247
+ if (agent.batcher) {
248
+ agent.batcher.cancel();
249
+ agent.batcher.destroy();
218
250
  }
219
- // Clean up accumulators (clears edit timers)
220
- for (const [, acc] of agent.accumulators) {
221
- acc.finalize();
251
+ // Close MCP socket
252
+ const socketPath = join(this.config.global.socketDir, `${agentId}.sock`);
253
+ this.mcpServer.close(socketPath);
254
+ // Clean up accumulator
255
+ if (agent.accumulator) {
256
+ agent.accumulator.finalize();
257
+ agent.accumulator = null;
222
258
  }
223
- agent.accumulators.clear();
224
- // Clean up sub-agent trackers (stops mailbox watchers)
225
- for (const [, tracker] of agent.subAgentTrackers) {
226
- tracker.reset();
259
+ // Clean up sub-agent tracker
260
+ if (agent.subAgentTracker) {
261
+ agent.subAgentTracker.reset();
262
+ agent.subAgentTracker = null;
227
263
  }
228
- agent.subAgentTrackers.clear();
229
- // Clear remaining maps
230
- // Clear typing indicators
231
- for (const [, interval] of agent.typingIntervals) {
232
- clearInterval(interval);
264
+ // Clear typing indicator
265
+ if (agent.typingInterval) {
266
+ clearInterval(agent.typingInterval);
267
+ agent.typingInterval = null;
233
268
  }
234
- agent.typingIntervals.clear();
235
269
  agent.pendingPermissions.clear();
236
- agent.processes.clear();
237
- agent.batchers.clear();
270
+ agent.ccProcess = null;
271
+ agent.batcher = null;
238
272
  // Close control socket
239
273
  const ctlSocketPath = join('/tmp/tgcc/ctl', `${agentId}.sock`);
240
274
  this.ctlServer.close(ctlSocketPath);
@@ -246,19 +280,18 @@ export class Bridge extends EventEmitter {
246
280
  if (!agent)
247
281
  return;
248
282
  this.logger.debug({ agentId, userId: msg.userId, type: msg.type }, 'TG message received');
249
- // Ensure batcher exists
250
- if (!agent.batchers.has(msg.userId)) {
251
- agent.batchers.set(msg.userId, new MessageBatcher(2000, (combined) => {
252
- this.sendToCC(agentId, msg.userId, msg.chatId, combined);
253
- }));
283
+ // Ensure batcher exists (one per agent, not per user)
284
+ if (!agent.batcher) {
285
+ agent.batcher = new MessageBatcher(2000, (combined) => {
286
+ this.sendToCC(agentId, combined, { chatId: msg.chatId, spawnSource: 'telegram' });
287
+ });
254
288
  }
255
289
  // Prepare text with reply context
256
290
  let text = msg.text;
257
291
  if (msg.replyToText) {
258
292
  text = `[Replying to: '${msg.replyToText}']\n\n${text}`;
259
293
  }
260
- const batcher = agent.batchers.get(msg.userId);
261
- batcher.add({
294
+ agent.batcher.add({
262
295
  text,
263
296
  imageBase64: msg.imageBase64,
264
297
  imageMediaType: msg.imageMediaType,
@@ -266,7 +299,7 @@ export class Bridge extends EventEmitter {
266
299
  fileName: msg.fileName,
267
300
  });
268
301
  }
269
- sendToCC(agentId, userId, chatId, data) {
302
+ sendToCC(agentId, data, source) {
270
303
  const agent = this.agents.get(agentId);
271
304
  if (!agent)
272
305
  return;
@@ -281,378 +314,306 @@ export class Bridge extends EventEmitter {
281
314
  else {
282
315
  ccMsg = createTextMessage(data.text);
283
316
  }
284
- const clientRef = { agentId, userId, chatId };
285
- // Check if this client already has a process via the registry
286
- let existingEntry = this.processRegistry.findByClient(clientRef);
287
- let proc = existingEntry?.ccProcess;
317
+ let proc = agent.ccProcess;
288
318
  if (proc?.takenOver) {
289
319
  // Session was taken over externally — discard old process
290
- const entry = this.processRegistry.findByClient(clientRef);
291
- this.processRegistry.destroy(entry.repo, entry.sessionId);
292
- agent.processes.delete(userId);
293
- proc = undefined;
294
- existingEntry = null;
295
- }
296
- // Staleness check: detect if session was modified by another client
297
- // Skip if background sub-agents are running — their results grow the JSONL
298
- // and would cause false-positive staleness detection
299
- const accKey2 = `${userId}:${chatId}`;
300
- const activeTracker = agent.subAgentTrackers.get(accKey2);
301
- const hasBackgroundAgents = activeTracker?.hasDispatchedAgents ?? false;
302
- if (proc && proc.state !== 'idle' && !hasBackgroundAgents) {
303
- const staleInfo = this.checkSessionStaleness(agentId, userId);
304
- if (staleInfo) {
305
- // Session was modified externally — silently reconnect for roaming support
306
- this.logger.info({ agentId, userId }, 'Session modified externally — reconnecting for roaming');
307
- const entry = this.processRegistry.findByClient(clientRef);
308
- this.processRegistry.unsubscribe(clientRef);
309
- agent.processes.delete(userId);
310
- proc = undefined;
311
- existingEntry = null;
312
- }
320
+ const entry = this.processRegistry.findByProcess(proc);
321
+ if (entry)
322
+ this.processRegistry.destroy(entry.repo, entry.sessionId);
323
+ agent.ccProcess = null;
324
+ proc = null;
313
325
  }
314
326
  if (!proc || proc.state === 'idle') {
315
- // Warn if no repo is configured — CC would run in ~ which is likely not intended
316
- const userState2 = this.sessionStore.getUser(agentId, userId);
317
- const resolvedRepo = userState2.repo || resolveUserConfig(agent.config, userId).repo;
318
- if (resolvedRepo === homedir()) {
319
- agent.tgBot.sendText(chatId, '<blockquote>⚠️ No project selected. Use /repo to pick one, or CC will run in your home directory.</blockquote>', 'HTML').catch(err => this.logger.error({ err }, 'Failed to send no-repo warning'));
320
- }
321
- // Check if another client already has a process for this repo+session
322
- const sessionId = userState2.currentSessionId;
323
- if (sessionId && resolvedRepo) {
324
- const sharedEntry = this.processRegistry.get(resolvedRepo, sessionId);
325
- if (sharedEntry && sharedEntry.ccProcess.state !== 'idle') {
326
- // Attach to existing process as subscriber
327
- this.processRegistry.subscribe(resolvedRepo, sessionId, clientRef);
328
- proc = sharedEntry.ccProcess;
329
- agent.processes.set(userId, proc);
330
- // Notify the user they've attached
331
- agent.tgBot.sendText(chatId, '<blockquote>📎 Attached to existing session process.</blockquote>', 'HTML').catch(err => this.logger.error({ err }, 'Failed to send attach notification'));
332
- // Show typing indicator and forward message
333
- this.startTypingIndicator(agent, userId, chatId);
334
- proc.sendMessage(ccMsg);
335
- return;
327
+ // Warn if no repo is configured
328
+ if (agent.repo === homedir()) {
329
+ const chatId = source?.chatId;
330
+ if (chatId && agent.tgBot) {
331
+ agent.tgBot.sendText(chatId, formatSystemMessage('status', 'No project selected. Use /repo to pick one, or CC will run in your home directory.'), 'HTML', true).catch(err => this.logger.error({ err }, 'Failed to send no-repo warning'));
336
332
  }
337
333
  }
338
- // Save first message text as pending session title
339
- if (data.text) {
334
+ proc = this.spawnCCProcess(agentId);
335
+ agent.ccProcess = proc;
336
+ // Emit cc_spawned event to supervisor
337
+ const spawnSource = source?.spawnSource ?? 'telegram';
338
+ this.logger.info({ agentId, sessionId: proc.sessionId, source: spawnSource }, 'CC process spawned');
339
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
340
+ this.sendToSupervisor({
341
+ type: 'event',
342
+ event: 'cc_spawned',
343
+ agentId,
344
+ sessionId: proc.sessionId,
345
+ source: spawnSource,
346
+ });
340
347
  }
341
- proc = this.spawnCCProcess(agentId, userId, chatId);
342
- agent.processes.set(userId, proc);
343
348
  }
344
- // Show typing indicator (repeated every 4s — TG typing expires after ~5s)
345
- this.startTypingIndicator(agent, userId, chatId);
349
+ // Show typing indicator
350
+ if (source?.chatId) {
351
+ this.startTypingIndicator(agent, source.chatId);
352
+ }
353
+ // Log user message in event buffer
354
+ agent.eventBuffer.push({ ts: Date.now(), type: 'user', text: data.text });
346
355
  proc.sendMessage(ccMsg);
347
356
  }
348
- /** Check if the session JSONL was modified externally since we last tracked it. */
349
- checkSessionStaleness(_agentId, _userId) {
350
- // With shared process registry, staleness is handled by the registry itself
351
- return null;
352
- }
353
357
  // ── Process cleanup helper ──
354
358
  /**
355
- * Disconnect a client from its CC process.
356
- * If other subscribers remain, the process stays alive.
357
- * If this was the last subscriber, the process is destroyed.
359
+ * Kill the agent's CC process and clean up.
358
360
  */
359
- disconnectClient(agentId, userId, chatId) {
361
+ killAgentProcess(agentId) {
360
362
  const agent = this.agents.get(agentId);
361
363
  if (!agent)
362
364
  return;
363
- const clientRef = { agentId, userId, chatId };
364
- const proc = agent.processes.get(userId);
365
- const destroyed = this.processRegistry.unsubscribe(clientRef);
366
- if (!destroyed && proc) {
367
- // Other subscribers still attached — just remove from this agent's map
365
+ const proc = agent.ccProcess;
366
+ if (proc) {
367
+ const entry = this.processRegistry.findByProcess(proc);
368
+ if (entry) {
369
+ this.processRegistry.destroy(entry.repo, entry.sessionId);
370
+ }
371
+ else {
372
+ proc.destroy();
373
+ }
368
374
  }
369
- else if (proc && !destroyed) {
370
- // Not in registry but has a process — destroy directly (legacy path)
371
- proc.destroy();
375
+ agent.ccProcess = null;
376
+ // Clean up accumulator & tracker
377
+ if (agent.accumulator) {
378
+ agent.accumulator.finalize();
379
+ agent.accumulator = null;
372
380
  }
373
- agent.processes.delete(userId);
381
+ if (agent.subAgentTracker) {
382
+ agent.subAgentTracker.reset();
383
+ agent.subAgentTracker = null;
384
+ }
385
+ this.stopTypingIndicator(agent);
374
386
  }
375
387
  // ── Typing indicator management ──
376
- startTypingIndicator(agent, userId, chatId) {
377
- const key = `${userId}:${chatId}`;
388
+ /** Get the primary TG chat ID for an agent (first allowed user). */
389
+ getAgentChatId(agent) {
390
+ // If we have a typing chatId, use that (most recent active chat)
391
+ if (agent.typingChatId)
392
+ return agent.typingChatId;
393
+ // Fall back to first allowed user
394
+ const firstUser = agent.config.allowedUsers[0];
395
+ return firstUser ? Number(firstUser) : null;
396
+ }
397
+ startTypingIndicator(agent, chatId) {
378
398
  // Don't create duplicate intervals
379
- if (agent.typingIntervals.has(key))
399
+ if (agent.typingInterval)
400
+ return;
401
+ if (!agent.tgBot)
380
402
  return;
403
+ agent.typingChatId = chatId;
381
404
  // Send immediately, then repeat every 4s (TG typing badge lasts ~5s)
382
405
  agent.tgBot.sendTyping(chatId);
383
406
  const interval = setInterval(() => {
384
- agent.tgBot.sendTyping(chatId);
407
+ if (agent.typingChatId)
408
+ agent.tgBot?.sendTyping(agent.typingChatId);
385
409
  }, 4_000);
386
- agent.typingIntervals.set(key, interval);
410
+ agent.typingInterval = interval;
387
411
  }
388
- stopTypingIndicator(agent, userId, chatId) {
389
- const key = `${userId}:${chatId}`;
390
- const interval = agent.typingIntervals.get(key);
391
- if (interval) {
392
- clearInterval(interval);
393
- agent.typingIntervals.delete(key);
412
+ stopTypingIndicator(agent) {
413
+ if (agent.typingInterval) {
414
+ clearInterval(agent.typingInterval);
415
+ agent.typingInterval = null;
416
+ agent.typingChatId = null;
394
417
  }
395
418
  }
396
- spawnCCProcess(agentId, userId, chatId) {
419
+ spawnCCProcess(agentId) {
397
420
  const agent = this.agents.get(agentId);
398
- const userConfig = resolveUserConfig(agent.config, userId);
399
- // Check session store for repo/permission overrides
400
- const userState = this.sessionStore.getUser(agentId, userId);
401
- if (userState.repo)
402
- userConfig.repo = userState.repo;
403
- // Model priority: /model override > running process > session JSONL > agent config default
404
- const modelOverride = this.sessionModelOverrides.get(`${agentId}:${userId}`);
405
- if (modelOverride) {
406
- userConfig.model = modelOverride;
407
- // Don't delete yet — cleared when CC writes first assistant message
421
+ const agentState = this.sessionStore.getAgent(agentId);
422
+ // Build userConfig from agent-level state
423
+ const userConfig = resolveUserConfig(agent.config, agent.config.allowedUsers[0] || 'default');
424
+ userConfig.repo = agent.repo;
425
+ userConfig.model = agent.model;
426
+ if (agentState.permissionMode) {
427
+ userConfig.permissionMode = agentState.permissionMode;
408
428
  }
409
- else {
410
- const currentSessionId = userState.currentSessionId;
411
- if (currentSessionId && userConfig.repo) {
412
- const registryEntry = this.processRegistry.get(userConfig.repo, currentSessionId);
413
- if (registryEntry?.model) {
414
- userConfig.model = registryEntry.model;
415
- }
416
- else {
417
- const sessions = discoverCCSessions(userConfig.repo, 20);
418
- const sessionInfo = sessions.find(s => s.id === currentSessionId);
419
- if (sessionInfo?.model) {
420
- userConfig.model = sessionInfo.model;
421
- }
422
- }
423
- }
424
- }
425
- if (userState.permissionMode) {
426
- userConfig.permissionMode = userState.permissionMode;
427
- }
428
- // Generate MCP config
429
+ // Determine session ID: pending from /resume, or let CC create new
430
+ const sessionId = agent.pendingSessionId ?? undefined;
431
+ agent.pendingSessionId = null; // consumed
432
+ // Generate MCP config (use agentId as the "userId" for socket naming)
429
433
  const mcpServerPath = resolveMcpServerPath();
430
- const mcpConfigPath = generateMcpConfig(agentId, userId, this.config.global.socketDir, mcpServerPath);
431
- // Start MCP socket listener for this agent-user pair
432
- const socketPath = join(this.config.global.socketDir, `${agentId}-${userId}.sock`);
434
+ const mcpConfigPath = generateMcpConfig(agentId, agentId, // single socket per agent
435
+ this.config.global.socketDir, mcpServerPath);
436
+ // Start MCP socket listener for this agent
437
+ const socketPath = join(this.config.global.socketDir, `${agentId}-${agentId}.sock`);
433
438
  this.mcpServer.listen(socketPath);
434
439
  const proc = new CCProcess({
435
440
  agentId,
436
- userId,
441
+ userId: agentId, // agent is the sole "user"
437
442
  ccBinaryPath: this.config.global.ccBinaryPath,
438
443
  userConfig,
439
444
  mcpConfigPath,
440
- sessionId: userState.currentSessionId ?? undefined,
441
- continueSession: !!userState.currentSessionId,
445
+ sessionId,
446
+ continueSession: !!sessionId,
442
447
  logger: this.logger,
443
448
  });
444
449
  // Register in the process registry
445
- const ownerRef = { agentId, userId, chatId };
446
- // We'll register once we know the sessionId (on init), but we need a
447
- // temporary entry for pre-init event routing. Use a placeholder sessionId
448
- // that gets updated on init.
449
- const tentativeSessionId = userState.currentSessionId ?? `pending-${Date.now()}`;
450
+ const ownerRef = { agentId, userId: agentId, chatId: 0 };
451
+ const tentativeSessionId = sessionId ?? `pending-${Date.now()}`;
450
452
  const registryEntry = this.processRegistry.register(userConfig.repo, tentativeSessionId, userConfig.model || 'default', proc, ownerRef);
451
453
  // ── Helper: get all subscribers for this process from the registry ──
452
454
  const getEntry = () => this.processRegistry.findByProcess(proc);
453
455
  // ── Wire up event handlers (broadcast to all subscribers) ──
454
456
  proc.on('init', (event) => {
455
- this.sessionStore.setCurrentSession(agentId, userId, event.session_id);
457
+ this.sessionStore.updateLastActivity(agentId);
458
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: `Session initialized: ${event.session_id}` });
456
459
  // Update registry key if session ID changed from tentative
457
460
  if (event.session_id !== tentativeSessionId) {
458
- // Re-register with the real session ID
459
461
  const entry = getEntry();
460
462
  if (entry) {
461
- // Save subscribers before removing
462
- const savedSubs = [...entry.subscribers.entries()];
463
463
  this.processRegistry.remove(userConfig.repo, tentativeSessionId);
464
- const newEntry = this.processRegistry.register(userConfig.repo, event.session_id, userConfig.model || 'default', proc, ownerRef);
465
- // Restore additional subscribers
466
- for (const [, sub] of savedSubs) {
467
- if (sub.client.agentId !== agentId || sub.client.userId !== userId || sub.client.chatId !== chatId) {
468
- this.processRegistry.subscribe(userConfig.repo, event.session_id, sub.client);
469
- const newSub = this.processRegistry.getSubscriber(newEntry, sub.client);
470
- if (newSub) {
471
- newSub.accumulator = sub.accumulator;
472
- newSub.tracker = sub.tracker;
473
- }
474
- }
475
- }
464
+ this.processRegistry.register(userConfig.repo, event.session_id, userConfig.model || 'default', proc, ownerRef);
476
465
  }
477
466
  }
478
467
  });
479
468
  proc.on('stream_event', (event) => {
480
- const entry = getEntry();
481
- if (!entry) {
482
- // Fallback: single subscriber mode (shouldn't happen)
483
- this.handleStreamEvent(agentId, userId, chatId, event);
484
- return;
485
- }
486
- for (const sub of this.processRegistry.subscribers(entry)) {
487
- this.handleStreamEvent(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
488
- }
469
+ this.highSignalDetector.handleStreamEvent(agentId, event);
470
+ this.handleStreamEvent(agentId, event);
489
471
  });
490
472
  proc.on('tool_result', (event) => {
491
- const entry = getEntry();
492
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
493
- for (const sub of subscriberList) {
494
- const subAgent = this.agents.get(sub.client.agentId);
495
- if (!subAgent)
496
- continue;
497
- const accKey = `${sub.client.userId}:${sub.client.chatId}`;
498
- // Resolve tool indicator message with success/failure status
499
- const acc2 = subAgent.accumulators.get(accKey);
500
- if (acc2 && event.tool_use_id) {
501
- const isError = event.is_error === true;
502
- const contentStr = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
503
- const errorMsg = isError ? contentStr : undefined;
504
- acc2.resolveToolMessage(event.tool_use_id, isError, errorMsg, contentStr, event.tool_use_result);
505
- }
506
- const tracker = subAgent.subAgentTrackers.get(accKey);
507
- if (!tracker)
508
- continue;
509
- const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
510
- const meta = event.tool_use_result;
511
- // Log warning if structured metadata is missing
512
- if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
513
- this.logger.warn({ agentId: sub.client.agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
514
- }
515
- const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
516
- if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
517
- if (!tracker.currentTeamName) {
518
- this.logger.info({ agentId: sub.client.agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
519
- tracker.setTeamName(spawnMeta.team_name);
520
- // Wire the "all agents reported" callback to send follow-up to CC
521
- tracker.setOnAllReported(() => {
522
- if (proc.state === 'active') {
523
- proc.sendMessage(createTextMessage('[System] All background agents have reported back. Please read their results from the mailbox/files and provide a synthesis to the user.'));
524
- }
525
- });
526
- }
527
- // Set agent metadata from structured data or text fallback
528
- if (event.tool_use_id && spawnMeta.name) {
529
- tracker.setAgentMetadata(event.tool_use_id, {
530
- agentName: spawnMeta.name,
531
- agentType: spawnMeta.agent_type,
532
- color: spawnMeta.color,
533
- });
534
- }
535
- }
536
- // Handle tool result (sets status, edits TG message)
537
- if (event.tool_use_id) {
538
- tracker.handleToolResult(event.tool_use_id, resultText);
473
+ // Log to event buffer
474
+ const toolName = event.tool_use_result?.name ?? 'unknown';
475
+ const isToolErr = event.is_error === true;
476
+ const toolContent = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
477
+ const toolSummary = toolContent.length > 200 ? toolContent.slice(0, 200) + '…' : toolContent;
478
+ agent.eventBuffer.push({ ts: Date.now(), type: 'tool', text: `${isToolErr ? '❌' : '✅'} ${toolName}: ${toolSummary}` });
479
+ // High-signal detection
480
+ this.highSignalDetector.handleToolResult(agentId, event.tool_use_id, toolContent, isToolErr, toolName !== 'unknown' ? toolName : undefined);
481
+ // Resolve tool indicator message with success/failure status
482
+ const acc = agent.accumulator;
483
+ if (acc && event.tool_use_id) {
484
+ const isError = event.is_error === true;
485
+ const contentStr = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
486
+ const errorMsg = isError ? contentStr : undefined;
487
+ acc.resolveToolMessage(event.tool_use_id, isError, errorMsg, contentStr, event.tool_use_result);
488
+ }
489
+ const tracker = agent.subAgentTracker;
490
+ if (!tracker)
491
+ return;
492
+ const resultText = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
493
+ const meta = event.tool_use_result;
494
+ // Log warning if structured metadata is missing
495
+ if (!meta && /agent_id:\s*\S+@\S+/.test(resultText)) {
496
+ this.logger.warn({ agentId, toolUseId: event.tool_use_id }, 'Spawn detected in text but no structured tool_use_result metadata - skipping');
497
+ }
498
+ const spawnMeta = meta?.status === 'teammate_spawned' ? meta : undefined;
499
+ if (spawnMeta?.status === 'teammate_spawned' && spawnMeta.team_name) {
500
+ if (!tracker.currentTeamName) {
501
+ this.logger.info({ agentId, teamName: spawnMeta.team_name, agentName: spawnMeta.name, agentType: spawnMeta.agent_type }, 'Spawn detected');
502
+ tracker.setTeamName(spawnMeta.team_name);
503
+ // Wire the "all agents reported" callback to send follow-up to CC
504
+ tracker.setOnAllReported(() => {
505
+ if (proc.state === 'active') {
506
+ proc.sendMessage(createTextMessage('[System] All background agents have reported back. Please read their results from the mailbox/files and provide a synthesis to the user.'));
507
+ }
508
+ });
539
509
  }
540
- // Start mailbox watch AFTER handleToolResult has set agent names
541
- if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
542
- tracker.startMailboxWatch();
510
+ // Set agent metadata from structured data or text fallback
511
+ if (event.tool_use_id && spawnMeta.name) {
512
+ tracker.setAgentMetadata(event.tool_use_id, {
513
+ agentName: spawnMeta.name,
514
+ agentType: spawnMeta.agent_type,
515
+ color: spawnMeta.color,
516
+ });
543
517
  }
544
518
  }
519
+ // Handle tool result (sets status, edits TG message)
520
+ if (event.tool_use_id) {
521
+ tracker.handleToolResult(event.tool_use_id, resultText);
522
+ }
523
+ // Start mailbox watch AFTER handleToolResult has set agent names
524
+ if (tracker.currentTeamName && tracker.hasDispatchedAgents && !tracker.isMailboxWatching) {
525
+ tracker.startMailboxWatch();
526
+ }
545
527
  });
546
- // System events for background task tracking — broadcast to all subscribers
528
+ // System events for background task tracking
547
529
  proc.on('task_started', (event) => {
548
- const entry = getEntry();
549
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
550
- for (const sub of subscriberList) {
551
- const subAgent = this.agents.get(sub.client.agentId);
552
- if (!subAgent)
553
- continue;
554
- const accKey = `${sub.client.userId}:${sub.client.chatId}`;
555
- const tracker = subAgent.subAgentTrackers.get(accKey);
556
- if (tracker) {
557
- tracker.handleTaskStarted(event.tool_use_id, event.description, event.task_type);
558
- }
530
+ if (agent.subAgentTracker) {
531
+ agent.subAgentTracker.handleTaskStarted(event.tool_use_id, event.description, event.task_type);
559
532
  }
560
533
  });
561
534
  proc.on('task_progress', (event) => {
562
- const entry = getEntry();
563
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
564
- for (const sub of subscriberList) {
565
- const subAgent = this.agents.get(sub.client.agentId);
566
- if (!subAgent)
567
- continue;
568
- const accKey = `${sub.client.userId}:${sub.client.chatId}`;
569
- const tracker = subAgent.subAgentTrackers.get(accKey);
570
- if (tracker) {
571
- tracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
572
- }
535
+ if (agent.subAgentTracker) {
536
+ agent.subAgentTracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
573
537
  }
574
538
  });
575
539
  proc.on('task_completed', (event) => {
576
- const entry = getEntry();
577
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
578
- for (const sub of subscriberList) {
579
- const subAgent = this.agents.get(sub.client.agentId);
580
- if (!subAgent)
581
- continue;
582
- const accKey = `${sub.client.userId}:${sub.client.chatId}`;
583
- const tracker = subAgent.subAgentTrackers.get(accKey);
584
- if (tracker) {
585
- tracker.handleTaskCompleted(event.tool_use_id);
586
- }
540
+ if (agent.subAgentTracker) {
541
+ agent.subAgentTracker.handleTaskCompleted(event.tool_use_id);
587
542
  }
588
543
  });
589
- // Media from tool results (images, PDFs, etc.) — broadcast to all subscribers
544
+ // Media from tool results (images, PDFs, etc.)
590
545
  proc.on('media', (media) => {
591
546
  const buf = Buffer.from(media.data, 'base64');
592
- const entry = getEntry();
593
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
594
- for (const sub of subscriberList) {
595
- const subAgent = this.agents.get(sub.client.agentId);
596
- if (!subAgent)
597
- continue;
598
- if (media.kind === 'image') {
599
- subAgent.tgBot.sendPhotoBuffer(sub.client.chatId, buf).catch(err => {
600
- this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result image');
601
- });
602
- }
603
- else if (media.kind === 'document') {
604
- const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
605
- subAgent.tgBot.sendDocumentBuffer(sub.client.chatId, buf, `document${ext}`).catch(err => {
606
- this.logger.error({ err, agentId: sub.client.agentId, userId: sub.client.userId }, 'Failed to send tool_result document');
607
- });
608
- }
547
+ const chatId = this.getAgentChatId(agent);
548
+ if (!chatId || !agent.tgBot)
549
+ return;
550
+ if (media.kind === 'image') {
551
+ agent.tgBot.sendPhotoBuffer(chatId, buf).catch(err => {
552
+ this.logger.error({ err, agentId }, 'Failed to send tool_result image');
553
+ });
554
+ }
555
+ else if (media.kind === 'document') {
556
+ const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
557
+ agent.tgBot.sendDocumentBuffer(chatId, buf, `document${ext}`).catch(err => {
558
+ this.logger.error({ err, agentId }, 'Failed to send tool_result document');
559
+ });
609
560
  }
610
561
  });
611
562
  proc.on('assistant', (event) => {
612
- // Non-streaming fallback only used if stream_events don't fire
613
- // In practice, stream_events handle the display
563
+ // Log text and thinking blocks to event buffer
564
+ if (event.message?.content) {
565
+ for (const block of event.message.content) {
566
+ if (block.type === 'thinking' && block.thinking) {
567
+ const truncated = block.thinking.length > 300 ? block.thinking.slice(0, 300) + '…' : block.thinking;
568
+ agent.eventBuffer.push({ ts: Date.now(), type: 'thinking', text: truncated });
569
+ }
570
+ else if (block.type === 'text' && block.text) {
571
+ agent.eventBuffer.push({ ts: Date.now(), type: 'text', text: block.text });
572
+ }
573
+ else if (block.type === 'tool_use') {
574
+ const toolBlock = block;
575
+ this.highSignalDetector.handleAssistantToolUse(agentId, toolBlock.name, toolBlock.id, toolBlock.input);
576
+ }
577
+ }
578
+ }
614
579
  });
615
580
  proc.on('result', (event) => {
616
- // Model override consumed — CC has written to JSONL
617
- this.sessionModelOverrides.delete(`${agentId}:${userId}`);
618
- // Broadcast result to all subscribers
619
- const entry = getEntry();
620
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
621
- for (const sub of subscriberList) {
622
- const subAgent = this.agents.get(sub.client.agentId);
623
- if (!subAgent)
624
- continue;
625
- this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
626
- this.handleResult(sub.client.agentId, sub.client.userId, sub.client.chatId, event);
581
+ this.stopTypingIndicator(agent);
582
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: `Turn complete${event.is_error ? ' (error)' : ''}${event.total_cost_usd ? ` · $${event.total_cost_usd.toFixed(4)}` : ''}` });
583
+ this.handleResult(agentId, event);
584
+ // Forward to supervisor
585
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
586
+ const resultText = event.result ? String(event.result) : '';
587
+ this.sendToSupervisor({
588
+ type: 'event',
589
+ event: 'result',
590
+ agentId,
591
+ sessionId: proc.sessionId,
592
+ text: resultText,
593
+ is_error: event.is_error ?? false,
594
+ });
627
595
  }
628
596
  });
629
597
  proc.on('compact', (event) => {
630
- // Notify all subscribers that compaction happened
631
598
  const trigger = event.compact_metadata?.trigger ?? 'manual';
632
599
  const preTokens = event.compact_metadata?.pre_tokens;
633
600
  const tokenInfo = preTokens ? ` (was ${Math.round(preTokens / 1000)}k tokens)` : '';
634
- const entry = getEntry();
635
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
636
- for (const sub of subscriberList) {
637
- const subAgent = this.agents.get(sub.client.agentId);
638
- if (!subAgent)
639
- continue;
640
- const label = trigger === 'auto' ? '🗜️ Auto-compacted' : '🗜️ Compacted';
641
- subAgent.tgBot.sendText(sub.client.chatId, `<blockquote>${escapeHtml(label + tokenInfo)}</blockquote>`, 'HTML').catch((err) => this.logger.error({ err }, 'Failed to send compact notification'));
601
+ const label = trigger === 'auto' ? '🗜️ Auto-compacted' : '🗜️ Compacted';
602
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: label + tokenInfo });
603
+ const chatId = this.getAgentChatId(agent);
604
+ if (chatId && agent.tgBot) {
605
+ agent.tgBot.sendText(chatId, `<blockquote>${escapeHtml(label + tokenInfo)}</blockquote>`, 'HTML', true).catch((err) => this.logger.error({ err }, 'Failed to send compact notification'));
642
606
  }
643
607
  });
644
608
  proc.on('permission_request', (event) => {
645
- // Send permission request only to the owner (first subscriber)
646
609
  const req = event.request;
647
610
  const requestId = event.request_id;
648
- // Store pending permission on the owner's agent
649
611
  agent.pendingPermissions.set(requestId, {
650
612
  requestId,
651
- userId,
613
+ userId: agentId,
652
614
  toolName: req.tool_name,
653
615
  input: req.input,
654
616
  });
655
- // Build description of what CC wants to do
656
617
  const toolName = escapeHtml(req.tool_name);
657
618
  const inputPreview = req.input
658
619
  ? escapeHtml(JSON.stringify(req.input).slice(0, 200))
@@ -663,9 +624,24 @@ export class Bridge extends EventEmitter {
663
624
  const keyboard = new InlineKeyboard()
664
625
  .text('✅ Allow', `perm_allow:${requestId}`)
665
626
  .text('❌ Deny', `perm_deny:${requestId}`)
666
- .text('✅ Allow All', `perm_allow_all:${userId}`);
667
- agent.tgBot.sendTextWithKeyboard(chatId, text, keyboard, 'HTML')
668
- .catch(err => this.logger.error({ err }, 'Failed to send permission request'));
627
+ .text('✅ Allow All', `perm_allow_all:${agentId}`);
628
+ const permChatId = this.getAgentChatId(agent);
629
+ if (permChatId && agent.tgBot) {
630
+ agent.tgBot.sendTextWithKeyboard(permChatId, text, keyboard, 'HTML')
631
+ .catch(err => this.logger.error({ err }, 'Failed to send permission request'));
632
+ }
633
+ // Forward to supervisor so it can render approve/deny UI
634
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
635
+ const description = req.decision_reason || `CC wants to use ${req.tool_name}`;
636
+ this.sendToSupervisor({
637
+ type: 'event',
638
+ event: 'permission_request',
639
+ agentId,
640
+ toolName: req.tool_name,
641
+ requestId,
642
+ description,
643
+ });
644
+ }
669
645
  });
670
646
  proc.on('api_error', (event) => {
671
647
  const errMsg = event.error?.message || 'Unknown API error';
@@ -674,193 +650,165 @@ export class Bridge extends EventEmitter {
674
650
  const retryInfo = event.retryAttempt != null && event.maxRetries != null
675
651
  ? ` (retry ${event.retryAttempt}/${event.maxRetries})`
676
652
  : '';
653
+ agent.eventBuffer.push({ ts: Date.now(), type: 'error', text: `${errMsg}${retryInfo}` });
677
654
  const text = isOverloaded
678
- ? `<blockquote>⚠️ API overloaded, retrying...${retryInfo}</blockquote>`
679
- : `<blockquote>⚠️ ${escapeHtml(errMsg)}${retryInfo}</blockquote>`;
680
- // Broadcast API error to all subscribers
681
- const entry = getEntry();
682
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
683
- for (const sub of subscriberList) {
684
- const subAgent = this.agents.get(sub.client.agentId);
685
- if (!subAgent)
686
- continue;
687
- subAgent.tgBot.sendText(sub.client.chatId, text, 'HTML')
655
+ ? formatSystemMessage('error', `API overloaded, retrying...${retryInfo}`)
656
+ : formatSystemMessage('error', `${escapeHtml(errMsg)}${retryInfo}`);
657
+ const errChatId = this.getAgentChatId(agent);
658
+ if (errChatId && agent.tgBot) {
659
+ agent.tgBot.sendText(errChatId, text, 'HTML', true) // silent
688
660
  .catch(err => this.logger.error({ err }, 'Failed to send API error notification'));
689
661
  }
690
662
  });
691
663
  proc.on('hang', () => {
692
- // Broadcast hang notification to all subscribers
693
- const entry = getEntry();
694
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
695
- for (const sub of subscriberList) {
696
- const subAgent = this.agents.get(sub.client.agentId);
697
- if (!subAgent)
698
- continue;
699
- this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
700
- subAgent.tgBot.sendText(sub.client.chatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML')
664
+ this.stopTypingIndicator(agent);
665
+ const hangChatId = this.getAgentChatId(agent);
666
+ if (hangChatId && agent.tgBot) {
667
+ agent.tgBot.sendText(hangChatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML', true) // silent
701
668
  .catch(err => this.logger.error({ err }, 'Failed to send hang notification'));
702
669
  }
703
670
  });
704
671
  proc.on('takeover', () => {
705
- this.logger.warn({ agentId, userId }, 'Session takeover detected — keeping session for roaming');
706
- // Notify and clean up all subscribers
672
+ this.logger.warn({ agentId }, 'Session takeover detected — keeping session for roaming');
673
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: 'Session takeover detected' });
674
+ // Send TG system message for takeover
675
+ const takeoverChatId = this.getAgentChatId(agent);
676
+ if (takeoverChatId && agent.tgBot) {
677
+ agent.tgBot.sendText(takeoverChatId, '<blockquote>⚠️ Session taken over by another client</blockquote>', 'HTML', true)
678
+ .catch(err => this.logger.error({ err, agentId }, 'Failed to send takeover notification'));
679
+ }
680
+ // Notify supervisor and suppress subsequent exit event
681
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
682
+ this.sendToSupervisor({ type: 'event', event: 'session_takeover', agentId, sessionId: proc.sessionId });
683
+ this.suppressExitForProcess.add(proc.sessionId ?? '');
684
+ }
685
+ this.stopTypingIndicator(agent);
707
686
  const entry = getEntry();
708
687
  if (entry) {
709
- for (const sub of this.processRegistry.subscribers(entry)) {
710
- const subAgent = this.agents.get(sub.client.agentId);
711
- if (!subAgent)
712
- continue;
713
- this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
714
- subAgent.processes.delete(sub.client.userId);
715
- }
716
- // Remove from registry without destroying (already handling exit)
717
688
  this.processRegistry.remove(entry.repo, entry.sessionId);
718
689
  }
719
- // Don't clear session — allow roaming between clients.
690
+ agent.ccProcess = null;
720
691
  proc.destroy();
721
692
  });
722
693
  proc.on('exit', () => {
723
- // Clean up all subscribers
694
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: 'Process exited' });
695
+ this.highSignalDetector.cleanup(agentId);
696
+ // Forward to supervisor (unless suppressed by takeover)
697
+ if (this.suppressExitForProcess.has(proc.sessionId ?? '')) {
698
+ this.suppressExitForProcess.delete(proc.sessionId ?? '');
699
+ }
700
+ else if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
701
+ this.sendToSupervisor({ type: 'event', event: 'process_exit', agentId, sessionId: proc.sessionId, exitCode: null });
702
+ }
703
+ this.stopTypingIndicator(agent);
704
+ if (agent.accumulator) {
705
+ agent.accumulator.finalize();
706
+ agent.accumulator = null;
707
+ }
708
+ if (agent.subAgentTracker) {
709
+ agent.subAgentTracker.stopMailboxWatch();
710
+ agent.subAgentTracker = null;
711
+ }
724
712
  const entry = getEntry();
725
713
  if (entry) {
726
- for (const sub of this.processRegistry.subscribers(entry)) {
727
- const subAgent = this.agents.get(sub.client.agentId);
728
- if (!subAgent)
729
- continue;
730
- this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
731
- const accKey = `${sub.client.userId}:${sub.client.chatId}`;
732
- const acc = subAgent.accumulators.get(accKey);
733
- if (acc) {
734
- acc.finalize();
735
- subAgent.accumulators.delete(accKey);
736
- }
737
- const exitTracker = subAgent.subAgentTrackers.get(accKey);
738
- if (exitTracker) {
739
- exitTracker.stopMailboxWatch();
740
- }
741
- subAgent.subAgentTrackers.delete(accKey);
742
- }
743
- // Remove from registry (process already exited)
744
714
  this.processRegistry.remove(entry.repo, entry.sessionId);
745
715
  }
746
- else {
747
- // Fallback: clean up owner only
748
- this.stopTypingIndicator(agent, userId, chatId);
749
- const accKey = `${userId}:${chatId}`;
750
- const acc = agent.accumulators.get(accKey);
751
- if (acc) {
752
- acc.finalize();
753
- agent.accumulators.delete(accKey);
754
- }
755
- const exitTracker = agent.subAgentTrackers.get(accKey);
756
- if (exitTracker) {
757
- exitTracker.stopMailboxWatch();
758
- }
759
- agent.subAgentTrackers.delete(accKey);
760
- }
716
+ agent.ccProcess = null;
761
717
  });
762
718
  proc.on('error', (err) => {
763
- // Broadcast error to all subscribers
764
- const entry = getEntry();
765
- const subscriberList = entry ? [...this.processRegistry.subscribers(entry)] : [{ client: ownerRef }];
766
- for (const sub of subscriberList) {
767
- const subAgent = this.agents.get(sub.client.agentId);
768
- if (!subAgent)
769
- continue;
770
- this.stopTypingIndicator(subAgent, sub.client.userId, sub.client.chatId);
771
- subAgent.tgBot.sendText(sub.client.chatId, `<blockquote>⚠️ ${escapeHtml(String(err.message))}</blockquote>`, 'HTML')
719
+ agent.eventBuffer.push({ ts: Date.now(), type: 'error', text: err.message });
720
+ this.stopTypingIndicator(agent);
721
+ const errChatId = this.getAgentChatId(agent);
722
+ if (errChatId && agent.tgBot) {
723
+ agent.tgBot.sendText(errChatId, formatSystemMessage('error', escapeHtml(String(err.message))), 'HTML', true) // silent
772
724
  .catch(err2 => this.logger.error({ err: err2 }, 'Failed to send process error notification'));
773
725
  }
774
726
  });
775
727
  return proc;
776
728
  }
777
729
  // ── Stream event handling ──
778
- handleStreamEvent(agentId, userId, chatId, event) {
730
+ handleStreamEvent(agentId, event) {
779
731
  const agent = this.agents.get(agentId);
780
732
  if (!agent)
781
733
  return;
782
- const accKey = `${userId}:${chatId}`;
783
- let acc = agent.accumulators.get(accKey);
784
- if (!acc) {
734
+ const chatId = this.getAgentChatId(agent);
735
+ if (!chatId)
736
+ return;
737
+ if (!agent.accumulator && agent.tgBot) {
738
+ const tgBot = agent.tgBot; // capture for closures (non-null here)
785
739
  const sender = {
786
- sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
787
- editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
788
- deleteMessage: (cid, msgId) => agent.tgBot.deleteMessage(cid, msgId),
789
- sendPhoto: (cid, buffer, caption) => agent.tgBot.sendPhotoBuffer(cid, buffer, caption),
740
+ sendMessage: (cid, text, parseMode) => {
741
+ this.logger.info({ agentId, chatId: cid, textLen: text.length }, 'TG accumulator sendMessage');
742
+ return tgBot.sendText(cid, text, parseMode, true); // silent — no push notification
743
+ },
744
+ editMessage: (cid, msgId, text, parseMode) => {
745
+ this.logger.info({ agentId, chatId: cid, msgId, textLen: text.length }, 'TG accumulator editMessage');
746
+ return tgBot.editText(cid, msgId, text, parseMode);
747
+ },
748
+ deleteMessage: (cid, msgId) => tgBot.deleteMessage(cid, msgId),
749
+ setReaction: (cid, msgId, emoji) => tgBot.setReaction(cid, msgId, emoji),
750
+ sendPhoto: (cid, buffer, caption) => tgBot.sendPhotoBuffer(cid, buffer, caption),
790
751
  };
791
752
  const onError = (err, context) => {
792
- this.logger.error({ err, context, agentId, userId }, 'Stream accumulator error');
793
- agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(context)}</blockquote>`, 'HTML').catch(() => { });
753
+ this.logger.error({ err, context, agentId }, 'Stream accumulator error');
754
+ tgBot.sendText(chatId, formatSystemMessage('error', escapeHtml(context)), 'HTML', true).catch(() => { }); // silent
794
755
  };
795
- acc = new StreamAccumulator({ chatId, sender, logger: this.logger, onError });
796
- agent.accumulators.set(accKey, acc);
756
+ agent.accumulator = new StreamAccumulator({ chatId, sender, logger: this.logger, onError });
797
757
  }
798
- // Sub-agent tracker create lazily alongside the accumulator
799
- let tracker = agent.subAgentTrackers.get(accKey);
800
- if (!tracker) {
758
+ if (!agent.subAgentTracker && agent.tgBot) {
759
+ const tgBot = agent.tgBot; // capture for closures (non-null here)
801
760
  const subAgentSender = {
802
- sendMessage: (cid, text, parseMode) => agent.tgBot.sendText(cid, text, parseMode),
803
- editMessage: (cid, msgId, text, parseMode) => agent.tgBot.editText(cid, msgId, text, parseMode),
804
- setReaction: (cid, msgId, emoji) => agent.tgBot.setReaction(cid, msgId, emoji),
761
+ sendMessage: (cid, text, parseMode) => tgBot.sendText(cid, text, parseMode, true), // silent
762
+ editMessage: (cid, msgId, text, parseMode) => tgBot.editText(cid, msgId, text, parseMode),
763
+ setReaction: (cid, msgId, emoji) => tgBot.setReaction(cid, msgId, emoji),
805
764
  };
806
- tracker = new SubAgentTracker({
765
+ agent.subAgentTracker = new SubAgentTracker({
807
766
  chatId,
808
767
  sender: subAgentSender,
809
768
  });
810
- agent.subAgentTrackers.set(accKey, tracker);
811
769
  }
812
770
  // On message_start: new CC turn — full reset for text accumulator.
813
- // Tool indicator messages are independent and persist across turns.
814
771
  if (event.type === 'message_start') {
815
- acc.reset();
816
- // Only reset tracker if no agents still dispatched
817
- if (!tracker.hasDispatchedAgents) {
818
- tracker.reset();
772
+ agent.accumulator?.reset();
773
+ if (agent.subAgentTracker && !agent.subAgentTracker.hasDispatchedAgents) {
774
+ agent.subAgentTracker.reset();
819
775
  }
820
776
  }
821
- acc.handleEvent(event);
822
- tracker.handleEvent(event);
777
+ agent.accumulator?.handleEvent(event);
778
+ agent.subAgentTracker?.handleEvent(event);
823
779
  }
824
- handleResult(agentId, userId, chatId, event) {
780
+ handleResult(agentId, event) {
825
781
  const agent = this.agents.get(agentId);
826
782
  if (!agent)
827
783
  return;
784
+ const chatId = this.getAgentChatId(agent);
828
785
  // Set usage stats on the accumulator before finalizing
829
- const accKey = `${userId}:${chatId}`;
830
- const acc = agent.accumulators.get(accKey);
786
+ const acc = agent.accumulator;
831
787
  if (acc) {
832
- // Extract usage from result event
833
788
  if (event.usage) {
789
+ const proc = agent.ccProcess;
790
+ const entry = proc ? this.processRegistry.findByProcess(proc) : null;
834
791
  acc.setTurnUsage({
835
792
  inputTokens: event.usage.input_tokens ?? 0,
836
793
  outputTokens: event.usage.output_tokens ?? 0,
837
794
  cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
838
795
  cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
839
796
  costUsd: event.total_cost_usd ?? null,
840
- model: event.model
841
- ?? this.processRegistry.findByClient({ agentId, userId, chatId })?.model,
797
+ model: event.model ?? entry?.model,
842
798
  });
843
799
  }
844
800
  acc.finalize();
845
- // Don't delete — next turn will reset via message_start and create a new message
846
- }
847
- // Update session store with cost
848
- if (event.total_cost_usd) {
849
801
  }
850
- // Update JSONL tracking after our own CC turn completes
851
- // This prevents false-positive staleness on our own writes
852
- // Handle errors
853
- if (event.is_error && event.result) {
854
- agent.tgBot.sendText(chatId, `<blockquote>⚠️ ${escapeHtml(String(event.result))}</blockquote>`, 'HTML')
802
+ // Handle errors (only send to TG if bot available)
803
+ if (event.is_error && event.result && chatId && agent.tgBot) {
804
+ agent.tgBot.sendText(chatId, formatSystemMessage('error', escapeHtml(String(event.result))), 'HTML', true) // silent
855
805
  .catch(err => this.logger.error({ err }, 'Failed to send result error notification'));
856
806
  }
857
807
  // If background sub-agents are still running, mailbox watcher handles them.
858
- // Ensure mailbox watch is started if we have a team name and dispatched agents.
859
- const tracker = agent.subAgentTrackers.get(accKey);
808
+ const tracker = agent.subAgentTracker;
860
809
  if (tracker?.hasDispatchedAgents && tracker.currentTeamName) {
861
810
  this.logger.info({ agentId }, 'Turn ended with background sub-agents still running');
862
- // Clear idle timer — don't kill CC while background agents are working
863
- const ccProcess = agent.processes.get(userId);
811
+ const ccProcess = agent.ccProcess;
864
812
  if (ccProcess)
865
813
  ccProcess.clearIdleTimer();
866
814
  // Start mailbox watcher (works for general-purpose agents that have SendMessage)
@@ -871,12 +819,11 @@ export class Bridge extends EventEmitter {
871
819
  tracker.hasPendingFollowUp = true;
872
820
  setTimeout(() => {
873
821
  if (!tracker.hasDispatchedAgents)
874
- return; // already resolved via mailbox
875
- const proc = agent.processes.get(userId);
822
+ return;
823
+ const proc = agent.ccProcess;
876
824
  if (!proc)
877
825
  return;
878
826
  this.logger.info({ agentId }, 'Mailbox timeout — sending single follow-up for remaining agents');
879
- // Mark all remaining dispatched agents as completed (CC already has the results)
880
827
  for (const info of tracker.activeAgents) {
881
828
  if (info.status === 'dispatched') {
882
829
  tracker.markCompleted(info.toolUseId, '(results delivered in CC response)');
@@ -892,27 +839,14 @@ export class Bridge extends EventEmitter {
892
839
  const agent = this.agents.get(agentId);
893
840
  if (!agent)
894
841
  return;
842
+ if (!agent.tgBot)
843
+ return; // ephemeral agents don't have TG bots
895
844
  this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
896
845
  switch (cmd.command) {
897
846
  case 'start': {
898
- const userConf = resolveUserConfig(agent.config, cmd.userId);
899
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
900
- const repo = userState.repo || userConf.repo;
901
- const session = userState.currentSessionId;
902
- // Model: running process > session JSONL > user default
903
- let model = userConf.model;
904
- if (session && repo) {
905
- const registryEntry = this.processRegistry.get(repo, session);
906
- if (registryEntry?.model) {
907
- model = registryEntry.model;
908
- }
909
- else {
910
- const sessions = discoverCCSessions(repo, 20);
911
- const sessionInfo = sessions.find(s => s.id === session);
912
- if (sessionInfo?.model)
913
- model = sessionInfo.model;
914
- }
915
- }
847
+ const repo = agent.repo;
848
+ const model = agent.model;
849
+ const session = agent.ccProcess?.sessionId;
916
850
  const lines = ['👋 <b>TGCC</b> — Telegram ↔ Claude Code bridge'];
917
851
  if (repo)
918
852
  lines.push(`📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>`);
@@ -934,14 +868,12 @@ export class Bridge extends EventEmitter {
934
868
  await agent.tgBot.sendText(cmd.chatId, HELP_TEXT, 'HTML');
935
869
  break;
936
870
  case 'ping': {
937
- const proc = agent.processes.get(cmd.userId);
938
- const state = proc?.state ?? 'idle';
871
+ const state = agent.ccProcess?.state ?? 'idle';
939
872
  await agent.tgBot.sendText(cmd.chatId, `pong — process: <b>${state.toUpperCase()}</b>`, 'HTML');
940
873
  break;
941
874
  }
942
875
  case 'status': {
943
- const proc = agent.processes.get(cmd.userId);
944
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
876
+ const proc = agent.ccProcess;
945
877
  const uptime = proc?.spawnedAt
946
878
  ? formatDuration(Date.now() - proc.spawnedAt.getTime())
947
879
  : 'N/A';
@@ -949,77 +881,56 @@ export class Bridge extends EventEmitter {
949
881
  `<b>Agent:</b> ${escapeHtml(agentId)}`,
950
882
  `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
951
883
  `<b>Session:</b> <code>${escapeHtml(proc?.sessionId?.slice(0, 8) ?? 'none')}</code>`,
952
- `<b>Model:</b> ${escapeHtml(resolveUserConfig(agent.config, cmd.userId).model)}`,
953
- `<b>Repo:</b> ${escapeHtml(userState.repo || resolveUserConfig(agent.config, cmd.userId).repo)}`,
884
+ `<b>Model:</b> ${escapeHtml(agent.model)}`,
885
+ `<b>Repo:</b> ${escapeHtml(agent.repo)}`,
954
886
  `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
955
887
  ].join('\n');
956
888
  await agent.tgBot.sendText(cmd.chatId, status, 'HTML');
957
889
  break;
958
890
  }
959
891
  case 'cost': {
960
- const proc = agent.processes.get(cmd.userId);
961
- await agent.tgBot.sendText(cmd.chatId, `<b>Session cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`, 'HTML');
892
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session cost:</b> $${(agent.ccProcess?.totalCostUsd ?? 0).toFixed(4)}`, 'HTML');
962
893
  break;
963
894
  }
964
895
  case 'new': {
965
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
966
- this.sessionStore.clearSession(agentId, cmd.userId);
967
- const newConf = resolveUserConfig(agent.config, cmd.userId);
968
- const newState = this.sessionStore.getUser(agentId, cmd.userId);
969
- const newRepo = newState.repo || newConf.repo;
970
- const newModel = newState.model || newConf.model;
896
+ this.killAgentProcess(agentId);
897
+ agent.pendingSessionId = null; // next message spawns fresh
971
898
  const newLines = ['Session cleared. Next message starts fresh.'];
972
- if (newRepo)
973
- newLines.push(`📂 <code>${escapeHtml(shortenRepoPath(newRepo))}</code>`);
974
- if (newModel)
975
- newLines.push(`🤖 ${escapeHtml(newModel)}`);
899
+ if (agent.repo)
900
+ newLines.push(`📂 <code>${escapeHtml(shortenRepoPath(agent.repo))}</code>`);
901
+ if (agent.model)
902
+ newLines.push(`🤖 ${escapeHtml(agent.model)}`);
976
903
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>${newLines.join('\n')}</blockquote>`, 'HTML');
977
904
  break;
978
905
  }
979
906
  case 'continue': {
980
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
981
- const contConf = resolveUserConfig(agent.config, cmd.userId);
982
- const contState = this.sessionStore.getUser(agentId, cmd.userId);
983
- const contRepo = contState.repo || contConf.repo;
984
- let contSession = contState.currentSessionId;
985
- // If no current session, auto-pick the most recent one
986
- if (!contSession && contRepo) {
987
- const recent = discoverCCSessions(contRepo, 1);
907
+ // Remember the current session before killing
908
+ const contSession = agent.ccProcess?.sessionId;
909
+ this.killAgentProcess(agentId);
910
+ // If no session, auto-pick the most recent one
911
+ let sessionToResume = contSession;
912
+ if (!sessionToResume && agent.repo) {
913
+ const recent = discoverCCSessions(agent.repo, 1);
988
914
  if (recent.length > 0) {
989
- contSession = recent[0].id;
990
- this.sessionStore.setCurrentSession(agentId, cmd.userId, contSession);
915
+ sessionToResume = recent[0].id;
991
916
  }
992
917
  }
993
- // Model priority: running process registry > session JSONL > user default
994
- let contModel = contConf.model;
995
- if (contSession && contRepo) {
996
- // Check if a process is already running for this session
997
- const registryEntry = this.processRegistry.get(contRepo, contSession);
998
- if (registryEntry?.model) {
999
- contModel = registryEntry.model;
1000
- }
1001
- else {
1002
- const sessions = discoverCCSessions(contRepo, 20);
1003
- const sessionInfo = sessions.find(s => s.id === contSession);
1004
- if (sessionInfo?.model)
1005
- contModel = sessionInfo.model;
1006
- }
918
+ if (sessionToResume) {
919
+ agent.pendingSessionId = sessionToResume;
1007
920
  }
1008
921
  const contLines = ['Process respawned. Session kept.'];
1009
- if (contRepo)
1010
- contLines.push(`📂 <code>${escapeHtml(shortenRepoPath(contRepo))}</code>`);
1011
- if (contModel)
1012
- contLines.push(`🤖 ${escapeHtml(contModel)}`);
1013
- if (contSession)
1014
- contLines.push(`📎 <code>${escapeHtml(contSession.slice(0, 8))}</code>`);
922
+ if (agent.repo)
923
+ contLines.push(`📂 <code>${escapeHtml(shortenRepoPath(agent.repo))}</code>`);
924
+ if (agent.model)
925
+ contLines.push(`🤖 ${escapeHtml(agent.model)}`);
926
+ if (sessionToResume)
927
+ contLines.push(`📎 <code>${escapeHtml(sessionToResume.slice(0, 8))}</code>`);
1015
928
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>${contLines.join('\n')}</blockquote>`, 'HTML');
1016
929
  break;
1017
930
  }
1018
931
  case 'sessions': {
1019
- const userConf = resolveUserConfig(agent.config, cmd.userId);
1020
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1021
- const repo = userState.repo || userConf.repo;
1022
- const currentSessionId = userState.currentSessionId;
932
+ const repo = agent.repo;
933
+ const currentSessionId = agent.ccProcess?.sessionId ?? null;
1023
934
  // Discover sessions from CC's session directory
1024
935
  const discovered = repo ? discoverCCSessions(repo, 5) : [];
1025
936
  const merged = discovered.map(d => {
@@ -1051,7 +962,7 @@ export class Bridge extends EventEmitter {
1051
962
  const kb = new InlineKeyboard();
1052
963
  if (s.isCurrent) {
1053
964
  const repoLine = repo ? `\n📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>` : '';
1054
- const sessModel = userConf.model;
965
+ const sessModel = agent.model;
1055
966
  const modelLine = sessModel ? `\n🤖 ${escapeHtml(sessModel)}` : '';
1056
967
  const sessionLine = `\n📎 <code>${escapeHtml(s.id.slice(0, 8))}</code>`;
1057
968
  const text = `<blockquote><b>Current session:</b>\n${displayTitle}\n${s.detail} · ${s.age}${repoLine}${modelLine}${sessionLine}</blockquote>`;
@@ -1071,22 +982,21 @@ export class Bridge extends EventEmitter {
1071
982
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /resume &lt;session-id&gt;</blockquote>', 'HTML');
1072
983
  break;
1073
984
  }
1074
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1075
- this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
985
+ this.killAgentProcess(agentId);
986
+ agent.pendingSessionId = cmd.args.trim();
1076
987
  await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
1077
988
  break;
1078
989
  }
1079
990
  case 'session': {
1080
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1081
- if (!userState.currentSessionId) {
991
+ const currentSessionId = agent.ccProcess?.sessionId;
992
+ if (!currentSessionId) {
1082
993
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session.</blockquote>', 'HTML');
1083
994
  break;
1084
995
  }
1085
- const sessRepo = userState.repo || resolveUserConfig(agent.config, cmd.userId).repo;
1086
- const discovered = sessRepo ? discoverCCSessions(sessRepo, 20) : [];
1087
- const info = discovered.find(d => d.id === userState.currentSessionId);
996
+ const discovered = agent.repo ? discoverCCSessions(agent.repo, 20) : [];
997
+ const info = discovered.find(d => d.id === currentSessionId);
1088
998
  if (!info) {
1089
- await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(userState.currentSessionId.slice(0, 8))}</code>`, 'HTML');
999
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(currentSessionId.slice(0, 8))}</code>`, 'HTML');
1090
1000
  break;
1091
1001
  }
1092
1002
  const ctxLine = info.contextPct !== null ? `\n<b>Context:</b> ${info.contextPct}%` : '';
@@ -1097,23 +1007,7 @@ export class Bridge extends EventEmitter {
1097
1007
  case 'model': {
1098
1008
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
1099
1009
  if (!cmd.args) {
1100
- // Show model from: running process > JSONL > agent config
1101
- const uState = this.sessionStore.getUser(agentId, cmd.userId);
1102
- const uConf = resolveUserConfig(agent.config, cmd.userId);
1103
- const uRepo = uState.repo || uConf.repo;
1104
- const uSession = uState.currentSessionId;
1105
- let current = uConf.model || 'default';
1106
- if (uSession && uRepo) {
1107
- const re = this.processRegistry.get(uRepo, uSession);
1108
- if (re?.model)
1109
- current = re.model;
1110
- else {
1111
- const ds = discoverCCSessions(uRepo, 20);
1112
- const si = ds.find(s => s.id === uSession);
1113
- if (si?.model)
1114
- current = si.model;
1115
- }
1116
- }
1010
+ const current = agent.model || 'default';
1117
1011
  const keyboard = new InlineKeyboard();
1118
1012
  for (const m of MODEL_OPTIONS) {
1119
1013
  const isCurrent = current.includes(m);
@@ -1124,11 +1018,13 @@ export class Bridge extends EventEmitter {
1124
1018
  break;
1125
1019
  }
1126
1020
  const newModel = cmd.args.trim();
1127
- // Store as session-level override (not user default)
1128
- const curSession = this.sessionStore.getUser(agentId, cmd.userId).currentSessionId;
1129
- this.sessionModelOverrides.set(`${agentId}:${cmd.userId}`, newModel);
1130
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1021
+ const oldModel = agent.model;
1022
+ agent.model = newModel;
1023
+ this.sessionStore.setModel(agentId, newModel);
1024
+ this.killAgentProcess(agentId);
1131
1025
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Model set to <code>${escapeHtml(newModel)}</code>. Process respawned.</blockquote>`, 'HTML');
1026
+ // Emit state_changed event
1027
+ this.emitStateChanged(agentId, 'model', oldModel, newModel, 'telegram');
1132
1028
  break;
1133
1029
  }
1134
1030
  case 'repo': {
@@ -1247,8 +1143,7 @@ export class Bridge extends EventEmitter {
1247
1143
  break;
1248
1144
  }
1249
1145
  if (!cmd.args) {
1250
- const current = this.sessionStore.getUser(agentId, cmd.userId).repo
1251
- || resolveUserConfig(agent.config, cmd.userId).repo;
1146
+ const current = agent.repo;
1252
1147
  // Show available repos as inline keyboard buttons
1253
1148
  const repoEntries = Object.entries(this.config.repos);
1254
1149
  if (repoEntries.length > 0) {
@@ -1271,17 +1166,19 @@ export class Bridge extends EventEmitter {
1271
1166
  break;
1272
1167
  }
1273
1168
  // Kill current process (different CWD needs new process)
1274
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1275
- this.sessionStore.setRepo(agentId, cmd.userId, repoPath);
1276
- // Always clear session when repo changes — sessions are project-specific
1277
- this.sessionStore.clearSession(agentId, cmd.userId);
1169
+ const oldRepo = agent.repo;
1170
+ this.killAgentProcess(agentId);
1171
+ agent.repo = repoPath;
1172
+ agent.pendingSessionId = null; // clear session when repo changes
1173
+ this.sessionStore.setRepo(agentId, repoPath);
1278
1174
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1175
+ // Emit state_changed event
1176
+ this.emitStateChanged(agentId, 'repo', oldRepo, repoPath, 'telegram');
1279
1177
  break;
1280
1178
  }
1281
1179
  case 'cancel': {
1282
- const proc = agent.processes.get(cmd.userId);
1283
- if (proc && proc.state === 'active') {
1284
- proc.cancel();
1180
+ if (agent.ccProcess && agent.ccProcess.state === 'active') {
1181
+ agent.ccProcess.cancel();
1285
1182
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Cancelled.</blockquote>', 'HTML');
1286
1183
  }
1287
1184
  else {
@@ -1290,18 +1187,15 @@ export class Bridge extends EventEmitter {
1290
1187
  break;
1291
1188
  }
1292
1189
  case 'compact': {
1293
- // Trigger CC's built-in /compact slash command — like the Claude Code extension does
1294
- const proc = agent.processes.get(cmd.userId);
1295
- if (!proc || proc.state !== 'active') {
1190
+ if (!agent.ccProcess || agent.ccProcess.state !== 'active') {
1296
1191
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session to compact. Start one first.</blockquote>', 'HTML');
1297
1192
  break;
1298
1193
  }
1299
- // Build the compact message: "/compact [optional-instructions]"
1300
1194
  const compactMsg = cmd.args?.trim()
1301
1195
  ? `/compact ${cmd.args.trim()}`
1302
1196
  : '/compact';
1303
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>🗜️ Compacting…</blockquote>', 'HTML');
1304
- proc.sendMessage(createTextMessage(compactMsg));
1197
+ await agent.tgBot.sendText(cmd.chatId, formatSystemMessage('status', 'Compacting'), 'HTML');
1198
+ agent.ccProcess.sendMessage(createTextMessage(compactMsg));
1305
1199
  break;
1306
1200
  }
1307
1201
  case 'catchup': {
@@ -1311,22 +1205,20 @@ export class Bridge extends EventEmitter {
1311
1205
  }
1312
1206
  case 'permissions': {
1313
1207
  const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
1314
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1315
1208
  const agentDefault = agent.config.defaults.permissionMode;
1316
- const currentMode = userState.permissionMode || agentDefault;
1209
+ const agentState = this.sessionStore.getAgent(agentId);
1210
+ const currentMode = agentState.permissionMode || agentDefault;
1317
1211
  if (cmd.args) {
1318
1212
  const mode = cmd.args.trim();
1319
1213
  if (!validModes.includes(mode)) {
1320
1214
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Invalid mode. Valid: ${validModes.join(', ')}</blockquote>`, 'HTML');
1321
1215
  break;
1322
1216
  }
1323
- this.sessionStore.setPermissionMode(agentId, cmd.userId, mode);
1324
- // Kill current process so new mode takes effect on next spawn
1325
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1217
+ this.sessionStore.setPermissionMode(agentId, mode);
1218
+ this.killAgentProcess(agentId);
1326
1219
  await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1327
1220
  break;
1328
1221
  }
1329
- // No args — show current mode + inline keyboard
1330
1222
  const keyboard = new InlineKeyboard();
1331
1223
  keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('✏️ Accept Edits', 'permissions:acceptEdits').row();
1332
1224
  keyboard.text('🔒 Default', 'permissions:default').text('📋 Plan', 'permissions:plan').row();
@@ -1340,22 +1232,23 @@ export class Bridge extends EventEmitter {
1340
1232
  const agent = this.agents.get(agentId);
1341
1233
  if (!agent)
1342
1234
  return;
1235
+ if (!agent.tgBot)
1236
+ return; // ephemeral agents don't have TG bots
1343
1237
  this.logger.debug({ agentId, action: query.action, data: query.data }, 'Callback query');
1344
1238
  switch (query.action) {
1345
1239
  case 'resume': {
1346
1240
  const sessionId = query.data;
1347
- this.disconnectClient(agentId, query.userId, query.chatId);
1348
- this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
1241
+ this.killAgentProcess(agentId);
1242
+ agent.pendingSessionId = sessionId;
1349
1243
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
1350
1244
  await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
1351
1245
  break;
1352
1246
  }
1353
1247
  case 'delete': {
1354
1248
  const sessionId = query.data;
1355
- // Clear current session if it matches
1356
- const uState = this.sessionStore.getUser(agentId, query.userId);
1357
- if (uState.currentSessionId === sessionId) {
1358
- this.sessionStore.setCurrentSession(agentId, query.userId, '');
1249
+ // Kill process if it's running this session
1250
+ if (agent.ccProcess?.sessionId === sessionId) {
1251
+ this.killAgentProcess(agentId);
1359
1252
  }
1360
1253
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session cleared');
1361
1254
  await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> cleared.`, 'HTML');
@@ -1368,13 +1261,15 @@ export class Bridge extends EventEmitter {
1368
1261
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Path not found');
1369
1262
  break;
1370
1263
  }
1371
- // Kill current process (different CWD needs new process)
1372
- this.disconnectClient(agentId, query.userId, query.chatId);
1373
- this.sessionStore.setRepo(agentId, query.userId, repoPath);
1374
- // Always clear session when repo changes — sessions are project-specific
1375
- this.sessionStore.clearSession(agentId, query.userId);
1264
+ const oldRepoCb = agent.repo;
1265
+ this.killAgentProcess(agentId);
1266
+ agent.repo = repoPath;
1267
+ agent.pendingSessionId = null;
1268
+ this.sessionStore.setRepo(agentId, repoPath);
1376
1269
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
1377
1270
  await agent.tgBot.sendText(query.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1271
+ // Emit state_changed event
1272
+ this.emitStateChanged(agentId, 'repo', oldRepoCb, repoPath, 'telegram');
1378
1273
  break;
1379
1274
  }
1380
1275
  case 'repo_add': {
@@ -1389,10 +1284,14 @@ export class Bridge extends EventEmitter {
1389
1284
  await agent.tgBot.sendText(query.chatId, 'Send: <code>/model &lt;model-name&gt;</code>', 'HTML');
1390
1285
  break;
1391
1286
  }
1392
- this.sessionModelOverrides.set(`${agentId}:${query.userId}`, model);
1393
- this.disconnectClient(agentId, query.userId, query.chatId);
1287
+ const oldModelCb = agent.model;
1288
+ agent.model = model;
1289
+ this.sessionStore.setModel(agentId, model);
1290
+ this.killAgentProcess(agentId);
1394
1291
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Model: ${model}`);
1395
1292
  await agent.tgBot.sendText(query.chatId, `<blockquote>Model set to <code>${escapeHtml(model)}</code>. Process respawned.</blockquote>`, 'HTML');
1293
+ // Emit state_changed event
1294
+ this.emitStateChanged(agentId, 'model', oldModelCb, model, 'telegram');
1396
1295
  break;
1397
1296
  }
1398
1297
  case 'permissions': {
@@ -1402,9 +1301,8 @@ export class Bridge extends EventEmitter {
1402
1301
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Invalid mode');
1403
1302
  break;
1404
1303
  }
1405
- this.sessionStore.setPermissionMode(agentId, query.userId, mode);
1406
- // Kill current process so new mode takes effect on next spawn
1407
- this.disconnectClient(agentId, query.userId, query.chatId);
1304
+ this.sessionStore.setPermissionMode(agentId, mode);
1305
+ this.killAgentProcess(agentId);
1408
1306
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
1409
1307
  await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1410
1308
  break;
@@ -1431,9 +1329,8 @@ export class Bridge extends EventEmitter {
1431
1329
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1432
1330
  break;
1433
1331
  }
1434
- const proc = agent.processes.get(pending.userId);
1435
- if (proc) {
1436
- proc.respondToPermission(requestId, true);
1332
+ if (agent.ccProcess) {
1333
+ agent.ccProcess.respondToPermission(requestId, true);
1437
1334
  }
1438
1335
  agent.pendingPermissions.delete(requestId);
1439
1336
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '✅ Allowed');
@@ -1446,27 +1343,22 @@ export class Bridge extends EventEmitter {
1446
1343
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1447
1344
  break;
1448
1345
  }
1449
- const proc = agent.processes.get(pending.userId);
1450
- if (proc) {
1451
- proc.respondToPermission(requestId, false);
1346
+ if (agent.ccProcess) {
1347
+ agent.ccProcess.respondToPermission(requestId, false);
1452
1348
  }
1453
1349
  agent.pendingPermissions.delete(requestId);
1454
1350
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '❌ Denied');
1455
1351
  break;
1456
1352
  }
1457
1353
  case 'perm_allow_all': {
1458
- // Allow all pending permissions for this user
1459
- const targetUserId = query.data;
1354
+ // Allow all pending permissions for this agent
1460
1355
  const toAllow = [];
1461
- for (const [reqId, pending] of agent.pendingPermissions) {
1462
- if (pending.userId === targetUserId) {
1463
- toAllow.push(reqId);
1464
- }
1356
+ for (const [reqId] of agent.pendingPermissions) {
1357
+ toAllow.push(reqId);
1465
1358
  }
1466
- const proc = agent.processes.get(targetUserId);
1467
1359
  for (const reqId of toAllow) {
1468
- if (proc)
1469
- proc.respondToPermission(reqId, true);
1360
+ if (agent.ccProcess)
1361
+ agent.ccProcess.respondToPermission(reqId, true);
1470
1362
  agent.pendingPermissions.delete(reqId);
1471
1363
  }
1472
1364
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `✅ Allowed ${toAllow.length} permission(s)`);
@@ -1480,24 +1372,18 @@ export class Bridge extends EventEmitter {
1480
1372
  handleCtlMessage(agentId, text, sessionId) {
1481
1373
  const agent = this.agents.get(agentId);
1482
1374
  if (!agent) {
1483
- // Return error via the CtlAckResponse shape won't work — but the ctl-server
1484
- // protocol handles errors separately. We'll throw and let it catch.
1485
1375
  throw new Error(`Unknown agent: ${agentId}`);
1486
1376
  }
1487
- // Use the first allowed user as the "CLI user" identity
1488
- const userId = agent.config.allowedUsers[0];
1489
- const chatId = Number(userId);
1490
- // If explicit session requested, set it
1377
+ // If explicit session requested, set it as pending
1491
1378
  if (sessionId) {
1492
- this.sessionStore.setCurrentSession(agentId, userId, sessionId);
1379
+ agent.pendingSessionId = sessionId;
1493
1380
  }
1494
1381
  // Route through the same sendToCC path as Telegram
1495
- this.sendToCC(agentId, userId, chatId, { text });
1496
- const proc = agent.processes.get(userId);
1382
+ this.sendToCC(agentId, { text }, { spawnSource: 'cli' });
1497
1383
  return {
1498
1384
  type: 'ack',
1499
- sessionId: proc?.sessionId ?? this.sessionStore.getUser(agentId, userId).currentSessionId,
1500
- state: proc?.state ?? 'idle',
1385
+ sessionId: agent.ccProcess?.sessionId ?? null,
1386
+ state: agent.ccProcess?.state ?? 'idle',
1501
1387
  };
1502
1388
  }
1503
1389
  handleCtlStatus(agentId) {
@@ -1508,30 +1394,16 @@ export class Bridge extends EventEmitter {
1508
1394
  const agent = this.agents.get(id);
1509
1395
  if (!agent)
1510
1396
  continue;
1511
- // Aggregate process state across users
1512
- let state = 'idle';
1513
- for (const [, proc] of agent.processes) {
1514
- if (proc.state === 'active') {
1515
- state = 'active';
1516
- break;
1517
- }
1518
- if (proc.state === 'spawning')
1519
- state = 'spawning';
1520
- }
1521
- const userId = agent.config.allowedUsers[0];
1522
- const proc = agent.processes.get(userId);
1523
- const userConfig = resolveUserConfig(agent.config, userId);
1397
+ const state = agent.ccProcess?.state ?? 'idle';
1524
1398
  agents.push({
1525
1399
  id,
1526
1400
  state,
1527
- sessionId: proc?.sessionId ?? null,
1528
- repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1401
+ sessionId: agent.ccProcess?.sessionId ?? null,
1402
+ repo: agent.repo,
1529
1403
  });
1530
- // List sessions for this agent from CC's session directory
1531
- const userState = this.sessionStore.getUser(id, userId);
1532
- const sessRepo = userState.repo || userConfig.repo;
1533
- if (sessRepo) {
1534
- for (const d of discoverCCSessions(sessRepo, 5)) {
1404
+ // List sessions from CC's session directory
1405
+ if (agent.repo) {
1406
+ for (const d of discoverCCSessions(agent.repo, 5)) {
1535
1407
  sessions.push({
1536
1408
  id: d.id,
1537
1409
  agentId: id,
@@ -1549,10 +1421,59 @@ export class Bridge extends EventEmitter {
1549
1421
  if (!agent) {
1550
1422
  return { id: request.id, success: false, error: `Unknown agent: ${request.agentId}` };
1551
1423
  }
1552
- // Find the chat ID for this user (from the most recent message)
1553
- // We use the userId to find which chat to send to
1554
- const chatId = Number(request.userId); // In TG, private chat ID === user ID
1555
1424
  try {
1425
+ // Supervisor-routed tools (don't need TG chatId)
1426
+ switch (request.tool) {
1427
+ case 'notify_parent': {
1428
+ if (!this.supervisorWrite) {
1429
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1430
+ }
1431
+ this.sendToSupervisor({
1432
+ type: 'event',
1433
+ event: 'cc_message',
1434
+ agentId: request.agentId,
1435
+ text: request.params.message,
1436
+ priority: request.params.priority || 'info',
1437
+ });
1438
+ return { id: request.id, success: true };
1439
+ }
1440
+ case 'supervisor_exec': {
1441
+ if (!this.supervisorWrite) {
1442
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1443
+ }
1444
+ const timeoutMs = request.params.timeoutMs || 60000;
1445
+ const result = await this.sendSupervisorRequest({
1446
+ type: 'command',
1447
+ requestId: randomUUID(),
1448
+ action: 'exec',
1449
+ params: {
1450
+ command: request.params.command,
1451
+ agentId: request.agentId,
1452
+ timeoutMs,
1453
+ },
1454
+ }, timeoutMs);
1455
+ return { id: request.id, success: true, result };
1456
+ }
1457
+ case 'supervisor_notify': {
1458
+ if (!this.supervisorWrite) {
1459
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1460
+ }
1461
+ this.sendToSupervisor({
1462
+ type: 'event',
1463
+ event: 'notification',
1464
+ agentId: request.agentId,
1465
+ title: request.params.title,
1466
+ body: request.params.body,
1467
+ priority: request.params.priority || 'active',
1468
+ });
1469
+ return { id: request.id, success: true };
1470
+ }
1471
+ }
1472
+ // TG tools (need chatId and tgBot)
1473
+ const chatId = this.getAgentChatId(agent);
1474
+ if (!chatId || !agent.tgBot) {
1475
+ return { id: request.id, success: false, error: `No chat ID for agent: ${request.agentId}` };
1476
+ }
1556
1477
  switch (request.tool) {
1557
1478
  case 'send_file':
1558
1479
  await agent.tgBot.sendFile(chatId, request.params.path, request.params.caption);
@@ -1571,6 +1492,320 @@ export class Bridge extends EventEmitter {
1571
1492
  return { id: request.id, success: false, error: err instanceof Error ? err.message : 'Unknown error' };
1572
1493
  }
1573
1494
  }
1495
+ // ── Supervisor protocol ──
1496
+ isSupervisorSubscribed(agentId, sessionId) {
1497
+ return this.supervisorSubscriptions.has(`${agentId}:*`) ||
1498
+ (sessionId !== null && this.supervisorSubscriptions.has(`${agentId}:${sessionId}`));
1499
+ }
1500
+ registerSupervisor(agentId, capabilities, writeFn) {
1501
+ this.supervisorAgentId = agentId;
1502
+ this.supervisorWrite = writeFn;
1503
+ this.supervisorSubscriptions.clear();
1504
+ this.logger.info({ agentId, capabilities }, 'Supervisor registered');
1505
+ }
1506
+ handleSupervisorDetach() {
1507
+ this.logger.info({ agentId: this.supervisorAgentId }, 'Supervisor detached');
1508
+ this.supervisorSubscriptions.clear();
1509
+ this.supervisorWrite = null;
1510
+ this.supervisorAgentId = null;
1511
+ }
1512
+ handleSupervisorLine(line) {
1513
+ let msg;
1514
+ try {
1515
+ msg = JSON.parse(line);
1516
+ }
1517
+ catch {
1518
+ this.sendToSupervisor({ type: 'error', message: 'Invalid JSON' });
1519
+ return;
1520
+ }
1521
+ // Handle responses to commands we sent to the supervisor (e.g. exec results)
1522
+ if (msg.type === 'response' && msg.requestId) {
1523
+ const pending = this.supervisorPendingRequests.get(msg.requestId);
1524
+ if (pending) {
1525
+ clearTimeout(pending.timer);
1526
+ this.supervisorPendingRequests.delete(msg.requestId);
1527
+ if (msg.error) {
1528
+ pending.reject(new Error(msg.error));
1529
+ }
1530
+ else {
1531
+ pending.resolve(msg.result);
1532
+ }
1533
+ }
1534
+ return;
1535
+ }
1536
+ if (msg.type !== 'command' || !msg.action) {
1537
+ this.sendToSupervisor({ type: 'error', message: 'Expected {type:"command", action:"..."}' });
1538
+ return;
1539
+ }
1540
+ const requestId = msg.requestId;
1541
+ const params = msg.params ?? {};
1542
+ try {
1543
+ const result = this.handleSupervisorCommand(msg.action, params);
1544
+ this.sendToSupervisor({ type: 'response', requestId, result });
1545
+ }
1546
+ catch (err) {
1547
+ this.sendToSupervisor({ type: 'response', requestId, error: err instanceof Error ? err.message : String(err) });
1548
+ }
1549
+ }
1550
+ handleSupervisorCommand(action, params) {
1551
+ switch (action) {
1552
+ case 'ping':
1553
+ return { pong: true, uptime: process.uptime() };
1554
+ // ── Phase 2: Ephemeral agents ──
1555
+ case 'create_agent': {
1556
+ const agentId = params.agentId || `oc-spawn-${randomUUID().slice(0, 8)}`;
1557
+ const repo = params.repo;
1558
+ if (!repo)
1559
+ throw new Error('Missing required param: repo');
1560
+ if (this.agents.has(agentId)) {
1561
+ throw new Error(`Agent already exists: ${agentId}`);
1562
+ }
1563
+ // Map supervisor permissionMode to CC permissionMode
1564
+ let permMode = 'default';
1565
+ const reqPerm = params.permissionMode;
1566
+ if (reqPerm === 'bypassPermissions' || reqPerm === 'dangerously-skip')
1567
+ permMode = 'dangerously-skip';
1568
+ else if (reqPerm === 'acceptEdits')
1569
+ permMode = 'acceptEdits';
1570
+ else if (reqPerm === 'plan')
1571
+ permMode = 'plan';
1572
+ const ephemeralConfig = {
1573
+ botToken: '',
1574
+ allowedUsers: [],
1575
+ defaults: {
1576
+ model: params.model || 'sonnet',
1577
+ repo,
1578
+ maxTurns: 200,
1579
+ idleTimeoutMs: 300_000,
1580
+ hangTimeoutMs: 300_000,
1581
+ permissionMode: permMode,
1582
+ },
1583
+ };
1584
+ const instance = {
1585
+ id: agentId,
1586
+ config: ephemeralConfig,
1587
+ tgBot: null,
1588
+ ephemeral: true,
1589
+ repo,
1590
+ model: ephemeralConfig.defaults.model,
1591
+ ccProcess: null,
1592
+ accumulator: null,
1593
+ subAgentTracker: null,
1594
+ batcher: null,
1595
+ pendingPermissions: new Map(),
1596
+ typingInterval: null,
1597
+ typingChatId: null,
1598
+ pendingSessionId: null,
1599
+ destroyTimer: null,
1600
+ eventBuffer: new EventBuffer(),
1601
+ };
1602
+ // Auto-destroy timer
1603
+ const timeoutMs = params.timeoutMs;
1604
+ if (timeoutMs && timeoutMs > 0) {
1605
+ instance.destroyTimer = setTimeout(() => {
1606
+ this.logger.info({ agentId, timeoutMs }, 'Ephemeral agent timeout — auto-destroying');
1607
+ this.destroyEphemeralAgent(agentId);
1608
+ }, timeoutMs);
1609
+ }
1610
+ this.agents.set(agentId, instance);
1611
+ this.logger.info({ agentId, repo, model: instance.model, ephemeral: true }, 'Ephemeral agent created');
1612
+ // Emit agent_created event (always sent, not subscription-gated)
1613
+ this.sendToSupervisor({ type: 'event', event: 'agent_created', agentId, agentType: 'ephemeral', repo });
1614
+ return { agentId, state: 'idle' };
1615
+ }
1616
+ case 'destroy_agent': {
1617
+ const agentId = params.agentId;
1618
+ if (!agentId)
1619
+ throw new Error('Missing agentId');
1620
+ const agent = this.agents.get(agentId);
1621
+ if (!agent)
1622
+ throw new Error(`Unknown agent: ${agentId}`);
1623
+ if (!agent.ephemeral)
1624
+ throw new Error(`Cannot destroy persistent agent: ${agentId}`);
1625
+ this.destroyEphemeralAgent(agentId);
1626
+ return { destroyed: true };
1627
+ }
1628
+ // ── Phase 1: Send + Subscribe ──
1629
+ case 'send_message': {
1630
+ const agentId = params.agentId;
1631
+ const text = params.text;
1632
+ if (!agentId || !text)
1633
+ throw new Error('Missing agentId or text');
1634
+ const agent = this.agents.get(agentId);
1635
+ if (!agent)
1636
+ throw new Error(`Unknown agent: ${agentId}`);
1637
+ // Auto-subscribe supervisor
1638
+ this.supervisorSubscriptions.add(`${agentId}:*`);
1639
+ // For persistent agents: send TG system message BEFORE spawning CC
1640
+ const tgChatId = this.getAgentChatId(agent);
1641
+ if (tgChatId && agent.tgBot) {
1642
+ const preview = text.length > 500 ? text.slice(0, 500) + '…' : text;
1643
+ agent.tgBot.sendText(tgChatId, `<blockquote>🦞 <b>OpenClaw:</b> ${escapeHtml(preview)}</blockquote>`, 'HTML', true) // silent
1644
+ .catch(err => this.logger.error({ err, agentId }, 'Failed to send supervisor TG notification'));
1645
+ }
1646
+ // Send to agent's single CC process
1647
+ this.sendToCC(agentId, { text }, { spawnSource: 'supervisor' });
1648
+ return {
1649
+ sessionId: agent.ccProcess?.sessionId ?? null,
1650
+ state: agent.ccProcess?.state ?? 'spawning',
1651
+ subscribed: true,
1652
+ };
1653
+ }
1654
+ case 'send_to_cc': {
1655
+ const agentId = params.agentId;
1656
+ const text = params.text;
1657
+ if (!agentId || !text)
1658
+ throw new Error('Missing agentId or text');
1659
+ const agent = this.agents.get(agentId);
1660
+ if (!agent)
1661
+ throw new Error(`Unknown agent: ${agentId}`);
1662
+ if (!agent.ccProcess || agent.ccProcess.state === 'idle') {
1663
+ throw new Error(`No active CC process for agent ${agentId}`);
1664
+ }
1665
+ agent.ccProcess.sendMessage(createTextMessage(text));
1666
+ return { sent: true };
1667
+ }
1668
+ case 'subscribe': {
1669
+ const agentId = params.agentId;
1670
+ const sessionId = params.sessionId;
1671
+ if (!agentId)
1672
+ throw new Error('Missing agentId');
1673
+ const key = sessionId ? `${agentId}:${sessionId}` : `${agentId}:*`;
1674
+ this.supervisorSubscriptions.add(key);
1675
+ return { subscribed: true, key };
1676
+ }
1677
+ case 'unsubscribe': {
1678
+ const agentId = params.agentId;
1679
+ if (!agentId)
1680
+ throw new Error('Missing agentId');
1681
+ // Remove all subscriptions for this agent
1682
+ for (const key of [...this.supervisorSubscriptions]) {
1683
+ if (key.startsWith(`${agentId}:`)) {
1684
+ this.supervisorSubscriptions.delete(key);
1685
+ }
1686
+ }
1687
+ return { unsubscribed: true };
1688
+ }
1689
+ case 'status': {
1690
+ const filterAgentId = params.agentId;
1691
+ const agents = [];
1692
+ const agentIds = filterAgentId ? [filterAgentId] : [...this.agents.keys()];
1693
+ for (const id of agentIds) {
1694
+ const agent = this.agents.get(id);
1695
+ if (!agent)
1696
+ continue;
1697
+ const state = agent.ccProcess?.state ?? 'idle';
1698
+ const sessionId = agent.ccProcess?.sessionId ?? null;
1699
+ agents.push({
1700
+ id,
1701
+ type: agent.ephemeral ? 'ephemeral' : 'persistent',
1702
+ state,
1703
+ repo: agent.repo,
1704
+ process: agent.ccProcess ? { sessionId, model: agent.model } : null,
1705
+ supervisorSubscribed: this.isSupervisorSubscribed(id, sessionId),
1706
+ });
1707
+ }
1708
+ return { agents };
1709
+ }
1710
+ case 'kill_cc': {
1711
+ const agentId = params.agentId;
1712
+ if (!agentId)
1713
+ throw new Error('Missing agentId');
1714
+ const agent = this.agents.get(agentId);
1715
+ if (!agent)
1716
+ throw new Error(`Unknown agent: ${agentId}`);
1717
+ const killed = agent.ccProcess != null && agent.ccProcess.state !== 'idle';
1718
+ if (killed) {
1719
+ this.killAgentProcess(agentId);
1720
+ }
1721
+ return { killed };
1722
+ }
1723
+ // ── Phase A: Observability ──
1724
+ case 'get_log': {
1725
+ const agentId = params.agentId;
1726
+ if (!agentId)
1727
+ throw new Error('Missing agentId');
1728
+ const agent = this.agents.get(agentId);
1729
+ if (!agent)
1730
+ throw new Error(`Unknown agent: ${agentId}`);
1731
+ return agent.eventBuffer.query({
1732
+ offset: params.offset,
1733
+ limit: params.limit,
1734
+ grep: params.grep,
1735
+ since: params.since,
1736
+ type: params.type,
1737
+ });
1738
+ }
1739
+ case 'permission_response': {
1740
+ const agentId = params.agentId;
1741
+ const permissionRequestId = params.permissionRequestId;
1742
+ const decision = params.decision;
1743
+ if (!agentId || !permissionRequestId || !decision) {
1744
+ throw new Error('Missing agentId, permissionRequestId, or decision');
1745
+ }
1746
+ const agent = this.agents.get(agentId);
1747
+ if (!agent)
1748
+ throw new Error(`Unknown agent: ${agentId}`);
1749
+ const pending = agent.pendingPermissions.get(permissionRequestId);
1750
+ if (!pending)
1751
+ throw new Error(`No pending permission with id: ${permissionRequestId}`);
1752
+ const allow = decision === 'allow';
1753
+ if (agent.ccProcess) {
1754
+ agent.ccProcess.respondToPermission(permissionRequestId, allow);
1755
+ }
1756
+ agent.pendingPermissions.delete(permissionRequestId);
1757
+ return { responded: true, decision };
1758
+ }
1759
+ default:
1760
+ throw new Error(`Unknown supervisor action: ${action}`);
1761
+ }
1762
+ }
1763
+ emitStateChanged(agentId, field, oldValue, newValue, source) {
1764
+ this.logger.info({ agentId, field, oldValue, newValue, source }, 'Agent state changed');
1765
+ const agent = this.agents.get(agentId);
1766
+ if (agent) {
1767
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: `State changed: ${field} → ${newValue}` });
1768
+ }
1769
+ if (this.isSupervisorSubscribed(agentId, agent?.ccProcess?.sessionId ?? null)) {
1770
+ this.sendToSupervisor({
1771
+ type: 'event',
1772
+ event: 'state_changed',
1773
+ agentId,
1774
+ field,
1775
+ oldValue,
1776
+ newValue,
1777
+ source,
1778
+ });
1779
+ }
1780
+ }
1781
+ sendToSupervisor(msg) {
1782
+ if (this.supervisorWrite) {
1783
+ try {
1784
+ this.supervisorWrite(JSON.stringify(msg) + '\n');
1785
+ }
1786
+ catch { }
1787
+ }
1788
+ }
1789
+ sendSupervisorRequest(msg, timeoutMs) {
1790
+ return new Promise((resolve, reject) => {
1791
+ const requestId = msg.requestId ?? '';
1792
+ const timer = setTimeout(() => {
1793
+ this.supervisorPendingRequests.delete(requestId);
1794
+ reject(new Error('Supervisor request timed out'));
1795
+ }, timeoutMs);
1796
+ this.supervisorPendingRequests.set(requestId, { resolve, reject, timer });
1797
+ this.sendToSupervisor(msg);
1798
+ });
1799
+ }
1800
+ destroyEphemeralAgent(agentId) {
1801
+ const agent = this.agents.get(agentId);
1802
+ if (!agent || !agent.ephemeral)
1803
+ return;
1804
+ this.killAgentProcess(agentId);
1805
+ this.agents.delete(agentId);
1806
+ this.logger.info({ agentId }, 'Ephemeral agent destroyed');
1807
+ this.sendToSupervisor({ type: 'event', event: 'agent_destroyed', agentId });
1808
+ }
1574
1809
  // ── Shutdown ──
1575
1810
  async stop() {
1576
1811
  this.logger.info('Stopping bridge');
@@ -1578,6 +1813,7 @@ export class Bridge extends EventEmitter {
1578
1813
  await this.stopAgent(agentId);
1579
1814
  }
1580
1815
  this.processRegistry.clear();
1816
+ this.highSignalDetector.destroy();
1581
1817
  this.mcpServer.closeAll();
1582
1818
  this.ctlServer.closeAll();
1583
1819
  this.removeAllListeners();