@jmoyers/harness 0.1.9 → 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 (54) hide show
  1. package/README.md +36 -155
  2. package/package.json +3 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +265 -14
  15. package/scripts/control-plane-daemon.ts +33 -5
  16. package/scripts/harness.ts +579 -134
  17. package/src/cli/default-gateway-pointer.ts +193 -0
  18. package/src/cli/gateway-record.ts +16 -1
  19. package/src/config/config-core.ts +13 -2
  20. package/src/config/harness-paths.ts +4 -7
  21. package/src/config/harness-runtime-migration.ts +142 -19
  22. package/src/config/secrets-core.ts +92 -4
  23. package/src/control-plane/prompt/thread-title-namer.ts +316 -0
  24. package/src/control-plane/stream-command-parser.ts +12 -0
  25. package/src/control-plane/stream-protocol.ts +6 -0
  26. package/src/control-plane/stream-server-background.ts +18 -2
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server.ts +460 -28
  29. package/src/domain/conversations.ts +11 -7
  30. package/src/domain/workspace.ts +9 -0
  31. package/src/mux/input-shortcuts.ts +38 -1
  32. package/src/mux/live-mux/git-parsing.ts +40 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  35. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  36. package/src/mux/live-mux/modal-overlays.ts +45 -0
  37. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  38. package/src/mux/render-frame.ts +1 -1
  39. package/src/mux/task-screen-keybindings.ts +29 -1
  40. package/src/services/control-plane.ts +22 -0
  41. package/src/services/runtime-control-actions.ts +69 -0
  42. package/src/services/runtime-conversation-activation.ts +25 -0
  43. package/src/services/runtime-conversation-starter.ts +31 -7
  44. package/src/services/runtime-input-router.ts +6 -0
  45. package/src/services/runtime-modal-input.ts +18 -0
  46. package/src/services/runtime-navigation-input.ts +4 -0
  47. package/src/services/runtime-rail-input.ts +5 -0
  48. package/src/services/runtime-repository-actions.ts +2 -0
  49. package/src/services/runtime-workspace-actions.ts +5 -0
  50. package/src/store/control-plane-store.ts +36 -0
  51. package/src/store/event-store.ts +36 -0
  52. package/src/ui/global-shortcut-input.ts +2 -0
  53. package/src/ui/input.ts +31 -0
  54. package/src/ui/modals/manager.ts +26 -0
