@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
package/README.md CHANGED
@@ -11,7 +11,7 @@ It is built for people who want to move faster than a single chat window: implem
11
11
  - Long-running work survives reconnects through a detached gateway.
12
12
  - Gateway control is resilient: lifecycle operations are lock-serialized per session, and missing stale records can be recovered automatically.
13
13
  - Fast left-rail navigation with automatic, readable thread titles.
14
- - Built-in GitHub PR actions (`Open PR` / `Create PR`) from inside Harness.
14
+ - Built-in GitHub actions (`Open GitHub`, `Show My Open Pull Requests`, `Open PR`, `Create PR`) from inside Harness.
15
15
 
16
16
  ## Demo
17
17
 
@@ -58,13 +58,17 @@ For restart/load diagnostics, use a named session with a non-default gateway por
58
58
  - Thread-scoped command palette (`[+ thread]`) can launch/install supported agent CLIs per project.
59
59
  - Critique review actions are available from the global palette and run in a terminal thread.
60
60
  - `ctrl+g` opens the project’s critique thread (or creates one if needed).
61
+ - `ctrl` and `cmd` shortcut chords are mirrored in both directions when your terminal/OS does not reserve the combination.
61
62
  - Theme selection is built in (`Set a Theme`) with OpenCode-compatible presets and live preview.
62
- - PR actions use either `GITHUB_TOKEN` or an authenticated `gh` CLI session.
63
+ - API keys can be set directly from `ctrl+p` / `cmd+p` (`Set Anthropic API Key`, `Set OpenAI API Key`), with overwrite warning and paste-friendly entry.
64
+ - `Create PR` uses either `GITHUB_TOKEN` or an authenticated `gh` CLI session.
63
65
 
64
66
  ## Configuration
65
67
 
66
68
  Runtime behavior is controlled by `harness.config.jsonc`.
67
69
 
70
+ When upgrading from a workspace-local `.harness`, Harness automatically migrates legacy config into the global config location if that global config is still uninitialized (missing, empty, or default template), then removes stale local `.harness` folders once migration targets are confirmed.
71
+
68
72
  Common customizations:
69
73
 
70
74
  - Set install commands for `codex`, `claude`, `cursor`, and `critique`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmoyers/harness",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@ import {
25
25
  } from '../src/config/config-core.ts';
26
26
  import { resolveHarnessRuntimePath } from '../src/config/harness-paths.ts';
27
27
  import { migrateLegacyHarnessLayout } from '../src/config/harness-runtime-migration.ts';
28
- import { loadHarnessSecrets } from '../src/config/secrets-core.ts';
28
+ import { loadHarnessSecrets, upsertHarnessSecret } from '../src/config/secrets-core.ts';
29
29
  import { detectMuxGlobalShortcut, resolveMuxShortcutBindings } from '../src/mux/input-shortcuts.ts';
30
30
  import { createMuxInputModeManager } from '../src/mux/terminal-input-modes.ts';
31
31
  import type { buildWorkspaceRailViewRows } from '../src/mux/workspace-rail-model.ts';
@@ -92,6 +92,7 @@ import {
92
92
  } from '../src/mux/live-mux/layout.ts';
