@agi-fans/dsh-tui 0.16.0 → 0.18.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.
Files changed (95) hide show
  1. package/lib/CHANGELOG.md +53 -1
  2. package/lib/chrome/status-config.d.ts +4 -1
  3. package/lib/chrome/status-config.js.map +1 -1
  4. package/lib/chrome/tool-args.d.ts +9 -0
  5. package/lib/chrome/tool-args.js +20 -0
  6. package/lib/chrome/tool-args.js.map +1 -0
  7. package/lib/chrome/tool-renderers.js +4 -15
  8. package/lib/chrome/tool-renderers.js.map +1 -1
  9. package/lib/commands/auth.js +4 -2
  10. package/lib/commands/auth.js.map +1 -1
  11. package/lib/commands/jobs.d.ts +2 -2
  12. package/lib/commands/jobs.js +2 -2
  13. package/lib/commands/jobs.js.map +1 -1
  14. package/lib/commands/session.js +2 -4
  15. package/lib/commands/session.js.map +1 -1
  16. package/lib/definition.d.ts +3 -0
  17. package/lib/definition.js.map +1 -1
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +1 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/runtime/composer-images.d.ts +44 -0
  22. package/lib/runtime/composer-images.js +149 -0
  23. package/lib/runtime/composer-images.js.map +1 -0
  24. package/lib/runtime/herdr-agent.d.ts +95 -0
  25. package/lib/runtime/herdr-agent.js +258 -0
  26. package/lib/runtime/herdr-agent.js.map +1 -0
  27. package/lib/runtime/plain-tui.d.ts +34 -0
  28. package/lib/runtime/plain-tui.js +119 -0
  29. package/lib/runtime/plain-tui.js.map +1 -0
  30. package/lib/runtime/provider-local.d.ts +46 -7
  31. package/lib/runtime/provider-local.js +181 -299
  32. package/lib/runtime/provider-local.js.map +1 -1
  33. package/lib/runtime/session-runtime.d.ts +9 -1
  34. package/lib/runtime/session-runtime.js +2 -2
  35. package/lib/runtime/session-runtime.js.map +1 -1
  36. package/lib/runtime/startup-notices.js +6 -9
  37. package/lib/runtime/startup-notices.js.map +1 -1
  38. package/lib/runtime/tool-presentation.js +18 -3
  39. package/lib/runtime/tool-presentation.js.map +1 -1
  40. package/lib/session/content-text.d.ts +12 -0
  41. package/lib/session/content-text.js +13 -0
  42. package/lib/session/content-text.js.map +1 -0
  43. package/lib/session/history-store.js.map +1 -0
  44. package/lib/session/job-notice.d.ts +2 -2
  45. package/lib/session/job-notice.js.map +1 -1
  46. package/lib/session/json-file.d.ts +5 -0
  47. package/lib/session/json-file.js +21 -0
  48. package/lib/session/json-file.js.map +1 -0
  49. package/lib/session/model-favorites.js +15 -25
  50. package/lib/session/model-favorites.js.map +1 -1
  51. package/lib/session/release-notes.js +1 -18
  52. package/lib/session/release-notes.js.map +1 -1
  53. package/lib/session/session-configuration.d.ts +0 -3
  54. package/lib/session/session-configuration.js +0 -4
  55. package/lib/session/session-configuration.js.map +1 -1
  56. package/lib/session/session-controller.d.ts +6 -4
  57. package/lib/session/session-controller.js +160 -182
  58. package/lib/session/session-controller.js.map +1 -1
  59. package/lib/session/session-library.d.ts +20 -0
  60. package/lib/session/session-library.js +55 -13
  61. package/lib/session/session-library.js.map +1 -1
  62. package/lib/session/subagent-roster.js +16 -46
  63. package/lib/session/subagent-roster.js.map +1 -1
  64. package/lib/session/subagent-tracker.d.ts +51 -0
  65. package/lib/session/subagent-tracker.js +128 -0
  66. package/lib/session/subagent-tracker.js.map +1 -0
  67. package/lib/session/tui-settings.d.ts +41 -18
  68. package/lib/session/tui-settings.js +57 -39
  69. package/lib/session/tui-settings.js.map +1 -1
  70. package/lib/session/update-check.js +2 -15
  71. package/lib/session/update-check.js.map +1 -1
  72. package/lib/session/version-compare.d.ts +3 -0
  73. package/lib/session/version-compare.js +21 -0
  74. package/lib/session/version-compare.js.map +1 -0
  75. package/lib/views/at-complete.d.ts +2 -0
  76. package/lib/views/at-complete.js +2 -2
  77. package/lib/views/at-complete.js.map +1 -1
  78. package/lib/views/event-views.d.ts +11 -270
  79. package/lib/views/event-views.js +21 -976
  80. package/lib/views/event-views.js.map +1 -1
  81. package/lib/views/settings-list.d.ts +2 -2
  82. package/lib/views/settings-list.js +12 -11
  83. package/lib/views/settings-list.js.map +1 -1
  84. package/lib/views/transcript-export.js +0 -2
  85. package/lib/views/transcript-export.js.map +1 -1
  86. package/lib/views/transcript-render.d.ts +179 -0
  87. package/lib/views/transcript-render.js +916 -0
  88. package/lib/views/transcript-render.js.map +1 -0
  89. package/lib/views/transcript-types.d.ts +104 -0
  90. package/lib/views/transcript-types.js +53 -0
  91. package/lib/views/transcript-types.js.map +1 -0
  92. package/package.json +39 -37
  93. package/lib/views/history-store.js.map +0 -1
  94. /package/lib/{views → session}/history-store.d.ts +0 -0
  95. /package/lib/{views → session}/history-store.js +0 -0
