@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.
Files changed (32) hide show
  1. package/README.md +33 -156
  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 +103 -3
  15. package/scripts/control-plane-daemon.ts +20 -3
  16. package/scripts/harness.ts +566 -133
  17. package/src/cli/gateway-record.ts +16 -1
  18. package/src/control-plane/prompt/thread-title-namer.ts +290 -0
  19. package/src/control-plane/stream-command-parser.ts +12 -0
  20. package/src/control-plane/stream-protocol.ts +6 -0
  21. package/src/control-plane/stream-server-command.ts +14 -0
  22. package/src/control-plane/stream-server.ts +382 -19
  23. package/src/mux/input-shortcuts.ts +9 -0
  24. package/src/mux/live-mux/git-parsing.ts +24 -0
  25. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  26. package/src/mux/render-frame.ts +1 -1
  27. package/src/services/control-plane.ts +22 -0
  28. package/src/services/runtime-control-actions.ts +69 -0
  29. package/src/services/runtime-navigation-input.ts +4 -0
  30. package/src/services/runtime-rail-input.ts +4 -0
  31. package/src/services/runtime-workspace-actions.ts +5 -0
  32. package/src/ui/global-shortcut-input.ts +2 -0
@@ -6,6 +6,7 @@ export const DEFAULT_GATEWAY_PORT = 7777;
6
6
  export const DEFAULT_GATEWAY_DB_PATH = '.harness/control-plane.sqlite';
7
7
  export const DEFAULT_GATEWAY_RECORD_PATH = '.harness/gateway.json';
8
8
  export const DEFAULT_GATEWAY_LOG_PATH = '.harness/gateway.log';
9
+ export const DEFAULT_GATEWAY_LOCK_PATH = '.harness/gateway.lock';
9
10
 
10
11
  export interface GatewayRecord {
11
12
  readonly version: number;
@@ -16,6 +17,7 @@ export interface GatewayRecord {
16
17
  readonly stateDbPath: string;
17
18
  readonly startedAt: string;
18
19
  readonly workspaceRoot: string;
20
+ readonly gatewayRunId?: string;
19
21
  }
20
22
 
21
23
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -74,6 +76,13 @@ export function resolveGatewayLogPath(
74
76
  return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_LOG_PATH, env);
75
77
  }
76
78
 
