@fonz/tgcc 0.6.15 → 0.6.18

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