@superjackfruitlabs/salaam 0.0.1 → 0.0.2
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/CHANGELOG.md +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- package/src/workspaces.ts +4 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
createWriteStream,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { join, dirname, resolve } from 'node:path';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { Readable, Transform } from 'node:stream';
|
|
17
|
+
import { pipeline } from 'node:stream/promises';
|
|
18
|
+
import { ROOT } from './config.js';
|
|
19
|
+
import { harnessRuntimeDigest, assertHarnessArtifactRoot } from './harness-backend.js';
|
|
20
|
+
|
|
21
|
+
/** Explicit operator setup, outside all worker turns. Fixed lock, no lifecycle scripts or inherited credentials. */
|
|
22
|
+
export async function prepareHarnessRuntime(
|
|
23
|
+
destination: string,
|
|
24
|
+
arch: string,
|
|
25
|
+
): Promise<{ root: string; digest: string; arch: string }> {
|
|
26
|
+
if (!['linux-arm64', 'linux-x64'].includes(arch))
|
|
27
|
+
throw Error('Select --arch linux-arm64 or linux-x64 for the Docker execution host');
|
|
28
|
+
const output = resolve(destination);
|
|
29
|
+
if (existsSync(output))
|
|
30
|
+
throw Error('Runtime destination already exists; choose a new directory for renewed admission');
|
|
31
|
+
const parent = realpathSync(dirname(output)),
|
|
32
|
+
temp = mkdtempSync(join(parent, '.salaam-runtime-')),
|
|
33
|
+
bundle = join(temp, 'bundle');
|
|
34
|
+
const exec = promisify(execFile),
|
|
35
|
+
home = join(temp, 'home');
|
|
36
|
+
try {
|
|
37
|
+
for (const p of [bundle, home]) mkdirSync(p, { mode: 0o700 });
|
|
38
|
+
for (const file of ['package.json', 'package-lock.json'])
|
|
39
|
+
writeFileSync(join(bundle, file), readFileSync(join(ROOT, 'harness-runtime', file)), {
|
|
40
|
+
mode: 0o600,
|
|
41
|
+
});
|
|
42
|
+
for (const name of ['user.npmrc', 'global.npmrc'])
|
|
43
|
+
writeFileSync(join(temp, name), '', { mode: 0o600 });
|
|
44
|
+
const env = {
|
|
45
|
+
HOME: home,
|
|
46
|
+
PATH: dirname(process.execPath) + ':/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin',
|
|
47
|
+
NPM_CONFIG_USERCONFIG: join(temp, 'user.npmrc'),
|
|
48
|
+
NPM_CONFIG_GLOBALCONFIG: join(temp, 'global.npmrc'),
|
|
49
|
+
NPM_CONFIG_CACHE: join(temp, 'cache'),
|
|
50
|
+
};
|
|
51
|
+
await exec(
|
|
52
|
+
'npm',
|
|
53
|
+
[
|
|
54
|
+
'ci',
|
|
55
|
+
'--ignore-scripts',
|
|
56
|
+
'--no-audit',
|
|
57
|
+
'--no-fund',
|
|
58
|
+
'--registry=https://registry.npmjs.org',
|
|
59
|
+
],
|
|
60
|
+
{ cwd: bundle, env, timeout: 300000, maxBuffer: 4 * 1024 * 1024 },
|
|
61
|
+
);
|
|
62
|
+
const pin = JSON.parse(readFileSync(join(ROOT, 'harness-runtime/codex-pins.json'), 'utf8'))[
|
|
63
|
+
arch
|
|
64
|
+
] as { url: string; integrity: string };
|
|
65
|
+
const response = await fetch(pin.url, {
|
|
66
|
+
redirect: 'error',
|
|
67
|
+
signal: AbortSignal.timeout(120000),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok || !response.body) throw Error('Pinned Codex archive download failed');
|
|
70
|
+
const archive = join(temp, 'codex.tgz'),
|
|
71
|
+
hash = createHash('sha512');
|
|
72
|
+
let size = 0;
|
|
73
|
+
await pipeline(
|
|
74
|
+
Readable.fromWeb(response.body as any),
|
|
75
|
+
new Transform({
|
|
76
|
+
transform(chunk: Buffer, _encoding, done) {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > 1024 * 1024 * 1024) return done(Error('Harness archive exceeds bound'));
|
|
79
|
+
hash.update(chunk);
|
|
80
|
+
done(null, chunk);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
createWriteStream(archive, { flags: 'wx', mode: 0o600 }),
|
|
84
|
+
);
|
|
85
|
+
if (hash.digest('base64') !== pin.integrity)
|
|
86
|
+
throw Error('Pinned Codex archive integrity mismatch');
|
|
87
|
+
const listing = await exec('tar', ['-tzf', archive], {
|
|
88
|
+
env,
|
|
89
|
+
timeout: 30000,
|
|
90
|
+
maxBuffer: 1048576,
|
|
91
|
+
});
|
|
92
|
+
if (
|
|
93
|
+
listing.stdout
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.some((path) => !path.startsWith('package/') || path.split('/').includes('..'))
|
|
97
|
+
)
|
|
98
|
+
throw Error('Unsafe pinned archive layout');
|
|
99
|
+
mkdirSync(join(bundle, 'codex'), { mode: 0o700 });
|
|
100
|
+
await exec('tar', ['-xzf', archive, '--strip-components=1', '-C', join(bundle, 'codex')], {
|
|
101
|
+
env,
|
|
102
|
+
timeout: 120000,
|
|
103
|
+
maxBuffer: 1048576,
|
|
104
|
+
});
|
|
105
|
+
assertHarnessArtifactRoot(bundle);
|
|
106
|
+
const hashValue = await harnessRuntimeDigest(bundle);
|
|
107
|
+
if (existsSync(output)) throw Error('Runtime destination appeared during setup');
|
|
108
|
+
renameSync(bundle, output);
|
|
109
|
+
return { root: output, digest: hashValue, arch };
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(temp, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { realpathSync, readFileSync, lstatSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { HarnessRpc, type HarnessLaunch, type RpcObject } from './harness-rpc.js';
|
|
8
|
+
import { digest, ServiceError } from './protocol.js';
|
|
9
|
+
import { VERSION } from './config.js';
|
|
10
|
+
|
|
11
|
+
export const HARNESS_TARGETS = {
|
|
12
|
+
'gemini-acp': { dialect: 'acp', args: ['--acp'], version: '0.64.0' },
|
|
13
|
+
'claude-acp': { dialect: 'acp', args: [], version: '0.23.1' },
|
|
14
|
+
'codex-app-server': { dialect: 'codex', args: ['app-server'], version: '0.163.0' },
|
|
15
|
+
} as const;
|
|
16
|
+
export type HarnessTarget = keyof typeof HARNESS_TARGETS;
|
|
17
|
+
export interface HarnessPin {
|
|
18
|
+
target: HarnessTarget;
|
|
19
|
+
executable: string;
|
|
20
|
+
executable_digest: string;
|
|
21
|
+
version: string;
|
|
22
|
+
}
|
|
23
|
+
/** Operator-only negotiation. No repository, credential, task or model request is admitted. */
|
|
24
|
+
export async function probeHarness(pin: HarnessPin): Promise<{
|
|
25
|
+
schema_version: 1;
|
|
26
|
+
pin: HarnessPin;
|
|
27
|
+
launch_digest: string;
|
|
28
|
+
capability_digest: string;
|
|
29
|
+
advertised: RpcObject;
|
|
30
|
+
enforced: {
|
|
31
|
+
files: false;
|
|
32
|
+
commands: false;
|
|
33
|
+
model_requests: false;
|
|
34
|
+
output_cap: false;
|
|
35
|
+
resume_dispatch: false;
|
|
36
|
+
isolation: 'unproven';
|
|
37
|
+
retention: 'unknown';
|
|
38
|
+
};
|
|
39
|
+
}> {
|
|
40
|
+
if (!Object.hasOwn(HARNESS_TARGETS, pin.target) || !isAbsolute(pin.executable))
|
|
41
|
+
throw new ServiceError('invalid_harness', 'Select a pinned supported absolute executable');
|
|
42
|
+
const target = HARNESS_TARGETS[pin.target],
|
|
43
|
+
executable = realpathSync(pin.executable),
|
|
44
|
+
stat = lstatSync(executable);
|
|
45
|
+
if (
|
|
46
|
+
!stat.isFile() ||
|
|
47
|
+
stat.size > 512 * 1024 * 1024 ||
|
|
48
|
+
createHash('sha256').update(readFileSync(executable)).digest('hex') !== pin.executable_digest ||
|
|
49
|
+
pin.version !== target.version
|
|
50
|
+
)
|
|
51
|
+
throw new ServiceError(
|
|
52
|
+
'harness_pin_changed',
|
|
53
|
+
'Harness executable digest or admitted version changed',
|
|
54
|
+
);
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), 'sal-harness-probe-')),
|
|
56
|
+
home = join(dir, 'home');
|
|
57
|
+
mkdirSync(home, { mode: 0o700 });
|
|
58
|
+
for (const name of ['.codex', '.config', '.cache']) mkdirSync(join(home, name), { mode: 0o700 });
|
|
59
|
+
const env = {
|
|
60
|
+
HOME: home,
|
|
61
|
+
CODEX_HOME: join(home, '.codex'),
|
|
62
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
63
|
+
XDG_CACHE_HOME: join(home, '.cache'),
|
|
64
|
+
TMPDIR: dir,
|
|
65
|
+
PATH: [dirname(process.execPath), dirname(executable), '/usr/bin', '/bin'].join(':'),
|
|
66
|
+
LANG: 'en_US.UTF-8',
|
|
67
|
+
NO_COLOR: '1',
|
|
68
|
+
GEMINI_CLI_NO_RELAUNCH: 'true',
|
|
69
|
+
};
|
|
70
|
+
let rpc: HarnessRpc | undefined;
|
|
71
|
+
try {
|
|
72
|
+
const version =
|
|
73
|
+
pin.target === 'claude-acp'
|
|
74
|
+
? {
|
|
75
|
+
stdout: (() => {
|
|
76
|
+
const metadata = JSON.parse(
|
|
77
|
+
readFileSync(join(dirname(executable), '../package.json'), 'utf8'),
|
|
78
|
+
);
|
|
79
|
+
if (metadata.name !== '@zed-industries/claude-agent-acp')
|
|
80
|
+
throw new Error('Unexpected Claude ACP package');
|
|
81
|
+
return metadata.version as string;
|
|
82
|
+
})(),
|
|
83
|
+
}
|
|
84
|
+
: await promisify(execFile)(executable, ['--version'], {
|
|
85
|
+
cwd: dir,
|
|
86
|
+
env,
|
|
87
|
+
timeout: 10000,
|
|
88
|
+
maxBuffer: 16384,
|
|
89
|
+
});
|
|
90
|
+
if (
|
|
91
|
+
!new RegExp('(^|[^0-9.])' + pin.version.replaceAll('.', '\\.') + '([^0-9.]|$)').test(
|
|
92
|
+
version.stdout.trim(),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
throw new ServiceError('harness_version_changed', 'Harness reported a different version');
|
|
96
|
+
const launch: HarnessLaunch = {
|
|
97
|
+
executable,
|
|
98
|
+
args: [...target.args],
|
|
99
|
+
cwd: dir,
|
|
100
|
+
env,
|
|
101
|
+
dialect: target.dialect,
|
|
102
|
+
};
|
|
103
|
+
rpc = new HarnessRpc(launch);
|
|
104
|
+
const advertised =
|
|
105
|
+
target.dialect === 'acp'
|
|
106
|
+
? await rpc.request('initialize', {
|
|
107
|
+
protocolVersion: 1,
|
|
108
|
+
clientInfo: { name: 'salaam', version: VERSION },
|
|
109
|
+
clientCapabilities: {
|
|
110
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
111
|
+
terminal: false,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
: await rpc.request('initialize', {
|
|
115
|
+
clientInfo: { name: 'salaam', version: VERSION },
|
|
116
|
+
capabilities: { experimentalApi: false, requestAttestation: false },
|
|
117
|
+
});
|
|
118
|
+
if (target.dialect === 'acp' && advertised.protocolVersion !== 1)
|
|
119
|
+
throw new ServiceError(
|
|
120
|
+
'harness_protocol_changed',
|
|
121
|
+
'Harness did not negotiate ACP protocol version 1',
|
|
122
|
+
);
|
|
123
|
+
if (target.dialect === 'codex') rpc.notify('initialized', {});
|
|
124
|
+
const enforced = {
|
|
125
|
+
files: false,
|
|
126
|
+
commands: false,
|
|
127
|
+
model_requests: false,
|
|
128
|
+
output_cap: false,
|
|
129
|
+
resume_dispatch: false,
|
|
130
|
+
isolation: 'unproven',
|
|
131
|
+
retention: 'unknown',
|
|
132
|
+
} as const;
|
|
133
|
+
const admittedPin = { ...pin, executable };
|
|
134
|
+
return {
|
|
135
|
+
schema_version: 1,
|
|
136
|
+
pin: admittedPin,
|
|
137
|
+
launch_digest: digest({
|
|
138
|
+
pin: admittedPin,
|
|
139
|
+
args: target.args,
|
|
140
|
+
environment: 'empty-home-no-credentials-v1',
|
|
141
|
+
}),
|
|
142
|
+
capability_digest: digest({ advertised, enforced }),
|
|
143
|
+
advertised,
|
|
144
|
+
enforced,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
await rpc?.close();
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { HarnessRpc, type HarnessLaunch, type RpcObject } from './harness-rpc.js';
|
|
2
|
+
import { digest, ServiceError } from './protocol.js';
|
|
3
|
+
|
|
4
|
+
export interface HarnessCheckpoint {
|
|
5
|
+
schema_version: 1;
|
|
6
|
+
dialect: 'acp' | 'codex';
|
|
7
|
+
worker_id: string;
|
|
8
|
+
attempt_id: string;
|
|
9
|
+
state: 'prepared' | 'creating' | 'idle' | 'dispatching' | 'running' | 'terminal' | 'unknown';
|
|
10
|
+
session_id: string | null;
|
|
11
|
+
turn_id: string | null;
|
|
12
|
+
prompt_digest: string | null;
|
|
13
|
+
cancel_requested: boolean;
|
|
14
|
+
cancel_state?: 'intent' | 'dispatching' | 'acknowledged';
|
|
15
|
+
result: RpcObject | null;
|
|
16
|
+
load_session: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface HarnessProtocolHooks {
|
|
19
|
+
/** Must commit synchronously. A failed save prevents forwarding. */
|
|
20
|
+
save: (checkpoint: HarnessCheckpoint) => undefined;
|
|
21
|
+
permission: (binding: {
|
|
22
|
+
worker_id: string;
|
|
23
|
+
attempt_id: string;
|
|
24
|
+
session_id: string;
|
|
25
|
+
turn_id: string | null;
|
|
26
|
+
method: string;
|
|
27
|
+
params: RpcObject;
|
|
28
|
+
}) => Promise<'approve' | 'deny'>;
|
|
29
|
+
event?: (method: string, params: RpcObject) => void;
|
|
30
|
+
failed?: (error: Error) => void;
|
|
31
|
+
}
|
|
32
|
+
const identity = (v: unknown): string => {
|
|
33
|
+
if (typeof v !== 'string' || !v.length || v.length > 256)
|
|
34
|
+
throw new ServiceError('harness_identity', 'Missing or invalid upstream identity');
|
|
35
|
+
return v;
|
|
36
|
+
};
|
|
37
|
+
/** One owned invocation per adapter. Resume inspects the existing identity; it never resubmits a prompt. */
|
|
38
|
+
export class HarnessProtocol {
|
|
39
|
+
readonly rpc: HarnessRpc;
|
|
40
|
+
private checkpoint: HarnessCheckpoint;
|
|
41
|
+
private pendingTurns: RpcObject[] = [];
|
|
42
|
+
private terminal?: { resolve: (value: RpcObject) => void; reject: (error: Error) => void };
|
|
43
|
+
private closing = false;
|
|
44
|
+
private failed = false;
|
|
45
|
+
constructor(
|
|
46
|
+
launch: HarnessLaunch,
|
|
47
|
+
checkpoint: HarnessCheckpoint,
|
|
48
|
+
readonly hooks: HarnessProtocolHooks,
|
|
49
|
+
) {
|
|
50
|
+
if (
|
|
51
|
+
checkpoint.schema_version !== 1 ||
|
|
52
|
+
checkpoint.dialect !== launch.dialect ||
|
|
53
|
+
!checkpoint.worker_id ||
|
|
54
|
+
!checkpoint.attempt_id
|
|
55
|
+
)
|
|
56
|
+
throw new Error('Harness checkpoint ownership mismatch');
|
|
57
|
+
this.checkpoint = structuredClone(checkpoint);
|
|
58
|
+
if (['creating', 'dispatching', 'running'].includes(this.checkpoint.state)) {
|
|
59
|
+
const recovered = { ...this.checkpoint, state: 'unknown' as const };
|
|
60
|
+
const saved: unknown = this.hooks.save(structuredClone(recovered));
|
|
61
|
+
if (saved !== undefined) throw new Error('Harness recovery must commit synchronously');
|
|
62
|
+
this.checkpoint = recovered;
|
|
63
|
+
}
|
|
64
|
+
this.rpc = new HarnessRpc(launch, {
|
|
65
|
+
request: (method, params) => this.permission(method, params),
|
|
66
|
+
notification: (method, params) => this.notification(method, params),
|
|
67
|
+
failed: (error) => {
|
|
68
|
+
if (!this.closing) {
|
|
69
|
+
try {
|
|
70
|
+
this.unknown();
|
|
71
|
+
} finally {
|
|
72
|
+
this.failed = true;
|
|
73
|
+
this.terminal?.reject(error);
|
|
74
|
+
this.hooks.failed?.(error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
view(): HarnessCheckpoint {
|
|
81
|
+
return structuredClone(this.checkpoint);
|
|
82
|
+
}
|
|
83
|
+
private save(update: Partial<HarnessCheckpoint>): void {
|
|
84
|
+
this.assertUsable();
|
|
85
|
+
const next = { ...this.checkpoint, ...update };
|
|
86
|
+
try {
|
|
87
|
+
const result: unknown = this.hooks.save(structuredClone(next));
|
|
88
|
+
if (result !== undefined)
|
|
89
|
+
throw new Error('Harness checkpoint save must commit synchronously');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.failed = true;
|
|
92
|
+
void this.rpc.close();
|
|
93
|
+
try {
|
|
94
|
+
this.hooks.failed?.(error as Error);
|
|
95
|
+
} catch {}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
this.checkpoint = next;
|
|
99
|
+
}
|
|
100
|
+
private assertUsable(): void {
|
|
101
|
+
if (this.failed)
|
|
102
|
+
throw new ServiceError('harness_control_failed', 'Harness control is no longer usable');
|
|
103
|
+
}
|
|
104
|
+
private unknown(): void {
|
|
105
|
+
if (this.failed) return;
|
|
106
|
+
if (!['terminal', 'prepared', 'idle'].includes(this.checkpoint.state))
|
|
107
|
+
this.save({ state: 'unknown' });
|
|
108
|
+
}
|
|
109
|
+
async initialize(gatewayAuth = false): Promise<RpcObject> {
|
|
110
|
+
this.assertUsable();
|
|
111
|
+
const result = await this.rpc.request(
|
|
112
|
+
'initialize',
|
|
113
|
+
this.checkpoint.dialect === 'acp'
|
|
114
|
+
? {
|
|
115
|
+
protocolVersion: 1,
|
|
116
|
+
clientCapabilities: {
|
|
117
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
118
|
+
terminal: false,
|
|
119
|
+
...(gatewayAuth ? { auth: { _meta: { gateway: true } } } : {}),
|
|
120
|
+
},
|
|
121
|
+
clientInfo: { name: 'salaam', version: '0.0.1' },
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
clientInfo: { name: 'salaam', version: '0.0.1' },
|
|
125
|
+
capabilities: { experimentalApi: false },
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
if (this.checkpoint.dialect === 'acp') {
|
|
129
|
+
if (result.protocolVersion !== 1)
|
|
130
|
+
throw new ServiceError('harness_protocol_changed', 'ACP v1 negotiation required');
|
|
131
|
+
const caps = result.agentCapabilities as RpcObject | undefined;
|
|
132
|
+
this.save({ load_session: caps?.loadSession === true });
|
|
133
|
+
} else this.rpc.notify('initialized', {});
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
async create(params: RpcObject): Promise<HarnessCheckpoint> {
|
|
137
|
+
this.assertUsable();
|
|
138
|
+
if (this.checkpoint.state !== 'prepared' || this.checkpoint.session_id)
|
|
139
|
+
throw new ServiceError('harness_replay', 'An owned session intent cannot be recreated');
|
|
140
|
+
this.save({ state: 'creating' });
|
|
141
|
+
try {
|
|
142
|
+
const result = await this.rpc.request(
|
|
143
|
+
this.checkpoint.dialect === 'acp' ? 'session/new' : 'thread/start',
|
|
144
|
+
params,
|
|
145
|
+
30000,
|
|
146
|
+
);
|
|
147
|
+
const id =
|
|
148
|
+
this.checkpoint.dialect === 'acp'
|
|
149
|
+
? identity(result.sessionId)
|
|
150
|
+
: identity((result.thread as RpcObject)?.id);
|
|
151
|
+
this.save({ session_id: id, state: 'idle' });
|
|
152
|
+
return this.view();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.unknown();
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async prompt(text: string): Promise<RpcObject> {
|
|
159
|
+
this.assertUsable();
|
|
160
|
+
if (
|
|
161
|
+
this.checkpoint.state !== 'idle' ||
|
|
162
|
+
!this.checkpoint.session_id ||
|
|
163
|
+
this.checkpoint.prompt_digest
|
|
164
|
+
)
|
|
165
|
+
throw new ServiceError(
|
|
166
|
+
'harness_replay',
|
|
167
|
+
'This invocation already owns a prompt or uncertain work',
|
|
168
|
+
);
|
|
169
|
+
if (!text.trim() || Buffer.byteLength(text) > 65536 || text.trimStart().startsWith('/'))
|
|
170
|
+
throw new Error('Invalid external instruction');
|
|
171
|
+
this.save({ state: 'dispatching', prompt_digest: digest(text) });
|
|
172
|
+
try {
|
|
173
|
+
if (this.checkpoint.dialect === 'acp') {
|
|
174
|
+
const result = await this.rpc.request(
|
|
175
|
+
'session/prompt',
|
|
176
|
+
{ sessionId: this.checkpoint.session_id, prompt: [{ type: 'text', text }] },
|
|
177
|
+
180000,
|
|
178
|
+
);
|
|
179
|
+
if (
|
|
180
|
+
!['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(
|
|
181
|
+
String(result.stopReason),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
throw new Error('Unknown ACP terminal stop reason');
|
|
185
|
+
this.save({ state: 'terminal', result });
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
const done = new Promise<RpcObject>((resolve, reject) => {
|
|
189
|
+
this.terminal = { resolve, reject };
|
|
190
|
+
});
|
|
191
|
+
// Avoid an unhandled rejection if transport failure happens before turn/start returns.
|
|
192
|
+
void done.catch(() => {});
|
|
193
|
+
const result = await this.rpc.request(
|
|
194
|
+
'turn/start',
|
|
195
|
+
{ threadId: this.checkpoint.session_id, input: [{ type: 'text', text }] },
|
|
196
|
+
30000,
|
|
197
|
+
);
|
|
198
|
+
const turn = identity((result.turn as RpcObject)?.id);
|
|
199
|
+
this.save({ state: 'running', turn_id: turn });
|
|
200
|
+
for (const notification of this.pendingTurns.splice(0)) this.codexTerminal(notification);
|
|
201
|
+
if (
|
|
202
|
+
this.checkpoint.cancel_requested &&
|
|
203
|
+
this.checkpoint.cancel_state === 'intent' &&
|
|
204
|
+
this.view().state !== 'terminal'
|
|
205
|
+
)
|
|
206
|
+
await this.forwardCancel();
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
try {
|
|
209
|
+
this.unknown();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.terminal?.reject(error as Error);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.terminal?.reject(
|
|
215
|
+
new ServiceError('harness_outcome_unknown', 'No owned terminal turn receipt'),
|
|
216
|
+
);
|
|
217
|
+
}, 180000);
|
|
218
|
+
try {
|
|
219
|
+
return await done;
|
|
220
|
+
} finally {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.unknown();
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async cancel(): Promise<HarnessCheckpoint> {
|
|
229
|
+
this.assertUsable();
|
|
230
|
+
if (!['dispatching', 'running'].includes(this.checkpoint.state) || !this.checkpoint.session_id)
|
|
231
|
+
throw new ServiceError('harness_not_running', 'No running owned prompt');
|
|
232
|
+
if (this.checkpoint.cancel_requested) return this.view();
|
|
233
|
+
this.save({ cancel_requested: true, cancel_state: 'intent' });
|
|
234
|
+
if (this.checkpoint.dialect === 'codex' && !this.checkpoint.turn_id) return this.view();
|
|
235
|
+
await this.forwardCancel();
|
|
236
|
+
return this.view();
|
|
237
|
+
}
|
|
238
|
+
private async forwardCancel(): Promise<void> {
|
|
239
|
+
this.save({ cancel_state: 'dispatching' });
|
|
240
|
+
if (this.checkpoint.dialect === 'acp')
|
|
241
|
+
this.rpc.notify('session/cancel', { sessionId: this.checkpoint.session_id });
|
|
242
|
+
else {
|
|
243
|
+
if (!this.checkpoint.turn_id)
|
|
244
|
+
throw new ServiceError('harness_outcome_unknown', 'Turn acceptance is not yet known');
|
|
245
|
+
await this.rpc.request('turn/interrupt', {
|
|
246
|
+
threadId: this.checkpoint.session_id,
|
|
247
|
+
turnId: this.checkpoint.turn_id,
|
|
248
|
+
});
|
|
249
|
+
this.save({ cancel_state: 'acknowledged' });
|
|
250
|
+
}
|
|
251
|
+
// Acknowledgement is not proof of a terminal turn or stopped tools.
|
|
252
|
+
}
|
|
253
|
+
async inspect(params: RpcObject = {}): Promise<RpcObject> {
|
|
254
|
+
this.assertUsable();
|
|
255
|
+
if (!this.checkpoint.session_id)
|
|
256
|
+
throw new ServiceError('harness_outcome_unknown', 'No accepted upstream session identity');
|
|
257
|
+
if (this.checkpoint.dialect === 'acp') {
|
|
258
|
+
if (this.checkpoint.state === 'unknown')
|
|
259
|
+
throw new ServiceError(
|
|
260
|
+
'harness_outcome_unknown',
|
|
261
|
+
'ACP cannot safely reconcile an uncertain prompt; inspect retained artifacts and resolve explicitly',
|
|
262
|
+
);
|
|
263
|
+
if (!this.checkpoint.load_session)
|
|
264
|
+
throw new ServiceError('harness_resume_unsupported', 'Peer did not admit session loading');
|
|
265
|
+
const result = await this.rpc.request('session/load', {
|
|
266
|
+
...params,
|
|
267
|
+
sessionId: this.checkpoint.session_id,
|
|
268
|
+
});
|
|
269
|
+
// ACP history loading cannot prove which uncertain prompt completed.
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
const result = await this.rpc.request('thread/read', {
|
|
273
|
+
threadId: this.checkpoint.session_id,
|
|
274
|
+
includeTurns: true,
|
|
275
|
+
});
|
|
276
|
+
const thread = result.thread as RpcObject;
|
|
277
|
+
if (identity(thread?.id) !== this.checkpoint.session_id)
|
|
278
|
+
throw new ServiceError('harness_identity', 'Returned thread belongs to another worker');
|
|
279
|
+
if (this.checkpoint.turn_id && Array.isArray(thread.turns)) {
|
|
280
|
+
const turn = thread.turns.find((t: any) => t.id === this.checkpoint.turn_id) as
|
|
281
|
+
RpcObject | undefined;
|
|
282
|
+
if (turn && ['completed', 'failed', 'interrupted'].includes(String(turn.status)))
|
|
283
|
+
this.save({ state: 'terminal', result: { turn } });
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
private async permission(method: string, params: RpcObject): Promise<unknown> {
|
|
288
|
+
this.assertUsable();
|
|
289
|
+
const state = this.checkpoint;
|
|
290
|
+
if (
|
|
291
|
+
!state.session_id ||
|
|
292
|
+
state.cancel_requested ||
|
|
293
|
+
!['dispatching', 'running'].includes(state.state)
|
|
294
|
+
)
|
|
295
|
+
throw new ServiceError('harness_permission_denied', 'No active owned invocation');
|
|
296
|
+
if (state.dialect === 'acp') {
|
|
297
|
+
if (method !== 'session/request_permission' || params.sessionId !== state.session_id)
|
|
298
|
+
throw new ServiceError('harness_permission_denied', 'Client method or session is unowned');
|
|
299
|
+
identity((params.toolCall as RpcObject)?.toolCallId);
|
|
300
|
+
} else {
|
|
301
|
+
if (
|
|
302
|
+
!['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(
|
|
303
|
+
method,
|
|
304
|
+
) ||
|
|
305
|
+
params.threadId !== state.session_id ||
|
|
306
|
+
!state.turn_id ||
|
|
307
|
+
params.turnId !== state.turn_id
|
|
308
|
+
)
|
|
309
|
+
throw new ServiceError('harness_permission_denied', 'Approval thread or turn is unowned');
|
|
310
|
+
identity(params.itemId);
|
|
311
|
+
}
|
|
312
|
+
const decision = await this.hooks.permission({
|
|
313
|
+
worker_id: state.worker_id,
|
|
314
|
+
attempt_id: state.attempt_id,
|
|
315
|
+
session_id: state.session_id,
|
|
316
|
+
turn_id: state.turn_id,
|
|
317
|
+
method,
|
|
318
|
+
params,
|
|
319
|
+
});
|
|
320
|
+
this.assertUsable();
|
|
321
|
+
if (
|
|
322
|
+
this.checkpoint.cancel_requested ||
|
|
323
|
+
!['dispatching', 'running'].includes(this.checkpoint.state)
|
|
324
|
+
)
|
|
325
|
+
throw new ServiceError(
|
|
326
|
+
'harness_permission_denied',
|
|
327
|
+
'Owned invocation stopped before the decision',
|
|
328
|
+
);
|
|
329
|
+
if (decision !== 'approve' && decision !== 'deny')
|
|
330
|
+
throw new ServiceError('harness_permission_denied', 'Invalid local permission decision');
|
|
331
|
+
if (state.dialect === 'acp') {
|
|
332
|
+
const options = Array.isArray(params.options) ? (params.options as RpcObject[]) : [];
|
|
333
|
+
const option = options.find(
|
|
334
|
+
(o) => o.kind === (decision === 'approve' ? 'allow_once' : 'reject_once'),
|
|
335
|
+
);
|
|
336
|
+
if (!option || typeof option.optionId !== 'string')
|
|
337
|
+
throw new ServiceError(
|
|
338
|
+
'harness_permission_denied',
|
|
339
|
+
'Peer lacks a once-only permission choice',
|
|
340
|
+
);
|
|
341
|
+
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
342
|
+
}
|
|
343
|
+
return { decision: decision === 'approve' ? 'accept' : 'decline' };
|
|
344
|
+
}
|
|
345
|
+
private notification(method: string, params: RpcObject): void {
|
|
346
|
+
const state = this.checkpoint;
|
|
347
|
+
if (state.dialect === 'acp' && method === 'session/update') {
|
|
348
|
+
if (params.sessionId !== state.session_id) {
|
|
349
|
+
if (state.state === 'creating') return;
|
|
350
|
+
throw new ServiceError('harness_identity', 'Unowned ACP update');
|
|
351
|
+
}
|
|
352
|
+
if (state.state === 'dispatching') this.save({ state: 'running' });
|
|
353
|
+
} else if (state.dialect === 'codex' && method.startsWith('turn/')) {
|
|
354
|
+
if (params.threadId !== state.session_id)
|
|
355
|
+
throw new ServiceError('harness_identity', 'Unowned Codex thread notification');
|
|
356
|
+
if (method === 'turn/completed') {
|
|
357
|
+
if (!state.turn_id && state.state === 'dispatching') {
|
|
358
|
+
if (this.pendingTurns.length >= 16) throw Error('Unaccepted turn event bound');
|
|
359
|
+
this.pendingTurns.push(params);
|
|
360
|
+
} else this.codexTerminal(params);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
this.hooks.event?.(method, params);
|
|
364
|
+
}
|
|
365
|
+
private codexTerminal(params: RpcObject): void {
|
|
366
|
+
const turn = params.turn as RpcObject;
|
|
367
|
+
if (
|
|
368
|
+
params.threadId !== this.checkpoint.session_id ||
|
|
369
|
+
identity(turn?.id) !== this.checkpoint.turn_id
|
|
370
|
+
)
|
|
371
|
+
throw new ServiceError('harness_identity', 'Terminal receipt belongs to another turn');
|
|
372
|
+
if (!['completed', 'failed', 'interrupted'].includes(String(turn.status)))
|
|
373
|
+
throw new Error('Nonterminal Codex completion');
|
|
374
|
+
const result = { turn };
|
|
375
|
+
if (this.checkpoint.state === 'terminal') {
|
|
376
|
+
if (digest(this.checkpoint.result) !== digest(result))
|
|
377
|
+
throw new Error('Conflicting terminal receipt');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
this.save({ state: 'terminal', result });
|
|
381
|
+
this.terminal?.resolve(result);
|
|
382
|
+
}
|
|
383
|
+
async close(): Promise<void> {
|
|
384
|
+
this.closing = true;
|
|
385
|
+
this.unknown();
|
|
386
|
+
await this.rpc.close();
|
|
387
|
+
}
|
|
388
|
+
}
|