@grinev/opencode-telegram-bot 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,9 @@ import { opencodeClient } from "../opencode/client.js";
3
3
  import { getCurrentSession } from "../session/manager.js";
4
4
  import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
5
5
  import { getStoredModel } from "../model/manager.js";
6
+ import { getModelContextLimit } from "../model/context-limit.js";
6
7
  import { t } from "../i18n/index.js";
8
+ import { DEFAULT_CONTEXT_LIMIT, formatContextLine, formatCostLine, formatModelDisplayName, } from "./format.js";
7
9
  class PinnedMessageManager {
8
10
  api = null;
9
11
  chatId = null;
@@ -147,6 +149,22 @@ class PinnedMessageManager {
147
149
  await this.refreshSessionTitle();
148
150
  await this.updatePinnedMessage();
149
151
  }
152
+ /**
153
+ * Update tokens in memory without triggering an API call.
154
+ * Used for intermediate (non-completed) message.updated events
155
+ * to keep pinned state in sync with keyboardManager.
156
+ */
157
+ updateTokensSilent(tokens) {
158
+ this.state.tokensUsed = tokens.input + tokens.cacheRead;
159
+ logger.debug(`[PinnedManager] Tokens updated (silent): ${this.state.tokensUsed}/${this.state.tokensLimit}`);
160
+ }
161
+ /**
162
+ * Refresh the pinned message with current in-memory state.
163
+ * Used at thinking time to push accumulated silent updates to Telegram.
164
+ */
165
+ async refresh() {
166
+ await this.updatePinnedMessage();
167
+ }
150
168
  /**
151
169
  * Called when cost info is received from SSE events
152
170
  */
@@ -162,6 +180,12 @@ class PinnedMessageManager {
162
180
  setOnKeyboardUpdate(callback) {
163
181
  this.onKeyboardUpdateCallback = callback;
164
182
  logger.debug("[PinnedManager] Keyboard update callback registered");
183
+ // Fire immediately with current state to fix race condition:
184
+ // onSessionChange may have already run before this callback was registered.
185
+ const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0;
186
+ if (limit > 0) {
187
+ callback(this.state.tokensUsed, limit);
188
+ }
165
189
  }
166
190
  /**
167
191
  * Get current context information
@@ -412,38 +436,13 @@ class PinnedMessageManager {
412
436
  async fetchContextLimit() {
413
437
  try {
414
438
  const model = getStoredModel();
415
- if (!model.providerID || !model.modelID) {
416
- logger.warn("[PinnedManager] No model configured, using default limit");
417
- this.contextLimit = 200000;
418
- this.state.tokensLimit = this.contextLimit;
419
- return;
420
- }
421
- const { data: providersData, error } = await opencodeClient.config.providers();
422
- if (error || !providersData) {
423
- logger.warn("[PinnedManager] Failed to fetch providers, using default limit");
424
- this.contextLimit = 200000;
425
- this.state.tokensLimit = this.contextLimit;
426
- return;
427
- }
428
- // Find the model in providers
429
- for (const provider of providersData.providers) {
430
- if (provider.id === model.providerID) {
431
- const modelInfo = provider.models[model.modelID];
432
- if (modelInfo?.limit?.context) {
433
- this.contextLimit = modelInfo.limit.context;
434
- this.state.tokensLimit = this.contextLimit;
435
- logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
436
- return;
437
- }
438
- }
439
- }
440
- logger.warn("[PinnedManager] Model not found in providers, using default limit");
441
- this.contextLimit = 200000;
439
+ this.contextLimit = await getModelContextLimit(model.providerID, model.modelID);
442
440
  this.state.tokensLimit = this.contextLimit;
441
+ logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
443
442
  }
444
443
  catch (err) {
445
444
  logger.error("[PinnedManager] Error fetching context limit:", err);
446
- this.contextLimit = 200000;
445
+ this.contextLimit = DEFAULT_CONTEXT_LIMIT;
447
446
  this.state.tokensLimit = this.contextLimit;
448
447
  }
449
448
  }
@@ -451,28 +450,16 @@ class PinnedMessageManager {
451
450
  * Format the pinned message text
452
451
  */
453
452
  formatMessage() {
454
- const percentage = this.state.tokensLimit > 0
455
- ? Math.round((this.state.tokensUsed / this.state.tokensLimit) * 100)
456
- : 0;
457
- const tokensFormatted = this.formatTokenCount(this.state.tokensUsed);
458
- const limitFormatted = this.formatTokenCount(this.state.tokensLimit);
459
- // Get current model info
460
453
  const currentModel = getStoredModel();
461
- const modelName = currentModel.providerID && currentModel.modelID
462
- ? `${currentModel.providerID}/${currentModel.modelID}`
463
- : t("pinned.unknown");
454
+ const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
464
455
  const lines = [
465
456
  `${this.state.sessionTitle}`,
466
457
  t("pinned.line.project", { project: this.state.projectName }),
467
458
  t("pinned.line.model", { model: modelName }),
468
- t("pinned.line.context", {
469
- used: tokensFormatted,
470
- limit: limitFormatted,
471
- percent: percentage,
472
- }),
459
+ formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
473
460
  ];
474
461
  if (this.state.cost !== undefined && this.state.cost !== null) {
475
- lines.push(t("pinned.line.cost", { cost: `$${this.state.cost.toFixed(2)}` }));
462
+ lines.push(formatCostLine(this.state.cost));
476
463
  }
477
464
  if (this.state.changedFiles.length > 0) {
478
465
  const maxFiles = 10;
@@ -496,18 +483,6 @@ class PinnedMessageManager {
496
483
  }
497
484
  return lines.join("\n");
498
485
  }
499
- /**
500
- * Format token count (e.g., 150000 -> "150K")
501
- */
502
- formatTokenCount(count) {
503
- if (count >= 1000000) {
504
- return `${(count / 1000000).toFixed(1)}M`;
505
- }
506
- else if (count >= 1000) {
507
- return `${Math.round(count / 1000)}K`;
508
- }
509
- return count.toString();
510
- }
511
486
  /**
512
487
  * Create and pin a new status message
513
488
  */
@@ -39,6 +39,7 @@ class SummaryAggregator {
39
39
  onThinkingCallback = null;
40
40
  onTokensCallback = null;
41
41
  onCostCallback = null;
42
+ onSubagentCallback = null;
42
43
  onSessionCompactedCallback = null;
43
44
  onSessionErrorCallback = null;
44
45
  onSessionRetryCallback = null;
@@ -54,6 +55,13 @@ class SummaryAggregator {
54
55
  typingTimer = null;
55
56
  typingIndicatorEnabled = true;
56
57
  partHashes = new Map();
58
+ trackedSessionParents = new Map();
59
+ subagentStates = new Map();
60
+ subagentOrder = [];
61
+ subagentCardIdBySessionId = new Map();
62
+ pendingSubagentCardIdsByParent = new Map();
63
+ pendingChildSessionIdsByParent = new Map();
64
+ fallbackSubagentCardIdsByParent = new Map();
57
65
  setBotAndChatId(bot, chatId) {
58
66
  this.bot = bot;
59
67
  this.chatId = chatId;
@@ -85,6 +93,9 @@ class SummaryAggregator {
85
93
  setOnCost(callback) {
86
94
  this.onCostCallback = callback;
87
95
  }
96
+ setOnSubagent(callback) {
97
+ this.onSubagentCallback = callback;
98
+ }
88
99
  setOnSessionCompacted(callback) {
89
100
  this.onSessionCompactedCallback = callback;
90
101
  }
@@ -150,6 +161,10 @@ class SummaryAggregator {
150
161
  logger.debug(`[Aggregator] Session event: ${event.type}`, JSON.stringify(event.properties, null, 2));
151
162
  }
152
163
  switch (event.type) {
164
+ case "session.created":
165
+ case "session.updated":
166
+ this.handleSessionCreatedOrUpdated(event);
167
+ break;
153
168
  case "message.updated":
154
169
  this.handleMessageUpdated(event);
155
170
  break;
@@ -195,6 +210,7 @@ class SummaryAggregator {
195
210
  if (this.currentSessionId !== sessionId) {
196
211
  this.clear();
197
212
  this.currentSessionId = sessionId;
213
+ this.trackedSessionParents.set(sessionId, null);
198
214
  }
199
215
  }
200
216
  clear() {
@@ -206,6 +222,13 @@ class SummaryAggregator {
206
222
  this.knownTextPartIds.clear();
207
223
  this.processedToolStates.clear();
208
224
  this.thinkingFiredForMessages.clear();
225
+ this.trackedSessionParents.clear();
226
+ this.subagentStates.clear();
227
+ this.subagentOrder = [];
228
+ this.subagentCardIdBySessionId.clear();
229
+ this.pendingSubagentCardIdsByParent.clear();
230
+ this.pendingChildSessionIdsByParent.clear();
231
+ this.fallbackSubagentCardIdsByParent.clear();
209
232
  this.messageCount = 0;
210
233
  this.lastUpdated = 0;
211
234
  if (this.onClearedCallback) {
@@ -217,8 +240,362 @@ class SummaryAggregator {
217
240
  }
218
241
  }
219
242
  }
243
+ isTrackedChildSession(sessionId) {
244
+ return this.trackedSessionParents.has(sessionId) && sessionId !== this.currentSessionId;
245
+ }
246
+ getQueue(map, parentSessionId) {
247
+ const existing = map.get(parentSessionId);
248
+ if (existing) {
249
+ return existing;
250
+ }
251
+ const queue = [];
252
+ map.set(parentSessionId, queue);
253
+ return queue;
254
+ }
255
+ dequeue(map, parentSessionId) {
256
+ const queue = map.get(parentSessionId);
257
+ if (!queue || queue.length === 0) {
258
+ return undefined;
259
+ }
260
+ const value = queue.shift();
261
+ if (queue.length === 0) {
262
+ map.delete(parentSessionId);
263
+ }
264
+ return value;
265
+ }
266
+ removeFromQueue(map, parentSessionId, value) {
267
+ const queue = map.get(parentSessionId);
268
+ if (!queue) {
269
+ return;
270
+ }
271
+ const index = queue.indexOf(value);
272
+ if (index >= 0) {
273
+ queue.splice(index, 1);
274
+ }
275
+ if (queue.length === 0) {
276
+ map.delete(parentSessionId);
277
+ }
278
+ }
279
+ emitSubagentState() {
280
+ if (!this.currentSessionId || !this.onSubagentCallback || this.subagentOrder.length === 0) {
281
+ return;
282
+ }
283
+ const subagents = this.subagentOrder
284
+ .map((cardId) => this.subagentStates.get(cardId))
285
+ .filter((state) => Boolean(state))
286
+ .map((state) => ({
287
+ cardId: state.cardId,
288
+ sessionId: state.sessionId,
289
+ parentSessionId: state.parentSessionId,
290
+ agent: state.agent,
291
+ description: state.description,
292
+ prompt: state.prompt,
293
+ command: state.command,
294
+ status: state.status,
295
+ providerID: state.providerID,
296
+ modelID: state.modelID,
297
+ tokens: { ...state.tokens },
298
+ cost: state.cost,
299
+ currentTool: state.currentTool,
300
+ currentToolInput: state.currentToolInput ? { ...state.currentToolInput } : undefined,
301
+ currentToolTitle: state.currentToolTitle,
302
+ terminalMessage: state.terminalMessage,
303
+ updatedAt: state.updatedAt,
304
+ }));
305
+ this.onSubagentCallback(this.currentSessionId, subagents);
306
+ }
307
+ createSubagentState(parentSessionId, sessionId, cardId = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`) {
308
+ const state = {
309
+ cardId,
310
+ sessionId,
311
+ parentSessionId,
312
+ agent: "",
313
+ description: "",
314
+ prompt: "",
315
+ status: "pending",
316
+ tokens: {
317
+ input: 0,
318
+ output: 0,
319
+ reasoning: 0,
320
+ cacheRead: 0,
321
+ cacheWrite: 0,
322
+ },
323
+ cost: 0,
324
+ terminalMessage: undefined,
325
+ updatedAt: Date.now(),
326
+ hasSubtaskMetadata: false,
327
+ hasTaskToolMetadata: false,
328
+ hasSessionTitleMetadata: false,
329
+ createdAt: Date.now(),
330
+ };
331
+ this.subagentStates.set(cardId, state);
332
+ this.subagentOrder.push(cardId);
333
+ if (sessionId) {
334
+ this.subagentCardIdBySessionId.set(sessionId, cardId);
335
+ }
336
+ return state;
337
+ }
338
+ enrichSubagentFromSubtask(state, details) {
339
+ state.agent = details.agent || state.agent;
340
+ state.description = details.description || details.prompt || state.description;
341
+ state.prompt = details.prompt;
342
+ state.command = details.command;
343
+ state.hasSubtaskMetadata = true;
344
+ state.updatedAt = Date.now();
345
+ }
346
+ enrichSubagentFromTaskTool(state, details) {
347
+ const nextDescription = details.description?.trim() || details.prompt?.trim();
348
+ if (details.agent?.trim()) {
349
+ state.agent = details.agent.trim();
350
+ }
351
+ if (nextDescription) {
352
+ state.description = nextDescription;
353
+ }
354
+ if (details.prompt?.trim()) {
355
+ state.prompt = details.prompt.trim();
356
+ }
357
+ if (details.command?.trim()) {
358
+ state.command = details.command.trim();
359
+ }
360
+ state.hasTaskToolMetadata = true;
361
+ state.updatedAt = Date.now();
362
+ }
363
+ enrichSubagentFromSessionTitle(state, title) {
364
+ const trimmedTitle = title?.trim();
365
+ if (!trimmedTitle) {
366
+ return;
367
+ }
368
+ const match = trimmedTitle.match(/^(.*?)(?:\s+\(@([^\s)]+)\s+subagent\))?$/i);
369
+ const rawDescription = match?.[1]?.trim() || trimmedTitle;
370
+ const rawAgent = match?.[2]?.trim();
371
+ if (rawDescription) {
372
+ state.description = rawDescription;
373
+ }
374
+ if (rawAgent) {
375
+ state.agent = rawAgent.replace(/^@/, "");
376
+ }
377
+ state.hasSessionTitleMetadata = true;
378
+ state.updatedAt = Date.now();
379
+ }
380
+ attachSessionToSubagent(cardId, sessionId) {
381
+ const state = this.subagentStates.get(cardId);
382
+ if (!state) {
383
+ return;
384
+ }
385
+ state.sessionId = sessionId;
386
+ state.updatedAt = Date.now();
387
+ this.subagentCardIdBySessionId.set(sessionId, cardId);
388
+ this.removeFromQueue(this.pendingSubagentCardIdsByParent, state.parentSessionId, cardId);
389
+ }
390
+ findPendingSubagentWithoutSession() {
391
+ for (const cardId of this.subagentOrder) {
392
+ const state = this.subagentStates.get(cardId);
393
+ if (state && !state.sessionId) {
394
+ return state;
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+ attachUnknownSessionToPendingSubagent(sessionId) {
400
+ const pendingState = this.findPendingSubagentWithoutSession();
401
+ if (!pendingState) {
402
+ return false;
403
+ }
404
+ this.trackedSessionParents.set(sessionId, pendingState.parentSessionId);
405
+ this.attachSessionToSubagent(pendingState.cardId, sessionId);
406
+ this.removeFromQueue(this.pendingChildSessionIdsByParent, pendingState.parentSessionId, sessionId);
407
+ this.emitSubagentState();
408
+ return true;
409
+ }
410
+ findNextSubagentForTaskTool(parentSessionId) {
411
+ for (const cardId of this.subagentOrder) {
412
+ const state = this.subagentStates.get(cardId);
413
+ if (state && state.parentSessionId === parentSessionId && !state.hasTaskToolMetadata) {
414
+ return state;
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+ updateSubagentFromTaskTool(parentSessionId, input) {
420
+ const subagent = this.findNextSubagentForTaskTool(parentSessionId);
421
+ if (!subagent || !input) {
422
+ return;
423
+ }
424
+ const description = typeof input.description === "string" ? input.description : undefined;
425
+ const prompt = typeof input.prompt === "string" ? input.prompt : undefined;
426
+ const agent = typeof input.subagent_type === "string" ? input.subagent_type : undefined;
427
+ const command = typeof input.command === "string" ? input.command : undefined;
428
+ if (!description && !prompt && !agent && !command) {
429
+ return;
430
+ }
431
+ this.enrichSubagentFromTaskTool(subagent, { agent, description, prompt, command });
432
+ this.emitSubagentState();
433
+ }
434
+ getOrCreateSubagentForSession(sessionId) {
435
+ const existingCardId = this.subagentCardIdBySessionId.get(sessionId);
436
+ if (existingCardId) {
437
+ return this.subagentStates.get(existingCardId);
438
+ }
439
+ const parentSessionId = this.trackedSessionParents.get(sessionId) ?? this.currentSessionId ?? sessionId;
440
+ this.removeFromQueue(this.pendingChildSessionIdsByParent, parentSessionId, sessionId);
441
+ const state = this.createSubagentState(parentSessionId, sessionId);
442
+ this.getQueue(this.fallbackSubagentCardIdsByParent, parentSessionId).push(state.cardId);
443
+ return state;
444
+ }
445
+ registerSubtaskPart(parentSessionId, partId, agent, description, prompt, command) {
446
+ const fallbackCardId = this.dequeue(this.fallbackSubagentCardIdsByParent, parentSessionId);
447
+ if (fallbackCardId) {
448
+ const fallbackState = this.subagentStates.get(fallbackCardId);
449
+ if (fallbackState) {
450
+ this.enrichSubagentFromSubtask(fallbackState, { agent, description, prompt, command });
451
+ this.emitSubagentState();
452
+ return;
453
+ }
454
+ }
455
+ const state = this.createSubagentState(parentSessionId, null, `subtask-${parentSessionId}-${partId}`);
456
+ this.enrichSubagentFromSubtask(state, { agent, description, prompt, command });
457
+ const pendingChildSessionId = this.dequeue(this.pendingChildSessionIdsByParent, parentSessionId);
458
+ if (pendingChildSessionId) {
459
+ this.attachSessionToSubagent(state.cardId, pendingChildSessionId);
460
+ }
461
+ else {
462
+ this.getQueue(this.pendingSubagentCardIdsByParent, parentSessionId).push(state.cardId);
463
+ }
464
+ this.emitSubagentState();
465
+ }
466
+ trackChildSession(sessionId, parentSessionId) {
467
+ this.trackedSessionParents.set(sessionId, parentSessionId);
468
+ const pendingCardId = this.dequeue(this.pendingSubagentCardIdsByParent, parentSessionId);
469
+ if (pendingCardId) {
470
+ this.attachSessionToSubagent(pendingCardId, sessionId);
471
+ this.emitSubagentState();
472
+ return;
473
+ }
474
+ this.getQueue(this.pendingChildSessionIdsByParent, parentSessionId).push(sessionId);
475
+ }
476
+ handleSessionCreatedOrUpdated(event) {
477
+ if (!this.currentSessionId) {
478
+ return;
479
+ }
480
+ const { info } = event.properties;
481
+ if (!info.parentID) {
482
+ return;
483
+ }
484
+ if (!this.trackedSessionParents.has(info.parentID)) {
485
+ return;
486
+ }
487
+ if (info.id === this.currentSessionId) {
488
+ return;
489
+ }
490
+ if (!this.trackedSessionParents.has(info.id)) {
491
+ this.trackChildSession(info.id, info.parentID);
492
+ }
493
+ const subagent = this.getOrCreateSubagentForSession(info.id);
494
+ this.enrichSubagentFromSessionTitle(subagent, info.title);
495
+ this.emitSubagentState();
496
+ }
497
+ updateSubagentFromAssistantMessage(info) {
498
+ const subagent = this.getOrCreateSubagentForSession(info.sessionID);
499
+ if (info.agent) {
500
+ subagent.agent = info.agent;
501
+ }
502
+ if (info.providerID) {
503
+ subagent.providerID = info.providerID;
504
+ }
505
+ if (info.modelID) {
506
+ subagent.modelID = info.modelID;
507
+ }
508
+ if (info.tokens) {
509
+ subagent.tokens = {
510
+ input: info.tokens.input,
511
+ output: info.tokens.output,
512
+ reasoning: info.tokens.reasoning,
513
+ cacheRead: info.tokens.cache?.read || 0,
514
+ cacheWrite: info.tokens.cache?.write || 0,
515
+ };
516
+ }
517
+ if (typeof info.cost === "number") {
518
+ subagent.cost = info.cost;
519
+ }
520
+ subagent.updatedAt = Date.now();
521
+ this.emitSubagentState();
522
+ }
523
+ updateSubagentToolState(sessionId, state, tool, input, title) {
524
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
525
+ const status = "status" in state ? state.status : undefined;
526
+ if (status === "running") {
527
+ subagent.status = "running";
528
+ subagent.terminalMessage = undefined;
529
+ }
530
+ if (status === "pending" && subagent.status === "pending") {
531
+ subagent.status = "pending";
532
+ subagent.terminalMessage = undefined;
533
+ }
534
+ subagent.currentTool = tool;
535
+ subagent.currentToolInput = input ? { ...input } : undefined;
536
+ subagent.currentToolTitle = title;
537
+ subagent.updatedAt = Date.now();
538
+ this.emitSubagentState();
539
+ }
540
+ updateSubagentStepStart(sessionId, snapshot) {
541
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
542
+ subagent.status = "running";
543
+ subagent.terminalMessage = undefined;
544
+ subagent.currentTool = undefined;
545
+ subagent.currentToolInput = undefined;
546
+ subagent.currentToolTitle = snapshot?.trim() || subagent.currentToolTitle;
547
+ subagent.updatedAt = Date.now();
548
+ this.emitSubagentState();
549
+ }
550
+ updateSubagentStepFinish(sessionId, tokens, cost, snapshot) {
551
+ const subagent = this.getOrCreateSubagentForSession(sessionId);
552
+ subagent.status = "running";
553
+ subagent.terminalMessage = undefined;
554
+ subagent.tokens = {
555
+ input: tokens.input,
556
+ output: tokens.output,
557
+ reasoning: tokens.reasoning,
558
+ cacheRead: tokens.cache.read,
559
+ cacheWrite: tokens.cache.write,
560
+ };
561
+ subagent.cost += cost;
562
+ if (snapshot?.trim()) {
563
+ subagent.currentToolTitle = snapshot.trim();
564
+ }
565
+ subagent.updatedAt = Date.now();
566
+ this.emitSubagentState();
567
+ }
568
+ setSubagentTerminalStatus(sessionId, status, terminalMessage) {
569
+ const cardId = this.subagentCardIdBySessionId.get(sessionId);
570
+ if (!cardId) {
571
+ return;
572
+ }
573
+ const subagent = this.subagentStates.get(cardId);
574
+ if (!subagent) {
575
+ return;
576
+ }
577
+ subagent.status = status;
578
+ subagent.currentTool = undefined;
579
+ subagent.currentToolInput = undefined;
580
+ subagent.currentToolTitle = undefined;
581
+ subagent.terminalMessage = terminalMessage?.trim() || undefined;
582
+ subagent.updatedAt = Date.now();
583
+ this.emitSubagentState();
584
+ }
220
585
  handleMessageUpdated(event) {
221
586
  const { info } = event.properties;
587
+ if (info.sessionID !== this.currentSessionId &&
588
+ !this.trackedSessionParents.has(info.sessionID) &&
589
+ info.role === "assistant") {
590
+ this.attachUnknownSessionToPendingSubagent(info.sessionID);
591
+ }
592
+ if (this.isTrackedChildSession(info.sessionID)) {
593
+ if (info.role === "assistant") {
594
+ const assistantInfo = info;
595
+ this.updateSubagentFromAssistantMessage(assistantInfo);
596
+ }
597
+ return;
598
+ }
222
599
  if (info.sessionID !== this.currentSessionId) {
223
600
  return;
224
601
  }
@@ -242,23 +619,24 @@ class SummaryAggregator {
242
619
  if (!isCompleted && textState.optimisticUpdateCount === 1) {
243
620
  this.emitPartialText(info.sessionID, messageID, messageText);
244
621
  }
622
+ // Extract and report tokens for EVERY message.updated with token data
623
+ // (both intermediate and completed). This keeps keyboard context in sync.
624
+ const assistantInfo = info;
625
+ if (this.onTokensCallback && assistantInfo.tokens) {
626
+ const tokens = {
627
+ input: assistantInfo.tokens.input,
628
+ output: assistantInfo.tokens.output,
629
+ reasoning: assistantInfo.tokens.reasoning,
630
+ cacheRead: assistantInfo.tokens.cache?.read || 0,
631
+ cacheWrite: assistantInfo.tokens.cache?.write || 0,
632
+ };
633
+ logger.debug(`[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}, cacheRead=${tokens.cacheRead}, cacheWrite=${tokens.cacheWrite}, completed=${isCompleted}`);
634
+ // Call synchronously so keyboardManager is updated before onComplete sends the reply
635
+ this.onTokensCallback(tokens, isCompleted);
636
+ }
245
637
  if (isCompleted) {
246
638
  const finalText = messageText;
247
639
  logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`);
248
- // Extract and report tokens BEFORE onComplete so keyboard context is updated
249
- const assistantInfo = info;
250
- if (this.onTokensCallback && assistantInfo.tokens) {
251
- const tokens = {
252
- input: assistantInfo.tokens.input,
253
- output: assistantInfo.tokens.output,
254
- reasoning: assistantInfo.tokens.reasoning,
255
- cacheRead: assistantInfo.tokens.cache?.read || 0,
256
- cacheWrite: assistantInfo.tokens.cache?.write || 0,
257
- };
258
- logger.debug(`[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}`);
259
- // Call synchronously so keyboardManager is updated before onComplete sends the reply
260
- this.onTokensCallback(tokens);
261
- }
262
640
  // Extract and report cost
263
641
  if (this.onCostCallback && assistantInfo.cost !== undefined) {
264
642
  logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`);
@@ -282,7 +660,35 @@ class SummaryAggregator {
282
660
  }
283
661
  handleMessagePartUpdated(event) {
284
662
  const { part } = event.properties;
285
- if (part.sessionID !== this.currentSessionId) {
663
+ if (part.sessionID !== this.currentSessionId &&
664
+ !this.trackedSessionParents.has(part.sessionID) &&
665
+ part.type !== "subtask") {
666
+ this.attachUnknownSessionToPendingSubagent(part.sessionID);
667
+ }
668
+ const isCurrentRootSession = part.sessionID === this.currentSessionId;
669
+ const isTrackedChildSession = this.isTrackedChildSession(part.sessionID);
670
+ if (!isCurrentRootSession && !isTrackedChildSession) {
671
+ return;
672
+ }
673
+ if (part.type === "subtask") {
674
+ this.registerSubtaskPart(part.sessionID, part.id, part.agent, part.description, part.prompt, part.command);
675
+ this.lastUpdated = Date.now();
676
+ return;
677
+ }
678
+ if (isTrackedChildSession) {
679
+ if (part.type === "tool") {
680
+ const state = part.state;
681
+ const input = "input" in state ? state.input : undefined;
682
+ const title = "title" in state ? state.title : undefined;
683
+ this.updateSubagentToolState(part.sessionID, state, part.tool, input, title);
684
+ }
685
+ if (part.type === "step-start") {
686
+ this.updateSubagentStepStart(part.sessionID, part.snapshot);
687
+ }
688
+ if (part.type === "step-finish") {
689
+ this.updateSubagentStepFinish(part.sessionID, part.tokens, part.cost, part.snapshot);
690
+ }
691
+ this.lastUpdated = Date.now();
286
692
  return;
287
693
  }
288
694
  const messageID = part.messageID;
@@ -337,6 +743,9 @@ class SummaryAggregator {
337
743
  const state = part.state;
338
744
  const input = "input" in state ? state.input : undefined;
339
745
  const title = "title" in state ? state.title : undefined;
746
+ if (part.tool === "task") {
747
+ this.updateSubagentFromTaskTool(part.sessionID, input);
748
+ }
340
749
  logger.debug(`[Aggregator] Tool event: callID=${part.callID}, tool=${part.tool}, status=${"status" in state ? state.status : "unknown"}`);
341
750
  if (part.tool === "question") {
342
751
  logger.debug(`[Aggregator] Question tool part update:`, JSON.stringify(part, null, 2));
@@ -618,6 +1027,11 @@ class SummaryAggregator {
618
1027
  }
619
1028
  handleSessionIdle(event) {
620
1029
  const { sessionID } = event.properties;
1030
+ if (this.isTrackedChildSession(sessionID)) {
1031
+ logger.info(`[Aggregator] Subagent session became idle: ${sessionID}`);
1032
+ this.setSubagentTerminalStatus(sessionID, "completed");
1033
+ return;
1034
+ }
621
1035
  if (sessionID !== this.currentSessionId) {
622
1036
  return;
623
1037
  }
@@ -644,10 +1058,15 @@ class SummaryAggregator {
644
1058
  }
645
1059
  handleSessionError(event) {
646
1060
  const { sessionID, error } = event.properties;
1061
+ const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
1062
+ if (sessionID && this.isTrackedChildSession(sessionID)) {
1063
+ logger.warn(`[Aggregator] Subagent session error: ${sessionID}: ${message}`);
1064
+ this.setSubagentTerminalStatus(sessionID, "error", message);
1065
+ return;
1066
+ }
647
1067
  if (sessionID !== this.currentSessionId) {
648
1068
  return;
649
1069
  }
650
- const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
651
1070
  logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`);
652
1071
  this.stopTypingIndicator();
653
1072
  if (this.onSessionErrorCallback) {