@@ -0,0 +1,316 @@
1
+ import { createAnthropic, generateText } from '../../../packages/harness-ai/src/index.ts';
2
+ import type { StreamSessionPromptRecord } from '../stream-protocol.ts';
3
+
4
+ const THREAD_TITLE_ADAPTER_STATE_KEY = 'harnessThreadTitle';
5
+ const MAX_SANITIZED_PROMPT_CHARS = 1200;
6
+ const IMAGE_DATA_URL_PATTERN = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+/giu;
7
+ const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\([^)]*\)/gu;
8
+ const HTML_IMAGE_PATTERN = /<img\b[^>]*>/giu;
9
+ const LONG_BASE64_LINE_PATTERN = /^[A-Za-z0-9+/=\s]{160,}$/u;
10
+ const TITLE_WORD_PATTERN = /[A-Za-z0-9]+(?:'[A-Za-z0-9]+)*/g;
11
+ const TARGET_TITLE_WORD_COUNT = 2;
12
+ const FALLBACK_FILL_WORDS = ['current', 'thread'] as const;
13
+ const FALLBACK_STOP_WORDS = new Set([
14
+ 'a',
15
+ 'an',
16
+ 'and',
17
+ 'are',
18
+ 'as',
19
+ 'at',
20
+ 'be',
21
+ 'build',
22
+ 'for',
23
+ 'from',
24
+ 'in',
25
+ 'into',
26
+ 'is',
27
+ 'it',
28
+ 'of',
29
+ 'on',
30
+ 'or',
31
+ 'that',
32
+ 'the',
33
+ 'this',
34
+ 'to',
35
+ 'up',
36
+ 'with',
37
+ ]);
38
+ const DEFAULT_HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
39
+ const FALLBACK_HAIKU_MODEL_IDS = ['claude-3-haiku-20240307'] as const;
40
+
41
+ interface ThreadTitlePromptHistoryEntry {
42
+ readonly text: string;
43
+ readonly observedAt: string;
44
+ readonly hash: string;
45
+ }
46
+
47
+ interface ThreadTitleNamerInput {
48
+ readonly conversationId: string;
49
+ readonly agentType: string;
50
+ readonly currentTitle: string;
51
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
52
+ }
53
+
54
+ export interface ThreadTitleNamer {
55
+ suggest(input: ThreadTitleNamerInput): Promise<string | null>;
56
+ }
57
+
58
+ interface AnthropicThreadTitleNamerOptions {
59
+ readonly apiKey: string;
60
+ readonly modelId?: string;
61
+ readonly baseUrl?: string;
62
+ readonly fetch?: typeof fetch;
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
+
81
+ function asRecord(value: unknown): Record<string, unknown> | null {
82
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
83
+ return null;
84
+ }
85
+ return value as Record<string, unknown>;
86
+ }
87
+
88
+ function trimmedString(value: unknown): string | null {
89
+ if (typeof value !== 'string') {
90
+ return null;
91
+ }
92
+ const trimmed = value.trim();
93
+ return trimmed.length > 0 ? trimmed : null;
94
+ }
95
+
96
+ function historyEntriesFromAdapterState(
97
+ adapterState: Record<string, unknown>,
98
+ ): readonly ThreadTitlePromptHistoryEntry[] {
99
+ const stored = asRecord(adapterState[THREAD_TITLE_ADAPTER_STATE_KEY]);
100
+ if (stored === null) {
101
+ return [];
102
+ }
103
+ const promptsRaw = stored['prompts'];
104
+ if (!Array.isArray(promptsRaw)) {
105
+ return [];
106
+ }
107
+ const parsed: ThreadTitlePromptHistoryEntry[] = [];
108
+ for (const item of promptsRaw) {
109
+ const record = asRecord(item);
110
+ if (record === null) {
111
+ continue;
112
+ }
113
+ const text = trimmedString(record['text']);
114
+ const observedAt = trimmedString(record['observedAt']);
115
+ const hash = trimmedString(record['hash']);
116
+ if (text === null || observedAt === null || hash === null) {
117
+ continue;
118
+ }
119
+ parsed.push({
120
+ text,
121
+ observedAt,
122
+ hash,
123
+ });
124
+ }
125
+ return parsed;
126
+ }
127
+
128
+ function serializePromptHistory(
129
+ entries: readonly ThreadTitlePromptHistoryEntry[],
130
+ ): Readonly<Record<string, unknown>> {
131
+ return {
132
+ prompts: entries.map((entry) => ({
133
+ text: entry.text,
134
+ observedAt: entry.observedAt,
135
+ hash: entry.hash,
136
+ })),
137
+ };
138
+ }
139
+
140
+ function sanitizePromptLine(line: string): string | null {
141
+ const withoutDataUrl = line
142
+ .replace(IMAGE_DATA_URL_PATTERN, ' ')
143
+ .replace(MARKDOWN_IMAGE_PATTERN, ' ')
144
+ .replace(HTML_IMAGE_PATTERN, ' ')
145
+ .trim();
146
+ if (withoutDataUrl.length === 0) {
147
+ return null;
148
+ }
149
+ if (LONG_BASE64_LINE_PATTERN.test(withoutDataUrl)) {
150
+ return null;
151
+ }
152
+ return withoutDataUrl;
153
+ }
154
+
155
+ export function sanitizePromptForThreadTitle(text: string): string | null {
156
+ const normalized = text
157
+ .replace(/\r\n/gu, '\n')
158
+ .replace(/\r/gu, '\n')
159
+ .split('\n')
160
+ .map((line) => sanitizePromptLine(line))
161
+ .filter((line): line is string => line !== null)
162
+ .join('\n')
163
+ .replace(/[ \t]{2,}/gu, ' ')
164
+ .trim();
165
+ if (normalized.length === 0) {
166
+ return null;
167
+ }
168
+ if (normalized.length <= MAX_SANITIZED_PROMPT_CHARS) {
169
+ return normalized;
170
+ }
171
+ return `${normalized.slice(0, MAX_SANITIZED_PROMPT_CHARS).trimEnd()}...`;
172
+ }
173
+
174
+ export function normalizeThreadTitleCandidate(value: string): string | null {
175
+ const compact = value.trim();
176
+ if (compact.length === 0) {
177
+ return null;
178
+ }
179
+ const words = compact.match(TITLE_WORD_PATTERN)?.map((word) => word.trim()) ?? [];
180
+ if (words.length < TARGET_TITLE_WORD_COUNT) {
181
+ return null;
182
+ }
183
+ const filtered = words
184
+ .map((word) => word.toLowerCase())
185
+ .filter((word) => word !== 'title' && word.length > 0);
186
+ if (filtered.length < TARGET_TITLE_WORD_COUNT) {
187
+ return null;
188
+ }
189
+ return filtered.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
190
+ }
191
+
192
+ export function fallbackThreadTitleFromPromptHistory(
193
+ promptHistory: readonly ThreadTitlePromptHistoryEntry[],
194
+ ): string {
195
+ const selected: string[] = [];
196
+ for (let index = promptHistory.length - 1; index >= 0; index -= 1) {
197
+ const entry = promptHistory[index];
198
+ if (entry === undefined) {
199
+ continue;
200
+ }
201
+ const words = entry.text.match(TITLE_WORD_PATTERN) ?? [];
202
+ for (const rawWord of words) {
203
+ const normalized = rawWord.toLowerCase();
204
+ if (normalized.length < 3 || FALLBACK_STOP_WORDS.has(normalized)) {
205
+ continue;
206
+ }
207
+ if (selected.includes(normalized)) {
208
+ continue;
209
+ }
210
+ selected.push(normalized);
211
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
212
+ return selected.join(' ');
213
+ }
214
+ }
215
+ }
216
+ for (const fallback of FALLBACK_FILL_WORDS) {
217
+ selected.push(fallback);
218
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
219
+ break;
220
+ }
221
+ }
222
+ return selected.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
223
+ }
224
+
225
+ export function readThreadTitlePromptHistory(
226
+ adapterState: Record<string, unknown>,
227
+ ): readonly ThreadTitlePromptHistoryEntry[] {
228
+ return historyEntriesFromAdapterState(adapterState);
229
+ }
230
+
231
+ export function appendThreadTitlePromptHistory(
232
+ adapterState: Record<string, unknown>,
233
+ prompt: StreamSessionPromptRecord,
234
+ ): {
235
+ readonly nextAdapterState: Record<string, unknown>;
236
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
237
+ readonly added: boolean;
238
+ } {
239
+ const text = prompt.text === null ? null : sanitizePromptForThreadTitle(prompt.text);
240
+ const existing = historyEntriesFromAdapterState(adapterState);
241
+ if (text === null) {
242
+ return {
243
+ nextAdapterState: adapterState,
244
+ promptHistory: existing,
245
+ added: false,
246
+ };
247
+ }
248
+ const nextHistory: ThreadTitlePromptHistoryEntry[] = [
249
+ ...existing,
250
+ {
251
+ text,
252
+ observedAt: prompt.observedAt,
253
+ hash: prompt.hash,
254
+ },
255
+ ];
256
+ const nextAdapterState: Record<string, unknown> = {
257
+ ...adapterState,
258
+ [THREAD_TITLE_ADAPTER_STATE_KEY]: serializePromptHistory(nextHistory),
259
+ };
260
+ return {
261
+ nextAdapterState,
262
+ promptHistory: nextHistory,
263
+ added: true,
264
+ };
265
+ }
266
+
267
+ export function createAnthropicThreadTitleNamer(
268
+ options: AnthropicThreadTitleNamerOptions,
269
+ ): ThreadTitleNamer {
270
+ const anthropic = createAnthropic({
271
+ apiKey: options.apiKey,
272
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
273
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
274
+ });
275
+ const modelCandidateIds = resolveModelCandidateIds(options.modelId);
276
+ return {
277
+ async suggest(input: ThreadTitleNamerInput): Promise<string | null> {
278
+ if (input.promptHistory.length === 0) {
279
+ return null;
280
+ }
281
+ const promptLines = input.promptHistory.map(
282
+ (entry, index) => `${String(index + 1)}. ${entry.text}`,
283
+ );
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);
314
+ },
315
+ };
316
+ }
@@ -421,6 +421,17 @@ function parseConversationUpdate(record: CommandRecord): StreamCommand | null {
421
421
  };
