@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for reading OpenCode configuration and auth files,
|
|
3
|
+
* and merging auth keys into provider configs so custom providers
|
|
4
|
+
* (e.g. routerAI) are correctly authenticated when the server starts.
|
|
5
|
+
*
|
|
6
|
+
* The OpenCode server reads `opencode.json` for provider definitions but
|
|
7
|
+
* does NOT automatically merge API keys from `auth.json` into custom
|
|
8
|
+
* config-based providers. When we spawn the server we pass the merged
|
|
9
|
+
* config via `OPENCODE_CONFIG_CONTENT` so every provider has its key.
|
|
10
|
+
*/
|
|
11
|
+
import type { ModelGatewayConfigSource } from './model-gateway';
|
|
12
|
+
export interface OpencodeAuthEntry {
|
|
13
|
+
type: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
refresh?: string;
|
|
16
|
+
access?: string;
|
|
17
|
+
expires?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reads the user's `~/.config/opencode/opencode.json`.
|
|
21
|
+
* Returns the parsed object or an empty object on any failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readOpencodeConfig(): Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* Reads the user's `~/.local/share/opencode/auth.json`.
|
|
26
|
+
* Returns a map of provider-id -> auth entry, or empty on failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readOpencodeAuth(): Record<string, OpencodeAuthEntry>;
|
|
29
|
+
/**
|
|
30
|
+
* Builds a complete config object suitable for `OPENCODE_CONFIG_CONTENT`.
|
|
31
|
+
*
|
|
32
|
+
* 1. Reads the user's `opencode.json`
|
|
33
|
+
* 2. Reads `auth.json`
|
|
34
|
+
* 3. For every provider defined in the config that has a matching auth entry
|
|
35
|
+
* but no `apiKey` in its options, injects the key from auth.json.
|
|
36
|
+
* 4. Merges any `extraConfig` on top (e.g. plugin paths from the adapter).
|
|
37
|
+
*
|
|
38
|
+
* This ensures custom providers like routerAI — whose API key lives only
|
|
39
|
+
* in auth.json — are included by the server in its `/config/providers` response.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildMergedOpencodeConfig(extraConfig?: Record<string, unknown>, gateway?: ModelGatewayConfigSource): Record<string, unknown>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Utilities for reading OpenCode configuration and auth files,
|
|
4
|
+
* and merging auth keys into provider configs so custom providers
|
|
5
|
+
* (e.g. routerAI) are correctly authenticated when the server starts.
|
|
6
|
+
*
|
|
7
|
+
* The OpenCode server reads `opencode.json` for provider definitions but
|
|
8
|
+
* does NOT automatically merge API keys from `auth.json` into custom
|
|
9
|
+
* config-based providers. When we spawn the server we pass the merged
|
|
10
|
+
* config via `OPENCODE_CONFIG_CONTENT` so every provider has its key.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.readOpencodeConfig = readOpencodeConfig;
|
|
14
|
+
exports.readOpencodeAuth = readOpencodeAuth;
|
|
15
|
+
exports.buildMergedOpencodeConfig = buildMergedOpencodeConfig;
|
|
16
|
+
/**
|
|
17
|
+
* Extracted verbatim from 20x `src/main/utils/opencode-config.ts`.
|
|
18
|
+
* CHANGED: the enterprise-AI-gateway dependency is now injected.
|
|
19
|
+
* `readEnterpriseAiGatewayConfig(db)` (which lives in an Electron-only module
|
|
20
|
+
* because it decrypts via `safeStorage`) is replaced by
|
|
21
|
+
* `gateway?.readModelGatewayConfig() ?? null`, and the second parameter of
|
|
22
|
+
* `buildMergedOpencodeConfig` changed from `Pick<DatabaseManager,'getSetting'>`
|
|
23
|
+
* to `ModelGatewayConfigSource`. All surrounding logic is untouched.
|
|
24
|
+
* Source formatting (no semicolons) preserved.
|
|
25
|
+
*/
|
|
26
|
+
const fs_1 = require("fs");
|
|
27
|
+
const url_1 = require("url");
|
|
28
|
+
const path_1 = require("path");
|
|
29
|
+
const os_1 = require("os");
|
|
30
|
+
const model_gateway_1 = require("./model-gateway");
|
|
31
|
+
/** Paths used by the OpenCode CLI to store config & auth. */
|
|
32
|
+
const OPENCODE_CONFIG_PATH = (0, path_1.join)((0, os_1.homedir)(), '.config', 'opencode', 'opencode.json');
|
|
33
|
+
const OPENCODE_AUTH_PATH = (0, path_1.join)((0, os_1.homedir)(), '.local', 'share', 'opencode', 'auth.json');
|
|
34
|
+
/**
|
|
35
|
+
* Reads the user's `~/.config/opencode/opencode.json`.
|
|
36
|
+
* Returns the parsed object or an empty object on any failure.
|
|
37
|
+
*/
|
|
38
|
+
function readOpencodeConfig() {
|
|
39
|
+
try {
|
|
40
|
+
if ((0, fs_1.existsSync)(OPENCODE_CONFIG_PATH)) {
|
|
41
|
+
return JSON.parse((0, fs_1.readFileSync)(OPENCODE_CONFIG_PATH, 'utf-8'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
console.log('[opencode-config] Could not read opencode.json:', e);
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reads the user's `~/.local/share/opencode/auth.json`.
|
|
51
|
+
* Returns a map of provider-id -> auth entry, or empty on failure.
|
|
52
|
+
*/
|
|
53
|
+
function readOpencodeAuth() {
|
|
54
|
+
try {
|
|
55
|
+
if ((0, fs_1.existsSync)(OPENCODE_AUTH_PATH)) {
|
|
56
|
+
return JSON.parse((0, fs_1.readFileSync)(OPENCODE_AUTH_PATH, 'utf-8'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
console.log('[opencode-config] Could not read auth.json:', e);
|
|
61
|
+
}
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
function is20xRuntimePluginPath(value) {
|
|
65
|
+
return typeof value === 'string' &&
|
|
66
|
+
(value.endsWith('/.opencode/plugins/20x-tilldone.js') ||
|
|
67
|
+
value.endsWith('/.opencode/plugins/20x-secret-injector.js'));
|
|
68
|
+
}
|
|
69
|
+
function pluginPathExists(pluginPath) {
|
|
70
|
+
try {
|
|
71
|
+
return (0, fs_1.existsSync)(pluginPath.startsWith('file://') ? (0, url_1.fileURLToPath)(pluginPath) : pluginPath);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function removeMissing20xRuntimePlugins(config) {
|
|
78
|
+
if (!Array.isArray(config.plugin))
|
|
79
|
+
return;
|
|
80
|
+
const plugins = config.plugin.filter((pluginPath) => {
|
|
81
|
+
if (!is20xRuntimePluginPath(pluginPath))
|
|
82
|
+
return true;
|
|
83
|
+
return pluginPathExists(pluginPath);
|
|
84
|
+
});
|
|
85
|
+
if (plugins.length > 0) {
|
|
86
|
+
config.plugin = plugins;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
delete config.plugin;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Builds a complete config object suitable for `OPENCODE_CONFIG_CONTENT`.
|
|
94
|
+
*
|
|
95
|
+
* 1. Reads the user's `opencode.json`
|
|
96
|
+
* 2. Reads `auth.json`
|
|
97
|
+
* 3. For every provider defined in the config that has a matching auth entry
|
|
98
|
+
* but no `apiKey` in its options, injects the key from auth.json.
|
|
99
|
+
* 4. Merges any `extraConfig` on top (e.g. plugin paths from the adapter).
|
|
100
|
+
*
|
|
101
|
+
* This ensures custom providers like routerAI — whose API key lives only
|
|
102
|
+
* in auth.json — are included by the server in its `/config/providers` response.
|
|
103
|
+
*/
|
|
104
|
+
function buildMergedOpencodeConfig(extraConfig, gateway) {
|
|
105
|
+
const config = readOpencodeConfig();
|
|
106
|
+
const auth = readOpencodeAuth();
|
|
107
|
+
removeMissing20xRuntimePlugins(config);
|
|
108
|
+
// Inject auth keys into providers that lack an inline apiKey
|
|
109
|
+
const providers = config.provider;
|
|
110
|
+
if (providers && typeof providers === 'object') {
|
|
111
|
+
for (const [providerId, providerCfg] of Object.entries(providers)) {
|
|
112
|
+
if (!providerCfg || typeof providerCfg !== 'object')
|
|
113
|
+
continue;
|
|
114
|
+
const options = (providerCfg.options ?? {});
|
|
115
|
+
// Skip if the provider already has an apiKey in its options
|
|
116
|
+
if (options.apiKey)
|
|
117
|
+
continue;
|
|
118
|
+
// Check auth.json for a matching key
|
|
119
|
+
const authEntry = auth[providerId];
|
|
120
|
+
if (authEntry?.key) {
|
|
121
|
+
providerCfg.options = { ...options, apiKey: authEntry.key };
|
|
122
|
+
console.log(`[opencode-config] Injected API key for provider "${providerId}" from auth.json`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const enterpriseAiGatewayConfig = gateway?.readModelGatewayConfig() ?? null;
|
|
127
|
+
if (enterpriseAiGatewayConfig) {
|
|
128
|
+
const existingProviders = config.provider ?? {};
|
|
129
|
+
config.provider = {
|
|
130
|
+
...existingProviders,
|
|
131
|
+
...(0, model_gateway_1.buildEnterpriseAiGatewayProviderConfig)(enterpriseAiGatewayConfig)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// NOTE: OpenCode's `external_directory` permission defaults to "ask".
|
|
135
|
+
// Permissions are now surfaced in the 20x UI via the OpenCode adapter's
|
|
136
|
+
// SSE event subscription and getPendingApproval()/respondToApproval()
|
|
137
|
+
// methods — no need to auto-allow here.
|
|
138
|
+
// Merge extra config (e.g. plugin paths) on top
|
|
139
|
+
if (extraConfig) {
|
|
140
|
+
for (const [key, value] of Object.entries(extraConfig)) {
|
|
141
|
+
if (value !== undefined) {
|
|
142
|
+
config[key] = value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return config;
|
|
147
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { RunnerRuntime } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* The well-known port a DESKTOP opencode server listens on.
|
|
4
|
+
*
|
|
5
|
+
* It is well known precisely because a human may already be using it. That is
|
|
6
|
+
* why it belongs to exactly one policy — `shared` — and why nothing in this
|
|
7
|
+
* module ever kills a process bound to it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SHARED_SERVER_URL = "http://localhost:4096";
|
|
10
|
+
/**
|
|
11
|
+
* WHO OWNS THE OPENCODE SERVER THIS RUNNER TALKS TO.
|
|
12
|
+
*
|
|
13
|
+
* This is the ONE place that encodes the decision, deliberately mirroring
|
|
14
|
+
* `createCredentialStore` in `credential-store.ts`: a property of the RUNTIME,
|
|
15
|
+
* resolved once, so a future runtime has a single line to change and cannot
|
|
16
|
+
* silently inherit the wrong policy.
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS EXISTS AT ALL. Until now every runtime went to
|
|
19
|
+
* `http://localhost:4096` and ADOPTED whatever answered. On a developer's
|
|
20
|
+
* laptop that is the human's own long-lived server: its session lifetime, its
|
|
21
|
+
* pruning and its state are outside the harness's control, and the agent's
|
|
22
|
+
* sessions share a pool with the human's own work. Measured on the owner's
|
|
23
|
+
* machine, that server had been up for four days and held 100 sessions from
|
|
24
|
+
* every use of opencode on that machine.
|
|
25
|
+
*
|
|
26
|
+
* - `sandbox` -> owned. The machine exists only to run this harness.
|
|
27
|
+
* - `self-hosted` -> owned. A customer VM the harness was installed on; the
|
|
28
|
+
* customer may well also use opencode there, and taking
|
|
29
|
+
* over their server would be exactly the same trespass.
|
|
30
|
+
* - `desktop` -> shared. 20x is a DIFFERENT case: the user may
|
|
31
|
+
* legitimately want one opencode server behind both the
|
|
32
|
+
* desktop app and the agent, and that behaviour ships
|
|
33
|
+
* today. It is not regressed here.
|
|
34
|
+
*/
|
|
35
|
+
export type OpencodeServerPolicy = {
|
|
36
|
+
readonly kind: 'owned';
|
|
37
|
+
/**
|
|
38
|
+
* opencode's state directory, honoured through `XDG_DATA_HOME`.
|
|
39
|
+
*
|
|
40
|
+
* MEASURED, not assumed: two opencode servers pointed at ONE state
|
|
41
|
+
* directory create sessions that the other can immediately read, and a
|
|
42
|
+
* session created before a server is killed is still readable after a
|
|
43
|
+
* fresh server starts on the same directory. So the unit of resumability
|
|
44
|
+
* is the STATE DIRECTORY, not the process and not the port.
|
|
45
|
+
*/
|
|
46
|
+
readonly stateDir: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: 'shared';
|
|
49
|
+
readonly url: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function createOpencodeServerPolicy(runtime: RunnerRuntime, workspaceRoot: string): OpencodeServerPolicy;
|
|
52
|
+
/**
|
|
53
|
+
* The identity of the backend instance that can honour a checkpoint.
|
|
54
|
+
*
|
|
55
|
+
* WHY A CHECKPOINT NEEDS ONE. `coding_agent_sessions.backend_name` already
|
|
56
|
+
* records that "a checkpoint is only meaningful to the backend that created
|
|
57
|
+
* it" — but it stops one level short. An opencode session id is not meaningful
|
|
58
|
+
* to *opencode*; it is meaningful to the opencode STATE DIRECTORY that minted
|
|
59
|
+
* it. Handing `ses_...` to a different opencode server is handing it a string.
|
|
60
|
+
*
|
|
61
|
+
* This is not hypothetical. On the owner's machine one conversation was leased
|
|
62
|
+
* alternately to a laptop runner and to a cloud-sandbox runner reached over
|
|
63
|
+
* ngrok. Each minted a session on its own opencode, the control plane stored
|
|
64
|
+
* the id unqualified, and the next turn on the other machine was handed an id
|
|
65
|
+
* its server had never issued — so it logged "Session no longer exists on
|
|
66
|
+
* server" and replayed the entire history. Every turn. Silently.
|
|
67
|
+
*
|
|
68
|
+
* The id is a random UUID written once into the state directory, so:
|
|
69
|
+
* - two harness processes sharing a state directory agree (which is why
|
|
70
|
+
* three successive runner restarts on one laptop DID resume correctly),
|
|
71
|
+
* - it survives a harness restart and an opencode restart,
|
|
72
|
+
* - and two different machines can never collide.
|
|
73
|
+
*/
|
|
74
|
+
export declare function backendInstanceId(policy: OpencodeServerPolicy): string;
|
|
75
|
+
/**
|
|
76
|
+
* A free loopback port, asked of the kernel rather than guessed.
|
|
77
|
+
*
|
|
78
|
+
* Binding to port 0 and reading back the assignment is the only way to pick a
|
|
79
|
+
* port without a race against a range someone else also scans, and without
|
|
80
|
+
* ever landing on a well-known one a human might be using.
|
|
81
|
+
*/
|
|
82
|
+
export declare function ephemeralLoopbackPort(): Promise<number>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SHARED_SERVER_URL = void 0;
|
|
4
|
+
exports.createOpencodeServerPolicy = createOpencodeServerPolicy;
|
|
5
|
+
exports.backendInstanceId = backendInstanceId;
|
|
6
|
+
exports.ephemeralLoopbackPort = ephemeralLoopbackPort;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const node_net_1 = require("node:net");
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
/**
|
|
12
|
+
* The well-known port a DESKTOP opencode server listens on.
|
|
13
|
+
*
|
|
14
|
+
* It is well known precisely because a human may already be using it. That is
|
|
15
|
+
* why it belongs to exactly one policy — `shared` — and why nothing in this
|
|
16
|
+
* module ever kills a process bound to it.
|
|
17
|
+
*/
|
|
18
|
+
exports.SHARED_SERVER_URL = 'http://localhost:4096';
|
|
19
|
+
function createOpencodeServerPolicy(runtime, workspaceRoot) {
|
|
20
|
+
if (runtime === 'desktop') {
|
|
21
|
+
return { kind: 'shared', url: exports.SHARED_SERVER_URL };
|
|
22
|
+
}
|
|
23
|
+
/*
|
|
24
|
+
* NOT inside a session's workspace directory. A session workspace now lives
|
|
25
|
+
* until the session ends, but backend state has a different owner and must
|
|
26
|
+
* also survive an explicit workspace wipe.
|
|
27
|
+
*/
|
|
28
|
+
return { kind: 'owned', stateDir: (0, node_path_1.join)(workspaceRoot, 'opencode-state') };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One id per state directory per process, so concurrent turns cannot disagree.
|
|
32
|
+
*
|
|
33
|
+
* The harness builds a NEW adapter for every turn and may run several turns at
|
|
34
|
+
* once, so this is called repeatedly and concurrently. Without the cache each
|
|
35
|
+
* call re-reads the file, and two callers that both miss would both mint.
|
|
36
|
+
*/
|
|
37
|
+
const instanceIdCache = new Map();
|
|
38
|
+
/**
|
|
39
|
+
* The identity of the backend instance that can honour a checkpoint.
|
|
40
|
+
*
|
|
41
|
+
* WHY A CHECKPOINT NEEDS ONE. `coding_agent_sessions.backend_name` already
|
|
42
|
+
* records that "a checkpoint is only meaningful to the backend that created
|
|
43
|
+
* it" — but it stops one level short. An opencode session id is not meaningful
|
|
44
|
+
* to *opencode*; it is meaningful to the opencode STATE DIRECTORY that minted
|
|
45
|
+
* it. Handing `ses_...` to a different opencode server is handing it a string.
|
|
46
|
+
*
|
|
47
|
+
* This is not hypothetical. On the owner's machine one conversation was leased
|
|
48
|
+
* alternately to a laptop runner and to a cloud-sandbox runner reached over
|
|
49
|
+
* ngrok. Each minted a session on its own opencode, the control plane stored
|
|
50
|
+
* the id unqualified, and the next turn on the other machine was handed an id
|
|
51
|
+
* its server had never issued — so it logged "Session no longer exists on
|
|
52
|
+
* server" and replayed the entire history. Every turn. Silently.
|
|
53
|
+
*
|
|
54
|
+
* The id is a random UUID written once into the state directory, so:
|
|
55
|
+
* - two harness processes sharing a state directory agree (which is why
|
|
56
|
+
* three successive runner restarts on one laptop DID resume correctly),
|
|
57
|
+
* - it survives a harness restart and an opencode restart,
|
|
58
|
+
* - and two different machines can never collide.
|
|
59
|
+
*/
|
|
60
|
+
function backendInstanceId(policy) {
|
|
61
|
+
const dir = policy.kind === 'owned' ? policy.stateDir : sharedStateDirectory();
|
|
62
|
+
const cached = instanceIdCache.get(dir);
|
|
63
|
+
if (cached)
|
|
64
|
+
return cached;
|
|
65
|
+
const marker = (0, node_path_1.join)(dir, '.pf-backend-instance-id');
|
|
66
|
+
const readExisting = () => {
|
|
67
|
+
try {
|
|
68
|
+
const existing = (0, node_fs_1.readFileSync)(marker, 'utf8').trim();
|
|
69
|
+
return existing || null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Not written yet, or unreadable. Both mean "try to mint".
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const existing = readExisting();
|
|
77
|
+
if (existing) {
|
|
78
|
+
instanceIdCache.set(dir, existing);
|
|
79
|
+
return existing;
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
* MINTED WITH AN EXCLUSIVE CREATE, NOT A PLAIN WRITE.
|
|
83
|
+
*
|
|
84
|
+
* Read-then-write is not atomic: two processes sharing a state directory —
|
|
85
|
+
* two harnesses on one machine, or a harness restarting beside itself — can
|
|
86
|
+
* both see no file and both write, and the second silently overwrites the
|
|
87
|
+
* first. They would then disagree about who owns every session in that
|
|
88
|
+
* directory, which is the very confusion this id exists to prevent.
|
|
89
|
+
*
|
|
90
|
+
* `wx` makes the create the arbitration point: exactly one caller wins, and
|
|
91
|
+
* every loser re-reads and adopts the winner's id.
|
|
92
|
+
*/
|
|
93
|
+
const minted = (0, node_crypto_1.randomUUID)();
|
|
94
|
+
try {
|
|
95
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
(0, node_fs_1.writeFileSync)(marker, minted, { flag: 'wx', mode: 0o600 });
|
|
97
|
+
instanceIdCache.set(dir, minted);
|
|
98
|
+
return minted;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error?.code === 'EEXIST') {
|
|
102
|
+
// Somebody else won the race. Their id is the directory's id.
|
|
103
|
+
const winner = readExisting();
|
|
104
|
+
if (winner) {
|
|
105
|
+
instanceIdCache.set(dir, winner);
|
|
106
|
+
return winner;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/*
|
|
110
|
+
* A state directory we cannot write is a state directory whose sessions we
|
|
111
|
+
* cannot trust to be ours. Returning the un-persisted id is correct and
|
|
112
|
+
* fails SAFE: it matches no stored checkpoint, so the next turn replays
|
|
113
|
+
* once instead of resuming something that may belong to someone else.
|
|
114
|
+
*
|
|
115
|
+
* NOT cached, so a directory that becomes writable later still settles on
|
|
116
|
+
* one persisted id rather than being stuck on a per-process invention.
|
|
117
|
+
*/
|
|
118
|
+
return minted;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Where a SHARED (desktop) opencode server keeps its state. */
|
|
122
|
+
function sharedStateDirectory() {
|
|
123
|
+
const home = process.env.HOME || process.env.USERPROFILE || '.';
|
|
124
|
+
return (0, node_path_1.join)(home, '.local', 'share', 'opencode');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A free loopback port, asked of the kernel rather than guessed.
|
|
128
|
+
*
|
|
129
|
+
* Binding to port 0 and reading back the assignment is the only way to pick a
|
|
130
|
+
* port without a race against a range someone else also scans, and without
|
|
131
|
+
* ever landing on a well-known one a human might be using.
|
|
132
|
+
*/
|
|
133
|
+
async function ephemeralLoopbackPort() {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const probe = (0, node_net_1.createServer)();
|
|
136
|
+
probe.unref();
|
|
137
|
+
probe.on('error', reject);
|
|
138
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
139
|
+
const address = probe.address();
|
|
140
|
+
if (typeof address === 'string' || address === null) {
|
|
141
|
+
probe.close(() => reject(new Error('Could not read an ephemeral port')));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { port } = address;
|
|
145
|
+
probe.close(() => resolve(port));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi coding-agent adapter.
|
|
3
|
+
*
|
|
4
|
+
* Pi exposes a JSONL RPC protocol over stdin/stdout. The harness keeps one Pi
|
|
5
|
+
* process per live turn and converts Pi events to the shared adapter shape.
|
|
6
|
+
*/
|
|
7
|
+
import type { CodingAgentAdapter, MessagePart, SessionConfig, SessionMessage, SessionStatus } from './coding-agent-adapter';
|
|
8
|
+
import { type ModelGatewayConfigSource } from './model-gateway';
|
|
9
|
+
/**
|
|
10
|
+
* Most model providers cap function/tool `name` at 64 characters. Pi forwards
|
|
11
|
+
* MCP tools as `<server>_<tool>`-style names, so a long MCP server name (e.g.
|
|
12
|
+
* "[Workflo] Organisation Workspace") plus a long tool name overflows the
|
|
13
|
+
* limit and the whole turn fails with "name must be at most 64 characters".
|
|
14
|
+
* Keep server slugs short to leave room for the tool suffix.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_PI_NAME_LENGTH = 64;
|
|
17
|
+
export declare const MAX_PI_MCP_SERVER_SLUG_LENGTH = 24;
|
|
18
|
+
/**
|
|
19
|
+
* Electron's main macOS executable belongs to the foreground application
|
|
20
|
+
* bundle. Reusing it as Node makes LaunchServices register every Pi process as
|
|
21
|
+
* another foreground 20x application, which adds a Dock icon. Electron's
|
|
22
|
+
* standard Helper.app is an LSUIElement and exposes the same bundled Node
|
|
23
|
+
* runtime without participating in the Dock.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findPiNodeRuntime(platform: NodeJS.Platform, electronExecutable: string, pathExists?: (path: string) => boolean): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Short Pi-side aliases for MCP servers whose display names are too long to
|
|
28
|
+
* fit the provider 64-char tool-name limit. The alias is only used in the
|
|
29
|
+
* Pi `--mcp-config` file — display names in settings, docs, and other
|
|
30
|
+
* backends are untouched.
|
|
31
|
+
*/
|
|
32
|
+
export declare const PI_MCP_SERVER_ALIASES: Record<string, string>;
|
|
33
|
+
export declare function sanitizePiSessionName(taskId: string): string;
|
|
34
|
+
export declare function sanitizePiMcpServerName(name: string, used: Set<string>): string;
|
|
35
|
+
export declare function withProviderNameLimitHint(error: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Build the pi-mcp-adapter document used by Peakflo harness sessions.
|
|
38
|
+
*
|
|
39
|
+
* Keep MCP servers behind namespace proxy tools (`mcp__<server>`). Direct MCP
|
|
40
|
+
* tools concatenate the server and tool names; a 24-character server slug and
|
|
41
|
+
* a 50-character generated workflow tool already produce a 76-character name.
|
|
42
|
+
* Providers commonly reject the entire request when any tool exceeds 64
|
|
43
|
+
* characters, before the model can call a tool.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildPiMcpConfigDocument(servers: NonNullable<SessionConfig['mcpServers']>, onRename?: (name: string, slug: string) => void): Record<string, unknown>;
|
|
46
|
+
export declare class PiAdapter implements CodingAgentAdapter {
|
|
47
|
+
private modelGateway;
|
|
48
|
+
private sessions;
|
|
49
|
+
private piExecutablePath;
|
|
50
|
+
onDataAvailable?: (sessionId: string) => void;
|
|
51
|
+
constructor(modelGateway: ModelGatewayConfigSource);
|
|
52
|
+
private agentDirectory;
|
|
53
|
+
/** Identity of the Pi session store that can honour native session files. */
|
|
54
|
+
backendInstanceId(): string;
|
|
55
|
+
initialize(): Promise<void>;
|
|
56
|
+
private findPiExecutable;
|
|
57
|
+
/**
|
|
58
|
+
* Install or update the Peakflo provider in Pi's normal models file. The key
|
|
59
|
+
* remains an environment reference; the harness supplies the session-scoped
|
|
60
|
+
* value only to the child process.
|
|
61
|
+
*/
|
|
62
|
+
private installPeakfloGateway;
|
|
63
|
+
private buildMcpConfig;
|
|
64
|
+
private installPermissionExtension;
|
|
65
|
+
private processEnv;
|
|
66
|
+
private buildSkillFiles;
|
|
67
|
+
/**
|
|
68
|
+
* Pi's npm launcher uses `#!/usr/bin/env node`. Launching it directly would
|
|
69
|
+
* therefore make Pi depend on whichever Node happens to be first on the
|
|
70
|
+
* desktop app's PATH. Recent Pi releases require Node >=22.19 and use zstd
|
|
71
|
+
* APIs that are absent from older runtimes. On Linux, run the launcher with
|
|
72
|
+
* Electron's main executable. On macOS, use Electron's LSUIElement helper;
|
|
73
|
+
* launching the foreground app executable creates another Dock icon for
|
|
74
|
+
* every Pi session. Both expose the same bundled Node runtime.
|
|
75
|
+
*
|
|
76
|
+
* Windows npm launchers are .cmd files, so they must continue to run through
|
|
77
|
+
* the shell.
|
|
78
|
+
*/
|
|
79
|
+
private piInvocation;
|
|
80
|
+
private createSessionState;
|
|
81
|
+
private splitModel;
|
|
82
|
+
private applySelection;
|
|
83
|
+
private spawnSession;
|
|
84
|
+
createSession(config: SessionConfig): Promise<string>;
|
|
85
|
+
resumeSession(sessionId: string, config: SessionConfig): Promise<SessionMessage[]>;
|
|
86
|
+
private attachProcess;
|
|
87
|
+
private consumeStdout;
|
|
88
|
+
private handleJsonlLine;
|
|
89
|
+
private rejectPending;
|
|
90
|
+
private handleEvent;
|
|
91
|
+
private handleExtensionUiRequest;
|
|
92
|
+
private uiRequestMessage;
|
|
93
|
+
private cancelPendingUiRequests;
|
|
94
|
+
private handleMessageUpdate;
|
|
95
|
+
private reconcileAssistantMessage;
|
|
96
|
+
private handleToolEvent;
|
|
97
|
+
private command;
|
|
98
|
+
private sendRecord;
|
|
99
|
+
sendPrompt(sessionId: string, parts: MessagePart[], _config: SessionConfig): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* THE LONG-TURNS SOFT WARNING'S DELIVERY MECHANISM.
|
|
102
|
+
*
|
|
103
|
+
* Reuses exactly the `streamingBehavior: 'steer'` path `sendPrompt` already
|
|
104
|
+
* takes for a message sent while Pi is busy — Pi delivers it after the
|
|
105
|
+
* current tool finishes and before the next model call. Deliberately does
|
|
106
|
+
* NOT touch `session.status`/`sawAgentActivity`/`promptMayBeCommandOnly`:
|
|
107
|
+
* those are `sendPrompt`'s own bookkeeping for a NEW prompt, and a steer
|
|
108
|
+
* into an already-running turn must not reset them.
|
|
109
|
+
*/
|
|
110
|
+
steerTurn(sessionId: string, text: string, _config: SessionConfig): Promise<boolean>;
|
|
111
|
+
getStatus(sessionId: string, _config: SessionConfig): Promise<SessionStatus>;
|
|
112
|
+
private settlePendingTurnError;
|
|
113
|
+
pollMessages(sessionId: string): Promise<MessagePart[]>;
|
|
114
|
+
getPendingApproval(sessionId: string): {
|
|
115
|
+
requestId: string;
|
|
116
|
+
toolCallId: string;
|
|
117
|
+
question: string;
|
|
118
|
+
options: Array<{
|
|
119
|
+
optionId: string;
|
|
120
|
+
name: string;
|
|
121
|
+
kind: string;
|
|
122
|
+
}>;
|
|
123
|
+
} | null;
|
|
124
|
+
respondToApproval(sessionId: string, approved: boolean, _optionId?: string, requestId?: string): Promise<boolean>;
|
|
125
|
+
respondToQuestion(sessionId: string, answers: Record<string, string>, _config: SessionConfig, requestId?: string): Promise<boolean | {
|
|
126
|
+
handled: false;
|
|
127
|
+
resolutionPart: MessagePart;
|
|
128
|
+
}>;
|
|
129
|
+
getRunningTools(sessionId: string): Promise<Array<{
|
|
130
|
+
partId: string;
|
|
131
|
+
toolName: string;
|
|
132
|
+
input?: Record<string, unknown>;
|
|
133
|
+
}>>;
|
|
134
|
+
private convertMessages;
|
|
135
|
+
getAllMessages(sessionId: string): Promise<SessionMessage[]>;
|
|
136
|
+
abortPrompt(sessionId: string): Promise<void>;
|
|
137
|
+
private terminateProcess;
|
|
138
|
+
destroySession(sessionId: string): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* SessionRunner creates one adapter per turn. Stop every process owned by
|
|
141
|
+
* this adapter after the turn while leaving Pi's session files intact for
|
|
142
|
+
* native resume on the next turn.
|
|
143
|
+
*/
|
|
144
|
+
stopServer(): Promise<void>;
|
|
145
|
+
registerMcpServer(_serverName: string, _mcpConfig: {
|
|
146
|
+
type: 'local' | 'remote';
|
|
147
|
+
command?: string[];
|
|
148
|
+
args?: string[];
|
|
149
|
+
url?: string;
|
|
150
|
+
headers?: Record<string, string>;
|
|
151
|
+
environment?: Record<string, string>;
|
|
152
|
+
}, _workspaceDir?: string): Promise<void>;
|
|
153
|
+
checkHealth(): Promise<{
|
|
154
|
+
available: boolean;
|
|
155
|
+
reason?: string;
|
|
156
|
+
}>;
|
|
157
|
+
getProviders(_serverUrl?: string, directory?: string): Promise<{
|
|
158
|
+
providers: Array<{
|
|
159
|
+
id: string;
|
|
160
|
+
name: string;
|
|
161
|
+
models: Array<{
|
|
162
|
+
id: string;
|
|
163
|
+
name: string;
|
|
164
|
+
}>;
|
|
165
|
+
}>;
|
|
166
|
+
default: Record<string, string>;
|
|
167
|
+
}>;
|
|
168
|
+
}
|