@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.
- package/README.md +6 -2
- package/package.json +1 -1
- package/scripts/codex-live-mux-runtime.ts +162 -11
- package/scripts/control-plane-daemon.ts +13 -2
- package/scripts/harness.ts +16 -4
- package/src/cli/default-gateway-pointer.ts +193 -0
- package/src/config/config-core.ts +13 -2
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/prompt/thread-title-namer.ts +49 -23
- package/src/control-plane/stream-server-background.ts +18 -2
- package/src/control-plane/stream-server.ts +79 -10
- package/src/domain/conversations.ts +11 -7
- package/src/domain/workspace.ts +9 -0
- package/src/mux/input-shortcuts.ts +29 -1
- package/src/mux/live-mux/git-parsing.ts +16 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
- package/src/mux/live-mux/modal-input-reducers.ts +34 -1
- package/src/mux/live-mux/modal-overlays.ts +45 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
- package/src/mux/task-screen-keybindings.ts +29 -1
- package/src/services/runtime-conversation-activation.ts +25 -0
- package/src/services/runtime-conversation-starter.ts +31 -7
- package/src/services/runtime-input-router.ts +6 -0
- package/src/services/runtime-modal-input.ts +18 -0
- package/src/services/runtime-rail-input.ts +1 -0
- package/src/services/runtime-repository-actions.ts +2 -0
- package/src/store/control-plane-store.ts +36 -0
- package/src/store/event-store.ts +36 -0
- package/src/ui/input.ts +31 -0
- 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
|
-
|
|
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
|
|
76
|
+
return workspaceRuntimeDirectory;
|
|
80
77
|
}
|
|
81
78
|
if (normalizedPath.startsWith(`${HARNESS_LEGACY_RELATIVE_ROOT}/`)) {
|
|
82
79
|
return resolve(
|
|
83
|
-
|
|
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(
|
|
88
|
+
return resolve(workspaceRuntimeDirectory, normalizedPath);
|
|
92
89
|
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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-
|
|
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
|
|
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
|
|
268
|
-
model
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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,
|