422
422
  }
423
423
 
424
+ function parseConversationTitleRefresh(record: CommandRecord): StreamCommand | null {
425
+ const conversationId = readString(record['conversationId']);
426
+ if (conversationId === null) {
427
+ return null;
428
+ }
429
+ return {
430
+ type: 'conversation.title.refresh',
431
+ conversationId,
432
+ };
433
+ }
434
+
424
435
  function parseConversationDelete(record: CommandRecord): StreamCommand | null {
425
436
  const conversationId = readString(record['conversationId']);
426
437
  if (conversationId === null) {
@@ -1628,6 +1639,7 @@ export const DEFAULT_STREAM_COMMAND_PARSERS: StreamCommandParserRegistry = {
1628
1639
  'conversation.list': parseConversationList,
1629
1640
  'conversation.archive': parseConversationArchive,
1630
1641
  'conversation.update': parseConversationUpdate,
1642
+ 'conversation.title.refresh': parseConversationTitleRefresh,
1631
1643
  'conversation.delete': parseConversationDelete,
1632
1644
  'repository.upsert': parseRepositoryUpsert,
1633
1645
  'repository.get': parseRepositoryGet,
@@ -119,6 +119,11 @@ interface ConversationUpdateCommand {
119
119
  title: string;
120
120
  }
121
121
 
122
+ interface ConversationTitleRefreshCommand {
123
+ type: 'conversation.title.refresh';
124
+ conversationId: string;
125
+ }
126
+
122
127
  interface ConversationDeleteCommand {
123
128
  type: 'conversation.delete';
124
129
  conversationId: string;
@@ -494,6 +499,7 @@ export type StreamCommand =
494
499
  | ConversationListCommand
495
500
  | ConversationArchiveCommand
496
501
  | ConversationUpdateCommand
502
+ | ConversationTitleRefreshCommand
497
503
  | ConversationDeleteCommand
498
504
  | RepositoryUpsertCommand
499
505
  | RepositoryGetCommand
@@ -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,
@@ -419,6 +419,11 @@ interface ExecuteCommandContext {
419
419
  }>;
420
420
  };
421
421
  readonly streamCursor: number;
422
+ refreshConversationTitle(conversationId: string): Promise<{
423
+ conversation: ControlPlaneConversationRecord;
424
+ status: 'updated' | 'unchanged' | 'skipped';
425
+ reason: string | null;
426
+ }>;
422
427
  refreshGitStatusForDirectory(
423
428
  directory: ControlPlaneDirectoryRecord,
424
429
  options?: {
@@ -1403,6 +1408,15 @@ export async function executeStreamServerCommand(
1403
1408
  };
1404
1409
  }
1405
1410
 
1411
+ if (command.type === 'conversation.title.refresh') {
1412
+ const refreshed = await ctx.refreshConversationTitle(command.conversationId);
1413
+ return {
1414
+ conversation: ctx.conversationRecord(refreshed.conversation),
1415
+ status: refreshed.status,
1416
+ reason: refreshed.reason,
1417
+ };
1418
+ }
1419
+
1406
1420
  if (command.type === 'conversation.delete') {
1407
1421
  const existing = ctx.stateStore.getConversation(command.conversationId);
1408
1422
  if (existing === null) {