@jmoyers/harness 0.1.10 → 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 (32) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/scripts/codex-live-mux-runtime.ts +162 -11
  4. package/scripts/control-plane-daemon.ts +13 -2
  5. package/scripts/harness.ts +16 -4
  6. package/src/cli/default-gateway-pointer.ts +193 -0
  7. package/src/config/config-core.ts +13 -2
  8. package/src/config/harness-paths.ts +4 -7
  9. package/src/config/harness-runtime-migration.ts +142 -19
  10. package/src/config/secrets-core.ts +92 -4
  11. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  12. package/src/control-plane/stream-server-background.ts +18 -2
  13. package/src/control-plane/stream-server.ts +79 -10
  14. package/src/domain/conversations.ts +11 -7
  15. package/src/domain/workspace.ts +9 -0
  16. package/src/mux/input-shortcuts.ts +29 -1
  17. package/src/mux/live-mux/git-parsing.ts +16 -0
  18. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  19. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  20. package/src/mux/live-mux/modal-overlays.ts +45 -0
  21. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  22. package/src/mux/task-screen-keybindings.ts +29 -1
  23. package/src/services/runtime-conversation-activation.ts +25 -0
  24. package/src/services/runtime-conversation-starter.ts +31 -7
  25. package/src/services/runtime-input-router.ts +6 -0
  26. package/src/services/runtime-modal-input.ts +18 -0
  27. package/src/services/runtime-rail-input.ts +1 -0
  28. package/src/services/runtime-repository-actions.ts +2 -0
  29. package/src/store/control-plane-store.ts +36 -0
  30. package/src/store/event-store.ts +36 -0
  31. package/src/ui/input.ts +31 -0
  32. package/src/ui/modals/manager.ts +26 -0
@@ -1381,7 +1381,18 @@ export class ControlPlaneStreamServer {
1381
1381
  }
1382
1382
 
1383
1383
  private pollHistoryTimerTick(): void {
1384
- 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
+ });
1385
1396
  }
1386
1397
 
1387
1398
  private stopHistoryPolling(): void {
@@ -1399,9 +1410,9 @@ export class ControlPlaneStreamServer {
1399
1410
  return;
1400
1411
  }
1401
1412
  this.reloadGitStatusDirectoriesFromStore();
1402
- void this.pollGitStatus();
1413
+ this.triggerGitStatusPoll();
1403
1414
  this.gitStatusPollTimer = setInterval(() => {
1404
- void this.pollGitStatus();
1415
+ this.triggerGitStatusPoll();
1405
1416
  }, this.gitStatusMonitor.pollMs);
1406
1417
  this.gitStatusPollTimer.unref();
1407
1418
  }
