@jmoyers/harness 0.1.9 → 0.1.11

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 (54) hide show
  1. package/README.md +36 -155
  2. package/package.json +3 -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 +265 -14
  15. package/scripts/control-plane-daemon.ts +33 -5
  16. package/scripts/harness.ts +579 -134
  17. package/src/cli/default-gateway-pointer.ts +193 -0
  18. package/src/cli/gateway-record.ts +16 -1
  19. package/src/config/config-core.ts +13 -2
  20. package/src/config/harness-paths.ts +4 -7
  21. package/src/config/harness-runtime-migration.ts +142 -19
  22. package/src/config/secrets-core.ts +92 -4
  23. package/src/control-plane/prompt/thread-title-namer.ts +316 -0
  24. package/src/control-plane/stream-command-parser.ts +12 -0
  25. package/src/control-plane/stream-protocol.ts +6 -0
  26. package/src/control-plane/stream-server-background.ts +18 -2
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server.ts +460 -28
  29. package/src/domain/conversations.ts +11 -7
  30. package/src/domain/workspace.ts +9 -0
  31. package/src/mux/input-shortcuts.ts +38 -1
  32. package/src/mux/live-mux/git-parsing.ts +40 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  35. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  36. package/src/mux/live-mux/modal-overlays.ts +45 -0
  37. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  38. package/src/mux/render-frame.ts +1 -1
  39. package/src/mux/task-screen-keybindings.ts +29 -1
  40. package/src/services/control-plane.ts +22 -0
  41. package/src/services/runtime-control-actions.ts +69 -0
  42. package/src/services/runtime-conversation-activation.ts +25 -0
  43. package/src/services/runtime-conversation-starter.ts +31 -7
  44. package/src/services/runtime-input-router.ts +6 -0
  45. package/src/services/runtime-modal-input.ts +18 -0
  46. package/src/services/runtime-navigation-input.ts +4 -0
  47. package/src/services/runtime-rail-input.ts +5 -0
  48. package/src/services/runtime-repository-actions.ts +2 -0
  49. package/src/services/runtime-workspace-actions.ts +5 -0
  50. package/src/store/control-plane-store.ts +36 -0
  51. package/src/store/event-store.ts +36 -0
  52. package/src/ui/global-shortcut-input.ts +2 -0
  53. package/src/ui/input.ts +31 -0
  54. package/src/ui/modals/manager.ts +26 -0
