@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
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
|
|
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
|
-
-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/scripts/harness.ts
CHANGED
|
@@ -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,
|
|
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
|
+
}
|