@@ -7,32 +7,36 @@
7
7
  * @module @agi-fans/dsh-tui/session-controller
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
+ import { join } from 'node:path';
10
11
  import { installModelSelection, } from '@deepseek-ai/dsh-agent';
11
12
  import { createUserMessage, } from '@deepseek-ai/dsh-llm';
12
13
  import { isUserInvocable } from '@deepseek-ai/dsh-skill';
14
+ import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence';
13
15
  import { readColdSessionLog } from '@deepseek-ai/dsh-session-query';
14
- import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
16
+ import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
15
17
  import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal';
16
18
  import { firstVisibleStreamTime } from "../views/stream-time.js";
17
19
  import { LiveAttemptTracker } from "./live-attempt-tracker.js";
20
+ import { blocksText } from "./content-text.js";
18
21
  import { jobNoticeFor } from "./job-notice.js";
19
- import { descendantDepth, isSteerableSubagent, SubagentRoster } from "./subagent-roster.js";
22
+ import { descendantDepth, isSteerableSubagent } from "./subagent-roster.js";
23
+ import { readRecentRows, writeRecentRows } from "./session-library.js";
24
+ import { readJsonFile, writeJsonAtomic } from "./json-file.js";
25
+ import { SubagentTracker } from "./subagent-tracker.js";
20
26
  import { commandPermission } from "../commands/permission.js";
21
- import { isBlankSession, toolPresentationForPreset, } from "./session-configuration.js";
27
+ import { isBlankSession, } from "./session-configuration.js";
22
28
  import { stripComposerImageMarkers } from "../input/image-paste.js";
