@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
@@ -7,6 +7,7 @@ import {
7
7
  unlinkSync,
8
8
  writeFileSync,
9
9
  } from 'node:fs';
10
+ import { homedir } from 'node:os';
10
11
  import { dirname, resolve } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
13
 
@@ -1435,14 +1436,24 @@ export function resolveHarnessConfigDirectory(
1435
1436
  env: NodeJS.ProcessEnv = process.env,
1436
1437
  ): string {
1437
1438
  const xdgConfigHome = readNonEmptyEnvPath(env.XDG_CONFIG_HOME);
1439
+ const homeDirectory = readNonEmptyEnvPath(env.HOME) ?? readNonEmptyEnvPath(homedir());
1440
+ return resolveHarnessConfigDirectoryFromRoots(cwd, xdgConfigHome, homeDirectory);
1441
+ }
1442
+
1443
+ export function resolveHarnessConfigDirectoryFromRoots(
1444
+ cwd: string,
1445
+ xdgConfigHome: string | null,
1446
+ homeDirectory: string | null,
1447
+ ): string {
1438
1448
  if (xdgConfigHome !== null) {
1439
1449
  return resolve(xdgConfigHome, HARNESS_CONFIG_XDG_DIRECTORY_NAME);
1440
1450
  }
1441
- const homeDirectory = readNonEmptyEnvPath(env.HOME);
1442
1451
  if (homeDirectory !== null) {
1443
1452
  return resolve(homeDirectory, HARNESS_CONFIG_HOME_DIRECTORY_NAME);
1444
1453
  }
1445
- return resolve(cwd, HARNESS_CONFIG_HOME_DIRECTORY_NAME);
1454
+ throw new Error(
1455
+ `unable to resolve harness config directory: HOME and XDG_CONFIG_HOME are unset (cwd=${cwd})`,
1456
+ );
1446
1457
  }
1447
1458
 