@@ -1494,6 +1505,9 @@ export class ControlPlaneStreamServer {
1494
1505
 
1495
1506
  private triggerGitHubPoll(): void {
1496
1507
  void this.pollGitHub().catch((error: unknown) => {
1508
+ if (this.markStateStoreClosedIfDetected(error)) {
1509
+ return;
1510
+ }
1497
1511
  if (this.shouldIgnoreGitHubPollError(error)) {
1498
1512
  return;
1499
1513
  }
@@ -1504,10 +1518,40 @@ export class ControlPlaneStreamServer {
1504
1518
  });
1505
1519
  }
1506
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
+
1507
1536
  private isStateStoreClosedError(error: unknown): boolean {
1508
1537
  const message = error instanceof Error ? error.message : String(error);
1509
1538
  const normalized = message.trim().toLowerCase();
1510
- return normalized.includes('database has closed') || normalized.includes('database is closed');
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;
1511
1555
  }
1512
1556
 
1513
1557
  private shouldSkipStateStoreWork(): boolean {
@@ -1526,6 +1570,9 @@ export class ControlPlaneStreamServer {
1526
1570
  try {
1527
1571
  await pollPromise;
1528
1572
  } catch (error: unknown) {
1573
+ if (this.markStateStoreClosedIfDetected(error)) {
1574
+ return;
1575
+ }
1529
1576
  if (!this.shouldIgnoreGitHubPollError(error)) {
1530
1577
  const message = error instanceof Error ? error.message : String(error);
1531
1578
  recordPerfEvent('control-plane.github.poll.failed-on-close', {
@@ -2205,9 +2252,15 @@ export class ControlPlaneStreamServer {
2205
2252
  }
2206
2253
 
2207
2254
  private async pollHistoryFile(): Promise<void> {
2208
- await pollStreamServerHistoryFile(
2209
- this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
2210
- );
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
+ }
2211
2264
  }
2212
2265
 
2213
2266
  private async pollHistoryFileUnsafe(): Promise<boolean> {
@@ -2217,9 +2270,15 @@ export class ControlPlaneStreamServer {
2217
2270
  }
2218
2271
 
2219
2272
  private async pollGitStatus(): Promise<void> {
2220
- await pollStreamServerGitStatus(
2221
- this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
2222
- );
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
+ }
2223
2282
  }
2224
2283
 
2225
2284
  private async refreshGitStatusForDirectory(
@@ -2314,6 +2373,10 @@ export class ControlPlaneStreamServer {
2314
2373
  this.githubPollPromise = pollPromise;
2315
2374
  try {
2316
2375
  await pollPromise;
2376
+ } catch (error: unknown) {
2377
+ if (!this.markStateStoreClosedIfDetected(error)) {
2378
+ throw error;
2379
+ }
2317
2380
  } finally {
2318
2381
  if (this.githubPollPromise === pollPromise) {
2319
2382
  this.githubPollPromise = null;
@@ -2491,6 +2554,9 @@ export class ControlPlaneStreamServer {
2491
2554
  lastErrorAt: null,
2492
2555
  });
2493
2556
  } catch (error: unknown) {
2557
+ if (this.markStateStoreClosedIfDetected(error)) {
2558
+ return;
2559
+ }
2494
2560
  if (this.shouldIgnoreGitHubPollError(error)) {
2495
2561
  return;
2496
2562
  }
@@ -2510,6 +2576,9 @@ export class ControlPlaneStreamServer {
2510
2576
  lastErrorAt: now,
2511
2577
  });
2512
2578
  } catch (syncStateError: unknown) {
2579
+ if (this.markStateStoreClosedIfDetected(syncStateError)) {
2580
+ return;
2581
+ }
2513
2582
  if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
2514
2583
  throw syncStateError;
2515
2584
  }
@@ -290,6 +290,8 @@ export class ConversationManager {
290
290
 
291
291
  upsertFromPersistedRecord(input: UpsertPersistedConversationInput): ConversationState {
292
292
  const { record } = input;
293
+ const existing = this.conversationsBySessionId.get(record.conversationId);
294
+ const preserveLiveRuntime = existing?.live === true;
293
295
  const conversation = input.ensureConversation(record.conversationId, {
294
296
  directoryId: record.directoryId,
295
297
  title: record.title,
@@ -299,14 +301,16 @@ export class ConversationManager {
299
301
  conversation.scope.tenantId = record.tenantId;
300
302
  conversation.scope.userId = record.userId;
301
303
  conversation.scope.workspaceId = record.workspaceId;
302
- const runtimeStatusModel = record.runtimeStatusModel;
303
- conversation.status = record.runtimeStatus;
304
- conversation.statusModel = runtimeStatusModel;
305
- conversation.attentionReason = runtimeStatusModel?.attentionReason ?? null;
306
- conversation.lastKnownWork = runtimeStatusModel?.lastKnownWork ?? null;
307
- conversation.lastKnownWorkAt = runtimeStatusModel?.lastKnownWorkAt ?? null;
304
+ if (!preserveLiveRuntime) {
305
+ const runtimeStatusModel = record.runtimeStatusModel;
306
+ conversation.status = record.runtimeStatus;
307
+ conversation.statusModel = runtimeStatusModel;
308
+ conversation.attentionReason = runtimeStatusModel?.attentionReason ?? null;
309
+ conversation.lastKnownWork = runtimeStatusModel?.lastKnownWork ?? null;
310
+ conversation.lastKnownWorkAt = runtimeStatusModel?.lastKnownWorkAt ?? null;
311
+ }
308
312
  // Persisted runtime flags are advisory; session.list is authoritative for live sessions.
309
- conversation.live = false;
313
+ conversation.live = preserveLiveRuntime ? true : false;
310
314
  return conversation;
311
315
  }
312
316
 
@@ -25,6 +25,14 @@ export interface RepositoryPromptState {
25
25
  readonly error: string | null;
26
26
  }
27
27
 
28
+ export interface ApiKeyPromptState {
29
+ readonly keyName: string;
30
+ readonly displayName: string;
31
+ readonly value: string;
32
+ readonly error: string | null;
33
+ readonly hasExistingValue: boolean;
34
+ }
35
+
28
36
  export interface TaskEditorPromptState {
29
37
  mode: 'create' | 'edit';
30
38
  taskId: string | null;
@@ -80,6 +88,7 @@ export class WorkspaceModel {
80
88
  selectionDrag: PaneSelectionDrag | null = null;
81
89
  selectionPinnedFollowOutput: boolean | null = null;
82
90
  repositoryPrompt: RepositoryPromptState | null = null;
91
+ apiKeyPrompt: ApiKeyPromptState | null = null;
83
92
  commandMenu: CommandMenuState | null = null;
84
93
  newThreadPrompt: ReturnType<typeof createNewThreadPromptState> | null = null;
85
94
  addDirectoryPrompt: { value: string; error: string | null } | null = null;
@@ -541,10 +541,38 @@ function strokesEqual(left: KeyStroke, right: KeyStroke): boolean {
541
541
 
542
542
  function parseBindingsForAction(rawBindings: readonly string[]): readonly ParsedShortcutBinding[] {
543
543
  const parsed: ParsedShortcutBinding[] = [];
544
+
545
+ const pushIfUnique = (candidate: ParsedShortcutBinding): void => {
546
+ if (parsed.some((existing) => strokesEqual(existing.stroke, candidate.stroke))) {
547
+ return;
548
+ }
549
+ parsed.push(candidate);
550
+ };
551
+
552
+ const ctrlMetaAliasStroke = (stroke: KeyStroke): KeyStroke | null => {
553
+ if (stroke.ctrl === stroke.meta) {
554
+ return null;
555
+ }
556
+ return {
557
+ key: stroke.key,
558
+ ctrl: !stroke.ctrl,
559
+ alt: stroke.alt,
560
+ shift: stroke.shift,
561
+ meta: !stroke.meta,
562
+ };
563
+ };
564
+
544
565
  for (const raw of rawBindings) {
545
566
  const normalized = parseShortcutBinding(raw);
546
567
  if (normalized !== null) {
547
- parsed.push(normalized);
568
+ pushIfUnique(normalized);
569
+ const aliasStroke = ctrlMetaAliasStroke(normalized.stroke);
570
+ if (aliasStroke !== null) {
571
+ pushIfUnique({
572
+ stroke: aliasStroke,
573
+ originalText: normalized.originalText,
574
+ });
575
+ }
548
576
  }
549
577
  }
550
578
  return parsed;
@@ -82,6 +82,22 @@ export function repositoryNameFromGitHubRemoteUrl(remoteUrl: string): string {
82
82
  return name;
83
83
  }
84
84
 
85
+ function normalizeDefaultBranchForActions(value: string | null): string | null {
86
+ const trimmed = value?.trim() ?? '';
87
+ return trimmed.length > 0 ? trimmed : null;
88
+ }
89
+
90
+ export function resolveGitHubDefaultBranchForActions(input: {
91
+ repositoryDefaultBranch: string | null;
92
+ snapshotDefaultBranch: string | null;
93
+ }): string | null {
94
+ const repositoryDefaultBranch = normalizeDefaultBranchForActions(input.repositoryDefaultBranch);
95
+ if (repositoryDefaultBranch !== null) {
96
+ return repositoryDefaultBranch;
97
+ }
98
+ return normalizeDefaultBranchForActions(input.snapshotDefaultBranch);
99
+ }
100
+
85
101
  export function shouldShowGitHubPrActions(input: {
86
102
  trackedBranch: string | null;
87
103
  defaultBranch: string | null;
@@ -46,9 +46,12 @@ export function handleLeftRailConversationClick(
46
46
  options.selectedConversationId === options.activeConversationId
47
47
  ) {
48
48
  if (!options.isConversationPaneActive) {
49
- options.ensureConversationPaneActive(options.selectedConversationId);
50
- }
51
- if (conversationClick.doubleClick) {
49
+ if (conversationClick.doubleClick) {
50
+ options.queueActivateConversationAndEdit(options.selectedConversationId);
51
+ } else {
52
+ options.queueActivateConversation(options.selectedConversationId);
53
+ }
54
+ } else if (conversationClick.doubleClick) {
52
55
  options.beginConversationTitleEdit(options.selectedConversationId);
53
56
  }
54
57
  options.markDirty();
@@ -19,10 +19,43 @@ interface LinePromptReduction {
19
19
  submit: boolean;
20
20
  }
21
21
 
22
+ const BRACKETED_PASTE_START = Buffer.from('\u001b[200~', 'utf8');
23
+ const BRACKETED_PASTE_END = Buffer.from('\u001b[201~', 'utf8');
24
+
25
+ function matchesSequence(input: Buffer, startIndex: number, sequence: Buffer): boolean {
26
+ if (startIndex < 0 || startIndex + sequence.length > input.length) {
27
+ return false;
28
+ }
29
+ for (let index = 0; index < sequence.length; index += 1) {
30
+ if (input[startIndex + index] !== sequence[index]) {
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ }
36
+
22
37
  export function reduceLinePromptInput(value: string, input: Buffer): LinePromptReduction {
23
38
  let nextValue = value;
24
39
  let submit = false;
25
- for (const byte of input) {
40
+ let inBracketedPaste = false;
41
+ for (let index = 0; index < input.length; index += 1) {
42
+ if (!inBracketedPaste && matchesSequence(input, index, BRACKETED_PASTE_START)) {
43
+ inBracketedPaste = true;
44
+ index += BRACKETED_PASTE_START.length - 1;
45
+ continue;
46
+ }
47
+ if (inBracketedPaste && matchesSequence(input, index, BRACKETED_PASTE_END)) {
48
+ inBracketedPaste = false;
49
+ index += BRACKETED_PASTE_END.length - 1;
50
+ continue;
51
+ }
52
+ const byte = input[index]!;
53
+ if (inBracketedPaste) {
54
+ if (byte >= 32 && byte <= 126) {
55
+ nextValue += String.fromCharCode(byte);
56
+ }
57
+ continue;
58
+ }
26
59
  if (byte === 0x0d || byte === 0x0a) {
27
60
  submit = true;
28
61
  break;
@@ -36,6 +36,14 @@ interface RepositoryPromptOverlayState {
36
36
  readonly error: string | null;
37
37
  }
38
38
 
39
+ interface ApiKeyPromptOverlayState {
40
+ readonly keyName: string;
41
+ readonly displayName: string;
42
+ readonly value: string;
43
+ readonly error: string | null;
44
+ readonly hasExistingValue: boolean;
45
+ }
46
+
39
47
  interface ConversationTitleOverlayState {
40
48
  value: string;
41
49
  lastSavedValue: string;
@@ -252,6 +260,43 @@ export function buildRepositoryModalOverlay(
252
260
  });
253
261
  }
254
262
 
263
+ export function buildApiKeyModalOverlay(
264
+ layoutCols: number,
265
+ viewportRows: number,
266
+ prompt: ApiKeyPromptOverlayState | null,
267
+ theme: UiModalThemeInput,
268
+ ): ReturnType<typeof buildUiModalOverlay> | null {
269
+ if (prompt === null) {
270
+ return null;
271
+ }
272
+ const modalSize = resolveGoldenModalSize(layoutCols, viewportRows, {
273
+ preferredHeight: 16,
274
+ minWidth: 34,
275
+ maxWidth: 64,
276
+ });
277
+ const promptValue = prompt.value.length > 0 ? prompt.value : '(enter value)';
278
+ const bodyLines = [`${prompt.keyName}: ${promptValue}_`];
279
+ if (prompt.error !== null && prompt.error.length > 0) {
280
+ bodyLines.push(`error: ${prompt.error}`);
281
+ } else if (prompt.hasExistingValue) {
282
+ bodyLines.push('warning: existing value detected (submit will overwrite)');
283
+ } else {
284
+ bodyLines.push('value is saved to user-global secrets.env');
285
+ }
286
+ return buildUiModalOverlay({
287
+ viewportCols: layoutCols,
288
+ viewportRows,
289
+ width: modalSize.width,
290
+ height: modalSize.height,
291
+ anchor: 'center',
292
+ marginRows: 1,
293
+ title: `Set ${prompt.displayName}`,
294
+ bodyLines,
295
+ footer: 'enter save esc',
296
+ theme,
297
+ });
298
+ }
299
+
255
300
  export function buildConversationTitleModalOverlay(
256
301
  layoutCols: number,
257
302
  viewportRows: number,
@@ -12,6 +12,14 @@ interface RepositoryPromptState {
12
12
  readonly error: string | null;
13
13
  }
14
14
 
15
+ interface ApiKeyPromptState {
16
+ readonly keyName: string;
17
+ readonly displayName: string;
18
+ readonly value: string;
19
+ readonly error: string | null;
20
+ readonly hasExistingValue: boolean;
21
+ }
22
+
15
23
  interface HandleAddDirectoryPromptInputOptions {
16
24
  input: Buffer;
17
25
  prompt: AddDirectoryPromptState | null;
@@ -36,6 +44,16 @@ interface HandleRepositoryPromptInputOptions {
36
44
  upsertRepositoryByRemoteUrl: (remoteUrl: string, existingRepositoryId?: string) => Promise<void>;
37
45
  }
38
46
 
47
+ interface HandleApiKeyPromptInputOptions {
48
+ input: Buffer;
49
+ prompt: ApiKeyPromptState | null;
50
+ isQuitShortcut: (input: Buffer) => boolean;
51
+ dismissOnOutsideClick: (input: Buffer, dismiss: () => void) => boolean;
52
+ setPrompt: (next: ApiKeyPromptState | null) => void;
53
+ markDirty: () => void;
54
+ persistApiKey: (keyName: string, value: string) => void;
55
+ }
56
+
39
57
  export function handleAddDirectoryPromptInput(
40
58
  options: HandleAddDirectoryPromptInputOptions,
41
59
  ): boolean {
@@ -185,3 +203,70 @@ export function handleRepositoryPromptInput(options: HandleRepositoryPromptInput
185
203
  markDirty();
186
204
  return true;
187
205
  }
206
+
207
+ export function handleApiKeyPromptInput(options: HandleApiKeyPromptInputOptions): boolean {
208
+ const {
209
+ input,
210
+ prompt,
211
+ isQuitShortcut,
212
+ dismissOnOutsideClick,
213
+ setPrompt,
214
+ markDirty,
215
+ persistApiKey,
216
+ } = options;
217
+ if (prompt === null) {
218
+ return false;
219
+ }
220
+ if (input.length === 1 && input[0] === 0x03) {
221
+ return false;
222
+ }
223
+ if (isQuitShortcut(input)) {
224
+ setPrompt(null);
225
+ markDirty();
226
+ return true;
227
+ }
228
+ if (
229
+ dismissOnOutsideClick(input, () => {
230
+ setPrompt(null);
231
+ markDirty();
232
+ })
233
+ ) {
234
+ return true;
235
+ }
236
+
237
+ const reduced = reduceLinePromptInput(prompt.value, input);
238
+ const value = reduced.value;
239
+ if (!reduced.submit) {
240
+ setPrompt({
241
+ ...prompt,
242
+ value,
243
+ error: null,
244
+ });
245
+ markDirty();
246
+ return true;
247
+ }
248
+
249
+ const trimmed = value.trim();
250
+ if (trimmed.length === 0) {
251
+ setPrompt({
252
+ ...prompt,
253
+ value,
254
+ error: `${prompt.displayName.toLowerCase()} required`,
255
+ });
256
+ markDirty();
257
+ return true;
258
+ }
259
+ try {
260
+ persistApiKey(prompt.keyName, trimmed);
261
+ setPrompt(null);
262
+ } catch (error: unknown) {
263
+ const message = error instanceof Error ? error.message : String(error);
264
+ setPrompt({
265
+ ...prompt,
266
+ value,
267
+ error: message,
268
+ });
269
+ }
270
+ markDirty();
271
+ return true;
272
+ }
@@ -437,10 +437,38 @@ function parseBinding(input: string): ParsedBinding | null {
437
437
 
438
438
  function bindingsForAction(raw: readonly string[]): readonly ParsedBinding[] {
439
439
  const parsed: ParsedBinding[] = [];
440
+
441
+ const pushIfUnique = (candidate: ParsedBinding): void => {
442
+ if (parsed.some((existing) => strokesEqual(existing.stroke, candidate.stroke))) {
443
+ return;
444
+ }
445
+ parsed.push(candidate);
446
+ };
447
+
448
+ const ctrlMetaAliasStroke = (stroke: KeyStroke): KeyStroke | null => {
449
+ if (stroke.ctrl === stroke.meta) {
450
+ return null;
451
+ }
452
+ return {
453
+ key: stroke.key,
454
+ ctrl: !stroke.ctrl,
455
+ alt: stroke.alt,
456
+ shift: stroke.shift,
457
+ meta: !stroke.meta,
458
+ };
459
+ };
460
+
440
461
  for (const value of raw) {
441
462
  const next = parseBinding(value);
442
463
  if (next !== null) {
443
- parsed.push(next);
464
+ pushIfUnique(next);
465
+ const aliasStroke = ctrlMetaAliasStroke(next.stroke);
466
+ if (aliasStroke !== null) {
467
+ pushIfUnique({
468
+ stroke: aliasStroke,
469
+ originalText: next.originalText,
470
+ });
471
+ }
444
472
  }
445
473
  }
446
474
  return parsed;
@@ -30,7 +30,32 @@ export class RuntimeConversationActivation {
30
30
  async activateConversation(sessionId: string): Promise<void> {
31
31
  if (this.options.getActiveConversationId() === sessionId) {
32
32
  if (!this.options.isConversationPaneMode()) {
33
+ const targetConversation = this.options.conversationById(sessionId);
33
34
  this.options.enterConversationPaneForActiveSession(sessionId);
35
+ this.options.noteGitActivity(targetConversation?.directoryId ?? null);
36
+ if (
37
+ targetConversation !== undefined &&
38
+ !targetConversation.live &&
39
+ targetConversation.status !== 'exited'
40
+ ) {
41
+ await this.options.startConversation(sessionId);
42
+ }
43
+ if (targetConversation?.status !== 'exited') {
44
+ try {
45
+ await this.options.attachConversation(sessionId);
46
+ } catch (error: unknown) {
47
+ if (
48
+ !this.options.isSessionNotFoundError(error) &&
49
+ !this.options.isSessionNotLiveError(error)
50
+ ) {
51
+ throw error;
52
+ }
53
+ this.options.markSessionUnavailable(sessionId);
54
+ await this.options.startConversation(sessionId);
55
+ await this.options.attachConversation(sessionId);
56
+ }
57
+ }
58
+ this.options.schedulePtyResizeImmediate();
34
59
  this.options.markDirty();
35
60
  }
36
61
  return;
@@ -33,6 +33,13 @@ interface RuntimeConversationStarterLaunchArgsInput {
33
33
 
34
34
  type RuntimeConversationStarterSpanAttributes = Record<string, string | number | boolean>;
35
35
 
36
+ function isSessionAlreadyExistsError(error: unknown): boolean {
37
+ if (!(error instanceof Error)) {
38
+ return false;
39
+ }
40
+ return error.message.includes('session already exists');
41
+ }
42
+
36
43
  export interface RuntimeConversationStarterOptions<
37
44
  TConversation extends RuntimeConversationStarterConversationRecord,
38
45
  TSessionSummary,
@@ -134,19 +141,36 @@ export class RuntimeConversationStarter<
134
141
  if (this.options.terminalBackgroundHex !== undefined) {
135
142
  ptyStartInput.terminalBackgroundHex = this.options.terminalBackgroundHex;
136
143
  }
137
- await this.options.startPtySession(ptyStartInput);
144
+ let startedSession = false;
145
+ try {
146
+ await this.options.startPtySession(ptyStartInput);
147
+ startedSession = true;
148
+ } catch (error: unknown) {
149
+ if (!isSessionAlreadyExistsError(error)) {
150
+ throw error;
151
+ }
152
+ }
138
153
  this.options.setPtySize(sessionId, {
139
154
  cols: layout.rightCols,
140
155
  rows: layout.paneRows,
141
156
  });
142
157
  this.options.sendResize(sessionId, layout.rightCols, layout.paneRows);
143
- this.endStartCommandSpanIfTarget(sessionId, {
144
- alreadyLive: false,
145
- argCount: launchArgs.length,
146
- resumed: launchArgs[0] === 'resume',
147
- });
158
+ if (startedSession) {
159
+ this.endStartCommandSpanIfTarget(sessionId, {
160
+ alreadyLive: false,
161
+ argCount: launchArgs.length,
162
+ resumed: launchArgs[0] === 'resume',
163
+ });
164
+ } else {
165
+ this.endStartCommandSpanIfTarget(sessionId, {
166
+ alreadyLive: true,
167
+ recoveredDuplicateStart: true,
168
+ });
169
+ }
148
170
  const state = this.options.ensureConversation(sessionId);
149
- this.options.recordStartCommand(sessionId, launchArgs);
171
+ if (startedSession) {
172
+ this.options.recordStartCommand(sessionId, launchArgs);
173
+ }
150
174
  const statusSummary = await this.options.getSessionStatus(sessionId);
151
175
  if (statusSummary !== null) {
152
176
  this.options.upsertFromSessionSummary(statusSummary);
@@ -41,6 +41,7 @@ interface RuntimeInputRouterOptions {
41
41
  readonly scheduleConversationTitlePersist: () => void;
42
42
  readonly resolveCommandMenuActions: () => readonly CommandMenuActionDescriptor[];
43
43
  readonly executeCommandMenuAction: (actionId: string) => void;
44
+ readonly persistApiKey?: RuntimeModalInputOptions['persistApiKey'];
44
45
  readonly requestStop: RuntimeRailInputOptions['requestStop'];
45
46
  readonly resolveDirectoryForAction: RuntimeRailInputOptions['resolveDirectoryForAction'];
46
47
  readonly toggleCommandMenu: RuntimeRailInputOptions['toggleCommandMenu'];
@@ -112,6 +113,11 @@ export class RuntimeInputRouter {
112
113
  scheduleConversationTitlePersist: options.scheduleConversationTitlePersist,
113
114
  resolveCommandMenuActions: options.resolveCommandMenuActions,
114
115
  executeCommandMenuAction: options.executeCommandMenuAction,
116
+ ...(options.persistApiKey === undefined
117
+ ? {}
118
+ : {
119
+ persistApiKey: options.persistApiKey,
120
+ }),
115
121
  });
116
122
  const runtimeRailOptions: RuntimeRailInputOptions = {
117
123
  workspace: options.workspace,
@@ -37,6 +37,7 @@ interface RuntimeModalInputOptions {
37
37
  readonly scheduleConversationTitlePersist: () => void;
38
38
  readonly resolveCommandMenuActions: () => readonly CommandMenuActionDescriptor[];
39
39
  readonly executeCommandMenuAction: (actionId: string) => void;
40
+ readonly persistApiKey?: (keyName: string, value: string) => void;
40
41
  readonly markDirty: () => void;
41
42
  }
42
43
 
@@ -97,6 +98,23 @@ export class RuntimeModalInput {
97
98
  submitTaskEditorPayload: (payload) => {
98
99
  options.taskEditorActions.submitTaskEditorPayload(payload);
99
100
  },
101
+ ...(options.persistApiKey === undefined
102
+ ? {}
103
+ : {
104
+ getApiKeyPrompt: () => options.workspace.apiKeyPrompt,
105
+ setApiKeyPrompt: (
106
+ next: {
107
+ keyName: string;
108
+ displayName: string;
109
+ value: string;
110
+ error: string | null;
111
+ hasExistingValue: boolean;
112
+ } | null,
113
+ ) => {
114
+ options.workspace.apiKeyPrompt = next;
115
+ },
116
+ persistApiKey: options.persistApiKey,
117
+ }),
100
118
  getConversationTitleEdit: () => options.workspace.conversationTitleEdit,
101
119
  getNewThreadPrompt: () => options.workspace.newThreadPrompt,
102
120
  setNewThreadPrompt: (prompt) => {