23
29
  async function setupAgentContext(agentCtx, agent, selection) {
24
30
  installModelSelection(agentCtx, selection);
25
31
  const agentPresets = agentCtx.get('agentPresets');
26
32
  const sessionProjections = agentCtx.get('sessionProjections');
27
- const tools = agentCtx.get('tools');
28
- if (agentPresets === undefined || sessionProjections === undefined || tools === undefined) {
33
+ if (agentPresets === undefined || sessionProjections === undefined) {
29
34
  throw new Error('agent configuration services are unavailable');
30
35
  }
31
36
  const agentPreset = sessionProjections.stateOf(agent.session, 'agentPreset') ?? agentPresets.defaultId;
32
37
  const mounted = await agentPresets.mount(agentCtx, agentPreset);
33
- const disposeToolPresentation = tools.presentAs(toolPresentationForPreset(mounted.id));
34
38
  await agentCtx.plugin(commandPermission(agent));
35
- return { agentPreset: mounted.id, disposeToolPresentation };
39
+ return { agentPreset: mounted.id };
36
40
  }
37
41
  function parseControl(line) {
38
42
  const match = /^\/([a-z][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)?)(?:\s+(.*))?$/su.exec(line.trim());
@@ -245,12 +249,7 @@ function explicitSessionTitle(events) {
245
249
  function humanMessageText(event) {
246
250
  if (event.type !== 'user/message' || event.data.source.kind !== 'user')
247
251
  return undefined;
248
- const text = event.data.content
249
- .filter((block) => block.type === 'text')
250
- .map(block => block.text)
251
- .join(' ')
252
- .replace(/\s+/gu, ' ')
253
- .trim();
252
+ const text = blocksText(event.data.content, { collapse: true });
254
253
  return text === '' ? undefined : text;
255
254
  }
256
255
  /** Find direct human messages whose preceding log prefix is safe to seed into a fork. */
@@ -270,12 +269,7 @@ export function conversationTurns(events) {
270
269
  }
271
270
  if (open === undefined || event.type !== 'user/message' || event.data.source.kind !== 'user')
272
271
  continue;
273
- const text = event.data.content
274
- .filter((block) => block.type === 'text')
275
- .map(block => block.text)
276
- .join('\n')
277
- .replace(/\s+/gu, ' ')
278
- .trim();
272
+ const text = blocksText(event.data.content, { collapse: true });
279
273
  const imageCount = event.data.content.filter(block => block.type === 'image').length;
280
274
  if (text === '' && imageCount === 0)
281
275
  continue;
@@ -364,10 +358,7 @@ export function encodeCommandAttachments(images) {
364
358
  }
365
359
  /** Rehydrate one durable human inbox message into an editable composer draft. */
366
360
  export async function restoreSubmissionMessage(message, attachments) {
367
- const text = message.content
368
- .filter((block) => block.type === 'text')
369
- .map(block => block.text)
370
- .join('\n');
361
+ const text = blocksText(message.content);
371
362
  const refs = message.content
372
363
  .filter((block) => block.type === 'image')
373
364
  .map(block => block.attachment);
@@ -401,19 +392,38 @@ export class SessionRuntime {
401
392
  #releasedHandles = new WeakSet();
402
393
  #disposed = false;
403
394
  #off = [];
404
- #subagents = new SubagentRoster();
405
- #publishedSubagents;
395
+ #tracker;
406
396
  #streamAttempts = new LiveAttemptTracker();
407
397
  #recentCache = new Map();
398
+ #recentRowsPath;
399
+ #upgradeMarkerPath;
400
+ #recentRowsLoaded = false;
408
401
  #refreshInFlight = null;
409
402
  #refreshDirty = false;
410
- #subagentEpoch = 0;
411
403
  #inspectEpoch = 0;
412
404
  #activationEpoch = 0;
413
405
  #inspectedId;
414
- constructor(ctx, tui) {
406
+ constructor(ctx, tui, options = {}) {
415
407
  this.#ctx = ctx;
416
408
  this.#tui = tui;
409
+ this.#recentRowsPath = options.stateDir === undefined ? undefined : join(options.stateDir, 'recent-sessions.json');
410
+ this.#upgradeMarkerPath = options.stateDir === undefined ? undefined : join(options.stateDir, 'sessions-upgraded.json');
411
+ this.#tracker = new SubagentTracker({
412
+ rootId: () => this.#active?.handle.agent.id,
413
+ depth: (session) => {
414
+ const rootId = this.#active?.handle.agent.id;
415
+ return rootId === undefined ? undefined : descendantDepth(session, rootId, this.#lookupSession);
416
+ },
417
+ agentStatus: id => this.#ctx.get('agents')?.get(SessionId(id))?.status,
418
+ sessions: () => this.#ctx.get('sessions')?.list() ?? [],
419
+ listChildren: rootId => this.#ctx.get('subagents')?.listChildren(SessionId(rootId)),
420
+ publish: (roster) => {
421
+ this.#tui.setSubagents(roster);
422
+ if (this.#inspectedId === undefined)
423
+ return;
424
+ this.#tui.setInspectedSubagent(this.#inspectView(this.#inspectedId));
425
+ },
426
+ });
417
427
  this.#off.push(tui.onInspectSubagent(id => { void this.#inspectSubagent(id); }));
418
428
  this.#off.push(tui.onInspectClose(() => { this.#closeInspect(); }));
419
429
  this.#off.push(tui.onInspectSubmit(submission => { void this.#steerInspected(submission); }));
@@ -443,8 +453,13 @@ export class SessionRuntime {
443
453
  }
444
454
  const jobs = this.#ctx.get('jobs');
445
455
  if (jobs !== undefined) {
446
- this.#off.push(jobs.onJobDone((snapshot, owner) => {
447
- const notice = jobNoticeFor(snapshot, owner, this.#active?.handle.agent);
456
+ // Settlements speak session ids, so the owning Agent is resolved here;
457
+ // a job whose owner is gone has no notice to show.
458
+ this.#off.push(jobs.events.subscribe({ owners: 'scope' }, (event) => {
459
+ if (event.type !== 'settled')
460
+ return;
461
+ const owner = event.job.owner === undefined ? undefined : this.#ctx.get('agents')?.get(event.job.owner);
462
+ const notice = jobNoticeFor(event.job, owner, this.#active?.handle.agent);
448
463
  if (notice !== undefined)
449
464
  tui.notice(notice);
450
465
  }));
@@ -457,16 +472,13 @@ export class SessionRuntime {
457
472
  }
458
473
  if (payload.agent.id === this.#inspectedId)
459
474
  tui.setStatus(payload.status);
460
- this.#noteSubagentStatus(payload.agent.session, payload.status);
475
+ this.#tracker.noteStatus(payload.agent.session, payload.status);
461
476
  }));
462
477
  this.#off.push(ctx.on('session/created', (session) => {
463
- this.#noteSubagentSession(session);
478
+ this.#tracker.noteSession(session);
464
479
  }));
465
480
  this.#off.push(ctx.on('session/disposed', (session) => {
466
- if (!this.#subagents.owns(session.id))
467
- return;
468
- this.#subagents.setAgentStatus(session.id, 'gone');
469
- this.#pushSubagents();
481
+ this.#tracker.noteGone(session);
470
482
  }));
