@jmoyers/harness 0.1.9 → 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 (32) hide show
  1. package/README.md +33 -156
  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 +103 -3
  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/prompt/thread-title-namer.ts +290 -0
  19. package/src/control-plane/stream-command-parser.ts +12 -0
  20. package/src/control-plane/stream-protocol.ts +6 -0
  21. package/src/control-plane/stream-server-command.ts +14 -0
  22. package/src/control-plane/stream-server.ts +382 -19
  23. package/src/mux/input-shortcuts.ts +9 -0
  24. package/src/mux/live-mux/git-parsing.ts +24 -0
  25. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  26. package/src/mux/render-frame.ts +1 -1
  27. package/src/services/control-plane.ts +22 -0
  28. package/src/services/runtime-control-actions.ts +69 -0
  29. package/src/services/runtime-navigation-input.ts +4 -0
  30. package/src/services/runtime-rail-input.ts +4 -0
  31. package/src/services/runtime-workspace-actions.ts +5 -0
  32. package/src/ui/global-shortcut-input.ts +2 -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);
@@ -1396,9 +1477,9 @@ export class ControlPlaneStreamServer {
1396
1477
  if (!this.github.enabled || this.githubPollTimer !== null) {
1397
1478
  return;
1398
1479
  }
1399
- void this.pollGitHub();
1480
+ this.triggerGitHubPoll();
1400
1481
  this.githubPollTimer = setInterval(() => {
1401
- void this.pollGitHub();
1482
+ this.triggerGitHubPoll();
1402
1483
  }, this.github.pollMs);
1403
1484
  this.githubPollTimer.unref();
1404
1485
  }
@@ -1411,6 +1492,49 @@ export class ControlPlaneStreamServer {
1411
1492
  this.githubPollTimer = null;
1412
1493
  }
