@fonz/tgcc 0.6.17 → 0.6.19

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,318 @@ 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;
380
+ }
381
+ if (agent.subAgentTracker) {
382
+ agent.subAgentTracker.reset();
383
+ agent.subAgentTracker = null;
372
384
  }
373
- agent.processes.delete(userId);
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);
532
+ }
533
+ // Update the in-turn sub-agent segment in the main bubble
534
+ if (agent.accumulator && event.tool_use_id) {
535
+ agent.accumulator.updateSubAgentSegment(event.tool_use_id, 'dispatched', event.description);
559
536
  }
560
537
  });
561
538
  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
- }
539
+ if (agent.subAgentTracker) {
540
+ agent.subAgentTracker.handleTaskProgress(event.tool_use_id, event.description, event.last_tool_name);
541
+ }
542
+ // Update the in-turn sub-agent segment in the main bubble with progress
543
+ if (agent.accumulator && event.tool_use_id) {
544
+ agent.accumulator.appendSubAgentProgress(event.tool_use_id, event.description, event.last_tool_name);
573
545
  }
574
546
  });
575
547
  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
- }
548
+ if (agent.subAgentTracker) {
549
+ agent.subAgentTracker.handleTaskCompleted(event.tool_use_id);
550
+ }
551
+ // Update the in-turn sub-agent segment in the main bubble
552
+ if (agent.accumulator && event.tool_use_id) {
553
+ agent.accumulator.updateSubAgentSegment(event.tool_use_id, 'completed');
587
554
  }
588
555
  });
589
- // Media from tool results (images, PDFs, etc.) — broadcast to all subscribers
556
+ // Media from tool results (images, PDFs, etc.)
590
557
  proc.on('media', (media) => {
591
558
  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
- }
559
+ const chatId = this.getAgentChatId(agent);
560
+ if (!chatId || !agent.tgBot)
561
+ return;
562
+ if (media.kind === 'image') {
563
+ agent.tgBot.sendPhotoBuffer(chatId, buf).catch(err => {
564
+ this.logger.error({ err, agentId }, 'Failed to send tool_result image');
565
+ });
566
+ }
567
+ else if (media.kind === 'document') {
568
+ const ext = media.media_type === 'application/pdf' ? '.pdf' : '';
569
+ agent.tgBot.sendDocumentBuffer(chatId, buf, `document${ext}`).catch(err => {
570
+ this.logger.error({ err, agentId }, 'Failed to send tool_result document');
571
+ });
609
572
  }
610
573
  });
611
574
  proc.on('assistant', (event) => {
612
- // Non-streaming fallback only used if stream_events don't fire
613
- // In practice, stream_events handle the display
575
+ // Log text and thinking blocks to event buffer
576
+ if (event.message?.content) {
577
+ for (const block of event.message.content) {
578
+ if (block.type === 'thinking' && block.thinking) {
579
+ const truncated = block.thinking.length > 300 ? block.thinking.slice(0, 300) + '…' : block.thinking;
580
+ agent.eventBuffer.push({ ts: Date.now(), type: 'thinking', text: truncated });
581
+ }
582
+ else if (block.type === 'text' && block.text) {
583
+ agent.eventBuffer.push({ ts: Date.now(), type: 'text', text: block.text });
584
+ }
585
+ else if (block.type === 'tool_use') {
586
+ const toolBlock = block;
587
+ this.highSignalDetector.handleAssistantToolUse(agentId, toolBlock.name, toolBlock.id, toolBlock.input);
588
+ }
589
+ }
590
+ }
614
591
  });
615
592
  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);
593
+ this.stopTypingIndicator(agent);
594
+ 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)}` : ''}` });
595
+ this.handleResult(agentId, event);
596
+ // Forward to supervisor
597
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
598
+ const resultText = event.result ? String(event.result) : '';
599
+ this.sendToSupervisor({
600
+ type: 'event',
601
+ event: 'result',
602
+ agentId,
603
+ sessionId: proc.sessionId,
604
+ text: resultText,
605
+ is_error: event.is_error ?? false,
606
+ });
627
607
  }
628
608
  });
629
609
  proc.on('compact', (event) => {
630
- // Notify all subscribers that compaction happened
631
610
  const trigger = event.compact_metadata?.trigger ?? 'manual';
632
611
  const preTokens = event.compact_metadata?.pre_tokens;
633
612
  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'));
613
+ const label = trigger === 'auto' ? '🗜️ Auto-compacted' : '🗜️ Compacted';
614
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: label + tokenInfo });
615
+ const chatId = this.getAgentChatId(agent);
616
+ if (chatId && agent.tgBot) {
617
+ agent.tgBot.sendText(chatId, `<blockquote>${escapeHtml(label + tokenInfo)}</blockquote>`, 'HTML', true).catch((err) => this.logger.error({ err }, 'Failed to send compact notification'));
642
618
  }
643
619
  });
644
620
  proc.on('permission_request', (event) => {
645
- // Send permission request only to the owner (first subscriber)
646
621
  const req = event.request;
647
622
  const requestId = event.request_id;
648
- // Store pending permission on the owner's agent
649
623
  agent.pendingPermissions.set(requestId, {
650
624
  requestId,
651
- userId,
625
+ userId: agentId,
652
626
  toolName: req.tool_name,
653
627
  input: req.input,
654
628
  });
655
- // Build description of what CC wants to do
656
629
  const toolName = escapeHtml(req.tool_name);
657
630
  const inputPreview = req.input
658
631
  ? escapeHtml(JSON.stringify(req.input).slice(0, 200))
@@ -663,9 +636,24 @@ export class Bridge extends EventEmitter {
663
636
  const keyboard = new InlineKeyboard()
664
637
  .text('✅ Allow', `perm_allow:${requestId}`)
665
638
  .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'));
639
+ .text('✅ Allow All', `perm_allow_all:${agentId}`);
640
+ const permChatId = this.getAgentChatId(agent);
641
+ if (permChatId && agent.tgBot) {
642
+ agent.tgBot.sendTextWithKeyboard(permChatId, text, keyboard, 'HTML')
643
+ .catch(err => this.logger.error({ err }, 'Failed to send permission request'));
644
+ }
645
+ // Forward to supervisor so it can render approve/deny UI
646
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
647
+ const description = req.decision_reason || `CC wants to use ${req.tool_name}`;
648
+ this.sendToSupervisor({
649
+ type: 'event',
650
+ event: 'permission_request',
651
+ agentId,
652
+ toolName: req.tool_name,
653
+ requestId,
654
+ description,
655
+ });
656
+ }
669
657
  });
670
658
  proc.on('api_error', (event) => {
671
659
  const errMsg = event.error?.message || 'Unknown API error';
@@ -674,195 +662,178 @@ export class Bridge extends EventEmitter {
674
662
  const retryInfo = event.retryAttempt != null && event.maxRetries != null
675
663
  ? ` (retry ${event.retryAttempt}/${event.maxRetries})`
676
664
  : '';
665
+ agent.eventBuffer.push({ ts: Date.now(), type: 'error', text: `${errMsg}${retryInfo}` });
677
666
  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')
667
+ ? formatSystemMessage('error', `API overloaded, retrying...${retryInfo}`)
668
+ : formatSystemMessage('error', `${escapeHtml(errMsg)}${retryInfo}`);
669
+ const errChatId = this.getAgentChatId(agent);
670
+ if (errChatId && agent.tgBot) {
671
+ agent.tgBot.sendText(errChatId, text, 'HTML', true) // silent
688
672
  .catch(err => this.logger.error({ err }, 'Failed to send API error notification'));
689
673
  }
690
674
  });
691
675
  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')
676
+ this.stopTypingIndicator(agent);
677
+ const hangChatId = this.getAgentChatId(agent);
678
+ if (hangChatId && agent.tgBot) {
679
+ agent.tgBot.sendText(hangChatId, '<blockquote>⏸ Session paused. Send a message to continue.</blockquote>', 'HTML', true) // silent
701
680
  .catch(err => this.logger.error({ err }, 'Failed to send hang notification'));
702
681
  }
703
682
  });