79
+ export function resolveGatewayLockPath(
80
+ workspaceRoot: string,
81
+ env: NodeJS.ProcessEnv = process.env,
82
+ ): string {
83
+ return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_LOCK_PATH, env);
84
+ }
85
+
77
86
  export function normalizeGatewayHost(
78
87
  input: string | null | undefined,
79
88
  fallback = DEFAULT_GATEWAY_HOST,
@@ -149,6 +158,9 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
149
158
  const workspaceRoot = readNonEmptyString(record['workspaceRoot']);
150
159
  const authTokenRaw = record['authToken'];
151
160
  const authToken = authTokenRaw === null ? null : readNonEmptyString(authTokenRaw);
161
+ const gatewayRunIdRaw = record['gatewayRunId'];
162
+ const gatewayRunId =
163
+ gatewayRunIdRaw === undefined ? undefined : readNonEmptyString(gatewayRunIdRaw);
152
164
 
153
165
  if (
154
166
  pid === null ||
@@ -157,10 +169,12 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
157
169
  stateDbPath === null ||
158
170
  startedAt === null ||
159
171
  workspaceRoot === null ||
160
- (authToken === null && authTokenRaw !== null)
172
+ (authToken === null && authTokenRaw !== null) ||
173
+ (gatewayRunIdRaw !== undefined && gatewayRunId === null)
161
174
  ) {
162
175
  return null;
163
176
  }
177
+ const parsedGatewayRunId = gatewayRunId === null ? undefined : gatewayRunId;
164
178
 
165
179
  return {
166
180
  version,
@@ -171,6 +185,7 @@ export function parseGatewayRecordText(text: string): GatewayRecord | null {
171
185
  stateDbPath,
172
186
  startedAt,
173
187
  workspaceRoot,
188
+ ...(parsedGatewayRunId === undefined ? {} : { gatewayRunId: parsedGatewayRunId }),
174
189
  };
175
190
  }
176
191
 
@@ -0,0 +1,290 @@
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-3-5-haiku-latest';
39
+
40
+ interface ThreadTitlePromptHistoryEntry {
41
+ readonly text: string;
42
+ readonly observedAt: string;
43
+ readonly hash: string;
44
+ }
45
+
46
+ interface ThreadTitleNamerInput {
47
+ readonly conversationId: string;
48
+ readonly agentType: string;
49
+ readonly currentTitle: string;
50
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
51
+ }
52
+
53
+ export interface ThreadTitleNamer {
54
+ suggest(input: ThreadTitleNamerInput): Promise<string | null>;
55
+ }
56
+
57
+ interface AnthropicThreadTitleNamerOptions {
58
+ readonly apiKey: string;
59
+ readonly modelId?: string;
60
+ readonly baseUrl?: string;
61
+ readonly fetch?: typeof fetch;
62
+ }
63
+
64
+ function asRecord(value: unknown): Record<string, unknown> | null {
65
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
66
+ return null;
67
+ }
68
+ return value as Record<string, unknown>;
69
+ }
70
+
71
+ function trimmedString(value: unknown): string | null {
72
+ if (typeof value !== 'string') {
73
+ return null;
74
+ }
75
+ const trimmed = value.trim();
76
+ return trimmed.length > 0 ? trimmed : null;
77
+ }
78
+
79
+ function historyEntriesFromAdapterState(
80
+ adapterState: Record<string, unknown>,
81
+ ): readonly ThreadTitlePromptHistoryEntry[] {
82
+ const stored = asRecord(adapterState[THREAD_TITLE_ADAPTER_STATE_KEY]);
83
+ if (stored === null) {
84
+ return [];
85
+ }
86
+ const promptsRaw = stored['prompts'];
87
+ if (!Array.isArray(promptsRaw)) {
88
+ return [];
89
+ }
90
+ const parsed: ThreadTitlePromptHistoryEntry[] = [];
91
+ for (const item of promptsRaw) {
92
+ const record = asRecord(item);
93
+ if (record === null) {
94
+ continue;
95
+ }
96
+ const text = trimmedString(record['text']);
97
+ const observedAt = trimmedString(record['observedAt']);
98
+ const hash = trimmedString(record['hash']);
99
+ if (text === null || observedAt === null || hash === null) {
100
+ continue;
101
+ }
102
+ parsed.push({
103
+ text,
104
+ observedAt,
105
+ hash,
106
+ });
107
+ }
108
+ return parsed;
109
+ }
110
+
111
+ function serializePromptHistory(
112
+ entries: readonly ThreadTitlePromptHistoryEntry[],
113
+ ): Readonly<Record<string, unknown>> {
114
+ return {
115
+ prompts: entries.map((entry) => ({
116
+ text: entry.text,
117
+ observedAt: entry.observedAt,
118
+ hash: entry.hash,
119
+ })),
120
+ };
121
+ }
122
+
123
+ function sanitizePromptLine(line: string): string | null {
124
+ const withoutDataUrl = line
125
+ .replace(IMAGE_DATA_URL_PATTERN, ' ')
126
+ .replace(MARKDOWN_IMAGE_PATTERN, ' ')
127
+ .replace(HTML_IMAGE_PATTERN, ' ')
128
+ .trim();
129
+ if (withoutDataUrl.length === 0) {
130
+ return null;
131
+ }
132
+ if (LONG_BASE64_LINE_PATTERN.test(withoutDataUrl)) {
133
+ return null;
134
+ }
135
+ return withoutDataUrl;
136
+ }
137
+
138
+ export function sanitizePromptForThreadTitle(text: string): string | null {
139
+ const normalized = text
140
+ .replace(/\r\n/gu, '\n')
141
+ .replace(/\r/gu, '\n')
142
+ .split('\n')
143
+ .map((line) => sanitizePromptLine(line))
144
+ .filter((line): line is string => line !== null)
145
+ .join('\n')
146
+ .replace(/[ \t]{2,}/gu, ' ')
147
+ .trim();
148
+ if (normalized.length === 0) {
149
+ return null;
150
+ }
151
+ if (normalized.length <= MAX_SANITIZED_PROMPT_CHARS) {
152
+ return normalized;
153
+ }
154
+ return `${normalized.slice(0, MAX_SANITIZED_PROMPT_CHARS).trimEnd()}...`;
155
+ }
156
+
157
+ export function normalizeThreadTitleCandidate(value: string): string | null {
158
+ const compact = value.trim();
159
+ if (compact.length === 0) {
160
+ return null;
161
+ }
162
+ const words = compact.match(TITLE_WORD_PATTERN)?.map((word) => word.trim()) ?? [];
163
+ if (words.length < TARGET_TITLE_WORD_COUNT) {
164
+ return null;
165
+ }
166
+ const filtered = words
167
+ .map((word) => word.toLowerCase())
168
+ .filter((word) => word !== 'title' && word.length > 0);
169
+ if (filtered.length < TARGET_TITLE_WORD_COUNT) {
170
+ return null;
171
+ }
172
+ return filtered.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
173
+ }
174
+
175
+ export function fallbackThreadTitleFromPromptHistory(
176
+ promptHistory: readonly ThreadTitlePromptHistoryEntry[],
177
+ ): string {
178
+ const selected: string[] = [];
179
+ for (let index = promptHistory.length - 1; index >= 0; index -= 1) {
180
+ const entry = promptHistory[index];
181
+ if (entry === undefined) {
182
+ continue;
183
+ }
184
+ const words = entry.text.match(TITLE_WORD_PATTERN) ?? [];
185
+ for (const rawWord of words) {
186
+ const normalized = rawWord.toLowerCase();
187
+ if (normalized.length < 3 || FALLBACK_STOP_WORDS.has(normalized)) {
188
+ continue;
189
+ }
190
+ if (selected.includes(normalized)) {
191
+ continue;
192
+ }
193
+ selected.push(normalized);
194
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
195
+ return selected.join(' ');
196
+ }
197
+ }
198
+ }
199
+ for (const fallback of FALLBACK_FILL_WORDS) {
200
+ selected.push(fallback);
201
+ if (selected.length >= TARGET_TITLE_WORD_COUNT) {
202
+ break;
203
+ }
204
+ }
205
+ return selected.slice(0, TARGET_TITLE_WORD_COUNT).join(' ');
206
+ }
207
+
208
+ export function readThreadTitlePromptHistory(
209
+ adapterState: Record<string, unknown>,
210
+ ): readonly ThreadTitlePromptHistoryEntry[] {
211
+ return historyEntriesFromAdapterState(adapterState);
212
+ }
213
+
214
+ export function appendThreadTitlePromptHistory(
215
+ adapterState: Record<string, unknown>,
216
+ prompt: StreamSessionPromptRecord,
217
+ ): {
218
+ readonly nextAdapterState: Record<string, unknown>;
219
+ readonly promptHistory: readonly ThreadTitlePromptHistoryEntry[];
220
+ readonly added: boolean;
221
+ } {
222
+ const text = prompt.text === null ? null : sanitizePromptForThreadTitle(prompt.text);
223
+ const existing = historyEntriesFromAdapterState(adapterState);
224
+ if (text === null) {
225
+ return {
226
+ nextAdapterState: adapterState,
227
+ promptHistory: existing,
228
+ added: false,
229
+ };
230
+ }
231
+ const nextHistory: ThreadTitlePromptHistoryEntry[] = [
232
+ ...existing,
233
+ {
234
+ text,
235
+ observedAt: prompt.observedAt,
236
+ hash: prompt.hash,
237
+ },
238
+ ];
239
+ const nextAdapterState: Record<string, unknown> = {
240
+ ...adapterState,
241
+ [THREAD_TITLE_ADAPTER_STATE_KEY]: serializePromptHistory(nextHistory),
242
+ };
243
+ return {
244
+ nextAdapterState,
245
+ promptHistory: nextHistory,
246
+ added: true,
247
+ };
248
+ }
249
+
250
+ export function createAnthropicThreadTitleNamer(
251
+ options: AnthropicThreadTitleNamerOptions,
252
+ ): ThreadTitleNamer {
253
+ const anthropic = createAnthropic({
254
+ apiKey: options.apiKey,
255
+ ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
256
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
257
+ });
258
+ const model = anthropic(options.modelId ?? DEFAULT_HAIKU_MODEL_ID);
259
+ return {
260
+ async suggest(input: ThreadTitleNamerInput): Promise<string | null> {
261
+ if (input.promptHistory.length === 0) {
262
+ return null;
263
+ }
264
+ const promptLines = input.promptHistory.map(
265
+ (entry, index) => `${String(index + 1)}. ${entry.text}`,
266
+ );
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);
288
+ },
289
+ };
290
+ }
@@ -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
@@ -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) {