@jmoyers/harness 0.1.8 → 0.1.10

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 (41) hide show
  1. package/README.md +33 -155
  2. package/package.json +5 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +123 -7
  15. package/scripts/control-plane-daemon.ts +20 -3
  16. package/scripts/harness.ts +566 -133
  17. package/src/cli/gateway-record.ts +16 -1
  18. package/src/control-plane/agent-realtime-api.ts +4 -0
  19. package/src/control-plane/prompt/agent-prompt-extractor.ts +191 -0
  20. package/src/control-plane/prompt/extractors/claude-prompt-extractor.ts +53 -0
  21. package/src/control-plane/prompt/extractors/codex-prompt-extractor.ts +50 -0
  22. package/src/control-plane/prompt/extractors/cursor-prompt-extractor.ts +56 -0
  23. package/src/control-plane/prompt/session-prompt-engine.ts +69 -0
  24. package/src/control-plane/prompt/thread-title-namer.ts +290 -0
  25. package/src/control-plane/stream-command-parser.ts +12 -0
  26. package/src/control-plane/stream-protocol.ts +109 -0
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server-session-runtime.ts +12 -0
  29. package/src/control-plane/stream-server.ts +485 -19
  30. package/src/mux/input-shortcuts.ts +9 -0
  31. package/src/mux/live-mux/critique-review.ts +5 -1
  32. package/src/mux/live-mux/git-parsing.ts +24 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/render-frame.ts +1 -1
  35. package/src/pty/pty_host.ts +46 -1
  36. package/src/services/control-plane.ts +22 -0
  37. package/src/services/runtime-control-actions.ts +69 -0
  38. package/src/services/runtime-navigation-input.ts +4 -0
  39. package/src/services/runtime-rail-input.ts +4 -0
  40. package/src/services/runtime-workspace-actions.ts +5 -0
  41. package/src/ui/global-shortcut-input.ts +2 -0