704
683
  proc.on('takeover', () => {
705
- this.logger.warn({ agentId, userId }, 'Session takeover detected — keeping session for roaming');
706
- // Notify and clean up all subscribers
684
+ this.logger.warn({ agentId }, 'Session takeover detected — keeping session for roaming');
685
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: 'Session takeover detected' });
686
+ // Send TG system message for takeover
687
+ const takeoverChatId = this.getAgentChatId(agent);
688
+ if (takeoverChatId && agent.tgBot) {
689
+ agent.tgBot.sendText(takeoverChatId, '<blockquote>⚠️ Session taken over by another client</blockquote>', 'HTML', true)
690
+ .catch(err => this.logger.error({ err, agentId }, 'Failed to send takeover notification'));
691
+ }
692
+ // Notify supervisor and suppress subsequent exit event
693
+ if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
694
+ this.sendToSupervisor({ type: 'event', event: 'session_takeover', agentId, sessionId: proc.sessionId });
695
+ this.suppressExitForProcess.add(proc.sessionId ?? '');
696
+ }
697
+ this.stopTypingIndicator(agent);
707
698
  const entry = getEntry();
708
699
  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
700
  this.processRegistry.remove(entry.repo, entry.sessionId);
718
701
  }
719
- // Don't clear session — allow roaming between clients.
702
+ agent.ccProcess = null;
720
703
  proc.destroy();
721
704
  });
722
705
  proc.on('exit', () => {
723
- // Clean up all subscribers
706
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: 'Process exited' });
707
+ this.highSignalDetector.cleanup(agentId);
708
+ // Forward to supervisor (unless suppressed by takeover)
709
+ if (this.suppressExitForProcess.has(proc.sessionId ?? '')) {
710
+ this.suppressExitForProcess.delete(proc.sessionId ?? '');
711
+ }
712
+ else if (this.isSupervisorSubscribed(agentId, proc.sessionId)) {
713
+ this.sendToSupervisor({ type: 'event', event: 'process_exit', agentId, sessionId: proc.sessionId, exitCode: null });
714
+ }
715
+ this.stopTypingIndicator(agent);
716
+ if (agent.accumulator) {
717
+ agent.accumulator.finalize();
718
+ agent.accumulator = null;
719
+ }
720
+ if (agent.subAgentTracker) {
721
+ agent.subAgentTracker.stopMailboxWatch();
722
+ agent.subAgentTracker = null;
723
+ }
724
724
  const entry = getEntry();
725
725
  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
726
  this.processRegistry.remove(entry.repo, entry.sessionId);
745
727
  }
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
- }
728
+ agent.ccProcess = null;
761
729
  });
762
730
  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')
731
+ agent.eventBuffer.push({ ts: Date.now(), type: 'error', text: err.message });
732
+ this.stopTypingIndicator(agent);
733
+ const errChatId = this.getAgentChatId(agent);
734
+ if (errChatId && agent.tgBot) {
735
+ agent.tgBot.sendText(errChatId, formatSystemMessage('error', escapeHtml(String(err.message))), 'HTML', true) // silent
772
736
  .catch(err2 => this.logger.error({ err: err2 }, 'Failed to send process error notification'));
773
737
  }
774
738
  });
775
739
  return proc;
776
740
  }
777
741
  // ── Stream event handling ──
778
- handleStreamEvent(agentId, userId, chatId, event) {
742
+ handleStreamEvent(agentId, event) {
779
743
  const agent = this.agents.get(agentId);
780
744
  if (!agent)
781
745
  return;
782
- const accKey = `${userId}:${chatId}`;
783
- let acc = agent.accumulators.get(accKey);
784
- if (!acc) {
746
+ const chatId = this.getAgentChatId(agent);
747
+ if (!chatId)
748
+ return;
749
+ if (!agent.accumulator && agent.tgBot) {
750
+ const tgBot = agent.tgBot; // capture for closures (non-null here)
785
751
  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),
752
+ sendMessage: (cid, text, parseMode) => {
753
+ this.logger.info({ agentId, chatId: cid, textLen: text.length }, 'TG accumulator sendMessage');
754
+ return tgBot.sendText(cid, text, parseMode, true); // silent — no push notification
755
+ },
756
+ editMessage: (cid, msgId, text, parseMode) => {
757
+ this.logger.info({ agentId, chatId: cid, msgId, textLen: text.length }, 'TG accumulator editMessage');
758
+ return tgBot.editText(cid, msgId, text, parseMode);
759
+ },
760
+ deleteMessage: (cid, msgId) => tgBot.deleteMessage(cid, msgId),
761
+ setReaction: (cid, msgId, emoji) => tgBot.setReaction(cid, msgId, emoji),
762
+ sendPhoto: (cid, buffer, caption) => tgBot.sendPhotoBuffer(cid, buffer, caption),
790
763
  };
791
764
  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(() => { });
765
+ this.logger.error({ err, context, agentId }, 'Stream accumulator error');
766
+ tgBot.sendText(chatId, formatSystemMessage('error', escapeHtml(context)), 'HTML', true).catch(() => { }); // silent
794
767
  };
795
- acc = new StreamAccumulator({ chatId, sender, logger: this.logger, onError });
796
- agent.accumulators.set(accKey, acc);
768
+ agent.accumulator = new StreamAccumulator({ chatId, sender, logger: this.logger, onError });
797
769
  }
798
- // Sub-agent tracker create lazily alongside the accumulator
799
- let tracker = agent.subAgentTrackers.get(accKey);
800
- if (!tracker) {
770
+ if (!agent.subAgentTracker && agent.tgBot) {
771
+ const tgBot = agent.tgBot; // capture for closures (non-null here)
801
772
  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),
773
+ sendMessage: (cid, text, parseMode) => tgBot.sendText(cid, text, parseMode, true), // silent
774
+ editMessage: (cid, msgId, text, parseMode) => tgBot.editText(cid, msgId, text, parseMode),
775
+ setReaction: (cid, msgId, emoji) => tgBot.setReaction(cid, msgId, emoji),
805
776
  };