1413
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
+
1414
1538
  private reloadGitStatusDirectoriesFromStore(): void {
1415
1539
  const directories = this.stateStore.listDirectories({
1416
1540
  includeArchived: false,
@@ -2112,11 +2236,11 @@ export class ControlPlaneStreamServer {
2112
2236
  }
2113
2237
 
2114
2238
  private async pollGitHub(): Promise<void> {
2115
- if (!this.github.enabled || this.githubPollInFlight) {
2239
+ if (!this.github.enabled || this.githubPollInFlight || this.shouldSkipStateStoreWork()) {
2116
2240
  return;
2117
2241
  }
2118
2242
  this.githubPollInFlight = true;
2119
- try {
2243
+ const pollPromise = (async () => {
2120
2244
  const directories = this.stateStore.listDirectories({
2121
2245
  includeArchived: false,
2122
2246
  limit: 1000,
@@ -2132,6 +2256,9 @@ export class ControlPlaneStreamServer {
2132
2256
  }
2133
2257
  >();
2134
2258
  for (const directory of directories) {
2259
+ if (this.shouldSkipStateStoreWork()) {
2260
+ return;
2261
+ }
2135
2262
  const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
2136
2263
  const repositoryId = gitStatus?.repositoryId ?? null;
2137
2264
  if (repositoryId === null) {
@@ -2166,21 +2293,31 @@ export class ControlPlaneStreamServer {
2166
2293
  branchName,
2167
2294
  });
2168
2295
  }
2169
- if (targetsByKey.size === 0) {
2296
+ if (targetsByKey.size === 0 || this.shouldSkipStateStoreWork()) {
2170
2297
  return;
2171
2298
  }
2172
2299
  const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
2173
- if (token === null) {
2300
+ if (token === null || this.shouldSkipStateStoreWork()) {
2174
2301
  return;
2175
2302
  }
2176
2303
  await runWithConcurrencyLimit(
2177
2304
  [...targetsByKey.values()],
2178
2305
  this.github.maxConcurrency,
2179
2306
  async (target) => {
2307
+ if (this.shouldSkipStateStoreWork()) {
2308
+ return;
2309
+ }
2180
2310
  await this.syncGitHubBranch(target);
2181
2311
  },
2182
2312
  );
2313
+ })();
2314
+ this.githubPollPromise = pollPromise;
2315
+ try {
2316
+ await pollPromise;
2183
2317
  } finally {
2318
+ if (this.githubPollPromise === pollPromise) {
2319
+ this.githubPollPromise = null;
2320
+ }
2184
2321
  this.githubPollInFlight = false;
2185
2322
  }
2186
2323
  }
@@ -2192,6 +2329,9 @@ export class ControlPlaneStreamServer {
2192
2329
  repo: string;
2193
2330
  branchName: string;
2194
2331
  }): Promise<void> {
2332
+ if (this.shouldSkipStateStoreWork()) {
2333
+ return;
2334
+ }
2195
2335
  const now = new Date().toISOString();
2196
2336
  const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
2197
2337
  try {
@@ -2200,6 +2340,9 @@ export class ControlPlaneStreamServer {
2200
2340
  repo: input.repo,
2201
2341
  headBranch: input.branchName,
2202
2342
  });
2343
+ if (this.shouldSkipStateStoreWork()) {
2344
+ return;
2345
+ }
2203
2346
  if (remotePr === null) {
2204
2347
  const staleOpen = this.stateStore.listGitHubPullRequests({
2205
2348
  repositoryId: input.repository.repositoryId,
@@ -2286,6 +2429,9 @@ export class ControlPlaneStreamServer {
2286
2429
  repo: input.repo,
2287
2430
  headSha: storedPr.headSha,
2288
2431
  });
2432
+ if (this.shouldSkipStateStoreWork()) {
2433
+ return;
2434
+ }
2289
2435
  const rollup = summarizeGitHubCiRollup(jobs);
2290
2436
  const updatedPr =
2291
2437
  this.stateStore.updateGitHubPullRequestCiRollup(
@@ -2345,20 +2491,29 @@ export class ControlPlaneStreamServer {
2345
2491
  lastErrorAt: null,
2346
2492
  });
2347
2493
  } catch (error: unknown) {
2494
+ if (this.shouldIgnoreGitHubPollError(error)) {
2495
+ return;
2496
+ }
2348
2497
  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
- });
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
+ }
2362
2517
  }
2363
2518
  }
2364
2519
 
@@ -2648,6 +2803,27 @@ export class ControlPlaneStreamServer {
2648
2803
  );
2649
2804
  }
2650
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
+
2651
2827
  private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
2652
2828
  handleRuntimeInput(
2653
2829
  this as unknown as Parameters<typeof handleRuntimeInput>[0],
@@ -2749,6 +2925,7 @@ export class ControlPlaneStreamServer {
2749
2925
  if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
2750
2926
  return;
2751
2927
  }
2928
+ this.recordThreadPrompt(sessionId, prompt);
2752
2929
  this.publishObservedEvent(scope, {
2753
2930
  type: 'session-prompt-event',
2754
2931
  sessionId,
@@ -2774,6 +2951,190 @@ export class ControlPlaneStreamServer {
2774
2951
  this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
2775
2952
  }
2776
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
+
2777
3138
  private setSessionStatus(
2778
3139
  state: SessionState,
2779
3140
  status: StreamSessionRuntimeStatus,
@@ -3176,6 +3537,8 @@ export class ControlPlaneStreamServer {
3176
3537
 
3177
3538
  this.sessions.delete(sessionId);
3178
3539
  this.launchCommandBySessionId.delete(sessionId);
3540
+ this.threadTitleRevisionBySessionId.delete(sessionId);
3541
+ this.threadTitleRefreshBySessionId.delete(sessionId);
3179
3542
  for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
3180
3543
  if (mappedSessionId === sessionId) {
3181
3544
  this.telemetryTokenToSessionId.delete(token);
@@ -9,6 +9,7 @@ type MuxGlobalShortcutAction =
9
9
  | 'mux.conversation.critique.open-or-create'
10
10
  | 'mux.conversation.next'
11
11
  | 'mux.conversation.previous'
12
+ | 'mux.conversation.titles.refresh-all'
12
13
  | 'mux.conversation.interrupt'
13
14
  | 'mux.conversation.archive'
14
15
  | 'mux.conversation.takeover'
@@ -47,6 +48,7 @@ const ACTION_ORDER: readonly MuxGlobalShortcutAction[] = [
47
48
  'mux.conversation.critique.open-or-create',
48
49
  'mux.conversation.next',
49
50
  'mux.conversation.previous',
51
+ 'mux.conversation.titles.refresh-all',
50
52
  'mux.conversation.interrupt',
51
53
  'mux.conversation.archive',
52
54
  'mux.conversation.takeover',
@@ -68,6 +70,7 @@ const DEFAULT_MUX_SHORTCUT_BINDINGS_RAW: Readonly<
68
70
  'mux.conversation.critique.open-or-create': ['ctrl+g'],
69
71
  'mux.conversation.next': ['ctrl+j'],
70
72
  'mux.conversation.previous': ['ctrl+k'],
73
+ 'mux.conversation.titles.refresh-all': ['ctrl+r'],
71
74
  'mux.conversation.interrupt': [],
72
75
  'mux.conversation.archive': [],
73
76
  'mux.conversation.takeover': ['ctrl+l'],
@@ -580,6 +583,9 @@ function withDefaultBindings(
580
583
  'mux.conversation.previous':
581
584
  overrides?.['mux.conversation.previous'] ??
582
585
  DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.previous'],
586
+ 'mux.conversation.titles.refresh-all':
587
+ overrides?.['mux.conversation.titles.refresh-all'] ??
588
+ DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.titles.refresh-all'],
583
589
  'mux.conversation.interrupt':
584
590
  overrides?.['mux.conversation.interrupt'] ??
585
591
  DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.interrupt'],
@@ -625,6 +631,9 @@ export function resolveMuxShortcutBindings(
625
631
  ),
626
632
  'mux.conversation.next': parseBindingsForAction(rawByAction['mux.conversation.next']),
627
633
  'mux.conversation.previous': parseBindingsForAction(rawByAction['mux.conversation.previous']),
634
+ 'mux.conversation.titles.refresh-all': parseBindingsForAction(
635
+ rawByAction['mux.conversation.titles.refresh-all'],
636
+ ),
628
637
  'mux.conversation.interrupt': parseBindingsForAction(
629
638
  rawByAction['mux.conversation.interrupt'],
630
639
  ),
@@ -98,6 +98,30 @@ export function shouldShowGitHubPrActions(input: {
98
98
  return normalizedTrackedBranch !== 'main';
99
99
  }
100
100
 
101
+ function normalizeTrackedBranchForActions(value: string | null): string | null {
102
+ const trimmed = value?.trim() ?? '';
103
+ if (
104
+ trimmed.length === 0 ||
105
+ trimmed === '(detached)' ||
106
+ trimmed === '(loading)' ||
107
+ trimmed === 'HEAD'
108
+ ) {
109
+ return null;
110
+ }
111
+ return trimmed;
112
+ }
113
+
114
+ export function resolveGitHubTrackedBranchForActions(input: {
115
+ projectTrackedBranch: string | null;
116
+ currentBranch: string | null;
117
+ }): string | null {
118
+ const trackedBranch = normalizeTrackedBranchForActions(input.projectTrackedBranch);
119
+ if (trackedBranch !== null) {
120
+ return trackedBranch;
121
+ }
122
+ return normalizeTrackedBranchForActions(input.currentBranch);
123
+ }
124
+
101
125
  export function parseCommitCount(output: string): number | null {
102
126
  const trimmed = output.trim();
103
127
  if (trimmed.length === 0 || !/^\d+$/u.test(trimmed)) {
@@ -14,6 +14,7 @@ interface HandleGlobalShortcutOptions {
14
14
  conversationsHas: (sessionId: string) => boolean;
15
15
  queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
16
16
  archiveConversation: (sessionId: string) => Promise<void>;
17
+ refreshAllConversationTitles: () => Promise<void>;
17
18
  interruptConversation: (sessionId: string) => Promise<void>;
18
19
  takeoverConversation: (sessionId: string) => Promise<void>;
19
20
  openAddDirectoryPrompt: () => void;
@@ -37,6 +38,7 @@ export function handleGlobalShortcut(options: HandleGlobalShortcutOptions): bool
37
38
  conversationsHas,
38
39
  queueControlPlaneOp,
39
40
  archiveConversation,
41
+ refreshAllConversationTitles,
40
42
  interruptConversation,
41
43
  takeoverConversation,
42
44
  openAddDirectoryPrompt,
@@ -104,6 +106,12 @@ export function handleGlobalShortcut(options: HandleGlobalShortcutOptions): bool
104
106
  }
105
107
  return true;
106
108
  }
109
+ if (shortcut === 'mux.conversation.titles.refresh-all') {
110
+ queueControlPlaneOp(async () => {
111
+ await refreshAllConversationTitles();
112
+ }, 'shortcut-refresh-conversation-titles');
113
+ return true;
114
+ }
107
115
  if (shortcut === 'mux.conversation.interrupt') {
108
116
  const targetConversationId = resolveConversationForAction();
109
117
  if (targetConversationId !== null && conversationsHas(targetConversationId)) {