@@ -18,6 +18,7 @@ import {
18
18
  encodeStreamEnvelope,
19
19
  type StreamObservedEvent,
20
20
  type StreamSessionKeyEventRecord,
21
+ type StreamSessionPromptRecord,
21
22
  type StreamSessionController,
22
23
  type StreamSessionListSort,
23
24
  type StreamSessionRuntimeStatus,
@@ -83,6 +84,15 @@ import {
83
84
  } from './stream-server-session-runtime.ts';
84
85
  import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
85
86
  import { SessionStatusEngine } from './status/session-status-engine.ts';
87
+ import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
88
+ import {
89
+ appendThreadTitlePromptHistory,
90
+ createAnthropicThreadTitleNamer,
91
+ fallbackThreadTitleFromPromptHistory,
92
+ normalizeThreadTitleCandidate,
93
+ readThreadTitlePromptHistory,
94
+ type ThreadTitleNamer,
95
+ } from './prompt/thread-title-namer.ts';
86
96
  import {
87
97
  eventIncludesRepositoryId as filterEventIncludesRepositoryId,
88
98
  eventIncludesTaskId as filterEventIncludesTaskId,
@@ -220,6 +230,14 @@ interface GitHubIntegrationConfig {
220
230
  readonly viewerLogin: string | null;
221
231
  }
222
232
 
233
+ interface ThreadTitleConfig {
234
+ readonly enabled: boolean;
235
+ readonly apiKey: string | null;
236
+ readonly modelId: string | null;
237
+ readonly baseUrl: string | null;
238
+ readonly fetch: typeof fetch | null;
239
+ }
240
+
223
241
  interface GitHubRemotePullRequest {
224
242
  readonly number: number;
225
243
  readonly title: string;
@@ -283,6 +301,8 @@ interface StartControlPlaneStreamServerOptions {
283
301
  githubFetch?: typeof fetch;
284
302
  readGitDirectorySnapshot?: GitDirectorySnapshotReader;
285
303
  lifecycleHooks?: HarnessLifecycleHooksConfig;
304
+ threadTitle?: Partial<ThreadTitleConfig>;
305
+ threadTitleNamer?: ThreadTitleNamer;
286
306
  }
287
307
 
288
308
  interface ConnectionState {
@@ -383,6 +403,17 @@ interface StreamJournalEntry {
383
403
  event: StreamObservedEvent;
384
404
  }
385
405
 
406
+ interface ThreadTitleRefreshState {
407
+ inFlight: boolean;
408
+ pending: boolean;
409
+ }
410
+
411
+ interface ThreadTitleRefreshResult {
412
+ readonly status: 'updated' | 'unchanged' | 'skipped';
413
+ readonly conversation: ControlPlaneConversationRecord | null;
414
+ readonly reason: string | null;
415
+ }
416
+
386
417
  interface DirectoryGitStatusCacheEntry {
387
418
  readonly summary: GitDirectorySnapshot['summary'];
388
419
  readonly repositorySnapshot: GitDirectorySnapshot['repository'];
@@ -413,6 +444,8 @@ const DEFAULT_GITHUB_POLL_MS = 15_000;
413
444
  const HISTORY_POLL_JITTER_RATIO = 0.35;
414
445
  const SESSION_DIAGNOSTICS_BUCKET_MS = 10_000;
415
446
  const SESSION_DIAGNOSTICS_BUCKET_COUNT = 6;
447
+ const PROMPT_EVENT_DEDUPE_TTL_MS = 5 * 60 * 1000;
448
+ const MAX_PROMPT_EVENT_DEDUPE_ENTRIES = 4096;
416
449
  const DEFAULT_BOOTSTRAP_SESSION_COLS = 80;
417
450
  const DEFAULT_BOOTSTRAP_SESSION_ROWS = 24;
418
451
  const DEFAULT_TENANT_ID = 'tenant-local';
@@ -433,12 +466,17 @@ const DEFAULT_AGENT_INSTALL_COMMANDS: Readonly<Record<AgentToolType, string | nu
433
466
  const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
434
467
  new URL('../../scripts/cursor-hook-relay.ts', import.meta.url),
435
468
  );
469
+ const THREAD_TITLE_AGENT_TYPES = new Set(['codex', 'claude', 'cursor']);
436
470
  const LIFECYCLE_TELEMETRY_EVENT_NAMES = new Set([
437
471
  'codex.user_prompt',
438
472
  'codex.turn.e2e_duration_ms',
439
473
  'codex.conversation_starts',
440
474
  ]);
441
475
 
476
+ function isThreadTitleAgentType(agentType: string): boolean {
477
+ return THREAD_TITLE_AGENT_TYPES.has(agentType.trim().toLowerCase());
478
+ }
479
+
442
480
  function shellEscape(value: string): string {
443
481
  if (value.length === 0) {
444
482
  return "''";
@@ -734,6 +772,33 @@ function normalizeGitHubIntegrationConfig(
734
772
  };
735
773
  }
736
774
 
775
+ function normalizeThreadTitleConfig(
776
+ input: Partial<ThreadTitleConfig> | undefined,
777
+ ): ThreadTitleConfig {
778
+ const envApiKeyRaw = process.env.ANTHROPIC_API_KEY;
779
+ const envApiKey =
780
+ typeof envApiKeyRaw === 'string' && envApiKeyRaw.trim().length > 0 ? envApiKeyRaw.trim() : null;
781
+ const apiKeyRaw = input?.apiKey ?? envApiKey;
782
+ const apiKey =
783
+ typeof apiKeyRaw === 'string' && apiKeyRaw.trim().length > 0 ? apiKeyRaw.trim() : null;
784
+ const envModelRaw = process.env.HARNESS_THREAD_TITLE_MODEL;
785
+ const modelFromEnv =
786
+ typeof envModelRaw === 'string' && envModelRaw.trim().length > 0 ? envModelRaw.trim() : null;
787
+ const modelIdRaw = input?.modelId ?? modelFromEnv;
788
+ const modelId =
789
+ typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0 ? modelIdRaw.trim() : null;
790
+ const baseUrlRaw = input?.baseUrl;
791
+ const baseUrl =
792
+ typeof baseUrlRaw === 'string' && baseUrlRaw.trim().length > 0 ? baseUrlRaw.trim() : null;
793
+ return {
794
+ enabled: input?.enabled ?? apiKey !== null,
795
+ apiKey,
796
+ modelId,
797
+ baseUrl,
798
+ fetch: input?.fetch ?? null,
799
+ };
800
+ }
801
+
737
802
  function parseGitHubOwnerRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
738
803
  const trimmed = remoteUrl.trim();
739
804
  if (trimmed.length === 0) {
@@ -1054,6 +1119,11 @@ export class ControlPlaneStreamServer {
1054
1119
  };
1055
1120
  private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
1056
1121
  private readonly statusEngine = new SessionStatusEngine();
1122
+ private readonly promptEngine = new SessionPromptEngine();
1123
+ private readonly threadTitleNamer: ThreadTitleNamer | null;
1124
+ private readonly promptEventDedupeByKey = new Map<string, number>();
1125
+ private readonly threadTitleRevisionBySessionId = new Map<string, number>();
1126
+ private readonly threadTitleRefreshBySessionId = new Map<string, ThreadTitleRefreshState>();
1057
1127
  private readonly server: Server;
1058
1128
  private readonly telemetryServer: HttpServer | null;
1059
1129
  private telemetryAddress: AddressInfo | null = null;
@@ -1071,6 +1141,7 @@ export class ControlPlaneStreamServer {
1071
1141
  private githubTokenResolutionError: string | null = null;
1072
1142
  private gitStatusPollInFlight = false;
1073
1143
  private githubPollInFlight = false;
1144
+ private githubPollPromise: Promise<void> | null = null;
1074
1145
  private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
1075
1146
  private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
1076
1147
  private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
@@ -1082,6 +1153,7 @@ export class ControlPlaneStreamServer {
1082
1153
  private streamCursor = 0;
1083
1154
  private listening = false;
1084
1155
  private stateStoreClosed = false;
1156
+ private closing = false;
1085
1157
 
1086
1158
  constructor(options: StartControlPlaneStreamServerOptions = {}) {
1087
1159
  this.host = options.host ?? '127.0.0.1';
@@ -1127,6 +1199,19 @@ export class ControlPlaneStreamServer {
1127
1199
  await readGitDirectorySnapshot(cwd, undefined, {
1128
1200
  includeCommitCount: false,
1129
1201
  }));
1202
+ const threadTitleConfig = normalizeThreadTitleConfig(options.threadTitle);
1203
+ if (options.threadTitleNamer !== undefined) {
1204
+ this.threadTitleNamer = options.threadTitleNamer;
1205
+ } else if (threadTitleConfig.enabled && threadTitleConfig.apiKey !== null) {
1206
+ this.threadTitleNamer = createAnthropicThreadTitleNamer({
1207
+ apiKey: threadTitleConfig.apiKey,
1208
+ ...(threadTitleConfig.modelId === null ? {} : { modelId: threadTitleConfig.modelId }),
1209
+ ...(threadTitleConfig.baseUrl === null ? {} : { baseUrl: threadTitleConfig.baseUrl }),
1210
+ ...(threadTitleConfig.fetch === null ? {} : { fetch: threadTitleConfig.fetch }),
1211
+ });
1212
+ } else {
1213
+ this.threadTitleNamer = null;
1214
+ }
1130
1215
  this.lifecycleHooks = new LifecycleHooksRuntime(
1131
1216
  options.lifecycleHooks ?? {
1132
1217
  enabled: false,
@@ -1205,9 +1290,11 @@ export class ControlPlaneStreamServer {
1205
1290
  }
1206
1291
 
1207
1292
  async close(): Promise<void> {
1293
+ this.closing = true;
1208
1294
  this.stopHistoryPolling();
1209
1295
  this.stopGitStatusPolling();
1210
1296
  this.stopGitHubPolling();
1297
+ await this.waitForGitHubPollingToSettle();
1211
1298
 
1212
1299
  for (const sessionId of [...this.sessions.keys()]) {
1213
1300
  this.destroySession(sessionId, true);
@@ -1390,9 +1477,9 @@ export class ControlPlaneStreamServer {
1390
1477
  if (!this.github.enabled || this.githubPollTimer !== null) {
1391
1478
  return;
1392
1479
  }
1393
- void this.pollGitHub();
1480
+ this.triggerGitHubPoll();
1394
1481
  this.githubPollTimer = setInterval(() => {
1395
- void this.pollGitHub();
1482
+ this.triggerGitHubPoll();
1396
1483
  }, this.github.pollMs);
1397
1484
  this.githubPollTimer.unref();
1398
1485
  }
@@ -1405,6 +1492,49 @@ export class ControlPlaneStreamServer {
1405
1492
  this.githubPollTimer = null;
1406
1493
  }
1407
1494
 
1495
+ private triggerGitHubPoll(): void {
1496
+ void this.pollGitHub().catch((error: unknown) => {
1497
+ if (this.shouldIgnoreGitHubPollError(error)) {
1498
+ return;
1499
+ }
1500
+ const message = error instanceof Error ? error.message : String(error);
1501
+ recordPerfEvent('control-plane.github.poll.failed', {
1502
+ error: message,
1503
+ });
1504
+ });
1505
+ }
1506
+
1507
+ private isStateStoreClosedError(error: unknown): boolean {
1508
+ const message = error instanceof Error ? error.message : String(error);
1509
+ const normalized = message.trim().toLowerCase();
1510
+ return normalized.includes('database has closed') || normalized.includes('database is closed');
1511
+ }
1512
+
1513
+ private shouldSkipStateStoreWork(): boolean {
1514
+ return this.closing || this.stateStoreClosed;
1515
+ }
1516
+
1517
+ private shouldIgnoreGitHubPollError(error: unknown): boolean {
1518
+ return this.shouldSkipStateStoreWork() || this.isStateStoreClosedError(error);
1519
+ }
1520
+
1521
+ private async waitForGitHubPollingToSettle(): Promise<void> {
1522
+ const pollPromise = this.githubPollPromise;
1523
+ if (pollPromise === null) {
1524
+ return;
1525
+ }
1526
+ try {
1527
+ await pollPromise;
1528
+ } catch (error: unknown) {
1529
+ if (!this.shouldIgnoreGitHubPollError(error)) {
1530
+ const message = error instanceof Error ? error.message : String(error);
1531
+ recordPerfEvent('control-plane.github.poll.failed-on-close', {
1532
+ error: message,
1533
+ });
1534
+ }
1535
+ }
1536
+ }
1537
+
1408
1538
  private reloadGitStatusDirectoriesFromStore(): void {
1409
1539
  const directories = this.stateStore.listDirectories({
1410
1540
  includeArchived: false,
@@ -1943,6 +2073,31 @@ export class ControlPlaneStreamServer {
1943
2073
  event.observedAt,
1944
2074
  );
1945
2075
  }
2076
+ if (inserted && resolvedSessionId !== null) {
2077
+ const promptEvent = this.promptEngine.extractFromTelemetry({
2078
+ agentType: 'codex',
2079
+ source: event.source,
2080
+ eventName: event.eventName,
2081
+ summary: event.summary,
2082
+ payload: event.payload,
2083
+ observedAt: event.observedAt,
2084
+ });
2085
+ if (promptEvent !== null) {
2086
+ const liveState = this.sessions.get(resolvedSessionId);
2087
+ if (liveState !== undefined) {
2088
+ this.publishSessionPromptObservedEvent(liveState, promptEvent);
2089
+ } else {
2090
+ const observedScope = this.observedScopeForSessionId(resolvedSessionId);
2091
+ if (observedScope !== null) {
2092
+ this.publishSessionPromptObservedEventForScope(
2093
+ resolvedSessionId,
2094
+ observedScope,
2095
+ promptEvent,
2096
+ );
2097
+ }
2098
+ }
2099
+ }
2100
+ }
1946
2101
  if (!inserted || resolvedSessionId === null) {
1947
2102
  return;
1948
2103
  }
@@ -2081,11 +2236,11 @@ export class ControlPlaneStreamServer {
2081
2236
  }
2082
2237
 
2083
2238
  private async pollGitHub(): Promise<void> {
2084
- if (!this.github.enabled || this.githubPollInFlight) {
2239
+ if (!this.github.enabled || this.githubPollInFlight || this.shouldSkipStateStoreWork()) {
2085
2240
  return;
2086
2241
  }
2087
2242
  this.githubPollInFlight = true;
2088
- try {
2243
+ const pollPromise = (async () => {
2089
2244
  const directories = this.stateStore.listDirectories({
2090
2245
  includeArchived: false,
2091
2246
  limit: 1000,
@@ -2101,6 +2256,9 @@ export class ControlPlaneStreamServer {
2101
2256
  }
2102
2257
  >();
2103
2258
  for (const directory of directories) {
2259
+ if (this.shouldSkipStateStoreWork()) {
2260
+ return;
2261
+ }
2104
2262
  const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
2105
2263
  const repositoryId = gitStatus?.repositoryId ?? null;
2106
2264
  if (repositoryId === null) {
@@ -2135,21 +2293,31 @@ export class ControlPlaneStreamServer {
2135
2293
  branchName,
2136
2294
  });
2137
2295
  }
2138
- if (targetsByKey.size === 0) {
2296
+ if (targetsByKey.size === 0 || this.shouldSkipStateStoreWork()) {
2139
2297
  return;
2140
2298
  }
2141
2299
  const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
2142
- if (token === null) {
2300
+ if (token === null || this.shouldSkipStateStoreWork()) {
2143
2301
  return;
2144
2302
  }
2145
2303
  await runWithConcurrencyLimit(
2146
2304
  [...targetsByKey.values()],
2147
2305
  this.github.maxConcurrency,
2148
2306
  async (target) => {
2307
+ if (this.shouldSkipStateStoreWork()) {
2308
+ return;
2309
+ }
2149
2310
  await this.syncGitHubBranch(target);
2150
2311
  },
2151
2312
  );
2313
+ })();
2314
+ this.githubPollPromise = pollPromise;
2315
+ try {
2316
+ await pollPromise;
2152
2317
  } finally {
2318
+ if (this.githubPollPromise === pollPromise) {
2319
+ this.githubPollPromise = null;
2320
+ }
2153
2321
  this.githubPollInFlight = false;
2154
2322
  }
2155
2323
  }
@@ -2161,6 +2329,9 @@ export class ControlPlaneStreamServer {
2161
2329
  repo: string;
2162
2330
  branchName: string;
2163
2331
  }): Promise<void> {
2332
+ if (this.shouldSkipStateStoreWork()) {
2333
+ return;
2334
+ }
2164
2335
  const now = new Date().toISOString();
2165
2336
  const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
2166
2337
  try {
@@ -2169,6 +2340,9 @@ export class ControlPlaneStreamServer {
2169
2340
  repo: input.repo,
2170
2341
  headBranch: input.branchName,
2171
2342
  });
2343
+ if (this.shouldSkipStateStoreWork()) {
2344
+ return;
2345
+ }
2172
2346
  if (remotePr === null) {
2173
2347
  const staleOpen = this.stateStore.listGitHubPullRequests({
2174
2348
  repositoryId: input.repository.repositoryId,
@@ -2255,6 +2429,9 @@ export class ControlPlaneStreamServer {
2255
2429
  repo: input.repo,
2256
2430
  headSha: storedPr.headSha,
2257
2431
  });
2432
+ if (this.shouldSkipStateStoreWork()) {
2433
+ return;
2434
+ }
2258
2435
  const rollup = summarizeGitHubCiRollup(jobs);
2259
2436
  const updatedPr =
2260
2437
  this.stateStore.updateGitHubPullRequestCiRollup(
@@ -2314,20 +2491,29 @@ export class ControlPlaneStreamServer {
2314
2491
  lastErrorAt: null,
2315
2492
  });
2316
2493
  } catch (error: unknown) {
2494
+ if (this.shouldIgnoreGitHubPollError(error)) {
2495
+ return;
2496
+ }
2317
2497
  const message = error instanceof Error ? error.message : String(error);
2318
- this.stateStore.upsertGitHubSyncState({
2319
- stateId: syncStateId,
2320
- tenantId: input.directory.tenantId,
2321
- userId: input.directory.userId,
2322
- workspaceId: input.directory.workspaceId,
2323
- repositoryId: input.repository.repositoryId,
2324
- directoryId: input.directory.directoryId,
2325
- branchName: input.branchName,
2326
- lastSyncAt: now,
2327
- lastSuccessAt: null,
2328
- lastError: message,
2329
- lastErrorAt: now,
2330
- });
2498
+ try {
2499
+ this.stateStore.upsertGitHubSyncState({
2500
+ stateId: syncStateId,
2501
+ tenantId: input.directory.tenantId,
2502
+ userId: input.directory.userId,
2503
+ workspaceId: input.directory.workspaceId,
2504
+ repositoryId: input.repository.repositoryId,
2505
+ directoryId: input.directory.directoryId,
2506
+ branchName: input.branchName,
2507
+ lastSyncAt: now,
2508
+ lastSuccessAt: null,
2509
+ lastError: message,
2510
+ lastErrorAt: now,
2511
+ });
2512
+ } catch (syncStateError: unknown) {
2513
+ if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
2514
+ throw syncStateError;
2515
+ }
2516
+ }
2331
2517
  }
2332
2518
  }
2333
2519
 
@@ -2617,6 +2803,27 @@ export class ControlPlaneStreamServer {
2617
2803
  );
2618
2804
  }
2619
2805
 
2806
+ private async refreshConversationTitle(conversationId: string): Promise<{
2807
+ conversation: ControlPlaneConversationRecord;
2808
+ status: 'updated' | 'unchanged' | 'skipped';
2809
+ reason: string | null;
2810
+ }> {
2811
+ const existing = this.stateStore.getConversation(conversationId);
2812
+ if (existing === null) {
2813
+ throw new Error(`conversation not found: ${conversationId}`);
2814
+ }
2815
+ const refreshed = await this.refreshThreadTitle(conversationId);
2816
+ const conversation = this.stateStore.getConversation(conversationId);
2817
+ if (conversation === null) {
2818
+ throw new Error(`conversation not found: ${conversationId}`);
2819
+ }
2820
+ return {
2821
+ conversation,
2822
+ status: refreshed.status,
2823
+ reason: refreshed.reason,
2824
+ };
2825
+ }
2826
+
2620
2827
  private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
2621
2828
  handleRuntimeInput(
2622
2829
  this as unknown as Parameters<typeof handleRuntimeInput>[0],
@@ -2674,6 +2881,260 @@ export class ControlPlaneStreamServer {
2674
2881
  });
2675
2882
  }
2676
2883
 
2884
+ private promptEventSecondBucket(observedAt: string): string {
2885
+ const normalized = observedAt.trim();
2886
+ if (normalized.length >= 19) {
2887
+ return normalized.slice(0, 19);
2888
+ }
2889
+ return normalized;
2890
+ }
2891
+
2892
+ private shouldPublishPromptEvent(sessionId: string, prompt: StreamSessionPromptRecord): boolean {
2893
+ const bucket = this.promptEventSecondBucket(prompt.observedAt);
2894
+ const dedupeKey = `${sessionId}:${prompt.hash}:${prompt.providerEventName ?? ''}:${bucket}`;
2895
+ if (this.promptEventDedupeByKey.has(dedupeKey)) {
2896
+ return false;
2897
+ }
2898
+ const nowMs = Date.now();
2899
+ this.promptEventDedupeByKey.set(dedupeKey, nowMs);
2900
+ if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
2901
+ for (const [key, observedMs] of this.promptEventDedupeByKey) {
2902
+ if (nowMs - observedMs > PROMPT_EVENT_DEDUPE_TTL_MS) {
2903
+ this.promptEventDedupeByKey.delete(key);
2904
+ }
2905
+ }
2906
+ if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
2907
+ let dropCount = this.promptEventDedupeByKey.size - MAX_PROMPT_EVENT_DEDUPE_ENTRIES;
2908
+ for (const key of this.promptEventDedupeByKey.keys()) {
2909
+ this.promptEventDedupeByKey.delete(key);
2910
+ dropCount -= 1;
2911
+ if (dropCount <= 0) {
2912
+ break;
2913
+ }
2914
+ }
2915
+ }
2916
+ }
2917
+ return true;
2918
+ }
2919
+
2920
+ private publishSessionPromptObservedEventForScope(
2921
+ sessionId: string,
2922
+ scope: StreamObservedScope,
2923
+ prompt: StreamSessionPromptRecord,
2924
+ ): void {
2925
+ if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
2926
+ return;
2927
+ }
2928
+ this.recordThreadPrompt(sessionId, prompt);
2929
+ this.publishObservedEvent(scope, {
2930
+ type: 'session-prompt-event',
2931
+ sessionId,
2932
+ prompt: {
2933
+ text: prompt.text,
2934
+ hash: prompt.hash,
2935
+ confidence: prompt.confidence,
2936
+ captureSource: prompt.captureSource,
2937
+ providerEventName: prompt.providerEventName,
2938
+ providerPayloadKeys: [...prompt.providerPayloadKeys],
2939
+ observedAt: prompt.observedAt,
2940
+ },
2941
+ ts: new Date().toISOString(),
2942
+ directoryId: scope.directoryId,
2943
+ conversationId: scope.conversationId,
2944
+ });
2945
+ }
2946
+
2947
+ private publishSessionPromptObservedEvent(
2948
+ state: SessionState,
2949
+ prompt: StreamSessionPromptRecord,
2950
+ ): void {
2951
+ this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
2952
+ }
2953
+
2954
+ private recordThreadPrompt(sessionId: string, prompt: StreamSessionPromptRecord): void {
2955
+ const conversation = this.stateStore.getConversation(sessionId);
2956
+ if (conversation === null) {
2957
+ this.threadTitleRevisionBySessionId.delete(sessionId);
2958
+ this.threadTitleRefreshBySessionId.delete(sessionId);
2959
+ return;
2960
+ }
2961
+ if (!isThreadTitleAgentType(conversation.agentType)) {
2962
+ return;
2963
+ }
2964
+ const appended = appendThreadTitlePromptHistory(conversation.adapterState, prompt);
2965
+ if (!appended.added) {
2966
+ return;
2967
+ }
2968
+ this.stateStore.updateConversationAdapterState(sessionId, appended.nextAdapterState);
2969
+ const liveState = this.sessions.get(sessionId);
2970
+ if (liveState !== undefined) {
2971
+ liveState.adapterState = appended.nextAdapterState;
2972
+ }
2973
+ const nextRevision = (this.threadTitleRevisionBySessionId.get(sessionId) ?? 0) + 1;
2974
+ this.threadTitleRevisionBySessionId.set(sessionId, nextRevision);
2975
+ if (this.threadTitleNamer !== null) {
2976
+ this.scheduleThreadTitleRefresh(sessionId);
2977
+ }
2978
+ }
2979
+
2980
+ private scheduleThreadTitleRefresh(sessionId: string): void {
2981
+ const state = this.threadTitleRefreshBySessionId.get(sessionId) ?? {
2982
+ inFlight: false,
2983
+ pending: false,
2984
+ };
2985
+ this.threadTitleRefreshBySessionId.set(sessionId, state);
2986
+ if (state.inFlight) {
2987
+ state.pending = true;
2988
+ return;
2989
+ }
2990
+ state.inFlight = true;
2991
+ state.pending = false;
2992
+ void this.runThreadTitleRefreshLoop(sessionId, state);
2993
+ }
2994
+
2995
+ private async runThreadTitleRefreshLoop(
2996
+ sessionId: string,
2997
+ refreshState: ThreadTitleRefreshState,
2998
+ ): Promise<void> {
2999
+ let shouldReschedule = false;
3000
+ try {
3001
+ while (true) {
3002
+ refreshState.pending = false;
3003
+ const revision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3004
+ if (revision <= 0) {
3005
+ return;
3006
+ }
3007
+ await this.refreshThreadTitleForRevision(sessionId, revision);
3008
+ const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3009
+ if (!refreshState.pending && latestRevision === revision) {
3010
+ return;
3011
+ }
3012
+ }
3013
+ } finally {
3014
+ refreshState.inFlight = false;
3015
+ shouldReschedule = refreshState.pending;
3016
+ if (!shouldReschedule) {
3017
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3018
+ }
3019
+ }
3020
+ if (shouldReschedule) {
3021
+ this.scheduleThreadTitleRefresh(sessionId);
3022
+ }
3023
+ }
3024
+
3025
+ private async refreshThreadTitle(
3026
+ sessionId: string,
3027
+ expectedRevision?: number,
3028
+ ): Promise<ThreadTitleRefreshResult> {
3029
+ if (this.threadTitleNamer === null) {
3030
+ return {
3031
+ status: 'skipped',
3032
+ conversation: this.stateStore.getConversation(sessionId),
3033
+ reason: 'thread-title-namer-disabled',
3034
+ };
3035
+ }
3036
+ const conversation = this.stateStore.getConversation(sessionId);
3037
+ if (conversation === null) {
3038
+ if (expectedRevision !== undefined) {
3039
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3040
+ }
3041
+ return {
3042
+ status: 'skipped',
3043
+ conversation: null,
3044
+ reason: 'conversation-not-found',
3045
+ };
3046
+ }
3047
+ if (!isThreadTitleAgentType(conversation.agentType)) {
3048
+ return {
3049
+ status: 'skipped',
3050
+ conversation,
3051
+ reason: 'non-agent-thread',
3052
+ };
3053
+ }
3054
+ const promptHistory = readThreadTitlePromptHistory(conversation.adapterState);
3055
+ if (promptHistory.length === 0) {
3056
+ return {
3057
+ status: 'skipped',
3058
+ conversation,
3059
+ reason: 'prompt-history-empty',
3060
+ };
3061
+ }
3062
+
3063
+ let suggestedTitle: string | null = null;
3064
+ try {
3065
+ suggestedTitle = await this.threadTitleNamer.suggest({
3066
+ conversationId: conversation.conversationId,
3067
+ agentType: conversation.agentType,
3068
+ currentTitle: conversation.title,
3069
+ promptHistory,
3070
+ });
3071
+ } catch (error: unknown) {
3072
+ const message = error instanceof Error ? error.message : String(error);
3073
+ recordPerfEvent('control-plane.thread-title.error', {
3074
+ sessionId,
3075
+ error: message,
3076
+ });
3077
+ }
3078
+
3079
+ if (expectedRevision !== undefined) {
3080
+ const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3081
+ if (latestRevision !== expectedRevision) {
3082
+ return {
3083
+ status: 'skipped',
3084
+ conversation,
3085
+ reason: 'stale-revision',
3086
+ };
3087
+ }
3088
+ }
3089
+
3090
+ const nextTitle =
3091
+ (suggestedTitle === null ? null : normalizeThreadTitleCandidate(suggestedTitle)) ??
3092
+ fallbackThreadTitleFromPromptHistory(promptHistory);
3093
+ if (conversation.title === nextTitle) {
3094
+ return {
3095
+ status: 'unchanged',
3096
+ conversation,
3097
+ reason: null,
3098
+ };
3099
+ }
3100
+ const updated = this.stateStore.updateConversationTitle(sessionId, nextTitle);
3101
+ if (updated === null) {
3102
+ return {
3103
+ status: 'skipped',
3104
+ conversation: null,
3105
+ reason: 'conversation-not-found',
3106
+ };
3107
+ }
3108
+ this.publishConversationUpdatedObservedEvent(updated);
3109
+ return {
3110
+ status: 'updated',
3111
+ conversation: updated,
3112
+ reason: null,
3113
+ };
3114
+ }
3115
+
3116
+ private async refreshThreadTitleForRevision(sessionId: string, revision: number): Promise<void> {
3117
+ await this.refreshThreadTitle(sessionId, revision);
3118
+ }
3119
+
3120
+ private publishConversationUpdatedObservedEvent(
3121
+ conversation: ControlPlaneConversationRecord,
3122
+ ): void {
3123
+ this.publishObservedEvent(
3124
+ {
3125
+ tenantId: conversation.tenantId,
3126
+ userId: conversation.userId,
3127
+ workspaceId: conversation.workspaceId,
3128
+ directoryId: conversation.directoryId,
3129
+ conversationId: conversation.conversationId,
3130
+ },
3131
+ {
3132
+ type: 'conversation-updated',
3133
+ conversation: this.conversationRecord(conversation),
3134
+ },
3135
+ );
3136
+ }
3137
+
2677
3138
  private setSessionStatus(
2678
3139
  state: SessionState,
2679
3140
  status: StreamSessionRuntimeStatus,
@@ -3076,6 +3537,8 @@ export class ControlPlaneStreamServer {
3076
3537
 
3077
3538
  this.sessions.delete(sessionId);
3078
3539
  this.launchCommandBySessionId.delete(sessionId);
3540
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3541
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3079
3542
  for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
3080
3543
  if (mappedSessionId === sessionId) {
3081
3544
  this.telemetryTokenToSessionId.delete(token);
@@ -3212,6 +3675,9 @@ export class ControlPlaneStreamServer {
3212
3675
  if (event.type === 'session-key-event') {
3213
3676
  return event.sessionId;
3214
3677
  }
3678
+ if (event.type === 'session-prompt-event') {
3679
+ return event.sessionId;
3680
+ }
3215
3681
  if (event.type === 'session-control') {
3216
3682
  return event.sessionId;
3217
3683
  }