806
- tracker = new SubAgentTracker({
777
+ agent.subAgentTracker = new SubAgentTracker({
807
778
  chatId,
808
779
  sender: subAgentSender,
809
780
  });
810
- agent.subAgentTrackers.set(accKey, tracker);
811
781
  }
812
- // On message_start: new CC turn full reset for text accumulator.
813
- // Tool indicator messages are independent and persist across turns.
782
+ // On message_start: only start a new bubble if the previous turn was sealed
783
+ // (i.e. a result event finalized it). CC sends message_start on every tool-use
784
+ // loop within the same turn — those must reuse the same bubble via softReset.
814
785
  if (event.type === 'message_start') {
815
- acc.reset();
816
- // Only reset tracker if no agents still dispatched
817
- if (!tracker.hasDispatchedAgents) {
818
- tracker.reset();
786
+ if (agent.accumulator?.sealed) {
787
+ // Previous turn is done (result event sealed it) → new bubble
788
+ agent.accumulator.reset();
789
+ if (agent.subAgentTracker && !agent.subAgentTracker.hasDispatchedAgents) {
790
+ agent.subAgentTracker.reset();
791
+ }
792
+ }
793
+ else if (agent.accumulator) {
794
+ // Mid-turn tool-use loop → keep same bubble, clear transient state
795
+ agent.accumulator.softReset();
819
796
  }
820
797
  }
821
- acc.handleEvent(event);
822
- tracker.handleEvent(event);
798
+ agent.accumulator?.handleEvent(event);
799
+ agent.subAgentTracker?.handleEvent(event);
823
800
  }
824
- handleResult(agentId, userId, chatId, event) {
801
+ handleResult(agentId, event) {
825
802
  const agent = this.agents.get(agentId);
826
803
  if (!agent)
827
804
  return;
805
+ const chatId = this.getAgentChatId(agent);
828
806
  // Set usage stats on the accumulator before finalizing
829
- const accKey = `${userId}:${chatId}`;
830
- const acc = agent.accumulators.get(accKey);
807
+ const acc = agent.accumulator;
831
808
  if (acc) {
832
- // Extract usage from result event
833
809
  if (event.usage) {
810
+ const proc = agent.ccProcess;
811
+ const entry = proc ? this.processRegistry.findByProcess(proc) : null;
834
812
  acc.setTurnUsage({
835
813
  inputTokens: event.usage.input_tokens ?? 0,
836
814
  outputTokens: event.usage.output_tokens ?? 0,
837
815
  cacheReadTokens: event.usage.cache_read_input_tokens ?? 0,
838
816
  cacheCreationTokens: event.usage.cache_creation_input_tokens ?? 0,
839
817
  costUsd: event.total_cost_usd ?? null,
840
- model: event.model
841
- ?? this.processRegistry.findByClient({ agentId, userId, chatId })?.model,
818
+ model: event.model ?? entry?.model,
842
819
  });
843
820
  }
844
821
  acc.finalize();
845
- // Don't delete — next turn will reset via message_start and create a new message
846
822
  }
847
- // Update session store with cost
848
- if (event.total_cost_usd) {
849
- }
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')
823
+ // Handle errors (only send to TG if bot available)
824
+ if (event.is_error && event.result && chatId && agent.tgBot) {
825
+ agent.tgBot.sendText(chatId, formatSystemMessage('error', escapeHtml(String(event.result))), 'HTML', true) // silent
855
826
  .catch(err => this.logger.error({ err }, 'Failed to send result error notification'));
856
827
  }
857
828
  // 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);
829
+ const tracker = agent.subAgentTracker;
860
830
  if (tracker?.hasDispatchedAgents && tracker.currentTeamName) {
861
831
  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);
832
+ const ccProcess = agent.ccProcess;
864
833
  if (ccProcess)
865
834
  ccProcess.clearIdleTimer();
835
+ // Create standalone post-turn status bubble (main bubble is now sealed)
836
+ tracker.startPostTurnTracking().catch(err => this.logger.error({ err, agentId }, 'Failed to start post-turn tracking'));
866
837
  // Start mailbox watcher (works for general-purpose agents that have SendMessage)
867
838
  tracker.startMailboxWatch();
868
839
  // Fallback: send ONE follow-up after 60s if mailbox hasn't resolved all agents
@@ -871,12 +842,11 @@ export class Bridge extends EventEmitter {
871
842
  tracker.hasPendingFollowUp = true;
872
843
  setTimeout(() => {
873
844
  if (!tracker.hasDispatchedAgents)
874
- return; // already resolved via mailbox
875
- const proc = agent.processes.get(userId);
845
+ return;
846
+ const proc = agent.ccProcess;
876
847
  if (!proc)
877
848
  return;
878
849
  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
850
  for (const info of tracker.activeAgents) {
881
851
  if (info.status === 'dispatched') {
882
852
  tracker.markCompleted(info.toolUseId, '(results delivered in CC response)');
@@ -892,27 +862,14 @@ export class Bridge extends EventEmitter {
892
862
  const agent = this.agents.get(agentId);
893
863
  if (!agent)
894
864
  return;
865
+ if (!agent.tgBot)
866
+ return; // ephemeral agents don't have TG bots
895
867
  this.logger.debug({ agentId, command: cmd.command, args: cmd.args }, 'Slash command');
896
868
  switch (cmd.command) {
897
869
  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
- }
870
+ const repo = agent.repo;
871
+ const model = agent.model;
872
+ const session = agent.ccProcess?.sessionId;
916
873
  const lines = ['👋 <b>TGCC</b> — Telegram ↔ Claude Code bridge'];
917
874
  if (repo)
918
875
  lines.push(`📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>`);
@@ -934,14 +891,12 @@ export class Bridge extends EventEmitter {
934
891
  await agent.tgBot.sendText(cmd.chatId, HELP_TEXT, 'HTML');
935
892
  break;
936
893
  case 'ping': {
937
- const proc = agent.processes.get(cmd.userId);
938
- const state = proc?.state ?? 'idle';
894
+ const state = agent.ccProcess?.state ?? 'idle';
939
895
  await agent.tgBot.sendText(cmd.chatId, `pong — process: <b>${state.toUpperCase()}</b>`, 'HTML');
940
896
  break;
941
897
  }
942
898
  case 'status': {
943
- const proc = agent.processes.get(cmd.userId);
944
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
899
+ const proc = agent.ccProcess;
945
900
  const uptime = proc?.spawnedAt
946
901
  ? formatDuration(Date.now() - proc.spawnedAt.getTime())
947
902
  : 'N/A';
@@ -949,77 +904,56 @@ export class Bridge extends EventEmitter {
949
904
  `<b>Agent:</b> ${escapeHtml(agentId)}`,
950
905
  `<b>Process:</b> ${(proc?.state ?? 'idle').toUpperCase()} (uptime: ${uptime})`,
951
906
  `<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)}`,
907
+ `<b>Model:</b> ${escapeHtml(agent.model)}`,
908
+ `<b>Repo:</b> ${escapeHtml(agent.repo)}`,
954
909
  `<b>Cost:</b> $${(proc?.totalCostUsd ?? 0).toFixed(4)}`,
955
910
  ].join('\n');
956
911
  await agent.tgBot.sendText(cmd.chatId, status, 'HTML');
957
912
  break;
958
913
  }
959
914
  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');
915
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session cost:</b> $${(agent.ccProcess?.totalCostUsd ?? 0).toFixed(4)}`, 'HTML');
962
916
  break;
963
917
  }
964
918
  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;
919
+ this.killAgentProcess(agentId);
920
+ agent.pendingSessionId = null; // next message spawns fresh
971
921
  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)}`);
922
+ if (agent.repo)
923
+ newLines.push(`📂 <code>${escapeHtml(shortenRepoPath(agent.repo))}</code>`);
924
+ if (agent.model)
925
+ newLines.push(`🤖 ${escapeHtml(agent.model)}`);
976
926
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>${newLines.join('\n')}</blockquote>`, 'HTML');
977
927
  break;
978
928
  }
979
929
  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);
930
+ // Remember the current session before killing
931
+ const contSession = agent.ccProcess?.sessionId;
932
+ this.killAgentProcess(agentId);
933
+ // If no session, auto-pick the most recent one
934
+ let sessionToResume = contSession;
935
+ if (!sessionToResume && agent.repo) {
936
+ const recent = discoverCCSessions(agent.repo, 1);
988
937
  if (recent.length > 0) {
989
- contSession = recent[0].id;
990
- this.sessionStore.setCurrentSession(agentId, cmd.userId, contSession);
938
+ sessionToResume = recent[0].id;
991
939
  }
992
940
  }
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
- }
941
+ if (sessionToResume) {
942
+ agent.pendingSessionId = sessionToResume;
1007
943
  }
1008
944
  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>`);
945
+ if (agent.repo)
946
+ contLines.push(`📂 <code>${escapeHtml(shortenRepoPath(agent.repo))}</code>`);
947
+ if (agent.model)
948
+ contLines.push(`🤖 ${escapeHtml(agent.model)}`);
949
+ if (sessionToResume)
950
+ contLines.push(`📎 <code>${escapeHtml(sessionToResume.slice(0, 8))}</code>`);
1015
951
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>${contLines.join('\n')}</blockquote>`, 'HTML');
1016
952
  break;
1017
953
  }
1018
954
  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;
955
+ const repo = agent.repo;
956
+ const currentSessionId = agent.ccProcess?.sessionId ?? null;
1023
957
  // Discover sessions from CC's session directory
1024
958
  const discovered = repo ? discoverCCSessions(repo, 5) : [];
1025
959
  const merged = discovered.map(d => {
@@ -1051,7 +985,7 @@ export class Bridge extends EventEmitter {
1051
985
  const kb = new InlineKeyboard();
1052
986
  if (s.isCurrent) {
1053
987
  const repoLine = repo ? `\n📂 <code>${escapeHtml(shortenRepoPath(repo))}</code>` : '';
1054
- const sessModel = userConf.model;
988
+ const sessModel = agent.model;
1055
989
  const modelLine = sessModel ? `\n🤖 ${escapeHtml(sessModel)}` : '';
1056
990
  const sessionLine = `\n📎 <code>${escapeHtml(s.id.slice(0, 8))}</code>`;
1057
991
  const text = `<blockquote><b>Current session:</b>\n${displayTitle}\n${s.detail} · ${s.age}${repoLine}${modelLine}${sessionLine}</blockquote>`;
@@ -1071,22 +1005,21 @@ export class Bridge extends EventEmitter {
1071
1005
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Usage: /resume &lt;session-id&gt;</blockquote>', 'HTML');
1072
1006
  break;
1073
1007
  }
1074
- this.disconnectClient(agentId, cmd.userId, cmd.chatId);
1075
- this.sessionStore.setCurrentSession(agentId, cmd.userId, cmd.args.trim());
1008
+ this.killAgentProcess(agentId);
1009
+ agent.pendingSessionId = cmd.args.trim();
1076
1010
  await agent.tgBot.sendText(cmd.chatId, `Will resume session <code>${escapeHtml(cmd.args.trim().slice(0, 8))}</code> on next message.`, 'HTML');
1077
1011
  break;
1078
1012
  }
1079
1013
  case 'session': {
1080
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1081
- if (!userState.currentSessionId) {
1014
+ const currentSessionId = agent.ccProcess?.sessionId;
1015
+ if (!currentSessionId) {
1082
1016
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session.</blockquote>', 'HTML');
1083
1017
  break;
1084
1018
  }
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);
1019
+ const discovered = agent.repo ? discoverCCSessions(agent.repo, 20) : [];
1020
+ const info = discovered.find(d => d.id === currentSessionId);
1088
1021
  if (!info) {
1089
- await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(userState.currentSessionId.slice(0, 8))}</code>`, 'HTML');
1022
+ await agent.tgBot.sendText(cmd.chatId, `<b>Session:</b> <code>${escapeHtml(currentSessionId.slice(0, 8))}</code>`, 'HTML');
1090
1023
  break;
1091
1024
  }
1092
1025
  const ctxLine = info.contextPct !== null ? `\n<b>Context:</b> ${info.contextPct}%` : '';
@@ -1097,23 +1030,7 @@ export class Bridge extends EventEmitter {
1097
1030
  case 'model': {
1098
1031
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
1099
1032
  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
- }
1033
+ const current = agent.model || 'default';
1117
1034
  const keyboard = new InlineKeyboard();
1118
1035
  for (const m of MODEL_OPTIONS) {
1119
1036
  const isCurrent = current.includes(m);
@@ -1124,11 +1041,13 @@ export class Bridge extends EventEmitter {
1124
1041
  break;
1125
1042
  }
1126
1043
  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);
1044
+ const oldModel = agent.model;
1045
+ agent.model = newModel;
1046
+ this.sessionStore.setModel(agentId, newModel);
1047
+ this.killAgentProcess(agentId);
1131
1048
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Model set to <code>${escapeHtml(newModel)}</code>. Process respawned.</blockquote>`, 'HTML');
1049
+ // Emit state_changed event
1050
+ this.emitStateChanged(agentId, 'model', oldModel, newModel, 'telegram');
1132
1051
  break;
1133
1052
  }
1134
1053
  case 'repo': {
@@ -1247,8 +1166,7 @@ export class Bridge extends EventEmitter {
1247
1166
  break;
1248
1167
  }
1249
1168
  if (!cmd.args) {
1250
- const current = this.sessionStore.getUser(agentId, cmd.userId).repo
1251
- || resolveUserConfig(agent.config, cmd.userId).repo;
1169
+ const current = agent.repo;
1252
1170
  // Show available repos as inline keyboard buttons
1253
1171
  const repoEntries = Object.entries(this.config.repos);
1254
1172
  if (repoEntries.length > 0) {
@@ -1271,17 +1189,19 @@ export class Bridge extends EventEmitter {
1271
1189
  break;
1272
1190
  }
1273
1191
  // 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);
1192
+ const oldRepo = agent.repo;
1193
+ this.killAgentProcess(agentId);
1194
+ agent.repo = repoPath;
1195
+ agent.pendingSessionId = null; // clear session when repo changes
1196
+ this.sessionStore.setRepo(agentId, repoPath);
1278
1197
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1198
+ // Emit state_changed event
1199
+ this.emitStateChanged(agentId, 'repo', oldRepo, repoPath, 'telegram');
1279
1200
  break;
1280
1201
  }
1281
1202
  case 'cancel': {
1282
- const proc = agent.processes.get(cmd.userId);
1283
- if (proc && proc.state === 'active') {
1284
- proc.cancel();
1203
+ if (agent.ccProcess && agent.ccProcess.state === 'active') {
1204
+ agent.ccProcess.cancel();
1285
1205
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>Cancelled.</blockquote>', 'HTML');
1286
1206
  }
1287
1207
  else {
@@ -1290,18 +1210,15 @@ export class Bridge extends EventEmitter {
1290
1210
  break;
1291
1211
  }
1292
1212
  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') {
1213
+ if (!agent.ccProcess || agent.ccProcess.state !== 'active') {
1296
1214
  await agent.tgBot.sendText(cmd.chatId, '<blockquote>No active session to compact. Start one first.</blockquote>', 'HTML');
1297
1215
  break;
1298
1216
  }
1299
- // Build the compact message: "/compact [optional-instructions]"
1300
1217
  const compactMsg = cmd.args?.trim()
1301
1218
  ? `/compact ${cmd.args.trim()}`
1302
1219
  : '/compact';
1303
- await agent.tgBot.sendText(cmd.chatId, '<blockquote>🗜️ Compacting…</blockquote>', 'HTML');
1304
- proc.sendMessage(createTextMessage(compactMsg));
1220
+ await agent.tgBot.sendText(cmd.chatId, formatSystemMessage('status', 'Compacting'), 'HTML');
1221
+ agent.ccProcess.sendMessage(createTextMessage(compactMsg));
1305
1222
  break;
1306
1223
  }
1307
1224
  case 'catchup': {
@@ -1311,24 +1228,22 @@ export class Bridge extends EventEmitter {
1311
1228
  }
1312
1229
  case 'permissions': {
1313
1230
  const validModes = ['dangerously-skip', 'acceptEdits', 'default', 'plan'];
1314
- const userState = this.sessionStore.getUser(agentId, cmd.userId);
1315
1231
  const agentDefault = agent.config.defaults.permissionMode;
1316
- const currentMode = userState.permissionMode || agentDefault;
1232
+ const agentState = this.sessionStore.getAgent(agentId);
1233
+ const currentMode = agentState.permissionMode || agentDefault;
1317
1234
  if (cmd.args) {
1318
1235
  const mode = cmd.args.trim();
1319
1236
  if (!validModes.includes(mode)) {
1320
1237
  await agent.tgBot.sendText(cmd.chatId, `<blockquote>Invalid mode. Valid: ${validModes.join(', ')}</blockquote>`, 'HTML');
1321
1238
  break;
1322
1239
  }
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);
1240
+ this.sessionStore.setPermissionMode(agentId, mode);
1241
+ this.killAgentProcess(agentId);
1326
1242
  await agent.tgBot.sendText(cmd.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1327
1243
  break;
1328
1244
  }
1329
- // No args — show current mode + inline keyboard
1330
1245
  const keyboard = new InlineKeyboard();
1331
- keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('✏️ Accept Edits', 'permissions:acceptEdits').row();
1246
+ keyboard.text('🔓 Bypass', 'permissions:dangerously-skip').text('🛂 Accept Edits', 'permissions:acceptEdits').row();
1332
1247
  keyboard.text('🔒 Default', 'permissions:default').text('📋 Plan', 'permissions:plan').row();
1333
1248
  await agent.tgBot.sendTextWithKeyboard(cmd.chatId, `Current: <code>${escapeHtml(currentMode)}</code>\nDefault: <code>${escapeHtml(agentDefault)}</code>\n\nSelect a mode for this session:`, keyboard, 'HTML');
1334
1249
  break;
@@ -1340,22 +1255,23 @@ export class Bridge extends EventEmitter {
1340
1255
  const agent = this.agents.get(agentId);
1341
1256
  if (!agent)
1342
1257
  return;
1258
+ if (!agent.tgBot)
1259
+ return; // ephemeral agents don't have TG bots
1343
1260
  this.logger.debug({ agentId, action: query.action, data: query.data }, 'Callback query');
1344
1261
  switch (query.action) {
1345
1262
  case 'resume': {
1346
1263
  const sessionId = query.data;
1347
- this.disconnectClient(agentId, query.userId, query.chatId);
1348
- this.sessionStore.setCurrentSession(agentId, query.userId, sessionId);
1264
+ this.killAgentProcess(agentId);
1265
+ agent.pendingSessionId = sessionId;
1349
1266
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session set');
1350
1267
  await agent.tgBot.sendText(query.chatId, `Will resume session <code>${escapeHtml(sessionId.slice(0, 8))}</code> on next message.`, 'HTML');
1351
1268
  break;
1352
1269
  }
1353
1270
  case 'delete': {
1354
1271
  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, '');
1272
+ // Kill process if it's running this session
1273
+ if (agent.ccProcess?.sessionId === sessionId) {
1274
+ this.killAgentProcess(agentId);
1359
1275
  }
1360
1276
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Session cleared');
1361
1277
  await agent.tgBot.sendText(query.chatId, `Session <code>${escapeHtml(sessionId.slice(0, 8))}</code> cleared.`, 'HTML');
@@ -1368,13 +1284,15 @@ export class Bridge extends EventEmitter {
1368
1284
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Path not found');
1369
1285
  break;
1370
1286
  }
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);
1287
+ const oldRepoCb = agent.repo;
1288
+ this.killAgentProcess(agentId);
1289
+ agent.repo = repoPath;
1290
+ agent.pendingSessionId = null;
1291
+ this.sessionStore.setRepo(agentId, repoPath);
1376
1292
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Repo: ${repoName}`);
1377
1293
  await agent.tgBot.sendText(query.chatId, `<blockquote>Repo set to <code>${escapeHtml(shortenRepoPath(repoPath))}</code>. Session cleared.</blockquote>`, 'HTML');
1294
+ // Emit state_changed event
1295
+ this.emitStateChanged(agentId, 'repo', oldRepoCb, repoPath, 'telegram');
1378
1296
  break;
1379
1297
  }
1380
1298
  case 'repo_add': {
@@ -1389,10 +1307,14 @@ export class Bridge extends EventEmitter {
1389
1307
  await agent.tgBot.sendText(query.chatId, 'Send: <code>/model &lt;model-name&gt;</code>', 'HTML');
1390
1308
  break;
1391
1309
  }
1392
- this.sessionModelOverrides.set(`${agentId}:${query.userId}`, model);
1393
- this.disconnectClient(agentId, query.userId, query.chatId);
1310
+ const oldModelCb = agent.model;
1311
+ agent.model = model;
1312
+ this.sessionStore.setModel(agentId, model);
1313
+ this.killAgentProcess(agentId);
1394
1314
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Model: ${model}`);
1395
1315
  await agent.tgBot.sendText(query.chatId, `<blockquote>Model set to <code>${escapeHtml(model)}</code>. Process respawned.</blockquote>`, 'HTML');
1316
+ // Emit state_changed event
1317
+ this.emitStateChanged(agentId, 'model', oldModelCb, model, 'telegram');
1396
1318
  break;
1397
1319
  }
1398
1320
  case 'permissions': {
@@ -1402,9 +1324,8 @@ export class Bridge extends EventEmitter {
1402
1324
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Invalid mode');
1403
1325
  break;
1404
1326
  }
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);
1327
+ this.sessionStore.setPermissionMode(agentId, mode);
1328
+ this.killAgentProcess(agentId);
1408
1329
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `Mode: ${mode}`);
1409
1330
  await agent.tgBot.sendText(query.chatId, `Permission mode set to <code>${escapeHtml(mode)}</code>. Takes effect on next message.`, 'HTML');
1410
1331
  break;
@@ -1431,9 +1352,8 @@ export class Bridge extends EventEmitter {
1431
1352
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1432
1353
  break;
1433
1354
  }
1434
- const proc = agent.processes.get(pending.userId);
1435
- if (proc) {
1436
- proc.respondToPermission(requestId, true);
1355
+ if (agent.ccProcess) {
1356
+ agent.ccProcess.respondToPermission(requestId, true);
1437
1357
  }
1438
1358
  agent.pendingPermissions.delete(requestId);
1439
1359
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '✅ Allowed');
@@ -1446,27 +1366,22 @@ export class Bridge extends EventEmitter {
1446
1366
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, 'Permission expired');
1447
1367
  break;
1448
1368
  }
1449
- const proc = agent.processes.get(pending.userId);
1450
- if (proc) {
1451
- proc.respondToPermission(requestId, false);
1369
+ if (agent.ccProcess) {
1370
+ agent.ccProcess.respondToPermission(requestId, false);
1452
1371
  }
1453
1372
  agent.pendingPermissions.delete(requestId);
1454
1373
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, '❌ Denied');
1455
1374
  break;
1456
1375
  }
1457
1376
  case 'perm_allow_all': {
1458
- // Allow all pending permissions for this user
1459
- const targetUserId = query.data;
1377
+ // Allow all pending permissions for this agent
1460
1378
  const toAllow = [];
1461
- for (const [reqId, pending] of agent.pendingPermissions) {
1462
- if (pending.userId === targetUserId) {
1463
- toAllow.push(reqId);
1464
- }
1379
+ for (const [reqId] of agent.pendingPermissions) {
1380
+ toAllow.push(reqId);
1465
1381
  }
1466
- const proc = agent.processes.get(targetUserId);
1467
1382
  for (const reqId of toAllow) {
1468
- if (proc)
1469
- proc.respondToPermission(reqId, true);
1383
+ if (agent.ccProcess)
1384
+ agent.ccProcess.respondToPermission(reqId, true);
1470
1385
  agent.pendingPermissions.delete(reqId);
1471
1386
  }
1472
1387
  await agent.tgBot.answerCallbackQuery(query.callbackQueryId, `✅ Allowed ${toAllow.length} permission(s)`);
@@ -1480,24 +1395,18 @@ export class Bridge extends EventEmitter {
1480
1395
  handleCtlMessage(agentId, text, sessionId) {
1481
1396
  const agent = this.agents.get(agentId);
1482
1397
  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
1398
  throw new Error(`Unknown agent: ${agentId}`);
1486
1399
  }
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
1400
+ // If explicit session requested, set it as pending
1491
1401
  if (sessionId) {
1492
- this.sessionStore.setCurrentSession(agentId, userId, sessionId);
1402
+ agent.pendingSessionId = sessionId;
1493
1403
  }
1494
1404
  // Route through the same sendToCC path as Telegram
1495
- this.sendToCC(agentId, userId, chatId, { text });
1496
- const proc = agent.processes.get(userId);
1405
+ this.sendToCC(agentId, { text }, { spawnSource: 'cli' });
1497
1406
  return {
1498
1407
  type: 'ack',
1499
- sessionId: proc?.sessionId ?? this.sessionStore.getUser(agentId, userId).currentSessionId,
1500
- state: proc?.state ?? 'idle',
1408
+ sessionId: agent.ccProcess?.sessionId ?? null,
1409
+ state: agent.ccProcess?.state ?? 'idle',
1501
1410
  };
1502
1411
  }
1503
1412
  handleCtlStatus(agentId) {
@@ -1508,30 +1417,16 @@ export class Bridge extends EventEmitter {
1508
1417
  const agent = this.agents.get(id);
1509
1418
  if (!agent)
1510
1419
  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);
1420
+ const state = agent.ccProcess?.state ?? 'idle';
1524
1421
  agents.push({
1525
1422
  id,
1526
1423
  state,
1527
- sessionId: proc?.sessionId ?? null,
1528
- repo: this.sessionStore.getUser(id, userId).repo || userConfig.repo,
1424
+ sessionId: agent.ccProcess?.sessionId ?? null,
1425
+ repo: agent.repo,
1529
1426
  });
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)) {
1427
+ // List sessions from CC's session directory
1428
+ if (agent.repo) {
1429
+ for (const d of discoverCCSessions(agent.repo, 5)) {
1535
1430
  sessions.push({
1536
1431
  id: d.id,
1537
1432
  agentId: id,
@@ -1549,10 +1444,59 @@ export class Bridge extends EventEmitter {
1549
1444
  if (!agent) {
1550
1445
  return { id: request.id, success: false, error: `Unknown agent: ${request.agentId}` };
1551
1446
  }
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
1447
  try {
1448
+ // Supervisor-routed tools (don't need TG chatId)
1449
+ switch (request.tool) {
1450
+ case 'notify_parent': {
1451
+ if (!this.supervisorWrite) {
1452
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1453
+ }
1454
+ this.sendToSupervisor({
1455
+ type: 'event',
1456
+ event: 'cc_message',
1457
+ agentId: request.agentId,
1458
+ text: request.params.message,
1459
+ priority: request.params.priority || 'info',
1460
+ });
1461
+ return { id: request.id, success: true };
1462
+ }
1463
+ case 'supervisor_exec': {
1464
+ if (!this.supervisorWrite) {
1465
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1466
+ }
1467
+ const timeoutMs = request.params.timeoutMs || 60000;
1468
+ const result = await this.sendSupervisorRequest({
1469
+ type: 'command',
1470
+ requestId: randomUUID(),
1471
+ action: 'exec',
1472
+ params: {
1473
+ command: request.params.command,
1474
+ agentId: request.agentId,
1475
+ timeoutMs,
1476
+ },
1477
+ }, timeoutMs);
1478
+ return { id: request.id, success: true, result };
1479
+ }
1480
+ case 'supervisor_notify': {
1481
+ if (!this.supervisorWrite) {
1482
+ return { id: request.id, success: false, error: 'No supervisor connected' };
1483
+ }
1484
+ this.sendToSupervisor({
1485
+ type: 'event',
1486
+ event: 'notification',
1487
+ agentId: request.agentId,
1488
+ title: request.params.title,
1489
+ body: request.params.body,
1490
+ priority: request.params.priority || 'active',
1491
+ });
1492
+ return { id: request.id, success: true };
1493
+ }
1494
+ }
1495
+ // TG tools (need chatId and tgBot)
1496
+ const chatId = this.getAgentChatId(agent);
1497
+ if (!chatId || !agent.tgBot) {
1498
+ return { id: request.id, success: false, error: `No chat ID for agent: ${request.agentId}` };
1499
+ }
1556
1500
  switch (request.tool) {
1557
1501
  case 'send_file':
1558
1502
  await agent.tgBot.sendFile(chatId, request.params.path, request.params.caption);
@@ -1571,6 +1515,327 @@ export class Bridge extends EventEmitter {
1571
1515
  return { id: request.id, success: false, error: err instanceof Error ? err.message : 'Unknown error' };
1572
1516
  }
1573
1517
  }
1518
+ // ── Supervisor protocol ──
1519
+ isSupervisorSubscribed(agentId, sessionId) {
1520
+ return this.supervisorSubscriptions.has(`${agentId}:*`) ||
1521
+ (sessionId !== null && this.supervisorSubscriptions.has(`${agentId}:${sessionId}`));
1522
+ }
1523
+ registerSupervisor(agentId, capabilities, writeFn) {
1524
+ this.supervisorAgentId = agentId;
1525
+ this.supervisorWrite = writeFn;
1526
+ this.supervisorSubscriptions.clear();
1527
+ this.logger.info({ agentId, capabilities }, 'Supervisor registered');
1528
+ }
1529
+ handleSupervisorDetach() {
1530
+ this.logger.info({ agentId: this.supervisorAgentId }, 'Supervisor detached');
1531
+ this.supervisorSubscriptions.clear();
1532
+ this.supervisorWrite = null;
1533
+ this.supervisorAgentId = null;
1534
+ }
1535
+ handleSupervisorLine(line) {
1536
+ let msg;
1537
+ try {
1538
+ msg = JSON.parse(line);
1539
+ }
1540
+ catch {
1541
+ this.sendToSupervisor({ type: 'error', message: 'Invalid JSON' });
1542
+ return;
1543
+ }
1544
+ // Handle responses to commands we sent to the supervisor (e.g. exec results)
1545
+ if (msg.type === 'response' && msg.requestId) {
1546
+ const pending = this.supervisorPendingRequests.get(msg.requestId);
1547
+ if (pending) {
1548
+ clearTimeout(pending.timer);
1549
+ this.supervisorPendingRequests.delete(msg.requestId);
1550
+ if (msg.error) {
1551
+ pending.reject(new Error(msg.error));
1552
+ }
1553
+ else {
1554
+ pending.resolve(msg.result);
1555
+ }
1556
+ }
1557
+ return;
1558
+ }
1559
+ if (msg.type !== 'command' || !msg.action) {
1560
+ this.sendToSupervisor({ type: 'error', message: 'Expected {type:"command", action:"..."}' });
1561
+ return;
1562
+ }
1563
+ const requestId = msg.requestId;
1564
+ const params = msg.params ?? {};
1565
+ try {
1566
+ const result = this.handleSupervisorCommand(msg.action, params);
1567
+ this.sendToSupervisor({ type: 'response', requestId, result });
1568
+ }
1569
+ catch (err) {
1570
+ this.sendToSupervisor({ type: 'response', requestId, error: err instanceof Error ? err.message : String(err) });
1571
+ }
1572
+ }
1573
+ handleSupervisorCommand(action, params) {
1574
+ switch (action) {
1575
+ case 'ping':
1576
+ return { pong: true, uptime: process.uptime() };
1577
+ // ── Phase 2: Ephemeral agents ──
1578
+ case 'create_agent': {
1579
+ const agentId = params.agentId || `oc-spawn-${randomUUID().slice(0, 8)}`;
1580
+ const repo = params.repo;
1581
+ if (!repo)
1582
+ throw new Error('Missing required param: repo');
1583
+ if (this.agents.has(agentId)) {
1584
+ throw new Error(`Agent already exists: ${agentId}`);
1585
+ }
1586
+ // Map supervisor permissionMode to CC permissionMode
1587
+ let permMode = 'default';
1588
+ const reqPerm = params.permissionMode;
1589
+ if (reqPerm === 'bypassPermissions' || reqPerm === 'dangerously-skip')
1590
+ permMode = 'dangerously-skip';
1591
+ else if (reqPerm === 'acceptEdits')
1592
+ permMode = 'acceptEdits';
1593
+ else if (reqPerm === 'plan')
1594
+ permMode = 'plan';
1595
+ const ephemeralConfig = {
1596
+ botToken: '',
1597
+ allowedUsers: [],
1598
+ defaults: {
1599
+ model: params.model || 'sonnet',
1600
+ repo,
1601
+ maxTurns: 200,
1602
+ idleTimeoutMs: 300_000,
1603
+ hangTimeoutMs: 300_000,
1604
+ permissionMode: permMode,
1605
+ },
1606
+ };
1607
+ const instance = {
1608
+ id: agentId,
1609
+ config: ephemeralConfig,
1610
+ tgBot: null,
1611
+ ephemeral: true,
1612
+ repo,
1613
+ model: ephemeralConfig.defaults.model,
1614
+ ccProcess: null,
1615
+ accumulator: null,
1616
+ subAgentTracker: null,
1617
+ batcher: null,
1618
+ pendingPermissions: new Map(),
1619
+ typingInterval: null,
1620
+ typingChatId: null,
1621
+ pendingSessionId: null,
1622
+ destroyTimer: null,
1623
+ eventBuffer: new EventBuffer(),
1624
+ };
1625
+ // Auto-destroy timer
1626
+ const timeoutMs = params.timeoutMs;
1627
+ if (timeoutMs && timeoutMs > 0) {
1628
+ instance.destroyTimer = setTimeout(() => {
1629
+ this.logger.info({ agentId, timeoutMs }, 'Ephemeral agent timeout — auto-destroying');
1630
+ this.destroyEphemeralAgent(agentId);
1631
+ }, timeoutMs);
1632
+ }
1633
+ this.agents.set(agentId, instance);
1634
+ this.logger.info({ agentId, repo, model: instance.model, ephemeral: true }, 'Ephemeral agent created');
1635
+ // Emit agent_created event (always sent, not subscription-gated)
1636
+ this.sendToSupervisor({ type: 'event', event: 'agent_created', agentId, agentType: 'ephemeral', repo });
1637
+ return { agentId, state: 'idle' };
1638
+ }
1639
+ case 'destroy_agent': {
1640
+ const agentId = params.agentId;
1641
+ if (!agentId)
1642
+ throw new Error('Missing agentId');
1643
+ const agent = this.agents.get(agentId);
1644
+ if (!agent)
1645
+ throw new Error(`Unknown agent: ${agentId}`);
1646
+ if (!agent.ephemeral)
1647
+ throw new Error(`Cannot destroy persistent agent: ${agentId}`);
1648
+ this.destroyEphemeralAgent(agentId);
1649
+ return { destroyed: true };
1650
+ }
1651
+ // ── Phase 1: Send + Subscribe ──
1652
+ case 'send_message': {
1653
+ const agentId = params.agentId;
1654
+ const text = params.text;
1655
+ if (!agentId || !text)
1656
+ throw new Error('Missing agentId or text');
1657
+ const agent = this.agents.get(agentId);
1658
+ if (!agent)
1659
+ throw new Error(`Unknown agent: ${agentId}`);
1660
+ // Auto-subscribe supervisor
1661
+ this.supervisorSubscriptions.add(`${agentId}:*`);
1662
+ // For persistent agents: route supervisor message through the accumulator so it
1663
+ // appears inline in the current bubble. Fall back to a standalone silent message
1664
+ // if no accumulator is active or the previous turn is already sealed (agent idle).
1665
+ if (agent.accumulator && !agent.accumulator.sealed) {
1666
+ agent.accumulator.addSupervisorMessage(text);
1667
+ }
1668
+ else {
1669
+ const tgChatId = this.getAgentChatId(agent);
1670
+ if (tgChatId && agent.tgBot) {
1671
+ const preview = text.length > 500 ? text.slice(0, 500) + '…' : text;
1672
+ agent.tgBot.sendText(tgChatId, `<blockquote>🦞 ${escapeHtml(preview)}</blockquote>`, 'HTML', true) // silent
1673
+ .catch(err => this.logger.error({ err, agentId }, 'Failed to send supervisor TG notification'));
1674
+ }
1675
+ }
1676
+ // Send to agent's single CC process
1677
+ this.sendToCC(agentId, { text }, { spawnSource: 'supervisor' });
1678
+ return {
1679
+ sessionId: agent.ccProcess?.sessionId ?? null,
1680
+ state: agent.ccProcess?.state ?? 'spawning',
1681
+ subscribed: true,
1682
+ };
1683
+ }
1684
+ case 'send_to_cc': {
1685
+ const agentId = params.agentId;
1686
+ const text = params.text;
1687
+ if (!agentId || !text)
1688
+ throw new Error('Missing agentId or text');
1689
+ const agent = this.agents.get(agentId);
1690
+ if (!agent)
1691
+ throw new Error(`Unknown agent: ${agentId}`);
1692
+ if (!agent.ccProcess || agent.ccProcess.state === 'idle') {
1693
+ throw new Error(`No active CC process for agent ${agentId}`);
1694
+ }
1695
+ agent.ccProcess.sendMessage(createTextMessage(text));
1696
+ return { sent: true };
1697
+ }
1698
+ case 'subscribe': {
1699
+ const agentId = params.agentId;
1700
+ const sessionId = params.sessionId;
1701
+ if (!agentId)
1702
+ throw new Error('Missing agentId');
1703
+ const key = sessionId ? `${agentId}:${sessionId}` : `${agentId}:*`;
1704
+ this.supervisorSubscriptions.add(key);
1705
+ return { subscribed: true, key };
1706
+ }
1707
+ case 'unsubscribe': {
1708
+ const agentId = params.agentId;
1709
+ if (!agentId)
1710
+ throw new Error('Missing agentId');
1711
+ // Remove all subscriptions for this agent
1712
+ for (const key of [...this.supervisorSubscriptions]) {
1713
+ if (key.startsWith(`${agentId}:`)) {
1714
+ this.supervisorSubscriptions.delete(key);
1715
+ }
1716
+ }
1717
+ return { unsubscribed: true };
1718
+ }
1719
+ case 'status': {
1720
+ const filterAgentId = params.agentId;
1721
+ const agents = [];
1722
+ const agentIds = filterAgentId ? [filterAgentId] : [...this.agents.keys()];
1723
+ for (const id of agentIds) {
1724
+ const agent = this.agents.get(id);
1725
+ if (!agent)
1726
+ continue;
1727
+ const state = agent.ccProcess?.state ?? 'idle';
1728
+ const sessionId = agent.ccProcess?.sessionId ?? null;
1729
+ agents.push({
1730
+ id,
1731
+ type: agent.ephemeral ? 'ephemeral' : 'persistent',
1732
+ state,
1733
+ repo: agent.repo,
1734
+ process: agent.ccProcess ? { sessionId, model: agent.model } : null,
1735
+ supervisorSubscribed: this.isSupervisorSubscribed(id, sessionId),
1736
+ });
1737
+ }
1738
+ return { agents };
1739
+ }
1740
+ case 'kill_cc': {
1741
+ const agentId = params.agentId;
1742
+ if (!agentId)
1743
+ throw new Error('Missing agentId');
1744
+ const agent = this.agents.get(agentId);
1745
+ if (!agent)
1746
+ throw new Error(`Unknown agent: ${agentId}`);
1747
+ const killed = agent.ccProcess != null && agent.ccProcess.state !== 'idle';
1748
+ if (killed) {
1749
+ this.killAgentProcess(agentId);
1750
+ }
1751
+ return { killed };
1752
+ }
1753
+ // ── Phase A: Observability ──
1754
+ case 'get_log': {
1755
+ const agentId = params.agentId;
1756
+ if (!agentId)
1757
+ throw new Error('Missing agentId');
1758
+ const agent = this.agents.get(agentId);
1759
+ if (!agent)
1760
+ throw new Error(`Unknown agent: ${agentId}`);
1761
+ return agent.eventBuffer.query({
1762
+ offset: params.offset,
1763
+ limit: params.limit,
1764
+ grep: params.grep,
1765
+ since: params.since,
1766
+ type: params.type,
1767
+ });
1768
+ }
1769
+ case 'permission_response': {
1770
+ const agentId = params.agentId;
1771
+ const permissionRequestId = params.permissionRequestId;
1772
+ const decision = params.decision;
1773
+ if (!agentId || !permissionRequestId || !decision) {
1774
+ throw new Error('Missing agentId, permissionRequestId, or decision');
1775
+ }
1776
+ const agent = this.agents.get(agentId);
1777
+ if (!agent)
1778
+ throw new Error(`Unknown agent: ${agentId}`);
1779
+ const pending = agent.pendingPermissions.get(permissionRequestId);
1780
+ if (!pending)
1781
+ throw new Error(`No pending permission with id: ${permissionRequestId}`);
1782
+ const allow = decision === 'allow';
1783
+ if (agent.ccProcess) {
1784
+ agent.ccProcess.respondToPermission(permissionRequestId, allow);
1785
+ }
1786
+ agent.pendingPermissions.delete(permissionRequestId);
1787
+ return { responded: true, decision };
1788
+ }
1789
+ default:
1790
+ throw new Error(`Unknown supervisor action: ${action}`);
1791
+ }
1792
+ }
1793
+ emitStateChanged(agentId, field, oldValue, newValue, source) {
1794
+ this.logger.info({ agentId, field, oldValue, newValue, source }, 'Agent state changed');
1795
+ const agent = this.agents.get(agentId);
1796
+ if (agent) {
1797
+ agent.eventBuffer.push({ ts: Date.now(), type: 'system', text: `State changed: ${field} → ${newValue}` });
1798
+ }
1799
+ if (this.isSupervisorSubscribed(agentId, agent?.ccProcess?.sessionId ?? null)) {
1800
+ this.sendToSupervisor({
1801
+ type: 'event',
1802
+ event: 'state_changed',
1803
+ agentId,
1804
+ field,
1805
+ oldValue,
1806
+ newValue,
1807
+ source,
1808
+ });
1809
+ }
1810
+ }
1811
+ sendToSupervisor(msg) {
1812
+ if (this.supervisorWrite) {
1813
+ try {
1814
+ this.supervisorWrite(JSON.stringify(msg) + '\n');
1815
+ }
1816
+ catch { }
1817
+ }
1818
+ }
1819
+ sendSupervisorRequest(msg, timeoutMs) {
1820
+ return new Promise((resolve, reject) => {
1821
+ const requestId = msg.requestId ?? '';
1822
+ const timer = setTimeout(() => {
1823
+ this.supervisorPendingRequests.delete(requestId);
1824
+ reject(new Error('Supervisor request timed out'));
1825
+ }, timeoutMs);
1826
+ this.supervisorPendingRequests.set(requestId, { resolve, reject, timer });
1827
+ this.sendToSupervisor(msg);
1828
+ });
1829
+ }
1830
+ destroyEphemeralAgent(agentId) {
1831
+ const agent = this.agents.get(agentId);
1832
+ if (!agent || !agent.ephemeral)
1833
+ return;
1834
+ this.killAgentProcess(agentId);
1835
+ this.agents.delete(agentId);
1836
+ this.logger.info({ agentId }, 'Ephemeral agent destroyed');
1837
+ this.sendToSupervisor({ type: 'event', event: 'agent_destroyed', agentId });
1838
+ }
1574
1839
  // ── Shutdown ──
1575
1840
  async stop() {
1576
1841
  this.logger.info('Stopping bridge');
@@ -1578,6 +1843,7 @@ export class Bridge extends EventEmitter {
1578
1843
  await this.stopAgent(agentId);
1579
1844
  }
1580
1845
  this.processRegistry.clear();
1846
+ this.highSignalDetector.destroy();
1581
1847
  this.mcpServer.closeAll();
1582
1848
  this.ctlServer.closeAll();
1583
1849
  this.removeAllListeners();