471
483
  this.#off.push(ctx.on('session/event', (session, event) => {
472
484
  const active = this.#active;
@@ -475,7 +487,7 @@ export class SessionRuntime {
475
487
  if (session.id === this.#inspectedId) {
476
488
  const child = ctx.get('agents')?.get(session.id);
477
489
  tui.event(event, child === undefined ? undefined : ctx.get('tuiToolPresentation')?.event(child, event));
478
- this.#noteSubagentEvent(session, event);
490
+ this.#tracker.noteEvent(session, event);
479
491
  return;
480
492
  }
481
493
  if (session === active.handle.agent.session) {
@@ -483,14 +495,14 @@ export class SessionRuntime {
483
495
  tui.event(event, ctx.get('tuiToolPresentation')?.event(active.handle.agent, event));
484
496
  }
485
497
  if (event.type === 'subagent/catalog')
486
- this.#observeCatalogChildren(session, [event]);
498
+ this.#tracker.observeCatalog(session, [event]);
487
499
  if (shouldRefreshSessionInfoAfter(event))
488
500
  this.#pushSessionInfo();
489
501
  if (event.type === 'session/title')
490
502
  void this.refreshRecent();
491
503
  return;
492
504
  }
493
- this.#noteSubagentEvent(session, event);
505
+ this.#tracker.noteEvent(session, event);
494
506
  }));
495
507
  // Format v2 moved live assistant increments off the session log. The
496
508
  // durable `session/event` firehose now carries only settlements; chunk
@@ -535,6 +547,13 @@ export class SessionRuntime {
535
547
  this.#pushSessionInfo();
536
548
  }));
537
549
  }
550
+ // One-time per session format, off the first render: stored logs an earlier
551
+ // release wrote get their current-format generation here rather than on the
552
+ // next listing or search that has to decode them.
553
+ ctx.inject(['sessionPersistence'], (persistenceCtx) => {
554
+ void this.#upgradeStoredSessions(persistenceCtx.sessionPersistence)
555
+ .catch((error) => { persistenceCtx.logger.warn(error); });
556
+ });
538
557
  }
539
558
  get agent() {
540
559
  return this.#active?.handle.agent;
@@ -688,7 +707,6 @@ export class SessionRuntime {
688
707
  contextWindow: undefined,
689
708
  reasoningEffort: undefined,
690
709
  agentPreset: configuration.agentPreset,
691
- disposeToolPresentation: configuration.disposeToolPresentation,
692
710
  });
693
711
  await this.refreshRecent();
694
712
  }
@@ -760,10 +778,7 @@ export class SessionRuntime {
760
778
  if (message?.type !== 'user/message' || message.data.source.kind !== 'user') {
761
779
  throw new Error('The selected conversation turn is no longer available.');
762
780
  }
763
- const text = message.data.content
764
- .filter((block) => block.type === 'text')
765
- .map(block => block.text)
766
- .join('\n');
781
+ const text = blocksText(message.data.content);
767
782
  const imageRefs = message.data.content
768
783
  .filter((block) => block.type === 'image')
769
784
  .map(block => block.attachment);
@@ -820,7 +835,6 @@ export class SessionRuntime {
820
835
  contextWindow: undefined,
821
836
  reasoningEffort: undefined,
822
837
  agentPreset: configuration.agentPreset,
823
- disposeToolPresentation: configuration.disposeToolPresentation,
824
838
  });
825
839
  this.#tui.restoreInput({ text, images });