1448
1459
  export function resolveHarnessConfigPath(
@@ -39,11 +39,7 @@ export function resolveHarnessWorkspaceDirectory(
39
39
  invocationDirectory: string,
40
40
  env: NodeJS.ProcessEnv = process.env,
41
41
  ): string {
42
- const legacyWorkspaceDirectory = resolveLegacyHarnessDirectory(invocationDirectory);
43
42
  const configDirectory = resolveHarnessConfigDirectory(invocationDirectory, env);
44
- if (resolve(configDirectory) === legacyWorkspaceDirectory) {
45
- return legacyWorkspaceDirectory;
46
- }
47
43
  return resolve(
48
44
  configDirectory,
49
45
  HARNESS_WORKSPACES_DIRECTORY,
@@ -74,13 +70,14 @@ export function resolveHarnessRuntimePath(
74
70
  pathValue: string,
75
71
  env: NodeJS.ProcessEnv = process.env,
76
72
  ): string {
73
+ const workspaceRuntimeDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
77
74
  const normalizedPath = pathValue.trim();
78
75
  if (normalizedPath.length === 0 || normalizedPath === HARNESS_LEGACY_RELATIVE_ROOT) {
79
- return resolveHarnessWorkspaceDirectory(invocationDirectory, env);
76
+ return workspaceRuntimeDirectory;
80
77
  }
81
78
  if (normalizedPath.startsWith(`${HARNESS_LEGACY_RELATIVE_ROOT}/`)) {
82
79
  return resolve(
83
- resolveHarnessWorkspaceDirectory(invocationDirectory, env),
80
+ workspaceRuntimeDirectory,
84
81
  normalizedPath.slice(`${HARNESS_LEGACY_RELATIVE_ROOT}/`.length),
85
82
  );
86
83
  }
@@ -88,5 +85,5 @@ export function resolveHarnessRuntimePath(
88
85
  if (expandedHomePath !== null) {
89
86
  return expandedHomePath;
90
87
  }
91
- return resolve(invocationDirectory, normalizedPath);
88
+ return resolve(workspaceRuntimeDirectory, normalizedPath);
92
89
  }
@@ -1,6 +1,20 @@
1
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1
+ import {
2
+ copyFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
2
11
  import { dirname, resolve } from 'node:path';
3
- import { HARNESS_CONFIG_FILE_NAME, resolveHarnessConfigDirectory } from './config-core.ts';
12
+ import {
13
+ DEFAULT_HARNESS_CONFIG,
14
+ HARNESS_CONFIG_FILE_NAME,
15
+ parseHarnessConfigText,
16
+ resolveHarnessConfigDirectory,
17
+ } from './config-core.ts';
4
18
  import {
5
19
  resolveHarnessWorkspaceDirectory,
6
20
  resolveLegacyHarnessDirectory,
@@ -8,6 +22,7 @@ import {
8
22
 
9
23
  const LEGACY_SECRETS_FILE_NAME = 'secrets.env';
10
24
  const MIGRATION_MARKER_FILE_NAME = '.legacy-layout-migration-v1';
25
+ const MIGRATION_CONFIG_BACKUP_FILE_NAME = `${HARNESS_CONFIG_FILE_NAME}.pre-migration.bak`;
11
26
  const LEGACY_RUNTIME_EXCLUDE_NAMES = new Set([
12
27
  HARNESS_CONFIG_FILE_NAME,
13
28
  LEGACY_SECRETS_FILE_NAME,
@@ -18,9 +33,18 @@ interface HarnessLegacyLayoutMigrationResult {
18
33
  readonly migrated: boolean;
19
34
  readonly migratedEntries: number;
20
35
  readonly configCopied: boolean;
36
+ readonly configReplacedExisting: boolean;
37
+ readonly configBackupPath: string | null;
21
38
  readonly secretsCopied: boolean;
22
39
  readonly skipped: boolean;
23
40
  readonly markerPath: string;
41
+ readonly legacyRootRemoved: boolean;
42
+ }
43
+
44
+ interface ConfigCopyResult {
45
+ readonly copied: boolean;
46
+ readonly replacedExisting: boolean;
47
+ readonly backupPath: string | null;
24
48
  }
25
49
 
26
50
  function copyFileIfMissing(sourcePath: string, targetPath: string): boolean {
@@ -46,11 +70,96 @@ function copyEntryIfMissing(sourcePath: string, targetPath: string): boolean {
46
70
  return !targetExisted;
47
71
  }
48
72
 
73
+ function configEqualsDefaultConfig(text: string): boolean {
74
+ try {
75
+ const parsed = parseHarnessConfigText(text);
76
+ return JSON.stringify(parsed) === JSON.stringify(DEFAULT_HARNESS_CONFIG);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function copyConfigIfGlobalUninitialized(sourcePath: string, targetPath: string): ConfigCopyResult {
83
+ if (!existsSync(sourcePath)) {
84
+ return {
85
+ copied: false,
86
+ replacedExisting: false,
87
+ backupPath: null,
88
+ };
89
+ }
90
+ if (!existsSync(targetPath)) {
91
+ mkdirSync(dirname(targetPath), { recursive: true });
92
+ copyFileSync(sourcePath, targetPath);
93
+ return {
94
+ copied: true,
95
+ replacedExisting: false,
96
+ backupPath: null,
97
+ };
98
+ }
99
+
100
+ const targetText = readFileSync(targetPath, 'utf8');
101
+ const targetUninitialized =
102
+ targetText.trim().length === 0 || configEqualsDefaultConfig(targetText);
103
+ if (!targetUninitialized) {
104
+ return {
105
+ copied: false,
106
+ replacedExisting: false,
107
+ backupPath: null,
108
+ };
109
+ }
110
+
111
+ const backupPath = resolve(dirname(targetPath), MIGRATION_CONFIG_BACKUP_FILE_NAME);
112
+ if (!existsSync(backupPath)) {
113
+ copyFileSync(targetPath, backupPath);
114
+ }
115
+ copyFileSync(sourcePath, targetPath);
116
+ return {
117
+ copied: true,
118
+ replacedExisting: true,
119
+ backupPath,
120
+ };
121
+ }
122
+
49
123
  function writeMigrationMarker(markerPath: string): void {
50
124
  mkdirSync(dirname(markerPath), { recursive: true });
51
125
  writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
52
126
  }
53
127
 
128
+ function removeLegacyRootIfSafe(
129
+ legacyRoot: string,
130
+ configDirectory: string,
131
+ workspaceDirectory: string,
132
+ ): boolean {
133
+ if (!existsSync(legacyRoot) || resolve(configDirectory) === legacyRoot) {
134
+ return false;
135
+ }
136
+
137
+ const legacyEntries = readdirSync(legacyRoot, { withFileTypes: true }).map((entry) => entry.name);
138
+ for (const entryName of legacyEntries) {
139
+ if (entryName === HARNESS_CONFIG_FILE_NAME) {
140
+ if (!existsSync(resolve(configDirectory, HARNESS_CONFIG_FILE_NAME))) {
141
+ return false;
142
+ }
143
+ continue;
144
+ }
145
+ if (entryName === LEGACY_SECRETS_FILE_NAME) {
146
+ if (!existsSync(resolve(configDirectory, LEGACY_SECRETS_FILE_NAME))) {
147
+ return false;
148
+ }
149
+ continue;
150
+ }
151
+ if (entryName === 'workspaces') {
152
+ continue;
153
+ }
154
+ if (!existsSync(resolve(workspaceDirectory, entryName))) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ rmSync(legacyRoot, { recursive: true, force: true });
160
+ return !existsSync(legacyRoot);
161
+ }
162
+
54
163
  export function migrateLegacyHarnessLayout(
55
164
  invocationDirectory: string,
56
165
  env: NodeJS.ProcessEnv = process.env,
@@ -60,7 +169,7 @@ export function migrateLegacyHarnessLayout(
60
169
  const workspaceDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
61
170
  const markerPath = resolve(workspaceDirectory, MIGRATION_MARKER_FILE_NAME);
62
171
 
63
- const configCopied = copyFileIfMissing(
172
+ const configCopy = copyConfigIfGlobalUninitialized(
64
173
  resolve(legacyRoot, HARNESS_CONFIG_FILE_NAME),
65
174
  resolve(configDirectory, HARNESS_CONFIG_FILE_NAME),
66
175
  );
@@ -68,38 +177,50 @@ export function migrateLegacyHarnessLayout(
68
177
  resolve(legacyRoot, LEGACY_SECRETS_FILE_NAME),
69
178
  resolve(configDirectory, LEGACY_SECRETS_FILE_NAME),
70
179
  );
180
+ const withCleanupResult = (
181
+ result: Omit<HarnessLegacyLayoutMigrationResult, 'legacyRootRemoved'>,
182
+ ): HarnessLegacyLayoutMigrationResult => ({
183
+ ...result,
184
+ legacyRootRemoved: removeLegacyRootIfSafe(legacyRoot, configDirectory, workspaceDirectory),
185
+ });
71
186
 
72
187
  if (resolve(configDirectory) === legacyRoot) {
73
- return {
74
- migrated: configCopied || secretsCopied,
188
+ return withCleanupResult({
189
+ migrated: configCopy.copied || secretsCopied,
75
190
  migratedEntries: 0,
76
- configCopied,
191
+ configCopied: configCopy.copied,
192
+ configReplacedExisting: configCopy.replacedExisting,
193
+ configBackupPath: configCopy.backupPath,
77
194
  secretsCopied,
78
195
  skipped: true,
79
196
  markerPath,
80
- };
197
+ });
81
198
  }
82
199
 
83
200
  if (!existsSync(legacyRoot)) {
84
- return {
85
- migrated: configCopied || secretsCopied,
201
+ return withCleanupResult({
202
+ migrated: configCopy.copied || secretsCopied,
86
203
  migratedEntries: 0,
87
- configCopied,
204
+ configCopied: configCopy.copied,
205
+ configReplacedExisting: configCopy.replacedExisting,
206
+ configBackupPath: configCopy.backupPath,
88
207
  secretsCopied,
89
208
  skipped: true,
90
209
  markerPath,
91
- };
210
+ });
92
211
  }
93
212
 
94
213
  if (existsSync(markerPath)) {
95
- return {
96
- migrated: configCopied || secretsCopied,
214
+ return withCleanupResult({
215
+ migrated: configCopy.copied || secretsCopied,
97
216
  migratedEntries: 0,
98
- configCopied,
217
+ configCopied: configCopy.copied,
218
+ configReplacedExisting: configCopy.replacedExisting,
219
+ configBackupPath: configCopy.backupPath,
99
220
  secretsCopied,
100
221
  skipped: true,
101
222
  markerPath,
102
- };
223
+ });
103
224
  }
104
225
 
105
226
  const legacyEntries = readdirSync(legacyRoot, { withFileTypes: true })
@@ -119,12 +240,14 @@ export function migrateLegacyHarnessLayout(
119
240
  writeMigrationMarker(markerPath);
120
241
  }
121
242
 
122
- return {
123
- migrated: configCopied || secretsCopied || migratedEntries > 0,
243
+ return withCleanupResult({
244
+ migrated: configCopy.copied || secretsCopied || migratedEntries > 0,
124
245
  migratedEntries,
125
- configCopied,
246
+ configCopied: configCopy.copied,
247
+ configReplacedExisting: configCopy.replacedExisting,
248
+ configBackupPath: configCopy.backupPath,
126
249
  secretsCopied,
127
250
  skipped: false,
128
251
  markerPath,
129
- };
252
+ });
130
253
  }
@@ -1,9 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
3
  import { resolveHarnessConfigDirectory } from './config-core.ts';
4
4
 
5
- export const HARNESS_SECRETS_FILE_PATH = '.harness/secrets.env';
6
-
7
5
  interface HarnessSecretEntry {
8
6
  readonly key: string;
9
7
  readonly value: string;
@@ -23,6 +21,20 @@ interface LoadedHarnessSecrets {
23
21
  readonly skippedKeys: readonly string[];
24
22
  }
25
23
 
24
+ interface UpsertHarnessSecretOptions {
25
+ readonly key: string;
26
+ readonly value: string;
27
+ readonly cwd?: string;
28
+ readonly filePath?: string;
29
+ readonly env?: NodeJS.ProcessEnv;
30
+ }
31
+
32
+ interface UpsertHarnessSecretResult {
33
+ readonly filePath: string;
34
+ readonly createdFile: boolean;
35
+ readonly replacedExisting: boolean;
36
+ }
37
+
26
38
  function isValidSecretKey(value: string): boolean {
27
39
  return /^[A-Za-z_][A-Za-z0-9_]*$/u.test(value);
28
40
  }
@@ -107,6 +119,38 @@ function parseLineValue(rawValue: string, lineNumber: number): string {
107
119
  return rawValue.replace(/\s+#.*$/u, '').trim();
108
120
  }
109
121
 
122
+ function parseSecretLineKey(line: string): string | null {
123
+ const trimmed = line.trim();
124
+ if (trimmed.length === 0 || trimmed.startsWith('#')) {
125
+ return null;
126
+ }
127
+ const withoutExport = trimmed.startsWith('export ')
128
+ ? trimmed.slice('export '.length).trimStart()
129
+ : trimmed;
130
+ const equalIndex = withoutExport.indexOf('=');
131
+ if (equalIndex <= 0) {
132
+ return null;
133
+ }
134
+ const key = withoutExport.slice(0, equalIndex).trim();
135
+ return isValidSecretKey(key) ? key : null;
136
+ }
137
+
138
+ function encodeSecretValue(value: string): string {
139
+ if (value.length === 0) {
140
+ return '""';
141
+ }
142
+ if (/^[A-Za-z0-9._:@/+,-]+$/u.test(value)) {
143
+ return value;
144
+ }
145
+ const escaped = value
146
+ .replaceAll('\\', '\\\\')
147
+ .replaceAll('"', '\\"')
148
+ .replaceAll('\n', '\\n')
149
+ .replaceAll('\r', '\\r')
150
+ .replaceAll('\t', '\\t');
151
+ return `"${escaped}"`;
152
+ }
153
+
110
154
  function parseHarnessSecretLine(line: string, lineNumber: number): HarnessSecretEntry | null {
111
155
  const trimmed = line.trim();
112
156
  if (trimmed.length === 0 || trimmed.startsWith('#')) {
@@ -186,3 +230,47 @@ export function loadHarnessSecrets(options: LoadHarnessSecretsOptions = {}): Loa
186
230
  skippedKeys,
187
231
  };
188
232
  }
233
+
234
+ export function upsertHarnessSecret(
235
+ options: UpsertHarnessSecretOptions,
236
+ ): UpsertHarnessSecretResult {
237
+ const key = options.key.trim();
238
+ if (!isValidSecretKey(key)) {
239
+ throw new Error(`invalid secret key: ${options.key}`);
240
+ }
241
+ const cwd = options.cwd ?? process.cwd();
242
+ const env = options.env ?? process.env;
243
+ const filePath = resolveHarnessSecretsPath(cwd, options.filePath, env);
244
+ const hadFile = existsSync(filePath);
245
+ const existingText = hadFile ? readFileSync(filePath, 'utf8') : '';
246
+ const sourceLines = existingText.split(/\r?\n/u);
247
+ if (sourceLines[sourceLines.length - 1] === '') {
248
+ sourceLines.pop();
249
+ }
250
+ const nextLines: string[] = [];
251
+ const encoded = encodeSecretValue(options.value);
252
+ let replacedExisting = false;
253
+ for (const line of sourceLines) {
254
+ const lineKey = parseSecretLineKey(line);
255
+ if (lineKey !== key) {
256
+ nextLines.push(line);
257
+ continue;
258
+ }
259
+ if (!replacedExisting) {
260
+ nextLines.push(`${key}=${encoded}`);
261
+ replacedExisting = true;
262
+ }
263
+ }
264
+ if (!replacedExisting) {
265
+ nextLines.push(`${key}=${encoded}`);
266
+ }
267
+ mkdirSync(dirname(filePath), { recursive: true });
268
+ const tempPath = `${filePath}.tmp.${String(process.pid)}`;
269
+ writeFileSync(tempPath, `${nextLines.join('\n')}\n`, 'utf8');
270
+ renameSync(tempPath, filePath);
271
+ return {
272
+ filePath,
273
+ createdFile: !hadFile,
274
+ replacedExisting,
275
+ };
276
+ }
@@ -35,7 +35,8 @@ const FALLBACK_STOP_WORDS = new Set([
35
35
  'up',
36
36
  'with',
37
37
  ]);
38
- const DEFAULT_HAIKU_MODEL_ID = 'claude-3-5-haiku-latest';
38
+ const DEFAULT_HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
39
+ const FALLBACK_HAIKU_MODEL_IDS = ['claude-3-haiku-20240307'] as const;
39
40
 
40
41
  interface ThreadTitlePromptHistoryEntry {
41
42
  readonly text: string;
@@ -61,6 +62,22 @@ interface AnthropicThreadTitleNamerOptions {
61
62
  readonly fetch?: typeof fetch;
62
63
  }
63
64
 
65
+ function resolveModelCandidateIds(modelId: string | undefined): readonly string[] {
66
+ const ordered = [modelId, DEFAULT_HAIKU_MODEL_ID, ...FALLBACK_HAIKU_MODEL_IDS];
67
+ const deduped: string[] = [];
68
+ for (const candidate of ordered) {
69
+ if (typeof candidate !== 'string') {
70
+ continue;
71
+ }
72
+ const trimmed = candidate.trim();
73
+ if (trimmed.length === 0 || deduped.includes(trimmed)) {
74
+ continue;
75
+ }
76
+ deduped.push(trimmed);
77
+ }
78
+ return deduped;
79
+ }
80
+
64
81
  function asRecord(value: unknown): Record<string, unknown> | null {
65
82
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
66
83
  return null;
@@ -255,7 +272,7 @@ export function createAnthropicThreadTitleNamer(
255
272
  ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
256
273
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
257
274
  });
258
- const model = anthropic(options.modelId ?? DEFAULT_HAIKU_MODEL_ID);
275
+ const modelCandidateIds = resolveModelCandidateIds(options.modelId);
259
276
  return {
260
277
  async suggest(input: ThreadTitleNamerInput): Promise<string | null> {
261
278
  if (input.promptHistory.length === 0) {
@@ -264,27 +281,36 @@ export function createAnthropicThreadTitleNamer(
264
281
  const promptLines = input.promptHistory.map(
265
282
  (entry, index) => `${String(index + 1)}. ${entry.text}`,
266
283
  );
267
- const response = await generateText({
268
- model,
269
- system: [
270
- 'You name active coding-agent threads.',
271
- 'Use the full user prompt history to keep titles relevant and fresh.',
272
- 'Stay high-level and avoid low-level implementation details.',
273
- 'Return exactly 2 words in lowercase with no punctuation and no extra text.',
274
- ].join(' '),
275
- prompt: [
276
- `Agent: ${input.agentType}`,
277
- `Current title: ${input.currentTitle}`,
278
- `Conversation id: ${input.conversationId}`,
279
- 'Prompt history (oldest to newest):',
280
- ...promptLines,
281
- 'Return a new title now.',
282
- ].join('\n'),
283
- maxOutputTokens: 16,
284
- temperature: 0,
285
- });
286
- const normalized = normalizeThreadTitleCandidate(response.text);
287
- return normalized ?? fallbackThreadTitleFromPromptHistory(input.promptHistory);
284
+ for (const modelId of modelCandidateIds) {
285
+ const model = anthropic(modelId);
286
+ const response = await generateText({
287
+ model,
288
+ system: [
289
+ 'You name active coding-agent threads.',
290
+ 'Use the full user prompt history to keep titles relevant and fresh.',
291
+ 'Stay high-level and avoid low-level implementation details.',
292
+ 'Return exactly 2 words in lowercase with no punctuation and no extra text.',
293
+ ].join(' '),
294
+ prompt: [
295
+ `Agent: ${input.agentType}`,
296
+ `Current title: ${input.currentTitle}`,
297
+ `Conversation id: ${input.conversationId}`,
298
+ 'Prompt history (oldest to newest):',
299
+ ...promptLines,
300
+ 'Return a new title now.',
301
+ ].join('\n'),
302
+ maxOutputTokens: 16,
303
+ temperature: 0,
304
+ });
305
+ const normalized = normalizeThreadTitleCandidate(response.text);
306
+ if (normalized !== null) {
307
+ return normalized;
308
+ }
309
+ if (response.finishReason !== 'error') {
310
+ break;
311
+ }
312
+ }
313
+ return fallbackThreadTitleFromPromptHistory(input.promptHistory);
288
314
  },
289
315
  };
290
316
  }
@@ -13,6 +13,16 @@ const HISTORY_POLL_JITTER_RATIO = 0.35;
13
13
  const HISTORY_POLL_MAX_DELAY_MS = 60_000;
14
14
  const LINE_FEED_BYTE = '\n'.charCodeAt(0);
15
15
 
16
+ function isClosedDatabaseError(error: unknown): boolean {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ const normalized = message.trim().toLowerCase();
19
+ return (
20
+ normalized.includes('database has closed') ||
21
+ normalized.includes('database is closed') ||
22
+ normalized.includes('cannot use a closed database')
23
+ );
24
+ }
25
+
16
26
  interface GitStatusSummary {
17
27
  branch: string | null;
18
28
  changedFiles: number;
@@ -215,7 +225,10 @@ export async function pollHistoryFile(ctx: BackgroundContext): Promise<void> {
215
225
  );
216
226
  ctx.historyNextAllowedPollAtMs = Date.now() + jitterDelayMs(backoffMs);
217
227
  }
218
- } catch {
228
+ } catch (error: unknown) {
229
+ if (isClosedDatabaseError(error)) {
230
+ throw error;
231
+ }
219
232
  ctx.historyIdleStreak = Math.min(ctx.historyIdleStreak + 1, 4);
220
233
  const backoffMs = Math.min(
221
234
  HISTORY_POLL_MAX_DELAY_MS,
@@ -467,7 +480,10 @@ export async function refreshGitStatusForDirectory(
467
480
  forcePublished: options.forcePublish ? 1 : 0,
468
481
  repositoryLinked: repositoryId === null ? 0 : 1,
469
482
  });
470
- } catch {
483
+ } catch (error: unknown) {
484
+ if (isClosedDatabaseError(error)) {
485
+ throw error;
486
+ }
471
487
  if (previous !== null) {
472
488
  ctx.gitStatusByDirectoryId.set(directory.directoryId, {
473
489
  ...previous,