93
93
  import {
94
94
  normalizeGitHubRemoteUrl,
95
+ resolveGitHubDefaultBranchForActions,
95
96
  repositoryNameFromGitHubRemoteUrl,
96
97
  resolveGitHubTrackedBranchForActions,
97
98
  shouldShowGitHubPrActions,
@@ -244,6 +245,7 @@ interface RuntimeCommandMenuContext {
244
245
  readonly leftNavSelectionKind: WorkspaceModel['leftNavSelection']['kind'];
245
246
  readonly profileRunning: boolean;
246
247
  readonly statusTimelineRunning: boolean;
248
+ readonly githubRepositoryId: string | null;
247
249
  readonly githubRepositoryUrl: string | null;
248
250
  readonly githubDefaultBranch: string | null;
249
251
  readonly githubTrackedBranch: string | null;
@@ -271,6 +273,13 @@ interface ThemePickerSessionState {
271
273
  previewActionId: string | null;
272
274
  }
273
275
 
276
+ interface AllowedCommandMenuApiKey {
277
+ readonly actionIdSuffix: string;
278
+ readonly envVar: 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY';
279
+ readonly displayName: string;
280
+ readonly aliases: readonly string[];
281
+ }
282
+
274
283
  const DEFAULT_RESIZE_MIN_INTERVAL_MS = 33;
275
284
  const DEFAULT_PTY_RESIZE_SETTLE_MS = 75;
276
285
  const DEFAULT_STARTUP_SETTLE_QUIET_MS = 300;
@@ -290,6 +299,21 @@ const REPOSITORY_COLLAPSE_ALL_CHORD_PREFIX = Buffer.from([0x0b]);
290
299
  const UNTRACKED_REPOSITORY_GROUP_ID = 'untracked';
291
300
  const THEME_PICKER_SCOPE = 'theme-select';
292
301
  const THEME_ACTION_ID_PREFIX = 'theme.set.';
302
+ const API_KEY_ACTION_ID_PREFIX = 'api-key.set.';
303
+ const COMMAND_MENU_ALLOWED_API_KEYS: readonly AllowedCommandMenuApiKey[] = [
304
+ {
305
+ actionIdSuffix: 'anthropic',
306
+ envVar: 'ANTHROPIC_API_KEY',
307
+ displayName: 'Anthropic API Key',
308
+ aliases: ['anthropic api key', 'claude api key'],
309
+ },
310
+ {
311
+ actionIdSuffix: 'openai',
312
+ envVar: 'OPENAI_API_KEY',
313
+ displayName: 'OpenAI API Key',
314
+ aliases: ['openai api key', 'codex api key'],
315
+ },
316
+ ];
293
317
  const GIT_SUMMARY_LOADING: GitSummary = {
294
318
  branch: '(loading)',
295
319
  changedFiles: 0,
@@ -339,6 +363,15 @@ function parseGitHubPrUrl(result: Record<string, unknown>): string | null {
339
363
  return typeof url === 'string' ? url : null;
340
364
  }
341
365
 
366
+ function parseGitHubUrl(result: Record<string, unknown>): string | null {
367
+ const url = result['url'];
368
+ if (typeof url !== 'string') {
369
+ return null;
370
+ }
371
+ const trimmed = url.trim();
372
+ return trimmed.length > 0 ? trimmed : null;
373
+ }
374
+
342
375
  function commandMenuProjectPathTail(path: string): string {
343
376
  const normalized = path.trim().replaceAll('\\', '/').replace(/\/+$/u, '');
344
377
  if (normalized.length === 0) {
@@ -685,7 +718,7 @@ async function main(): Promise<number> {
685
718
  await startControlPlaneStreamServer({
686
719
  stateStorePath: resolveHarnessRuntimePath(
687
720
  options.invocationDirectory,
688
- process.env.HARNESS_CONTROL_PLANE_DB_PATH ?? '.harness/control-plane.sqlite',
721
+ '.harness/control-plane.sqlite',
689
722
  ),
690
723
  codexTelemetry: loadedConfig.config.codex.telemetry,
691
724
  codexHistory: loadedConfig.config.codex.history,
@@ -793,7 +826,7 @@ async function main(): Promise<number> {
793
826
  workspace.taskPaneRepositoryEditClickState = null;
794
827
  workspace.homePaneDragState = null;
795
828
 
796
- const sessionEnv = {
829
+ const sessionEnv: Record<string, string> = {
797
830
  ...sanitizeProcessEnv(),
798
831
  TERM: process.env.TERM ?? 'xterm-256color',
799
832
  };
@@ -1458,6 +1491,33 @@ async function main(): Promise<number> {
1458
1491
  activeDirectoryId === null
1459
1492
  ? null
1460
1493
  : (directoryRepositorySnapshotByDirectoryId.get(activeDirectoryId) ?? null);
1494
+ let githubRepositoryId =
1495
+ activeDirectoryId === null
1496
+ ? null
1497
+ : (repositoryAssociationByDirectoryId.get(activeDirectoryId) ?? null);
1498
+ if (githubRepositoryId !== null) {
1499
+ const associatedRepository = repositories.get(githubRepositoryId);
1500
+ if (associatedRepository === undefined || associatedRepository.archivedAt !== null) {
1501
+ githubRepositoryId = null;
1502
+ }
1503
+ }
1504
+ const snapshotRemoteUrl = activeDirectoryRepositorySnapshot?.normalizedRemoteUrl ?? null;
1505
+ if (githubRepositoryId === null && snapshotRemoteUrl !== null) {
1506
+ const snapshotRemote = normalizeGitHubRemoteUrl(snapshotRemoteUrl);
1507
+ if (snapshotRemote !== null) {
1508
+ for (const repository of repositories.values()) {
1509
+ if (repository.archivedAt !== null) {
1510
+ continue;
1511
+ }
1512
+ if (normalizeGitHubRemoteUrl(repository.remoteUrl) === snapshotRemote) {
1513
+ githubRepositoryId = repository.repositoryId;
1514
+ break;
1515
+ }
1516
+ }
1517
+ }
1518
+ }
1519
+ const githubRepositoryRecord =
1520
+ githubRepositoryId === null ? null : (repositories.get(githubRepositoryId) ?? null);
1461
1521
  const githubProjectPrState =
1462
1522
  activeDirectoryId !== null &&
1463
1523
  commandMenuGitHubProjectPrState !== null &&
@@ -1491,8 +1551,12 @@ async function main(): Promise<number> {
1491
1551
  statusTimelineRunning: existsSync(
1492
1552
  resolveStatusTimelineStatePath(options.invocationDirectory, muxSessionName),
1493
1553
  ),
1554
+ githubRepositoryId,
1494
1555
  githubRepositoryUrl: activeDirectoryRepositorySnapshot?.normalizedRemoteUrl ?? null,
1495
- githubDefaultBranch: activeDirectoryRepositorySnapshot?.defaultBranch ?? null,
1556
+ githubDefaultBranch: resolveGitHubDefaultBranchForActions({
1557
+ repositoryDefaultBranch: githubRepositoryRecord?.defaultBranch ?? null,
1558
+ snapshotDefaultBranch: activeDirectoryRepositorySnapshot?.defaultBranch ?? null,
1559
+ }),
1496
1560
  githubTrackedBranch: trackedBranchForActions,
1497
1561
  githubOpenPrUrl: githubProjectPrState?.openPrUrl ?? null,
1498
1562
  githubProjectPrLoading: githubProjectPrState?.loading ?? false,
@@ -1560,6 +1624,7 @@ async function main(): Promise<number> {
1560
1624
  resolveCommandMenuActions,
1561
1625
  getNewThreadPrompt: () => workspace.newThreadPrompt,
1562
1626
  getAddDirectoryPrompt: () => workspace.addDirectoryPrompt,
1627
+ getApiKeyPrompt: () => workspace.apiKeyPrompt,
1563
1628
  getTaskEditorPrompt: () => workspace.taskEditorPrompt,
1564
1629
  getRepositoryPrompt: () => workspace.repositoryPrompt,
1565
1630
  getConversationTitleEdit: () => workspace.conversationTitleEdit,
@@ -2378,6 +2443,7 @@ async function main(): Promise<number> {
2378
2443
  }
2379
2444
  workspace.newThreadPrompt = null;
2380
2445
  workspace.addDirectoryPrompt = null;
2446
+ workspace.apiKeyPrompt = null;
2381
2447
  workspace.taskEditorPrompt = null;
2382
2448
  workspace.repositoryPrompt = null;
2383
2449
  if (workspace.conversationTitleEdit !== null) {
@@ -2617,6 +2683,7 @@ async function main(): Promise<number> {
2617
2683
  }
2618
2684
  workspace.newThreadPrompt = null;
2619
2685
  workspace.addDirectoryPrompt = null;
2686
+ workspace.apiKeyPrompt = null;
2620
2687
  workspace.taskEditorPrompt = null;
2621
2688
  workspace.repositoryPrompt = null;
2622
2689
  if (workspace.conversationTitleEdit !== null) {
@@ -2636,6 +2703,39 @@ async function main(): Promise<number> {
2636
2703
  markDirty();
2637
2704
  };
2638
2705
 
2706
+ const openApiKeyPrompt = (apiKey: AllowedCommandMenuApiKey): void => {
2707
+ workspace.newThreadPrompt = null;
2708
+ workspace.addDirectoryPrompt = null;
2709
+ workspace.taskEditorPrompt = null;
2710
+ workspace.repositoryPrompt = null;
2711
+ if (workspace.conversationTitleEdit !== null) {
2712
+ stopConversationTitleEdit(true);
2713
+ }
2714
+ workspace.conversationTitleEditClickState = null;
2715
+ const existingRaw = sessionEnv[apiKey.envVar] ?? process.env[apiKey.envVar];
2716
+ const hasExistingValue = typeof existingRaw === 'string' && existingRaw.trim().length > 0;
2717
+ workspace.apiKeyPrompt = {
2718
+ keyName: apiKey.envVar,
2719
+ displayName: apiKey.displayName,
2720
+ value: '',
2721
+ error: null,
2722
+ hasExistingValue,
2723
+ };
2724
+ markDirty();
2725
+ };
2726
+
2727
+ const persistApiKey = (keyName: string, value: string): void => {
2728
+ const result = upsertHarnessSecret({
2729
+ cwd: options.invocationDirectory,
2730
+ key: keyName,
2731
+ value,
2732
+ });
2733
+ sessionEnv[keyName] = value;
2734
+ process.env[keyName] = value;
2735
+ const action = result.replacedExisting ? 'updated' : 'saved';
2736
+ setCommandNotice(`${keyName} ${action}`);
2737
+ };
2738
+
2639
2739
  const startThreadFromCommandMenu = (
2640
2740
  directoryId: string,
2641
2741
  agentType: ReturnType<typeof normalizeThreadAgentType>,
@@ -2739,7 +2839,7 @@ async function main(): Promise<number> {
2739
2839
  return [
2740
2840
  {
2741
2841
  id: 'critique.review.unstaged',
2742
- title: 'Critique AI Review: Unstaged Changes',
2842
+ title: 'Critique AI Review: Unstaged Changes (git)',
2743
2843
  aliases: ['critique unstaged review', 'review unstaged diff', 'ai review unstaged'],
2744
2844
  keywords: ['critique', 'review', 'unstaged', 'diff', 'ai'],
2745
2845
  detail: 'runs critique review',
@@ -2749,7 +2849,7 @@ async function main(): Promise<number> {
2749
2849
  },
2750
2850
  {
2751
2851
  id: 'critique.review.staged',
2752
- title: 'Critique AI Review: Staged Changes',
2852
+ title: 'Critique AI Review: Staged Changes (git)',
2753
2853
  aliases: ['critique staged review', 'review staged diff', 'ai review staged'],
2754
2854
  keywords: ['critique', 'review', 'staged', 'diff', 'ai'],
2755
2855
  detail: 'runs critique review --staged',
@@ -2759,7 +2859,7 @@ async function main(): Promise<number> {
2759
2859
  },
2760
2860
  {
2761
2861
  id: 'critique.review.base-branch',
2762
- title: 'Critique AI Review: Current Branch vs Base',
2862
+ title: 'Critique AI Review: Current Branch vs Base (git)',
2763
2863
  aliases: ['critique base review', 'review against base branch', 'ai review base'],
2764
2864
  keywords: ['critique', 'review', 'base', 'branch', 'diff', 'ai'],
2765
2865
  detail: 'runs critique review <base> HEAD',
@@ -2902,6 +3002,21 @@ async function main(): Promise<number> {
2902
3002
  },
2903
3003
  });
2904
3004
 
3005
+ commandMenuRegistry.registerProvider('api-key.set', () => {
3006
+ return COMMAND_MENU_ALLOWED_API_KEYS.map(
3007
+ (apiKey): RegisteredCommandMenuAction<RuntimeCommandMenuContext> => ({
3008
+ id: `${API_KEY_ACTION_ID_PREFIX}${apiKey.actionIdSuffix}`,
3009
+ title: `Set ${apiKey.displayName}`,
3010
+ aliases: [...apiKey.aliases],
3011
+ keywords: ['api', 'key', 'set', apiKey.envVar.toLowerCase()],
3012
+ detail: apiKey.envVar,
3013
+ run: () => {
3014
+ openApiKeyPrompt(apiKey);
3015
+ },
3016
+ }),
3017
+ );
3018
+ });
3019
+
2905
3020
  commandMenuRegistry.registerProvider('theme.set', () => {
2906
3021
  const selectedThemeName = getActiveMuxTheme().name;
2907
3022
  return muxThemePresetNames().map(
@@ -2951,10 +3066,10 @@ async function main(): Promise<number> {
2951
3066
  if (repositoryUrl === null) {
2952
3067
  return [];
2953
3068
  }
2954
- return [
3069
+ const actions: RegisteredCommandMenuAction<RuntimeCommandMenuContext>[] = [
2955
3070
  {
2956
3071
  id: 'github.repo.open',
2957
- title: 'Open GitHub for This Repo',
3072
+ title: 'Open GitHub for This Repo (git)',
2958
3073
  aliases: ['open github for this repo', 'open github repo', 'open repository on github'],
2959
3074
  keywords: ['github', 'repository', 'repo', 'open'],
2960
3075
  detail: repositoryUrl,
@@ -2968,6 +3083,41 @@ async function main(): Promise<number> {
2968
3083
  },
2969
3084
  },
2970
3085
  ];
3086
+ if (context.githubRepositoryId !== null) {
3087
+ const repositoryId = context.githubRepositoryId;
3088
+ actions.push({
3089
+ id: 'github.repo.my-prs.open',
3090
+ title: 'Show My Open Pull Requests (git)',
3091
+ aliases: ['show my open pull requests', 'my open pull requests', 'show my prs', 'my prs'],
3092
+ keywords: ['github', 'pr', 'pull-request', 'open', 'my'],
3093
+ detail: repositoryUrl,
3094
+ run: async () => {
3095
+ queueControlPlaneOp(async () => {
3096
+ const result = await streamClient.sendCommand({
3097
+ type: 'github.repo-my-prs-url',
3098
+ repositoryId,
3099
+ });
3100
+ const parsedResult = asRecord(result);
3101
+ if (parsedResult === null) {
3102
+ setCommandNotice('github my open pull requests url unavailable');
3103
+ return;
3104
+ }
3105
+ const myPrsUrl = parseGitHubUrl(parsedResult);
3106
+ if (myPrsUrl === null) {
3107
+ setCommandNotice('github my open pull requests url unavailable');
3108
+ return;
3109
+ }
3110
+ const opened = openUrlInBrowser(myPrsUrl);
3111
+ setCommandNotice(
3112
+ opened
3113
+ ? 'opened my open pull requests in browser'
3114
+ : `open pull requests: ${myPrsUrl}`,
3115
+ );
3116
+ }, 'command-menu-open-my-open-prs');
3117
+ },
3118
+ });
3119
+ }
3120
+ return actions;
2971
3121
  });
2972
3122
 
2973
3123
  commandMenuRegistry.registerProvider('github.project-pr', (context) => {
@@ -2986,7 +3136,7 @@ async function main(): Promise<number> {
2986
3136
  return [
2987
3137
  {
2988
3138
  id: 'github.pr.open',
2989
- title: 'Open PR',
3139
+ title: 'Open PR (git)',
2990
3140
  aliases: ['open pull request', 'open pr'],
2991
3141
  keywords: ['github', 'pr', 'open', 'pull-request'],
2992
3142
  detail: context.githubTrackedBranch ?? 'current project',
@@ -3040,7 +3190,7 @@ async function main(): Promise<number> {
3040
3190
  return [
3041
3191
  {
3042
3192
  id: 'github.pr.create',
3043
- title: 'Create PR',
3193
+ title: 'Create PR (git)',
3044
3194
  aliases: ['create pull request', 'new pr'],
3045
3195
  keywords: ['github', 'pr', 'create', 'pull-request'],
3046
3196
  detail: context.githubTrackedBranch,
@@ -3494,6 +3644,7 @@ async function main(): Promise<number> {
3494
3644
  scheduleConversationTitlePersist,
3495
3645
  resolveCommandMenuActions,
3496
3646
  executeCommandMenuAction,
3647
+ persistApiKey,
3497
3648
  requestStop,
3498
3649
  resolveDirectoryForAction,
3499
3650
  openNewThreadPrompt,
@@ -72,7 +72,7 @@ function parseArgs(argv: string[], invocationDirectory: string): DaemonOptions {
72
72
  const defaultAuthToken = process.env.HARNESS_CONTROL_PLANE_AUTH_TOKEN ?? null;
73
73
  const defaultStateDbPath = resolveHarnessRuntimePath(
74
74
  invocationDirectory,
75
- process.env.HARNESS_CONTROL_PLANE_DB_PATH ?? '.harness/control-plane.sqlite',
75
+ '.harness/control-plane.sqlite',
76
76
  );
77
77
 
78
78
  let host = defaultHost;
@@ -117,7 +117,7 @@ function parseArgs(argv: string[], invocationDirectory: string): DaemonOptions {
117
117
  if (value === undefined) {
118
118
  throw new Error('missing value for --state-db-path');
119
119
  }
120
- stateDbPath = value;
120
+ stateDbPath = resolveHarnessRuntimePath(invocationDirectory, value);
121
121
  idx += 1;
122
122
  continue;
123
123
  }
@@ -132,6 +132,17 @@ function parseArgs(argv: string[], invocationDirectory: string): DaemonOptions {
132
132
  if (!loopbackHosts.has(host) && authToken === null) {
133
133
  throw new Error('non-loopback hosts require --auth-token or HARNESS_CONTROL_PLANE_AUTH_TOKEN');
134
134
  }
135
+ const workspaceRuntimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, process.env);
136
+ const normalizedRuntimeRoot = resolve(workspaceRuntimeRoot);
137
+ const normalizedStateDbPath = resolve(stateDbPath);
138
+ if (
139
+ normalizedStateDbPath !== normalizedRuntimeRoot &&
140
+ !normalizedStateDbPath.startsWith(`${normalizedRuntimeRoot}/`)
141
+ ) {
142
+ throw new Error(
143
+ `invalid --state-db-path: ${stateDbPath}. state db path must be under workspace runtime root ${workspaceRuntimeRoot}`,
144
+ );
145
+ }
135
146
 
136
147
  return {
137
148
  host,
@@ -19,6 +19,10 @@ import { connectControlPlaneStreamClient } from '../src/control-plane/stream-cli
19
19
  import { parseStreamCommand } from '../src/control-plane/stream-command-parser.ts';
20
20
  import type { StreamCommand } from '../src/control-plane/stream-protocol.ts';
21
21
  import { runHarnessAnimate } from './harness-animate.ts';
22
+ import {
23
+ clearDefaultGatewayPointerForRecordPath,
24
+ writeDefaultGatewayPointerFromGatewayRecord,
25
+ } from '../src/cli/default-gateway-pointer.ts';
22
26
  import {
23
27
  GATEWAY_RECORD_VERSION,
24
28
  DEFAULT_GATEWAY_DB_PATH,
@@ -960,6 +964,7 @@ function writeTextFileAtomically(filePath: string, text: string): void {
960
964
 
961
965
  function writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
962
966
  writeTextFileAtomically(recordPath, serializeGatewayRecord(record));
967
+ writeDefaultGatewayPointerFromGatewayRecord(recordPath, record, process.env);
963
968
  }
964
969
 
965
970
  function removeGatewayRecord(recordPath: string): void {
@@ -971,6 +976,7 @@ function removeGatewayRecord(recordPath: string): void {
971
976
  throw error;
972
977
  }
973
978
  }
979
+ clearDefaultGatewayPointerForRecordPath(recordPath, process.cwd(), process.env);
974
980
  }
975
981
 
976
982
  function readProcessStartedAt(pid: number): string | null {
@@ -1473,8 +1479,9 @@ function listGatewayDaemonProcesses(): readonly ParsedGatewayDaemonEntry[] {
1473
1479
  function isPathWithinWorkspaceRuntimeScope(
1474
1480
  pathValue: string,
1475
1481
  invocationDirectory: string,
1482
+ env: NodeJS.ProcessEnv = process.env,
1476
1483
  ): boolean {
1477
- const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, process.env);
1484
+ const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
1478
1485
  const normalizedRoot = resolve(runtimeRoot);
1479
1486
  const normalizedPath = resolve(pathValue);
1480
1487
  return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
@@ -1702,10 +1709,15 @@ function resolveGatewaySettings(
1702
1709
  const port = normalizeGatewayPort(
1703
1710
  overrides.port ?? record?.port ?? env.HARNESS_CONTROL_PLANE_PORT,
1704
1711
  );
1705
- const configuredStateDbPath =
1706
- overrides.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH ?? defaultStateDbPath;
1712
+ const configuredStateDbPath = overrides.stateDbPath ?? defaultStateDbPath;
1707
1713
  const stateDbPathRaw = normalizeGatewayStateDbPath(configuredStateDbPath, defaultStateDbPath);
1708
1714
  const stateDbPath = resolveHarnessRuntimePath(invocationDirectory, stateDbPathRaw, env);
1715
+ if (!isPathWithinWorkspaceRuntimeScope(stateDbPath, invocationDirectory, env)) {
1716
+ const runtimeRoot = resolveHarnessWorkspaceDirectory(invocationDirectory, env);
1717
+ throw new Error(
1718
+ `invalid --state-db-path: ${stateDbPath}. state db path must be under workspace runtime root ${runtimeRoot}`,
1719
+ );
1720
+ }
1709
1721
 
1710
1722
  const envToken =
1711
1723
  typeof env.HARNESS_CONTROL_PLANE_AUTH_TOKEN === 'string' &&
@@ -2916,7 +2928,7 @@ async function main(): Promise<number> {
2916
2928
  const migration = migrateLegacyHarnessLayout(invocationDirectory, process.env);
2917
2929
  if (migration.migrated) {
2918
2930
  process.stdout.write(
2919
- `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)})\n`,
2931
+ `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)}, legacyRootRemoved=${String(migration.legacyRootRemoved)})\n`,
2920
2932
  );
2921
2933
  }
2922
2934
  loadHarnessSecrets({ cwd: invocationDirectory });
@@ -0,0 +1,193 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { resolveHarnessConfigDirectory } from '../config/config-core.ts';
4
+ import type { GatewayRecord } from './gateway-record.ts';
5
+
6
+ const DEFAULT_GATEWAY_POINTER_VERSION = 1;
7
+ const DEFAULT_GATEWAY_RECORD_PATH_PATTERN = /[\\/]gateway\.json$/u;
8
+ const NAMED_SESSION_GATEWAY_RECORD_PATH_PATTERN = /[\\/]sessions[\\/][^\\/]+[\\/]gateway\.json$/u;
9
+
10
+ const DEFAULT_GATEWAY_POINTER_FILE_NAME = 'default-gateway.json';
11
+
12
+ interface DefaultGatewayPointerRecord {
13
+ readonly version: number;
14
+ readonly workspaceRoot: string;
15
+ readonly workspaceRuntimeRoot: string;
16
+ readonly gatewayRecordPath: string;
17
+ readonly gatewayLogPath: string;
18
+ readonly stateDbPath: string;
19
+ readonly pid: number;
20
+ readonly startedAt: string;
21
+ readonly updatedAt: string;
22
+ readonly gatewayRunId?: string;
23
+ }
24
+
25
+ function asRecord(value: unknown): Record<string, unknown> | null {
26
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
27
+ return null;
28
+ }
29
+ return value as Record<string, unknown>;
30
+ }
31
+
32
+ function readNonEmptyString(value: unknown): string | null {
33
+ if (typeof value !== 'string') {
34
+ return null;
35
+ }
36
+ const trimmed = value.trim();
37
+ return trimmed.length > 0 ? trimmed : null;
38
+ }
39
+
40
+ function readPositiveInt(value: unknown): number | null {
41
+ if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
42
+ return null;
43
+ }
44
+ return value > 0 ? value : null;
45
+ }
46
+
47
+ function isDefaultGatewayRecordPath(recordPath: string): boolean {
48
+ const normalizedPath = resolve(recordPath);
49
+ return (
50
+ DEFAULT_GATEWAY_RECORD_PATH_PATTERN.test(normalizedPath) &&
51
+ !NAMED_SESSION_GATEWAY_RECORD_PATH_PATTERN.test(normalizedPath)
52
+ );
53
+ }
54
+
55
+ export function resolveDefaultGatewayPointerPath(
56
+ invocationDirectory: string,
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ ): string {
59
+ return resolve(
60
+ resolveHarnessConfigDirectory(invocationDirectory, env),
61
+ DEFAULT_GATEWAY_POINTER_FILE_NAME,
62
+ );
63
+ }
64
+
65
+ export function parseDefaultGatewayPointerText(text: string): DefaultGatewayPointerRecord | null {
66
+ let parsed: unknown;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ } catch {
70
+ return null;
71
+ }
72
+ const record = asRecord(parsed);
73
+ if (record === null) {
74
+ return null;
75
+ }
76
+ if (record['version'] !== DEFAULT_GATEWAY_POINTER_VERSION) {
77
+ return null;
78
+ }
79
+ const workspaceRoot = readNonEmptyString(record['workspaceRoot']);
80
+ const workspaceRuntimeRoot = readNonEmptyString(record['workspaceRuntimeRoot']);
81
+ const gatewayRecordPath = readNonEmptyString(record['gatewayRecordPath']);
82
+ const gatewayLogPath = readNonEmptyString(record['gatewayLogPath']);
83
+ const stateDbPath = readNonEmptyString(record['stateDbPath']);
84
+ const startedAt = readNonEmptyString(record['startedAt']);
85
+ const updatedAt = readNonEmptyString(record['updatedAt']);
86
+ const pid = readPositiveInt(record['pid']);
87
+ const gatewayRunIdRaw = record['gatewayRunId'];
88
+ const gatewayRunId =
89
+ gatewayRunIdRaw === undefined ? undefined : readNonEmptyString(gatewayRunIdRaw);
90
+
91
+ if (
92
+ workspaceRoot === null ||
93
+ workspaceRuntimeRoot === null ||
94
+ gatewayRecordPath === null ||
95
+ gatewayLogPath === null ||
96
+ stateDbPath === null ||
97
+ startedAt === null ||
98
+ updatedAt === null ||
99
+ pid === null ||
100
+ (gatewayRunIdRaw !== undefined && gatewayRunId === null)
101
+ ) {
102
+ return null;
103
+ }
104
+ const parsedGatewayRunId = gatewayRunId === null ? undefined : gatewayRunId;
105
+
106
+ return {
107
+ version: DEFAULT_GATEWAY_POINTER_VERSION,
108
+ workspaceRoot,
109
+ workspaceRuntimeRoot,
110
+ gatewayRecordPath,
111
+ gatewayLogPath,
112
+ stateDbPath,
113
+ pid,
114
+ startedAt,
115
+ updatedAt,
116
+ ...(parsedGatewayRunId === undefined ? {} : { gatewayRunId: parsedGatewayRunId }),
117
+ };
118
+ }
119
+
120
+ export function readDefaultGatewayPointer(
121
+ invocationDirectory: string,
122
+ env: NodeJS.ProcessEnv = process.env,
123
+ ): DefaultGatewayPointerRecord | null {
124
+ const pointerPath = resolveDefaultGatewayPointerPath(invocationDirectory, env);
125
+ if (!existsSync(pointerPath)) {
126
+ return null;
127
+ }
128
+ try {
129
+ return parseDefaultGatewayPointerText(readFileSync(pointerPath, 'utf8'));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ export function writeDefaultGatewayPointerFromGatewayRecord(
136
+ recordPath: string,
137
+ record: GatewayRecord,
138
+ env: NodeJS.ProcessEnv = process.env,
139
+ ): void {
140
+ if (!isDefaultGatewayRecordPath(recordPath)) {
141
+ return;
142
+ }
143
+ const normalizedRecordPath = resolve(recordPath);
144
+ const pointerPath = resolveDefaultGatewayPointerPath(record.workspaceRoot, env);
145
+ const payload: DefaultGatewayPointerRecord = {
146
+ version: DEFAULT_GATEWAY_POINTER_VERSION,
147
+ workspaceRoot: record.workspaceRoot,
148
+ workspaceRuntimeRoot: dirname(normalizedRecordPath),
149
+ gatewayRecordPath: normalizedRecordPath,
150
+ gatewayLogPath: resolve(dirname(normalizedRecordPath), 'gateway.log'),
151
+ stateDbPath: record.stateDbPath,
152
+ pid: record.pid,
153
+ startedAt: record.startedAt,
154
+ updatedAt: new Date().toISOString(),
155
+ ...(record.gatewayRunId == null ? {} : { gatewayRunId: record.gatewayRunId }),
156
+ };
157
+ mkdirSync(dirname(pointerPath), { recursive: true });
158
+ writeFileSync(pointerPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
159
+ }
160
+
161
+ export function clearDefaultGatewayPointerForRecordPath(
162
+ recordPath: string,
163
+ invocationDirectory: string = process.cwd(),
164
+ env: NodeJS.ProcessEnv = process.env,
165
+ ): void {
166
+ if (!isDefaultGatewayRecordPath(recordPath)) {
167
+ return;
168
+ }
169
+ const pointerPath = resolveDefaultGatewayPointerPath(invocationDirectory, env);
170
+ if (!existsSync(pointerPath)) {
171
+ return;
172
+ }
173
+ let pointer: DefaultGatewayPointerRecord | null = null;
174
+ try {
175
+ pointer = parseDefaultGatewayPointerText(readFileSync(pointerPath, 'utf8'));
176
+ } catch {
177
+ pointer = null;
178
+ }
179
+ if (pointer === null) {
180
+ return;
181
+ }
182
+ if (resolve(pointer.gatewayRecordPath) !== resolve(recordPath)) {
183
+ return;
184
+ }
185
+ try {
186
+ unlinkSync(pointerPath);
187
+ } catch (error: unknown) {
188
+ const code = (error as NodeJS.ErrnoException).code;
189
+ if (code !== 'ENOENT') {
190
+ throw error;
191
+ }
192
+ }
193
+ }