@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.
- package/README.md +33 -156
- package/package.json +3 -1
- package/packages/harness-ai/src/anthropic-client.ts +99 -0
- package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
- package/packages/harness-ai/src/anthropic-provider.ts +82 -0
- package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
- package/packages/harness-ai/src/index.ts +36 -0
- package/packages/harness-ai/src/json-parse.ts +66 -0
- package/packages/harness-ai/src/sse.ts +80 -0
- package/packages/harness-ai/src/stream-object.ts +96 -0
- package/packages/harness-ai/src/stream-text.ts +1340 -0
- package/packages/harness-ai/src/types.ts +330 -0
- package/packages/harness-ai/src/ui-stream.ts +217 -0
- package/scripts/codex-live-mux-runtime.ts +103 -3
- package/scripts/control-plane-daemon.ts +20 -3
- package/scripts/harness.ts +566 -133
- package/src/cli/gateway-record.ts +16 -1
- package/src/control-plane/prompt/thread-title-namer.ts +290 -0
- package/src/control-plane/stream-command-parser.ts +12 -0
- package/src/control-plane/stream-protocol.ts +6 -0
- package/src/control-plane/stream-server-command.ts +14 -0
- package/src/control-plane/stream-server.ts +382 -19
- package/src/mux/input-shortcuts.ts +9 -0
- package/src/mux/live-mux/git-parsing.ts +24 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
- package/src/mux/render-frame.ts +1 -1
- package/src/services/control-plane.ts +22 -0
- package/src/services/runtime-control-actions.ts +69 -0
- package/src/services/runtime-navigation-input.ts +4 -0
- package/src/services/runtime-rail-input.ts +4 -0
- package/src/services/runtime-workspace-actions.ts +5 -0
- 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
|
-
|
|
1480
|
+
this.triggerGitHubPoll();
|
|
1400
1481
|
this.githubPollTimer = setInterval(() => {
|
|
1401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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)) {
|