@@ -85,6 +85,14 @@ import {
85
85
  import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
86
86
  import { SessionStatusEngine } from './status/session-status-engine.ts';
87
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';
88
96
  import {
89
97
  eventIncludesRepositoryId as filterEventIncludesRepositoryId,
90
98
  eventIncludesTaskId as filterEventIncludesTaskId,
@@ -222,6 +230,14 @@ interface GitHubIntegrationConfig {
222
230
  readonly viewerLogin: string | null;
223
231
  }
224
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
+
225
241
  interface GitHubRemotePullRequest {
226
242
  readonly number: number;
227
243
  readonly title: string;
@@ -285,6 +301,8 @@ interface StartControlPlaneStreamServerOptions {
285
301
  githubFetch?: typeof fetch;
286
302
  readGitDirectorySnapshot?: GitDirectorySnapshotReader;
287
303
  lifecycleHooks?: HarnessLifecycleHooksConfig;
304
+ threadTitle?: Partial<ThreadTitleConfig>;
305
+ threadTitleNamer?: ThreadTitleNamer;
288
306
  }
289
307
 
290
308
  interface ConnectionState {
@@ -385,6 +403,17 @@ interface StreamJournalEntry {
385
403
  event: StreamObservedEvent;
386
404
  }
387
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
+
388
417
  interface DirectoryGitStatusCacheEntry {
389
418
  readonly summary: GitDirectorySnapshot['summary'];
390
419
  readonly repositorySnapshot: GitDirectorySnapshot['repository'];
@@ -437,12 +466,17 @@ const DEFAULT_AGENT_INSTALL_COMMANDS: Readonly<Record<AgentToolType, string | nu
437
466
  const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
438
467
  new URL('../../scripts/cursor-hook-relay.ts', import.meta.url),
439
468
  );
469
+ const THREAD_TITLE_AGENT_TYPES = new Set(['codex', 'claude', 'cursor']);
440
470
  const LIFECYCLE_TELEMETRY_EVENT_NAMES = new Set([
441
471
  'codex.user_prompt',
442
472
  'codex.turn.e2e_duration_ms',
443
473
  'codex.conversation_starts',
444
474
  ]);
445
475
 
476
+ function isThreadTitleAgentType(agentType: string): boolean {
477
+ return THREAD_TITLE_AGENT_TYPES.has(agentType.trim().toLowerCase());
478
+ }
479
+
446
480
  function shellEscape(value: string): string {
447
481
  if (value.length === 0) {
448
482
  return "''";
@@ -738,6 +772,33 @@ function normalizeGitHubIntegrationConfig(
738
772
  };
739
773
  }
740
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
+
741
802
  function parseGitHubOwnerRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
742
803
  const trimmed = remoteUrl.trim();
743
804
  if (trimmed.length === 0) {
@@ -1059,7 +1120,10 @@ export class ControlPlaneStreamServer {
1059
1120
  private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
1060
1121
  private readonly statusEngine = new SessionStatusEngine();
1061
1122
  private readonly promptEngine = new SessionPromptEngine();
1123
+ private readonly threadTitleNamer: ThreadTitleNamer | null;
1062
1124
  private readonly promptEventDedupeByKey = new Map<string, number>();
1125
+ private readonly threadTitleRevisionBySessionId = new Map<string, number>();
1126
+ private readonly threadTitleRefreshBySessionId = new Map<string, ThreadTitleRefreshState>();
1063
1127
  private readonly server: Server;
1064
1128
  private readonly telemetryServer: HttpServer | null;
1065
1129
  private telemetryAddress: AddressInfo | null = null;
@@ -1077,6 +1141,7 @@ export class ControlPlaneStreamServer {
1077
1141
  private githubTokenResolutionError: string | null = null;
1078
1142
  private gitStatusPollInFlight = false;
1079
1143
  private githubPollInFlight = false;
1144
+ private githubPollPromise: Promise<void> | null = null;
1080
1145
  private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
1081
1146
  private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
1082
1147
  private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
@@ -1088,6 +1153,7 @@ export class ControlPlaneStreamServer {
1088
1153
  private streamCursor = 0;
1089
1154
  private listening = false;
1090
1155
  private stateStoreClosed = false;
1156
+ private closing = false;
1091
1157
 
1092
1158
  constructor(options: StartControlPlaneStreamServerOptions = {}) {
1093
1159
  this.host = options.host ?? '127.0.0.1';
@@ -1133,6 +1199,19 @@ export class ControlPlaneStreamServer {
1133
1199
  await readGitDirectorySnapshot(cwd, undefined, {
1134
1200
  includeCommitCount: false,
1135
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
+ }
1136
1215
  this.lifecycleHooks = new LifecycleHooksRuntime(
1137
1216
  options.lifecycleHooks ?? {
1138
1217
  enabled: false,
@@ -1211,9 +1290,11 @@ export class ControlPlaneStreamServer {
1211
1290
  }
1212
1291
 
1213
1292
  async close(): Promise<void> {
1293
+ this.closing = true;
1214
1294
  this.stopHistoryPolling();
1215
1295
  this.stopGitStatusPolling();
1216
1296
  this.stopGitHubPolling();
1297
+ await this.waitForGitHubPollingToSettle();
1217
1298
 
1218
1299
  for (const sessionId of [...this.sessions.keys()]) {
1219
1300
  this.destroySession(sessionId, true);
@@ -1300,7 +1381,18 @@ export class ControlPlaneStreamServer {
1300
1381
  }
1301
1382
 
1302
1383
  private pollHistoryTimerTick(): void {
1303
- void this.pollHistoryFile();
1384
+ void this.pollHistoryFile().catch((error: unknown) => {
1385
+ if (this.markStateStoreClosedIfDetected(error)) {
1386
+ return;
1387
+ }
1388
+ if (this.shouldSkipStateStoreWork()) {
1389
+ return;
1390
+ }
1391
+ const message = error instanceof Error ? error.message : String(error);
1392
+ recordPerfEvent('control-plane.history.poll.failed', {
1393
+ error: message,
1394
+ });
1395
+ });
1304
1396
  }
1305
1397
 
1306
1398
  private stopHistoryPolling(): void {
@@ -1318,9 +1410,9 @@ export class ControlPlaneStreamServer {
1318
1410
  return;
1319
1411
  }
1320
1412
  this.reloadGitStatusDirectoriesFromStore();
1321
- void this.pollGitStatus();
1413
+ this.triggerGitStatusPoll();
1322
1414
  this.gitStatusPollTimer = setInterval(() => {
1323
- void this.pollGitStatus();
1415
+ this.triggerGitStatusPoll();
1324
1416
  }, this.gitStatusMonitor.pollMs);
1325
1417
  this.gitStatusPollTimer.unref();
1326
1418
  }
@@ -1396,9 +1488,9 @@ export class ControlPlaneStreamServer {
1396
1488
  if (!this.github.enabled || this.githubPollTimer !== null) {
1397
1489
  return;
1398
1490
  }
1399
- void this.pollGitHub();
1491
+ this.triggerGitHubPoll();
1400
1492
  this.githubPollTimer = setInterval(() => {
1401
- void this.pollGitHub();
1493
+ this.triggerGitHubPoll();
1402
1494
  }, this.github.pollMs);
1403
1495
  this.githubPollTimer.unref();
1404
1496
  }
@@ -1411,6 +1503,85 @@ export class ControlPlaneStreamServer {
1411
1503
  this.githubPollTimer = null;
1412
1504
  }
1413
1505
 
1506
+ private triggerGitHubPoll(): void {
1507
+ void this.pollGitHub().catch((error: unknown) => {
1508
+ if (this.markStateStoreClosedIfDetected(error)) {
1509
+ return;
1510
+ }
1511
+ if (this.shouldIgnoreGitHubPollError(error)) {
1512
+ return;
1513
+ }
1514
+ const message = error instanceof Error ? error.message : String(error);
1515
+ recordPerfEvent('control-plane.github.poll.failed', {
1516
+ error: message,
1517
+ });
1518
+ });
1519
+ }
1520
+
1521
+ private triggerGitStatusPoll(): void {
1522
+ void this.pollGitStatus().catch((error: unknown) => {
1523
+ if (this.markStateStoreClosedIfDetected(error)) {
1524
+ return;
1525
+ }
1526
+ if (this.shouldSkipStateStoreWork()) {
1527
+ return;
1528
+ }
1529
+ const message = error instanceof Error ? error.message : String(error);
1530
+ recordPerfEvent('control-plane.git-status.poll.failed', {
1531
+ error: message,
1532
+ });
1533
+ });
1534
+ }
1535
+
1536
+ private isStateStoreClosedError(error: unknown): boolean {
1537
+ const message = error instanceof Error ? error.message : String(error);
1538
+ const normalized = message.trim().toLowerCase();
1539
+ return (
1540
+ normalized.includes('database has closed') ||
1541
+ normalized.includes('database is closed') ||
1542
+ normalized.includes('cannot use a closed database')
1543
+ );
1544
+ }
1545
+
1546
+ private markStateStoreClosedIfDetected(error: unknown): boolean {
1547
+ if (!this.isStateStoreClosedError(error)) {
1548
+ return false;
1549
+ }
1550
+ this.stateStoreClosed = true;
1551
+ this.stopGitHubPolling();
1552
+ this.stopGitStatusPolling();
1553
+ this.stopHistoryPolling();
1554
+ return true;
1555
+ }
1556
+
1557
+ private shouldSkipStateStoreWork(): boolean {
1558
+ return this.closing || this.stateStoreClosed;
1559
+ }
1560
+
1561
+ private shouldIgnoreGitHubPollError(error: unknown): boolean {
1562
+ return this.shouldSkipStateStoreWork() || this.isStateStoreClosedError(error);
1563
+ }
1564
+
1565
+ private async waitForGitHubPollingToSettle(): Promise<void> {
1566
+ const pollPromise = this.githubPollPromise;
1567
+ if (pollPromise === null) {
1568
+ return;
1569
+ }
1570
+ try {
1571
+ await pollPromise;
1572
+ } catch (error: unknown) {
1573
+ if (this.markStateStoreClosedIfDetected(error)) {
1574
+ return;
1575
+ }
1576
+ if (!this.shouldIgnoreGitHubPollError(error)) {
1577
+ const message = error instanceof Error ? error.message : String(error);
1578
+ recordPerfEvent('control-plane.github.poll.failed-on-close', {
1579
+ error: message,
1580
+ });
1581
+ }
1582
+ }
1583
+ }
1584
+
1414
1585
  private reloadGitStatusDirectoriesFromStore(): void {
1415
1586
  const directories = this.stateStore.listDirectories({
1416
1587
  includeArchived: false,
@@ -2081,9 +2252,15 @@ export class ControlPlaneStreamServer {
2081
2252
  }
2082
2253
 
2083
2254
  private async pollHistoryFile(): Promise<void> {
2084
- await pollStreamServerHistoryFile(
2085
- this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
2086
- );
2255
+ try {
2256
+ await pollStreamServerHistoryFile(
2257
+ this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
2258
+ );
2259
+ } catch (error: unknown) {
2260
+ if (!this.markStateStoreClosedIfDetected(error)) {
2261
+ throw error;
2262
+ }
2263
+ }
2087
2264
  }
2088
2265
 
2089
2266
  private async pollHistoryFileUnsafe(): Promise<boolean> {
@@ -2093,9 +2270,15 @@ export class ControlPlaneStreamServer {
2093
2270
  }
2094
2271
 
2095
2272
  private async pollGitStatus(): Promise<void> {
2096
- await pollStreamServerGitStatus(
2097
- this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
2098
- );
2273
+ try {
2274
+ await pollStreamServerGitStatus(
2275
+ this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
2276
+ );
2277
+ } catch (error: unknown) {
2278
+ if (!this.markStateStoreClosedIfDetected(error)) {
2279
+ throw error;
2280
+ }
2281
+ }
2099
2282
  }
2100
2283
 
2101
2284
  private async refreshGitStatusForDirectory(
@@ -2112,11 +2295,11 @@ export class ControlPlaneStreamServer {
2112
2295
  }
2113
2296
 
2114
2297
  private async pollGitHub(): Promise<void> {
2115
- if (!this.github.enabled || this.githubPollInFlight) {
2298
+ if (!this.github.enabled || this.githubPollInFlight || this.shouldSkipStateStoreWork()) {
2116
2299
  return;
2117
2300
  }
2118
2301
  this.githubPollInFlight = true;
2119
- try {
2302
+ const pollPromise = (async () => {
2120
2303
  const directories = this.stateStore.listDirectories({
2121
2304
  includeArchived: false,
2122
2305
  limit: 1000,
@@ -2132,6 +2315,9 @@ export class ControlPlaneStreamServer {
2132
2315
  }
2133
2316
  >();
2134
2317
  for (const directory of directories) {
2318
+ if (this.shouldSkipStateStoreWork()) {
2319
+ return;
2320
+ }
2135
2321
  const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
2136
2322
  const repositoryId = gitStatus?.repositoryId ?? null;
2137
2323
  if (repositoryId === null) {
@@ -2166,21 +2352,35 @@ export class ControlPlaneStreamServer {
2166
2352
  branchName,
2167
2353
  });
2168
2354
  }
2169
- if (targetsByKey.size === 0) {
2355
+ if (targetsByKey.size === 0 || this.shouldSkipStateStoreWork()) {
2170
2356
  return;
2171
2357
  }
2172
2358
  const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
2173
- if (token === null) {
2359
+ if (token === null || this.shouldSkipStateStoreWork()) {
2174
2360
  return;
2175
2361
  }
2176
2362
  await runWithConcurrencyLimit(
2177
2363
  [...targetsByKey.values()],
2178
2364
  this.github.maxConcurrency,
2179
2365
  async (target) => {
2366
+ if (this.shouldSkipStateStoreWork()) {
2367
+ return;
2368
+ }
2180
2369
  await this.syncGitHubBranch(target);
2181
2370
  },
2182
2371
  );
2372
+ })();
2373
+ this.githubPollPromise = pollPromise;
2374
+ try {
2375
+ await pollPromise;
2376
+ } catch (error: unknown) {
2377
+ if (!this.markStateStoreClosedIfDetected(error)) {
2378
+ throw error;
2379
+ }
2183
2380
  } finally {
2381
+ if (this.githubPollPromise === pollPromise) {
2382
+ this.githubPollPromise = null;
2383
+ }
2184
2384
  this.githubPollInFlight = false;
2185
2385
  }
2186
2386
  }
@@ -2192,6 +2392,9 @@ export class ControlPlaneStreamServer {
2192
2392
  repo: string;
2193
2393
  branchName: string;
2194
2394
  }): Promise<void> {
2395
+ if (this.shouldSkipStateStoreWork()) {
2396
+ return;
2397
+ }
2195
2398
  const now = new Date().toISOString();
2196
2399
  const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
2197
2400
  try {
@@ -2200,6 +2403,9 @@ export class ControlPlaneStreamServer {
2200
2403
  repo: input.repo,
2201
2404
  headBranch: input.branchName,
2202
2405
  });
2406
+ if (this.shouldSkipStateStoreWork()) {
2407
+ return;
2408
+ }
2203
2409
  if (remotePr === null) {
2204
2410
  const staleOpen = this.stateStore.listGitHubPullRequests({
2205
2411
  repositoryId: input.repository.repositoryId,
@@ -2286,6 +2492,9 @@ export class ControlPlaneStreamServer {
2286
2492
  repo: input.repo,
2287
2493
  headSha: storedPr.headSha,
2288
2494
  });
2495
+ if (this.shouldSkipStateStoreWork()) {
2496
+ return;
2497
+ }
2289
2498
  const rollup = summarizeGitHubCiRollup(jobs);
2290
2499
  const updatedPr =
2291
2500
  this.stateStore.updateGitHubPullRequestCiRollup(
@@ -2345,20 +2554,35 @@ export class ControlPlaneStreamServer {
2345
2554
  lastErrorAt: null,
2346
2555
  });
2347
2556
  } catch (error: unknown) {
2557
+ if (this.markStateStoreClosedIfDetected(error)) {
2558
+ return;
2559
+ }
2560
+ if (this.shouldIgnoreGitHubPollError(error)) {
2561
+ return;
2562
+ }
2348
2563
  const message = error instanceof Error ? error.message : String(error);
2349
- this.stateStore.upsertGitHubSyncState({
2350
- stateId: syncStateId,
2351
- tenantId: input.directory.tenantId,
2352
- userId: input.directory.userId,
2353
- workspaceId: input.directory.workspaceId,
2354
- repositoryId: input.repository.repositoryId,
2355
- directoryId: input.directory.directoryId,
2356
- branchName: input.branchName,
2357
- lastSyncAt: now,
2358
- lastSuccessAt: null,
2359
- lastError: message,
2360
- lastErrorAt: now,
2361
- });
2564
+ try {
2565
+ this.stateStore.upsertGitHubSyncState({
2566
+ stateId: syncStateId,
2567
+ tenantId: input.directory.tenantId,
2568
+ userId: input.directory.userId,
2569
+ workspaceId: input.directory.workspaceId,
2570
+ repositoryId: input.repository.repositoryId,
2571
+ directoryId: input.directory.directoryId,
2572
+ branchName: input.branchName,
2573
+ lastSyncAt: now,
2574
+ lastSuccessAt: null,
2575
+ lastError: message,
2576
+ lastErrorAt: now,
2577
+ });
2578
+ } catch (syncStateError: unknown) {
2579
+ if (this.markStateStoreClosedIfDetected(syncStateError)) {
2580
+ return;
2581
+ }
2582
+ if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
2583
+ throw syncStateError;
2584
+ }
2585
+ }
2362
2586
  }
2363
2587
  }
2364
2588
 
@@ -2648,6 +2872,27 @@ export class ControlPlaneStreamServer {
2648
2872
  );
2649
2873
  }
2650
2874
 
2875
+ private async refreshConversationTitle(conversationId: string): Promise<{
2876
+ conversation: ControlPlaneConversationRecord;
2877
+ status: 'updated' | 'unchanged' | 'skipped';
2878
+ reason: string | null;
2879
+ }> {
2880
+ const existing = this.stateStore.getConversation(conversationId);
2881
+ if (existing === null) {
2882
+ throw new Error(`conversation not found: ${conversationId}`);
2883
+ }
2884
+ const refreshed = await this.refreshThreadTitle(conversationId);
2885
+ const conversation = this.stateStore.getConversation(conversationId);
2886
+ if (conversation === null) {
2887
+ throw new Error(`conversation not found: ${conversationId}`);
2888
+ }
2889
+ return {
2890
+ conversation,
2891
+ status: refreshed.status,
2892
+ reason: refreshed.reason,
2893
+ };
2894
+ }
2895
+
2651
2896
  private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
2652
2897
  handleRuntimeInput(
2653
2898
  this as unknown as Parameters<typeof handleRuntimeInput>[0],
@@ -2749,6 +2994,7 @@ export class ControlPlaneStreamServer {
2749
2994
  if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
2750
2995
  return;
2751
2996
  }
2997
+ this.recordThreadPrompt(sessionId, prompt);
2752
2998
  this.publishObservedEvent(scope, {
2753
2999
  type: 'session-prompt-event',
2754
3000
  sessionId,
@@ -2774,6 +3020,190 @@ export class ControlPlaneStreamServer {
2774
3020
  this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
2775
3021
  }
2776
3022
 
3023
+ private recordThreadPrompt(sessionId: string, prompt: StreamSessionPromptRecord): void {
3024
+ const conversation = this.stateStore.getConversation(sessionId);
3025
+ if (conversation === null) {
3026
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3027
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3028
+ return;
3029
+ }
3030
+ if (!isThreadTitleAgentType(conversation.agentType)) {
3031
+ return;
3032
+ }
3033
+ const appended = appendThreadTitlePromptHistory(conversation.adapterState, prompt);
3034
+ if (!appended.added) {
3035
+ return;
3036
+ }
3037
+ this.stateStore.updateConversationAdapterState(sessionId, appended.nextAdapterState);
3038
+ const liveState = this.sessions.get(sessionId);
3039
+ if (liveState !== undefined) {
3040
+ liveState.adapterState = appended.nextAdapterState;
3041
+ }
3042
+ const nextRevision = (this.threadTitleRevisionBySessionId.get(sessionId) ?? 0) + 1;
3043
+ this.threadTitleRevisionBySessionId.set(sessionId, nextRevision);
3044
+ if (this.threadTitleNamer !== null) {
3045
+ this.scheduleThreadTitleRefresh(sessionId);
3046
+ }
3047
+ }
3048
+
3049
+ private scheduleThreadTitleRefresh(sessionId: string): void {
3050
+ const state = this.threadTitleRefreshBySessionId.get(sessionId) ?? {
3051
+ inFlight: false,
3052
+ pending: false,
3053
+ };
3054
+ this.threadTitleRefreshBySessionId.set(sessionId, state);
3055
+ if (state.inFlight) {
3056
+ state.pending = true;
3057
+ return;
3058
+ }
3059
+ state.inFlight = true;
3060
+ state.pending = false;
3061
+ void this.runThreadTitleRefreshLoop(sessionId, state);
3062
+ }
3063
+
3064
+ private async runThreadTitleRefreshLoop(
3065
+ sessionId: string,
3066
+ refreshState: ThreadTitleRefreshState,
3067
+ ): Promise<void> {
3068
+ let shouldReschedule = false;
3069
+ try {
3070
+ while (true) {
3071
+ refreshState.pending = false;
3072
+ const revision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3073
+ if (revision <= 0) {
3074
+ return;
3075
+ }
3076
+ await this.refreshThreadTitleForRevision(sessionId, revision);
3077
+ const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3078
+ if (!refreshState.pending && latestRevision === revision) {
3079
+ return;
3080
+ }
3081
+ }
3082
+ } finally {
3083
+ refreshState.inFlight = false;
3084
+ shouldReschedule = refreshState.pending;
3085
+ if (!shouldReschedule) {
3086
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3087
+ }
3088
+ }
3089
+ if (shouldReschedule) {
3090
+ this.scheduleThreadTitleRefresh(sessionId);
3091
+ }
3092
+ }
3093
+
3094
+ private async refreshThreadTitle(
3095
+ sessionId: string,
3096
+ expectedRevision?: number,
3097
+ ): Promise<ThreadTitleRefreshResult> {
3098
+ if (this.threadTitleNamer === null) {
3099
+ return {
3100
+ status: 'skipped',
3101
+ conversation: this.stateStore.getConversation(sessionId),
3102
+ reason: 'thread-title-namer-disabled',
3103
+ };
3104
+ }
3105
+ const conversation = this.stateStore.getConversation(sessionId);
3106
+ if (conversation === null) {
3107
+ if (expectedRevision !== undefined) {
3108
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3109
+ }
3110
+ return {
3111
+ status: 'skipped',
3112
+ conversation: null,
3113
+ reason: 'conversation-not-found',
3114
+ };
3115
+ }
3116
+ if (!isThreadTitleAgentType(conversation.agentType)) {
3117
+ return {
3118
+ status: 'skipped',
3119
+ conversation,
3120
+ reason: 'non-agent-thread',
3121
+ };
3122
+ }
3123
+ const promptHistory = readThreadTitlePromptHistory(conversation.adapterState);
3124
+ if (promptHistory.length === 0) {
3125
+ return {
3126
+ status: 'skipped',
3127
+ conversation,
3128
+ reason: 'prompt-history-empty',
3129
+ };
3130
+ }
3131
+
3132
+ let suggestedTitle: string | null = null;
3133
+ try {
3134
+ suggestedTitle = await this.threadTitleNamer.suggest({
3135
+ conversationId: conversation.conversationId,
3136
+ agentType: conversation.agentType,
3137
+ currentTitle: conversation.title,
3138
+ promptHistory,
3139
+ });
3140
+ } catch (error: unknown) {
3141
+ const message = error instanceof Error ? error.message : String(error);
3142
+ recordPerfEvent('control-plane.thread-title.error', {
3143
+ sessionId,
3144
+ error: message,
3145
+ });
3146
+ }
3147
+
3148
+ if (expectedRevision !== undefined) {
3149
+ const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
3150
+ if (latestRevision !== expectedRevision) {
3151
+ return {
3152
+ status: 'skipped',
3153
+ conversation,
3154
+ reason: 'stale-revision',
3155
+ };
3156
+ }
3157
+ }
3158
+
3159
+ const nextTitle =
3160
+ (suggestedTitle === null ? null : normalizeThreadTitleCandidate(suggestedTitle)) ??
3161
+ fallbackThreadTitleFromPromptHistory(promptHistory);
3162
+ if (conversation.title === nextTitle) {
3163
+ return {
3164
+ status: 'unchanged',
3165
+ conversation,
3166
+ reason: null,
3167
+ };
3168
+ }
3169
+ const updated = this.stateStore.updateConversationTitle(sessionId, nextTitle);
3170
+ if (updated === null) {
3171
+ return {
3172
+ status: 'skipped',
3173
+ conversation: null,
3174
+ reason: 'conversation-not-found',
3175
+ };
3176
+ }
3177
+ this.publishConversationUpdatedObservedEvent(updated);
3178
+ return {
3179
+ status: 'updated',
3180
+ conversation: updated,
3181
+ reason: null,
3182
+ };
3183
+ }
3184
+
3185
+ private async refreshThreadTitleForRevision(sessionId: string, revision: number): Promise<void> {
3186
+ await this.refreshThreadTitle(sessionId, revision);
3187
+ }
3188
+
3189
+ private publishConversationUpdatedObservedEvent(
3190
+ conversation: ControlPlaneConversationRecord,
3191
+ ): void {
3192
+ this.publishObservedEvent(
3193
+ {
3194
+ tenantId: conversation.tenantId,
3195
+ userId: conversation.userId,
3196
+ workspaceId: conversation.workspaceId,
3197
+ directoryId: conversation.directoryId,
3198
+ conversationId: conversation.conversationId,
3199
+ },
3200
+ {
3201
+ type: 'conversation-updated',
3202
+ conversation: this.conversationRecord(conversation),
3203
+ },
3204
+ );
3205
+ }
3206
+
2777
3207
  private setSessionStatus(
2778
3208
  state: SessionState,
2779
3209
  status: StreamSessionRuntimeStatus,
@@ -3176,6 +3606,8 @@ export class ControlPlaneStreamServer {
3176
3606
 
3177
3607
  this.sessions.delete(sessionId);
3178
3608
  this.launchCommandBySessionId.delete(sessionId);
3609
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3610
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3179
3611
  for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
3180
3612
  if (mappedSessionId === sessionId) {
3181
3613
  this.telemetryTokenToSessionId.delete(token);