826
840
  this.#tui.notice(`Rewound to before turn ${selected.turn}. The original session remains available in /resume.`);
@@ -867,15 +881,7 @@ export class SessionRuntime {
867
881
  const active = this.#requiredActive();
868
882
  if (active.agentPreset === id)
869
883
  return active.agentPreset;
870
- const previousPreset = active.agentPreset;
871
884
  const preset = await this.#ctx.agentPresets.recompose(agent.ctx, id);
872
- try {
873
- this.#replaceToolPresentation(active, toolPresentationForPreset(preset.id));
874
- }
875
- catch (error) {
876
- await this.#ctx.agentPresets.recompose(agent.ctx, previousPreset);
877
- throw error;
878
- }
879
885
  active.agentPreset = preset.id;
880
886
  agent.session.append('agent-preset/selected', { agentPreset: preset.id });
881
887
  this.#pushTools();
@@ -895,6 +901,50 @@ export class SessionRuntime {
895
901
  this.#pushSessionInfo();
896
902
  return outcome;
897
903
  }
904
+ /**
905
+ * Publish a current-format generation for every stored session an earlier
906
+ * release wrote. A read open never upgrades a log, so without this pass
907
+ * every listing and search keeps decoding the historical corpus — the pass
908
+ * runs once per session format, in the background, and skips a session
909
+ * another live process owns.
910
+ */
911
+ async #upgradeStoredSessions(persistence) {
912
+ const markerPath = this.#upgradeMarkerPath;
913
+ if (markerPath === undefined)
914
+ return;
915
+ const marker = readJsonFile(markerPath);
916
+ if (typeof marker?.sessionFormat === 'number' && marker.sessionFormat >= SESSION_FORMAT_VERSION)
917
+ return;
918
+ const before = await persistence.list();
919
+ const revisions = new Map(before.map(snapshot => [String(snapshot.header.id), snapshot.revision]));
920
+ const active = this.#active?.handle.agent.id;
921
+ for (const snapshot of before) {
922
+ if (this.#disposed)
923
+ return;
924
+ if (String(snapshot.header.id) === active)
925
+ continue;
926
+ try {
927
+ const handle = await persistence.open(snapshot.header.id, 'write');
928
+ await handle.close();
929
+ }
930
+ catch (error) {
931
+ // Another process owns it, or the log is unreadable: leave it alone.
932
+ if (!(error instanceof SessionAlreadyOwnedError))
933
+ this.#ctx.logger.debug(error);
934
+ }
935
+ }
936
+ if (this.#disposed)
937
+ return;
938
+ let upgraded = 0;
939
+ for (const snapshot of await persistence.list()) {
940
+ if (revisions.get(String(snapshot.header.id)) !== snapshot.revision)
941
+ upgraded += 1;
942
+ }
943
+ writeJsonAtomic(markerPath, { sessionFormat: SESSION_FORMAT_VERSION });
944
+ if (upgraded > 0) {
945
+ this.#tui.notice(`Upgraded ${upgraded} stored session${upgraded === 1 ? '' : 's'} to the current format.`);
946
+ }
947
+ }
898
948
  async refreshRecent() {
899
949
  // Coalesce concurrent callers: the recent list is one snapshot, and a
900
950
  // burst of title/status events must not queue a read of every stored log.
@@ -924,24 +974,40 @@ export class SessionRuntime {
924
974
  }
925
975
  const snapshots = (await persistence.list()).filter(snapshot => snapshot.header.origin !== 'subagent')
926
976
  .sort((left, right) => right.header.createdAt - left.header.createdAt);
977
+ // A stored row stays valid while its revision holds, so a cold process
978
+ // renders labels for unchanged sessions without reading their logs; only a
979
+ // new or changed session pays a read.
980
+ if (!this.#recentRowsLoaded && this.#recentRowsPath !== undefined) {
981
+ this.#recentRowsLoaded = true;
982
+ for (const [id, memo] of readRecentRows(this.#recentRowsPath))
983
+ this.#recentCache.set(id, memo);
984
+ }
927
985
  const rows = [];
986
+ let memoChanged = false;
928
987
  const liveIds = new Set(snapshots.map(snapshot => snapshot.header.id));
929
- for (const id of this.#recentCache.keys())
930
- if (!liveIds.has(id))
931
- this.#recentCache.delete(id);
988
+ for (const id of this.#recentCache.keys()) {
989
+ if (liveIds.has(id))
990
+ continue;
991
+ this.#recentCache.delete(id);
992
+ memoChanged = true;
993
+ }
932
994
  for (const snapshot of snapshots) {
933
995
  const header = snapshot.header;
934
996
  const cached = this.#recentCache.get(header.id);
935
997
  if (cached?.revision === snapshot.revision) {
936
- rows.push(cached.row);
998
+ if (cached.row !== null)
999
+ rows.push(cached.row);
937
1000
  continue;
938
1001
  }
939
1002
  try {
940
1003
  const log = await readColdSessionLog(persistence, header.id);
941
1004
  const status = recentSessionStatus(log.events);
942
1005
  const content = recentSessionContent(log.events);
943
- if (content === undefined)
1006
+ if (content === undefined) {
1007
+ this.#recentCache.set(header.id, { revision: snapshot.revision, row: null });
1008
+ memoChanged = true;
944
1009
  continue;
1010
+ }
945
1011
  const row = {
946
1012
  id: header.id,
947
1013
  ...content,
@@ -951,12 +1017,15 @@ export class SessionRuntime {
951
1017
  ...(status === undefined ? {} : { status }),
952
1018
  };
953
1019
  this.#recentCache.set(header.id, { revision: snapshot.revision, row });
1020
+ memoChanged = true;
954
1021
  rows.push(row);
955
1022
  }
956
1023
  catch {
957
1024
  rows.push({ id: header.id, title: '(unavailable session)', createdAt: header.createdAt });
958
1025
  }
959
1026
  }
1027
+ if (memoChanged && this.#recentRowsPath !== undefined)
1028
+ writeRecentRows(this.#recentRowsPath, this.#recentCache);
960
1029
  this.#recent = rows;
961
1030
  this.#pushSessionInfo();
962
1031
  }
@@ -964,10 +1033,9 @@ export class SessionRuntime {
964
1033
  if (this.#disposed)
965
1034
  return;
966
1035
  this.#disposed = true;
967
- this.#subagentEpoch += 1;
968
1036
  this.#activationEpoch += 1;
969
1037
  this.#inspectedId = undefined;
970
- this.#subagents.reset();
1038
+ this.#tracker.reset();
971
1039
  this.#tui.setInspectedSubagent(undefined);
972
1040
  this.#tui.setSubagents(undefined);
973
1041
  this.#tui.setSessionSearch();
@@ -1006,9 +1074,30 @@ export class SessionRuntime {
1006
1074
  contextWindow: undefined,
1007
1075
  reasoningEffort: undefined,
1008
1076
  agentPreset: configuration.agentPreset,
1009
- disposeToolPresentation: configuration.disposeToolPresentation,
1010
1077
  };
1011
1078
  }
1079
+ /**
1080
+ * Claim the activated session into the cwd workspace so grouped session
1081
+ * surfaces (the harness web sidebar) file it instead of leaving it
1082
+ * ungrouped. Best-effort like `#resolveModelInfo`: the registry is an
1083
+ * optional service and a failed claim must not disturb activation.
1084
+ */
1085
+ async #claimWorkspace(active) {
1086
+ const registry = this.#ctx.get('workspaceRegistry');
1087
+ if (registry === undefined)
1088
+ return;
1089
+ try {
1090
+ const { session } = active.handle.agent;
1091
+ const cwd = session.header.cwd;
1092
+ if (cwd === undefined)
1093
+ return;
1094
+ const workspace = await registry.resolveByPath(cwd) ?? await registry.create(cwd);
1095
+ await workspace.attachSession(session.id);
1096
+ }
1097
+ catch {
1098
+ // The workspace claim is cosmetic interop; the session itself is fine.
1099
+ }
1100
+ }
1012
1101
  async #resolveModelInfo(selection) {
1013
1102
  try {
1014
1103
  return await this.#ctx.get('llm')?.resolveModelInfo(selection.provider, selection.model);
@@ -1026,6 +1115,7 @@ export class SessionRuntime {
1026
1115
  const previous = this.#active;
1027
1116
  const epoch = ++this.#activationEpoch;
1028
1117
  this.#active = next;
1118
+ void this.#claimWorkspace(next);
1029
1119
  try {
1030
1120
  this.#presentAgent(next);
1031
1121
  const selected = this.selection(next.handle.agent);
@@ -1093,7 +1183,7 @@ export class SessionRuntime {
1093
1183
  this.#inspectedId = undefined;
1094
1184
  this.#tui.setInspectedSubagent(undefined);
1095
1185
  this.#tui.setStatus(agent.status);
1096
- this.#syncSubagents();
1186
+ this.#tracker.sync();
1097
1187
  // Seed welcome metadata before replaceSession commits the startup header to
1098
1188
  // native scrollback; later updates cannot rewrite that frozen first frame.
1099
1189
  this.#pushSessionInfo();
@@ -1163,45 +1253,6 @@ export class SessionRuntime {
1163
1253
  return list.find(skill => skill.name === name && isUserInvocable(skill));
1164
1254
  }
1165
1255
  #lookupSession = (id) => this.#ctx.get('sessions')?.get(SessionId(id));
1166
- #subagentDepth(session) {
1167
- const rootId = this.#active?.handle.agent.id;
1168
- if (rootId === undefined)
1169
- return undefined;
1170
- return descendantDepth(session, rootId, this.#lookupSession);
1171
- }
1172
- #noteSubagentSession(session) {
1173
- const depth = this.#subagentDepth(session);
1174
- if (depth === undefined)
1175
- return;
1176
- const parentId = session.header.parentSession;
1177
- this.#subagents.remember({
1178
- id: session.id,
1179
- ...(parentId === undefined ? {} : { parentId }),
1180
- depth,
1181
- phase: this.#ctx.get('agents')?.get(session.id)?.status === 'running' ? 'running' : 'starting',
1182
- });
1183
- this.#pushSubagents();
1184
- }
1185
- #noteSubagentEvent(session, event) {
1186
- const depth = this.#subagentDepth(session);
1187
- if (depth === undefined)
1188
- return;
1189
- this.#subagents.apply(session, depth, event, this.#ctx.get('agents')?.get(session.id)?.status);
1190
- this.#pushSubagents();
1191
- }
1192
- /**
1193
- * Add the direct children a Session's durable catalog declares.
1194
- *
1195
- * The live roster is built from `session/created` and child events, so a
1196
- * resumed parent would otherwise show nothing: its children are neither
1197
- * loaded nor replayed. Catalog facts are durable, so a restored roster still
1198
- * lists those children, and `#inspectSubagent` opens each transcript from
1199
- * disk. Children the live path already knows keep their own state.
1200
- */
1201
- #observeCatalogChildren(session, events) {
1202
- if (this.#subagents.observeCatalog(session.id, events))
1203
- this.#pushSubagents();
1204
- }
1205
1256
  /** Route live output only to the visible session; background frames remain buffered for inspection. */
1206
1257
  #forwardLiveDelta(agent, delta) {
1207
1258
  const active = this.#active;
@@ -1229,57 +1280,8 @@ export class SessionRuntime {
1229
1280
  this.#tui.streamDelta(delta);
1230
1281
  }
1231
1282
  }
1232
- #noteSubagentStatus(session, status) {
1233
- if (this.#subagents.owns(session.id)) {
1234
- this.#subagents.setAgentStatus(session.id, status);
1235
- this.#pushSubagents();
1236
- return;
1237
- }
1238
- const depth = this.#subagentDepth(session);
1239
- if (depth === undefined)
1240
- return;
1241
- this.#subagents.hydrate(session, depth, this.#ctx.get('agents')?.get(session.id)?.status ?? status);
1242
- this.#pushSubagents();
1243
- }
1244
- #syncSubagents() {
1245
- const root = this.#active?.handle.agent;
1246
- const epoch = this.#subagentEpoch + 1;
1247
- this.#subagentEpoch = epoch;
1248
- if (root === undefined) {
1249
- this.#subagents.reset();
1250
- this.#tui.setSubagents(undefined);
1251
- return;
1252
- }
1253
- this.#subagents.reset(root.id);
1254
- for (const session of this.#ctx.get('sessions')?.list() ?? []) {
1255
- const depth = descendantDepth(session, root.id, this.#lookupSession);
1256
- if (depth === undefined)
1257
- continue;
1258
- this.#subagents.hydrate(session, depth, this.#ctx.get('agents')?.get(session.id)?.status);
1259
- }
1260
- this.#pushSubagents();
1261
- const listed = this.#ctx.get('subagents')?.listChildren(root.id);
1262
- if (listed === undefined)
1263
- return;
1264
- void listed.then((entries) => {
1265
- if (epoch !== this.#subagentEpoch || this.#active?.handle.agent !== root)
1266
- return;
1267
- for (const entry of entries) {
1268
- if (entry.kind !== 'child')
1269
- continue;
1270
- this.#subagents.remember({
1271
- id: entry.id,
1272
- depth: 1,
1273
- mode: entry.mode,
1274
- ...(entry.label === undefined ? {} : { label: entry.label }),
1275
- phase: entry.activity === 'running' ? 'running' : 'waiting',
1276
- });
1277
- }
1278
- this.#pushSubagents();
1279
- }, () => undefined);
1280
- }
1281
1283
  #inspectView(id, fallbackPhase = 'waiting') {
1282
- const view = this.#subagents.snapshot()?.agents.find(agent => agent.id === id);
1284
+ const view = this.#tracker.snapshot()?.agents.find(agent => agent.id === id);
1283
1285
  const mode = view?.mode;
1284
1286
  return {
1285
1287
  id,
@@ -1289,51 +1291,27 @@ export class SessionRuntime {
1289
1291
  writable: isSteerableSubagent(mode),
1290
1292
  };
1291
1293
  }
1292
- #pushSubagents() {
1293
- const roster = this.#subagents.snapshot();
1294
- if (roster === this.#publishedSubagents)
1295
- return;
1296
- this.#publishedSubagents = roster;
1297
- this.#tui.setSubagents(roster);
1298
- if (this.#inspectedId === undefined)
1299
- return;
1300
- this.#tui.setInspectedSubagent(this.#inspectView(this.#inspectedId));
1301
- }
1302
1294
  #pushSessionInfo() {
1303
1295
  const active = this.#active;
1304
1296
  if (active === undefined)
1305
1297
  return;
1306
1298
  const agent = active.handle.agent;
1307
1299
  const projection = this.#projection(active);
1308
- const title = explicitSessionTitle(agent.session.snapshotEvents());
1300
+ // One snapshot feeds both folds; each append invalidates the session's
1301
+ // cached snapshot, so a second call would pay another O(log) copy.
1302
+ const events = agent.session.snapshotEvents();
1303
+ const title = explicitSessionTitle(events);
1309
1304
  this.#tui.setSession({
1310
1305
  id: agent.id,
1311
1306
  ...(title === undefined ? {} : { title }),
1312
1307
  recent: this.#recent.filter(row => row.id !== agent.id),
1313
- stats: this.#stats(active, projection),
1308
+ stats: sessionStats(events, active.contextWindow, projection),
1314
1309
  controls: this.#sessionControls(active, projection),
1315
1310
  });
1316
1311
  }
1317
- #replaceToolPresentation(active, mode) {
1318
- const previousMode = toolPresentationForPreset(active.agentPreset);
1319
- active.disposeToolPresentation();
1320
- try {
1321
- const tools = active.handle.agent.ctx.get('tools');
1322
- if (tools === undefined)
1323
- throw new Error('tool registry is unavailable');
1324
- active.disposeToolPresentation = tools.presentAs(mode);
1325
- }
1326
- catch (error) {
1327
- const tools = active.handle.agent.ctx.get('tools');
1328
- if (tools === undefined)
1329
- throw error;
1330
- active.disposeToolPresentation = tools.presentAs(previousMode);
1331
- throw error;
1332
- }
1333
- }
1334
1312
  #replaceTranscript(agent) {
1335
1313
  const events = agent.session.snapshotEvents();
1336
- this.#observeCatalogChildren(agent.session, events);
1314
+ this.#tracker.observeCatalog(agent.session, events);
1337
1315
  this.#tui.replaceSession(events, this.#ctx.get('tuiToolPresentation')?.session(agent, events), agent.status);
1338
1316
  this.#replayLivePrefix(agent.session.id);
1339
1317
  }
@@ -1361,7 +1339,7 @@ export class SessionRuntime {
1361
1339
  async #inspectSubagent(id) {
1362
1340
  const request = this.#inspectEpoch + 1;
1363
1341
  this.#inspectEpoch = request;
1364
- if (!this.#subagents.owns(id)) {
1342
+ if (!this.#tracker.owns(id)) {
1365
1343
  this.#tui.notice('That subagent is no longer available.', { level: 'error' });
1366
1344
  return;
1367
1345
  }
@@ -1408,7 +1386,7 @@ export class SessionRuntime {
1408
1386
  return;
1409
1387
  }
1410
1388
  const live = this.#ctx.get('sessions')?.get(SessionId(childId));
1411
- const rosterParent = this.#subagents.snapshot()?.agents.find(agent => agent.id === childId)?.parentId;
1389
+ const rosterParent = this.#tracker.snapshot()?.agents.find(agent => agent.id === childId)?.parentId;
1412
1390
  const parentId = live?.header.parentSession ?? rosterParent;
1413
1391
  const parent = parentId === undefined || parentId === root.id
1414
1392
  ? root