mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-19 07:24:03 +00:00
feat(missions): bundle project coordination with the desktop runtime
Provision codenomad.missions through connected-daemon discovery and independent backend presence instead of requiring a source plugin in every project. Bundle the integration for Electron and Tauri, retain project-scoped mission journals across shutdown, and use the same secondary surface as Status. Complete the existing native actor execution work: persist explicit agent/model/variant choices, validate catalog selections and avoid switching busy actors. Admit assignment and report writes through the authenticated desktop bridge with durable-contract verification, session ownership, connection and worktree fences, and per-send profile environment synchronization. Advance the older source branch client/plugin pin to 2.0.11 and retain its bounded runtime metadata adaptation. The isolated 2.0.16 mission fixture passes native catalog, selection, busy queues, conflict, environment, reports, reconnect and idempotence checks. Server, UI and plugin typechecks, 31 mission tests, 10 lifecycle/transport tests and six resource-layout tests pass.
This commit is contained in:
parent
26178e18c0
commit
4d7408631d
61 changed files with 1057 additions and 260 deletions
11
.github/workflows/pr-build.yml
vendored
11
.github/workflows/pr-build.yml
vendored
|
|
@ -153,6 +153,9 @@ jobs:
|
|||
- version: 2.0.7
|
||||
scope: '@opencode'
|
||||
binary: opencode
|
||||
- version: 2.0.11
|
||||
scope: '@opencode'
|
||||
binary: opencode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
@ -164,6 +167,7 @@ jobs:
|
|||
- run: npm ci --ignore-scripts
|
||||
- run: npm run build:pruning --workspace @neuralnomads/codenomad
|
||||
- run: npm run build:automation --workspace @neuralnomads/codenomad
|
||||
- run: npm run build:missions --workspace @neuralnomads/codenomad
|
||||
- name: Cross the pinned client with an isolated runtime
|
||||
shell: pwsh
|
||||
env:
|
||||
|
|
@ -179,6 +183,10 @@ jobs:
|
|||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
node scripts/test-session-pruning-native.mjs $cli
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
if ($env:RUNTIME_VERSION -eq '2.0.11') {
|
||||
node scripts/test-missions-native.mjs $cli
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
}
|
||||
if ($env:RUNTIME_VERSION -eq '0.0.0-beta-19271' -or $env:RUNTIME_VERSION -eq '2.0.3') {
|
||||
node scripts/test-session-pruning-native.mjs $cli --legacy-pruning
|
||||
}
|
||||
|
|
@ -232,6 +240,9 @@ jobs:
|
|||
- name: Build bundled automation integration
|
||||
run: npm run build:automation --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Build bundled mission integration
|
||||
run: npm run build:missions --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Test desktop packaging invariants
|
||||
run: node --test scripts/desktop-server-resources.test.cjs scripts/prepare-node-runtime.test.cjs
|
||||
|
||||
|
|
|
|||
134
.opencode/package-lock.json
generated
134
.opencode/package-lock.json
generated
|
|
@ -5,7 +5,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "2.0.4"
|
||||
"@opencode/plugin": "2.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
|
|
@ -914,13 +914,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/ai": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.4.tgz",
|
||||
"integrity": "sha512-T/2wTuhy/I1Wxdy/GdhtMqugpl1OWKgVp+8A45mtS1jDrTSeHq2x5YhkComkJZ6BwdG/OvU6afTGDPA7/vd4RA==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.11.tgz",
|
||||
"integrity": "sha512-u6tkH/aQeRaUMGwf0O501n8VHTvWssB7hszfpiVG3TBzJYIbe4LvD1jqSU0BmhA32tHTvYegd61wxyjzyv+EsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
|
|
@ -929,13 +929,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.4.tgz",
|
||||
"integrity": "sha512-GBFWT2os+gEtGXpHRvDUNkf0H9x9njDUVbUvvc4drH2CuAihVcHqdi9Wm4UiwkBrSLjrKPs4U1NJBh2Z7/x4tQ==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.11.tgz",
|
||||
"integrity": "sha512-cx9/P1AlUm1wvEAk6Tjt1B7bILzFIw/sUncJRvyVpLkW6oooZBmgkmVi/9isHnr+LPcRkFnkqwmvqhlxlFsLFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.4",
|
||||
"@opencode/schema": "2.0.4"
|
||||
"@opencode/protocol": "2.0.11",
|
||||
"@opencode/schema": "2.0.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
|
|
@ -951,23 +951,23 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/plugin": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.4.tgz",
|
||||
"integrity": "sha512-NsC1STARfXxe+ZoXjXzpxvtS9l4h9Aen8ZccaCd1d9+1eyYdp+q8DU3Rxh7Ch0jiwiQMybS2Z+KgAfZJBnTBbA==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.11.tgz",
|
||||
"integrity": "sha512-LqagVqieE/HWW71fCKrXbeyi5xe4DW4xwW45VNRcc5zLVVmjqjFp102Jd6n0BMXr5f8f2NsR+gLNhdOvM9xM2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "2.0.4",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/protocol": "2.0.4",
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/util": "2.0.4",
|
||||
"@opencode/ai": "2.0.11",
|
||||
"@opencode/client": "2.0.11",
|
||||
"@opencode/protocol": "2.0.11",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"@opencode/util": "2.0.11",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "2.0.4",
|
||||
"@opencode/theme": "2.0.11",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0"
|
||||
|
|
@ -988,19 +988,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.4.tgz",
|
||||
"integrity": "sha512-uWe2dmMouprSkfBu29XLR6GCWckkhm0YOo9ve07kTRqJCSoJzgQDF/8pouWG0cDCJ6oWj3bRQajXriBQyWSpUw==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.11.tgz",
|
||||
"integrity": "sha512-57lsPuOshTuEZz8SYvTJhgZQXCvvT5MtpQAjPA+dAG3Pxna8xuoijPFJNeZSNH6isi0AGYKfznoD8MRoqVhM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.4.tgz",
|
||||
"integrity": "sha512-UyGcCsRb2hTH98rnBqfJogFve8kJsIbsEreRxqhkBIEGrtDTqcUd5NnqB7qlJp2khesMvpOH0GmCHlgz44C+uQ==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.11.tgz",
|
||||
"integrity": "sha512-AlJB2iyXDMuzxlEm2ies6vp1IIjhQxBpT6hJdWvqcuxKwFQMbbmvCpKFZU/j8iN1RW8qkaD0v9h7LwnalVqZGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
|
@ -1008,9 +1008,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/util": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.4.tgz",
|
||||
"integrity": "sha512-jo6zhWqJ4WaU+fUjM8/Ea4i06gxTQFEFgfq2XG9J8gSDUhfsj4l9wJ3cLtSuKFQGeUeWqJJwRgDhCW+rUbis6Q==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.11.tgz",
|
||||
"integrity": "sha512-FO5umf45fiO4roXCAr2sbbIZI9e0WVQkcraWqJMWpzKNWo/HtAiEDnvgHWmJPF6CjDR5OPNbT3VkRVPFhJx5mQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
|
|
@ -1452,12 +1452,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.33.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
|
||||
"integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
|
||||
"version": "3.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz",
|
||||
"integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.17.2",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1627,12 +1627,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
|
|
@ -1753,9 +1753,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
|
|
@ -2010,9 +2010,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
|
||||
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
|
||||
"integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -2309,9 +2309,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -2432,9 +2432,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -2771,9 +2771,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"version": "6.28.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
|
||||
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
|
@ -2890,9 +2890,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz",
|
||||
"integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==",
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz",
|
||||
"integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -3126,9 +3126,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
|
@ -3336,9 +3336,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/spdx-license-ids": {
|
||||
"version": "3.0.23",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz",
|
||||
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz",
|
||||
"integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
|
|
@ -3511,18 +3511,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz",
|
||||
"integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "2.0.4"
|
||||
"@opencode/plugin": "2.0.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "../../packages/server/src/opencode/missions-plugin.ts"
|
||||
|
|
@ -16,7 +16,7 @@ description: |
|
|||
|
||||
## Native OpenCode V2 Baseline
|
||||
|
||||
- Server and UI pin the official `@opencode/client@2.0.4` together; the pruning plugin pins `@opencode/plugin@2.0.4`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. The runtime CLI is managed independently and startup has no exact version gate.
|
||||
- Server and UI pin the official `@opencode/client@2.0.11` together; bundled plugins pin `@opencode/plugin@2.0.11`. The canonical server-info client is `server.info()`, adapted to the authenticated discovered route. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. The runtime CLI is managed independently and startup has no exact version gate.
|
||||
- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations.
|
||||
- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The narrow integrations are the bundled `codenomad.automation` plugin, project-local native session coordination (`codenomad.missions`), and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md`, `dev-docs/BROWSER_AUTOMATION.md`, `dev-docs/MISSIONS.md` and `dev-docs/SESSION_PRUNING_RPC.md`. All automation tools follow backend presence without a Developer Mode gate, sharing the authenticated native transport and execution-time session/window fences.
|
||||
- The server uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Client-state V3 is a per-window envelope over the V2 content-addressed partition
|
|||
|
||||
| Owner | Responsibilities | Main paths |
|
||||
|---|---|---|
|
||||
| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.4` contract across server and UI |
|
||||
| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.11` contract across server and UI |
|
||||
| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing, Developer Mode bridge | `packages/server/src/` |
|
||||
| CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` |
|
||||
| Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Package
|
||||
|
||||
CodeNomad server and UI pin `@opencode/client@2.0.4`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.status()` to the discovered route. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
|
||||
CodeNomad server and UI pin `@opencode/client@2.0.11`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.info()` to the discovered route. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
|
||||
|
||||
Cross-runtime adaptation lives in `packages/server/src/opencode/compatibility/`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Known published contracts select their serializer directly; unknown versions require authenticated bounded OpenAPI recognition before calls. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for the evidence matrix and maintained issue register.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Contract
|
||||
|
||||
- Server and UI pin `@opencode/client@2.0.4`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.status()` to the discovered route. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
|
||||
- Server and UI pin `@opencode/client@2.0.11`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.info()` to the discovered route. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
|
||||
- The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples.
|
||||
- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy.
|
||||
- That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically.
|
||||
|
|
|
|||
|
|
@ -14,4 +14,13 @@ Use a mission when independent root sessions should cooperate while remaining vi
|
|||
5. Actors must call `mission.report`. Reports are delivered back to the coordinator through the native durable inbox and resume it when possible.
|
||||
6. Inspect after reports, choose the next frontier task, and finish through `mission.report({ final: true, ... })` only when the objective is actually met.
|
||||
|
||||
Use `mission.inspect({ catalog: true })` before selecting a native execution profile.
|
||||
The mission `role` is independent of `execution.agent`. To pin execution, pass
|
||||
`execution: { agent: "catalog-agent-id", model: { providerID: "catalog-provider", id: "catalog-model", variant: "optional-catalog-variant" } }`.
|
||||
Repeat the same selection when retrying a task. Omitted fields use native defaults.
|
||||
Root actors accept primary/all agents; use the native `subagent` tool for child-only
|
||||
profiles and short child work. Reused actors must already match the selection;
|
||||
Missions will not switch the model of a busy session. Native queues do not freeze
|
||||
model or environment state against subsequent changes from another client.
|
||||
|
||||
The map is a coordination protocol, not a hidden executor. Do not create speculative tasks, polling loops, nested coordinators, or publication automation.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
|
||||
## Coding Principles
|
||||
|
||||
- Missions ships through `DesktopPluginLifecycle("missions")` and backend presence, with project-scoped native storage. Keep roles independent of native execution IDs. Persist explicit agent/model/variant selections and never switch a busy actor for a queued task. Mission prompt/report admission uses the authenticated desktop bridge, authoritative typed snapshot, ownership/connection/worktree fences and per-send profile environment. See `dev-docs/MISSIONS.md`; validate with the isolated `scripts/test-missions-native.mjs` fixture.
|
||||
|
||||
- Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`.
|
||||
|
||||
- One bundled `codenomad.automation` V2 plugin uses native discovery and backend presence. All browser/developer tools are available without a Developer Mode toggle; native instrumentation starts with the desktop host. Loading, tool availability and execution targeting are separate: retain the authenticated bridge and session/window fences. Tauri preview children receive no application capabilities; primary-renderer reload must dispose them and final-window checks must count native windows. See `dev-docs/BROWSER_AUTOMATION.md`.
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Then open a pull request on GitHub targeting the `dev` branch.
|
|||
|
||||
### OpenCode V2 Boundaries
|
||||
|
||||
- Server and UI pin `@opencode/client@2.0.4`; the pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade them together with the lockfile and isolated native validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes.
|
||||
- Server and UI pin `@opencode/client@2.0.11`; bundled plugins pin `@opencode/plugin@2.0.11`. Upgrade them together with the lockfile and isolated native validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes.
|
||||
- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), and `node_modules/@opencode/client/dist/promise/`.
|
||||
- `packages/server/src/workspaces/opencode-service.ts` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown.
|
||||
- WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa
|
|||
|
||||
The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format.
|
||||
|
||||
Server and UI pin the official `@opencode/client@2.0.4`; the bundled pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade the client, plugin and lock together using official V2 documentation, installed declarations and generated wire paths. The runtime CLI is independently managed: production startup validates authenticated `/api/status` (`version`, `pid`, `urls`), falling back only on HTTP 404 to the earlier V2 `/api/health` contract (`healthy: true`, `version`, positive `pid`). Both probes share the endpoint, credentials, response-size bound and absolute deadline. There is no exact version gate. Discovery compatibility alone does not establish compatibility for the remaining client APIs. The older beta reviews below are historical.
|
||||
Server and UI pin the official `@opencode/client@2.0.11`; bundled plugins use `@opencode/plugin@2.0.11`. Upgrade the client, plugin and lock together using official V2 documentation, installed declarations and generated wire paths. The runtime CLI is independently managed: production startup validates authenticated `/api/status`, `/api/health`, then `/api/info`, advancing only on HTTP 404 with shared credentials, bounds and deadline. The canonical client method is now `server.info()`, mapped to the discovered route. There is no exact version gate. Discovery compatibility alone does not establish compatibility for the remaining client APIs. The older beta reviews below are historical. See [Missions](dev-docs/MISSIONS.md) for isolated 2.0.11 execution-profile and plugin-presence validation.
|
||||
|
||||
The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md).
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ shared daemon is never stopped by this lifecycle.
|
|||
- Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls.
|
||||
- Inherit native durable JSON `SessionMetadata` directly from `SessionInfo`. Do not widen it to arbitrary `unknown` values or maintain a parallel CodeNomad-only metadata contract.
|
||||
- Read location-scoped native catalogs and refresh on `plugin.updated` and resource-specific updates. Stable 2.0.4 has no activation-wait endpoint.
|
||||
- Keep the narrow project-local `codenomad.automation` and `codenomad.missions` definitions for developer feedback and native root-session coordination, alongside the reviewed bundled session-pruning RPC. None restores the removed V1 plugin runtime or installs a global shim.
|
||||
- Keep the narrow bundled `codenomad.automation`, `codenomad.missions` and session-pruning integrations under authenticated discovery and backend presence. None restores the removed V1 plugin runtime.
|
||||
|
||||
### Beta 18866 Contract Review (Historical)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Missions are a thin coordination plane over native OpenCode V2 sessions. They in
|
|||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| Root sessions, prompts, durable inbox, execution, Forms, permissions | OpenCode V2 |
|
||||
| Mission map and role context | Project-local `codenomad.missions` plugin |
|
||||
| Mission map and role context | Bundled `codenomad.missions` plugin; project-scoped native storage |
|
||||
| Workspace/location authorization and browser access | CodeNomad |
|
||||
| Checkout isolation and Git policy | Existing CodeNomad worktree/Git modules |
|
||||
| Developer feedback | Separate `codenomad.automation` plugin and its visible-session fence |
|
||||
|
|
@ -20,11 +20,42 @@ The map is an append-only event journal in native plugin storage. Events have de
|
|||
|
||||
Snapshots are authoritative reconstructions of the journal. RPC events are only invalidations; the UI always reloads a snapshot after reconnect because native event subscriptions are live-only.
|
||||
|
||||
## Native execution selection (2.0.11)
|
||||
|
||||
`mission.inspect({ catalog: true })` reads the caller location's native agent/model catalog. `mission.delegate` accepts an optional `execution` selection, independent of its mission `role`:
|
||||
|
||||
```json
|
||||
{
|
||||
"taskKey": "review-spec",
|
||||
"title": "Review acceptance criteria",
|
||||
"brief": "Compare the diff with the requirements and report evidence.",
|
||||
"role": "review-spec",
|
||||
"execution": {
|
||||
"agent": "reviewer",
|
||||
"model": { "providerID": "provider-from-catalog", "id": "model-from-catalog", "variant": "variant-from-catalog" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use actual catalog IDs. New actors support visible `primary`/`all` agents and enabled tool-capable models. Explicit selections are persisted in the task contract and passed to native `session.create`; retries cannot replace them. Omitted fields retain native defaults, so specify both agent and model for a fully pinned assignment. The UI displays these native identifiers beside the role.
|
||||
|
||||
An existing actor must already match the requested selection. Missions never uses `switchAgent`/`switchModel` to repurpose a busy actor: those APIs alter subsequent turns, not a single queued assignment. Native queues do not freeze a per-assignment model; external client changes after admission remain possible.
|
||||
|
||||
Durable actors remain root sessions. `session.create` does not expose child creation, and subagent-only profiles belong to the native `subagent` tool. Use native subagents for bounded child work, not as durable mission-map actors. Root actors use native agent/project permission rules; they do not inherit a coordinator's child-session permission state.
|
||||
|
||||
## Distribution and environment
|
||||
|
||||
Desktop backends provision the content-addressed Missions bundle through `DesktopPluginLifecycle`, using the authenticated daemon's `config.get` discovery root. Backend leases live outside that watched root. Tools, context hooks and the sole typed snapshot RPC follow backend presence. No files are installed in the user's project. The old repository-local plugin entry is removed; `.opencode/checks/` still checks the published V2 plugin contract.
|
||||
|
||||
Assignment prompts and report synthetics use a narrow authenticated loopback bridge mode. The owning backend reconciles the persisted task/report via `codenomad.missions.snapshot`, validates complete native session ownership and selection, acquires the worktree mutation fence, and applies the current full profile environment before admission. Native environment errors are redacted and fail closed. No environment data travels through the UI or plugin. Multiple owning backends are rejected rather than choosing a profile arbitrarily.
|
||||
|
||||
This transport reuses desktop bridge discovery, not browser automation or its visible-window operations. Automation still has its independent execution-time session/window fences. Environment and model state are session-scoped, not atomic per-inbox-item snapshots; see [SESSION_ENVIRONMENT.md](SESSION_ENVIRONMENT.md).
|
||||
|
||||
## Safety limits
|
||||
|
||||
- One native project per mission.
|
||||
- Root sessions only; child sessions and foreign projects are rejected.
|
||||
- At most 8 actors, 96 tasks, and 2,000 stored events in one project view.
|
||||
- At most 8 actors, 96 tasks, 20 missions, and 2,000 stored events in one project view.
|
||||
- Existing root actors may be reused, but an actor cannot join two active missions.
|
||||
- Dependency tasks are mapped as blocked and are never auto-dispatched.
|
||||
- Completing a mission green requires every task to have a completed report.
|
||||
|
|
@ -34,6 +65,8 @@ Snapshots are authoritative reconstructions of the journal. RPC events are only
|
|||
- **Pocock Bug Expedition** preserves evidence-first diagnosis, behavioral TDD, independent fresh Standards and Spec reviews, implementer-session resolution, and a fresh read-only green gate. Completed role reports carry validated structured artifacts, while the coordinator still chooses each transition; no fixed state machine was ported.
|
||||
- **Wayfinder Map** adapts destination, map, frontier, claims, and fog-of-war planning to visible sessions. It remains planning-first and uses native Forms for human decisions.
|
||||
|
||||
The project-local entrypoint is `.opencode/plugins/codenomad-missions.ts`. It re-exports the in-repository definition and has no runtime SDK import, so a normal repository dependency install is sufficient for activation. `.opencode/package.json` pins the V2 plugin package only for the compile-time contract check in `.opencode/checks/`. Projects without that reviewed plugin remain fully functional; Mission Control reports the capability as unavailable. CodeNomad never writes plugin code or configuration into an unrelated project and never installs a global shim.
|
||||
## Validation
|
||||
|
||||
Build the shipped plugin with `npm run build:missions --workspace @neuralnomads/codenomad`, then run `node scripts/test-missions-native.mjs <absolute-opencode-executable>`. The fixture uses isolated configuration/storage, the real bundled plugin, typed RPC, WorkspaceManager and bridge, plus a local deterministic provider. It verifies catalog selection, variant persistence, a busy actor's durable queue, mismatched selection refusal, per-send actor/coordinator environment, presence removal/re-registration, and retry idempotence. No shared service or user provider is used. The fixture passed on Windows with CLI/client/plugin 2.0.11 on 2026-09-20.
|
||||
|
||||
The native runtime findings and recovery matrix are recorded in [`MISSIONS_RUNTIME_SPIKE.md`](MISSIONS_RUNTIME_SPIKE.md).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@
|
|||
|
||||
## Decision summary
|
||||
|
||||
**2026-09-20 package update:** UI/server now pin client 2.0.11 and the bundled
|
||||
plugins compile against plugin 2.0.11. The generated `server.info()` replaces
|
||||
`server.status()`; the connection adapter maps either GET spelling to the
|
||||
authenticated discovered info/status/health route. No write fallback is added.
|
||||
The isolated Windows Missions, automation-presence and full proxy/pruning native
|
||||
fixtures pass against CLI 2.0.11, including durable queueing, permissions, native
|
||||
worktrees, environment propagation and plugin recovery. Historical matrix runs
|
||||
below retain their original package versions; they are not retroactively relabeled.
|
||||
|
||||
At `bcfe4d24`, CodeNomad had a working modern-client path and backward-compatible service discovery, **not a complete backward-compatible V2 integration**. The follow-up change keeps that discovery fix and addresses the remaining issues through one connection-scoped integration module and one cross-runtime acceptance matrix.
|
||||
|
||||
The primary split is the pre-2.0.4 V2 contract versus the 2.0.4 contract. Earlier changes also matter, particularly message editing, catalog activation, location identity and native cache behavior. Support should be defined by tested contract families and features, not inferred from a healthy daemon, a package name, a beta-number comparison or a passing TypeScript build.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ store, applied-version cache, plugin or daemon restart.
|
|||
and acquisition of the worktree mutation fence. Both writes use the acquired
|
||||
connection; a stale connection or disconnected request prevents forwarding.
|
||||
- `WorkspaceManager.getSessionEnvironment` reads current settings for each send.
|
||||
- Bundled Missions also applies this snapshot before its assignment prompts and
|
||||
report synthetics, through the authenticated desktop bridge's narrow mission
|
||||
admission handler. It revalidates the durable contract, native ownership,
|
||||
connection and worktree fence before writing; plugin-originated inputs do not
|
||||
bypass profile environment handling. See [MISSIONS.md](MISSIONS.md).
|
||||
- `workspaces/session-environment.ts` builds the **complete** snapshot required by
|
||||
the native replacement API. On the host this is the backend process environment
|
||||
plus profile overrides, merging keys case-insensitively on Windows.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. Both server and UI pin the official `@opencode/client@2.0.4` network contract. The runtime CLI is independently managed.
|
||||
CodeNomad is a SolidJS UI and Fastify server hosted by Electron or Tauri. Both server and UI pin the official `@opencode/client@2.0.11` network contract. The runtime CLI is independently managed.
|
||||
|
||||
```text
|
||||
Desktop host -> CodeNomad server -> one shared OpenCode service
|
||||
|
|
|
|||
116
package-lock.json
generated
116
package-lock.json
generated
|
|
@ -4158,9 +4158,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@npmcli/agent/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -4252,9 +4252,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -4396,9 +4396,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@npmcli/git/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -4509,9 +4509,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -4685,9 +4685,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@npmcli/package-json/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -4844,14 +4844,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/ai": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.4.tgz",
|
||||
"integrity": "sha512-T/2wTuhy/I1Wxdy/GdhtMqugpl1OWKgVp+8A45mtS1jDrTSeHq2x5YhkComkJZ6BwdG/OvU6afTGDPA7/vd4RA==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.11.tgz",
|
||||
"integrity": "sha512-u6tkH/aQeRaUMGwf0O501n8VHTvWssB7hszfpiVG3TBzJYIbe4LvD1jqSU0BmhA32tHTvYegd61wxyjzyv+EsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
|
|
@ -4860,13 +4860,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.4.tgz",
|
||||
"integrity": "sha512-GBFWT2os+gEtGXpHRvDUNkf0H9x9njDUVbUvvc4drH2CuAihVcHqdi9Wm4UiwkBrSLjrKPs4U1NJBh2Z7/x4tQ==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.11.tgz",
|
||||
"integrity": "sha512-cx9/P1AlUm1wvEAk6Tjt1B7bILzFIw/sUncJRvyVpLkW6oooZBmgkmVi/9isHnr+LPcRkFnkqwmvqhlxlFsLFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.4",
|
||||
"@opencode/schema": "2.0.4"
|
||||
"@opencode/protocol": "2.0.11",
|
||||
"@opencode/schema": "2.0.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
|
|
@ -4882,24 +4882,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/plugin": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.4.tgz",
|
||||
"integrity": "sha512-NsC1STARfXxe+ZoXjXzpxvtS9l4h9Aen8ZccaCd1d9+1eyYdp+q8DU3Rxh7Ch0jiwiQMybS2Z+KgAfZJBnTBbA==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.11.tgz",
|
||||
"integrity": "sha512-LqagVqieE/HWW71fCKrXbeyi5xe4DW4xwW45VNRcc5zLVVmjqjFp102Jd6n0BMXr5f8f2NsR+gLNhdOvM9xM2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "2.0.4",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/protocol": "2.0.4",
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/util": "2.0.4",
|
||||
"@opencode/ai": "2.0.11",
|
||||
"@opencode/client": "2.0.11",
|
||||
"@opencode/protocol": "2.0.11",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"@opencode/util": "2.0.11",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "2.0.4",
|
||||
"@opencode/theme": "2.0.11",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0"
|
||||
|
|
@ -4930,19 +4930,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.4.tgz",
|
||||
"integrity": "sha512-uWe2dmMouprSkfBu29XLR6GCWckkhm0YOo9ve07kTRqJCSoJzgQDF/8pouWG0cDCJ6oWj3bRQajXriBQyWSpUw==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.11.tgz",
|
||||
"integrity": "sha512-57lsPuOshTuEZz8SYvTJhgZQXCvvT5MtpQAjPA+dAG3Pxna8xuoijPFJNeZSNH6isi0AGYKfznoD8MRoqVhM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.4",
|
||||
"@opencode/schema": "2.0.11",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.4.tgz",
|
||||
"integrity": "sha512-UyGcCsRb2hTH98rnBqfJogFve8kJsIbsEreRxqhkBIEGrtDTqcUd5NnqB7qlJp2khesMvpOH0GmCHlgz44C+uQ==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.11.tgz",
|
||||
"integrity": "sha512-AlJB2iyXDMuzxlEm2ies6vp1IIjhQxBpT6hJdWvqcuxKwFQMbbmvCpKFZU/j8iN1RW8qkaD0v9h7LwnalVqZGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
|
@ -4950,9 +4950,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/util": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.4.tgz",
|
||||
"integrity": "sha512-jo6zhWqJ4WaU+fUjM8/Ea4i06gxTQFEFgfq2XG9J8gSDUhfsj4l9wJ3cLtSuKFQGeUeWqJJwRgDhCW+rUbis6Q==",
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.11.tgz",
|
||||
"integrity": "sha512-FO5umf45fiO4roXCAr2sbbIZI9e0WVQkcraWqJMWpzKNWo/HtAiEDnvgHWmJPF6CjDR5OPNbT3VkRVPFhJx5mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5017,9 +5017,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@opencode/util/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -8199,9 +8199,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/cacache/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -13275,9 +13275,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/npm-package-arg/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
|
@ -15686,9 +15686,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/spdx-license-ids": {
|
||||
"version": "3.0.23",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz",
|
||||
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz",
|
||||
"integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
|
|
@ -18560,7 +18560,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/client": "2.0.11",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
@ -18576,7 +18576,7 @@
|
|||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode/plugin": "2.0.4",
|
||||
"@opencode/plugin": "2.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
|
|
@ -18616,7 +18616,7 @@
|
|||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/client": "2.0.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -17,19 +17,20 @@
|
|||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run build:pruning && npm run build:automation",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run build:pruning && npm run build:automation && npm run build:missions",
|
||||
"build:pruning": "node ./scripts/build-session-pruning.mjs",
|
||||
"build:automation": "node ./scripts/build-automation-plugin.mjs",
|
||||
"build:missions": "node ./scripts/build-missions-plugin.mjs",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"dev": "npm run build:pruning && npm run build:automation && cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"dev": "npm run build:pruning && npm run build:automation && npm run build:missions && cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/client": "2.0.11",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"@opencode/plugin": "2.0.4",
|
||||
"@opencode/plugin": "2.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
|
|
|
|||
11
packages/server/scripts/build-missions-plugin.mjs
Normal file
11
packages/server/scripts/build-missions-plugin.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { build } from "esbuild"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
await build({
|
||||
entryPoints: [fileURLToPath(new URL("../src/opencode/missions/desktop-plugin.ts", import.meta.url))],
|
||||
outfile: fileURLToPath(new URL("../dist/plugins/missions/plugin.mjs", import.meta.url)),
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node22",
|
||||
})
|
||||
|
|
@ -377,6 +377,7 @@ async function main() {
|
|||
const pruningLifecycle = new PruningLifecycle()
|
||||
const nativeParent = new NativeParent()
|
||||
const automationLifecycle = new DesktopPluginLifecycle("automation")
|
||||
const missionsLifecycle = new DesktopPluginLifecycle("missions")
|
||||
const prepareDesktopPlugins: NonNullable<ConstructorParameters<typeof WorkspaceManager>[0]["prepareDesktopPlugins"]> = async (launch, connection, deadlineAt) => {
|
||||
let paths
|
||||
try { paths = await resolveDesktopPluginPaths(connection, launch, deadlineAt) }
|
||||
|
|
@ -388,6 +389,7 @@ async function main() {
|
|||
await prepareDesktopPluginPresence(paths, connection.assertCurrent, {
|
||||
pruning: pruningLifecycle,
|
||||
automation: nativeParent.available ? automationLifecycle : undefined,
|
||||
missions: nativeParent.available ? missionsLifecycle : undefined,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
|
|
@ -662,6 +664,7 @@ async function main() {
|
|||
stopHttpServers: async () => {
|
||||
await pruningLifecycle.stop()
|
||||
await automationLifecycle.stop()
|
||||
await missionsLifecycle.stop()
|
||||
nativeParent.close()
|
||||
await removeAutomationBridge?.()
|
||||
yoloManager.stop()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { SessionMetadata } from "@opencode/client"
|
||||
import type { MissionExecution } from "./execution"
|
||||
import type { MissionActor, MissionJsonValue, MissionMap, MissionReportOutcome, MissionTemplateId } from "./model"
|
||||
import type { MissionRecipe, missionRecipeCatalog } from "./recipes"
|
||||
|
||||
export interface NativeMissionSession {
|
||||
export interface NativeMissionSession extends MissionExecution {
|
||||
id: string
|
||||
parentID?: string
|
||||
projectID: string
|
||||
|
|
@ -17,6 +18,8 @@ export interface MissionSessionAdapter {
|
|||
title: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
metadata: SessionMetadata
|
||||
agent?: MissionExecution["agent"]
|
||||
model?: MissionExecution["model"]
|
||||
}): Promise<NativeMissionSession>
|
||||
prompt(input: {
|
||||
sessionID: string
|
||||
|
|
@ -63,6 +66,7 @@ export interface MissionDelegateInput {
|
|||
blockedBy: string[]
|
||||
targetSessionID?: string
|
||||
delivery: "queue" | "steer"
|
||||
execution?: MissionExecution
|
||||
}
|
||||
|
||||
export interface MissionReportInput {
|
||||
|
|
@ -81,4 +85,13 @@ export interface MissionInspection {
|
|||
actor: MissionActor | null
|
||||
templates: ReturnType<typeof missionRecipeCatalog>
|
||||
playbook?: MissionRecipe
|
||||
catalog?: {
|
||||
agents: Array<{ id: string; mode: string; description?: string }>
|
||||
models: Array<{ providerID: string; id: string; variants: string[] }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MissionInputTransport {
|
||||
prompt(coordinatorID: string, input: Parameters<MissionSessionAdapter["prompt"]>[0]): Promise<unknown>
|
||||
synthetic(coordinatorID: string, input: Parameters<MissionSessionAdapter["synthetic"]>[0]): Promise<unknown>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ class FakeSessions implements MissionSessionAdapter {
|
|||
id: input.id,
|
||||
projectID: "project-1",
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
location: input.location,
|
||||
}
|
||||
this.sessions.set(session.id, session)
|
||||
|
|
@ -89,6 +91,36 @@ function harness() {
|
|||
return { storage, sessions, changed, create }
|
||||
}
|
||||
|
||||
test("persists native execution through a failed admission and restart without switching an existing actor", async () => {
|
||||
const { create, sessions } = harness()
|
||||
const control = create()
|
||||
await control.inspect("ses_coordinator", { start: { objective: "Review", template: "custom" } }, "start")
|
||||
const input = {
|
||||
taskKey: "native-review", title: "Review", brief: "Inspect", role: "reviewer", blockedBy: [], delivery: "queue" as const,
|
||||
execution: { agent: "review-all", model: { providerID: "fixture", id: "reasoner", variant: "high" } },
|
||||
}
|
||||
const prompt = sessions.prompt.bind(sessions)
|
||||
sessions.prompt = async () => { throw new Error("admission unavailable") }
|
||||
await assert.rejects(control.delegate("ses_coordinator", input), /admission unavailable/)
|
||||
const snapshot = await create().snapshot()
|
||||
assert.deepEqual(snapshot.missions[0].tasks[0].execution, input.execution)
|
||||
const actorID = snapshot.missions[0].tasks[0].actorSessionId!
|
||||
assert.deepEqual(sessions.sessions.get(actorID)?.model, input.execution.model)
|
||||
sessions.prompt = prompt
|
||||
await assert.rejects(create().delegate("ses_coordinator", { ...input, execution: { agent: "other" } }), /different contract/)
|
||||
sessions.sessions.get(actorID)!.agent = "other"
|
||||
await assert.rejects(create().delegate("ses_coordinator", input), /changed since dispatch/)
|
||||
assert.equal(sessions.prompts.length, 0)
|
||||
sessions.sessions.get(actorID)!.agent = "review-all"
|
||||
const retried = await create().delegate("ses_coordinator", input)
|
||||
assert.equal(retried.disposition, "dispatched")
|
||||
assert.equal(sessions.sessions.size, 2)
|
||||
assert.equal(sessions.prompts.length, 1)
|
||||
await assert.rejects(control.delegate("ses_coordinator", {
|
||||
...input, taskKey: "wrong-target", targetSessionID: actorID, execution: { agent: "other" },
|
||||
}), /differs from the task contract/)
|
||||
})
|
||||
|
||||
test("rejects a twenty-first mission without hiding an active mission", async () => {
|
||||
const { create, sessions, storage } = harness()
|
||||
const control = create()
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import {
|
|||
type MissionReport,
|
||||
type MissionSnapshot,
|
||||
} from "./model"
|
||||
import { buildActorContext, buildAssignmentPrompt, getMissionRecipe, missionRecipeCatalog } from "./recipes"
|
||||
import { buildActorContext, getMissionRecipe, missionRecipeCatalog } from "./recipes"
|
||||
import { assignmentInput, reportInput } from "./inputs"
|
||||
import { validateMissionCompletionPolicy, validateMissionDelegationPolicy, validateMissionReportArtifact } from "./contracts"
|
||||
import { runMissionExclusive } from "./exclusive"
|
||||
import { matchesExecution, sameExecution } from "./execution"
|
||||
import type {
|
||||
MissionDelegateInput,
|
||||
MissionInspectInput,
|
||||
|
|
@ -23,6 +25,7 @@ import type {
|
|||
MissionProject,
|
||||
MissionReportInput,
|
||||
MissionSessionAdapter,
|
||||
MissionInputTransport,
|
||||
NativeMissionSession,
|
||||
} from "./control-types"
|
||||
|
||||
|
|
@ -43,6 +46,8 @@ export class MissionControl {
|
|||
sessions: MissionSessionAdapter
|
||||
now?: () => number
|
||||
changed?: (missionID: string, revision: number) => Promise<void>
|
||||
validateExecution?: (input: MissionDelegateInput, coordinatorID: string) => Promise<void>
|
||||
transport?: MissionInputTransport
|
||||
}) {
|
||||
this.journal = new MissionJournal(options.storage, options.project.id, options.project.canonical, options.now)
|
||||
}
|
||||
|
|
@ -124,6 +129,8 @@ export class MissionControl {
|
|||
|
||||
let task = mission.tasks.find((candidate) => candidate.key === input.taskKey)
|
||||
if (!task) {
|
||||
if (input.targetSessionID) await this.ownedRootSession(input.targetSessionID)
|
||||
await this.options.validateExecution?.(input, sessionID)
|
||||
if (mission.tasks.length >= MISSION_MAX_TASKS) throw new MissionControlError("Mission task limit reached", "task-limit")
|
||||
if (input.blockedBy.includes(input.taskKey)) throw new MissionControlError("A task cannot block itself", "invalid-blocker")
|
||||
const existingTasks = mission.tasks
|
||||
|
|
@ -141,6 +148,7 @@ export class MissionControl {
|
|||
title: input.title,
|
||||
brief: input.brief,
|
||||
role: input.role,
|
||||
...(input.execution === undefined ? {} : { execution: input.execution }),
|
||||
blockedBy: input.blockedBy,
|
||||
},
|
||||
createdAt: this.timestamp(snapshot),
|
||||
|
|
@ -152,6 +160,7 @@ export class MissionControl {
|
|||
} else {
|
||||
const same = task.title === input.title && task.brief === input.brief && task.role === input.role
|
||||
&& equalStrings(task.blockedBy, input.blockedBy)
|
||||
&& sameExecution(task.execution, input.execution)
|
||||
if (!same) throw new MissionControlError("Task key already exists with a different contract", "task-conflict")
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +171,12 @@ export class MissionControl {
|
|||
return { disposition: "dispatched", mission }
|
||||
}
|
||||
|
||||
if (input.targetSessionID) {
|
||||
const target = await this.ownedRootSession(input.targetSessionID)
|
||||
if (!matchesExecution(task.execution, target)) {
|
||||
throw new MissionControlError("Target agent/model differs from the task contract; select another actor instead of switching a busy session", "execution-conflict")
|
||||
}
|
||||
}
|
||||
const actor = await this.selectActor(snapshot, mission, sessionID, task.role, task.title, input.targetSessionID)
|
||||
const admissionID = this.messageID(`assignment\0${mission.id}\0${task.key}`)
|
||||
await this.journal.append({
|
||||
|
|
@ -282,16 +297,14 @@ export class MissionControl {
|
|||
}
|
||||
const actor = mission.actors.find((candidate) => candidate.sessionId === task.actorSessionId)
|
||||
if (!actor) throw new MissionControlError("Task actor is missing", "invalid-dispatch")
|
||||
const session = await this.ensureActorSession(mission, actor, task.role, task.title)
|
||||
const session = await this.ensureActorSession(mission, actor, task)
|
||||
this.assertOwnedRoot(session)
|
||||
await this.options.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
id: task.admissionId,
|
||||
text: buildAssignmentPrompt(mission, task),
|
||||
metadata: this.metadata(mission.id, "assignment", { taskKey: task.key, role: task.role }),
|
||||
delivery: task.delivery,
|
||||
resume: true,
|
||||
})
|
||||
if (!matchesExecution(task.execution, session)) {
|
||||
throw new MissionControlError("Actor agent/model changed since dispatch; restore its selection before retrying", "execution-conflict")
|
||||
}
|
||||
const prompt = assignmentInput(mission, task)
|
||||
if (this.options.transport) await this.options.transport.prompt(mission.coordinatorSessionId, prompt)
|
||||
else await this.options.sessions.prompt(prompt)
|
||||
const snapshot = await this.snapshot()
|
||||
await this.journal.append({
|
||||
version: MISSION_SCHEMA_VERSION,
|
||||
|
|
@ -340,7 +353,7 @@ export class MissionControl {
|
|||
}
|
||||
}
|
||||
|
||||
private async ensureActorSession(mission: MissionMap, actor: MissionActor, role: string, taskTitle: string): Promise<NativeMissionSession> {
|
||||
private async ensureActorSession(mission: MissionMap, actor: MissionActor, task: MissionMap["tasks"][number]): Promise<NativeMissionSession> {
|
||||
try {
|
||||
return await this.options.sessions.get({ sessionID: actor.sessionId })
|
||||
} catch (getError) {
|
||||
|
|
@ -348,9 +361,10 @@ export class MissionControl {
|
|||
try {
|
||||
return await this.options.sessions.create({
|
||||
id: actor.sessionId,
|
||||
title: actor.title || `Mission · ${role}: ${taskTitle}`,
|
||||
title: actor.title || `Mission · ${task.role}: ${task.title}`,
|
||||
location: actor.location,
|
||||
metadata: this.metadata(mission.id, "actor", { role }),
|
||||
metadata: this.metadata(mission.id, "actor", { role: task.role }),
|
||||
...task.execution,
|
||||
})
|
||||
} catch (createError) {
|
||||
try {
|
||||
|
|
@ -363,20 +377,10 @@ export class MissionControl {
|
|||
}
|
||||
|
||||
private async notifyCoordinator(mission: MissionMap, report: MissionReport): Promise<void> {
|
||||
const admissionID = this.messageID(`report\0${report.id}`)
|
||||
await this.options.sessions.synthetic({
|
||||
sessionID: mission.coordinatorSessionId,
|
||||
id: admissionID,
|
||||
text: `Mission report received for “${report.taskKey}” from ${report.sessionId}. Outcome: ${report.outcome}.\n\n${report.summary}`,
|
||||
description: "CodeNomad mission report",
|
||||
metadata: this.metadata(mission.id, "report", {
|
||||
taskKey: report.taskKey,
|
||||
reportID: report.id,
|
||||
fromSessionID: report.sessionId,
|
||||
}),
|
||||
delivery: "queue",
|
||||
resume: true,
|
||||
})
|
||||
const notification = reportInput(mission, report)
|
||||
const admissionID = notification.id
|
||||
if (this.options.transport) await this.options.transport.synthetic(mission.coordinatorSessionId, notification)
|
||||
else await this.options.sessions.synthetic(notification)
|
||||
const snapshot = await this.snapshot()
|
||||
await this.journal.append({
|
||||
version: MISSION_SCHEMA_VERSION,
|
||||
|
|
|
|||
54
packages/server/src/missions/execution.ts
Normal file
54
packages/server/src/missions/execution.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { ModelRef } from "@opencode/client"
|
||||
|
||||
export interface MissionExecution {
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
}
|
||||
|
||||
export const executionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string", minLength: 1, maxLength: 240 },
|
||||
model: {
|
||||
type: "object",
|
||||
properties: {
|
||||
providerID: { type: "string", minLength: 1, maxLength: 240 },
|
||||
id: { type: "string", minLength: 1, maxLength: 240 },
|
||||
variant: { type: "string", minLength: 1, maxLength: 240 },
|
||||
},
|
||||
required: ["providerID", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const
|
||||
|
||||
export function parseExecution(input: unknown): MissionExecution | undefined {
|
||||
if (input === undefined) return undefined
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Invalid mission execution selection")
|
||||
const value = input as Record<string, unknown>
|
||||
if (Object.keys(value).some(key => key !== "agent" && key !== "model")) throw new Error("Unknown execution field")
|
||||
const text = (value: unknown): string => {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > 240) throw new Error("Invalid native execution identifier")
|
||||
return value
|
||||
}
|
||||
const agent = value.agent === undefined ? undefined : text(value.agent)
|
||||
if (value.model === undefined) return { agent }
|
||||
if (!value.model || typeof value.model !== "object" || Array.isArray(value.model)) throw new Error("Invalid model selection")
|
||||
const model = value.model as Record<string, unknown>
|
||||
if (Object.keys(model).some(key => !["providerID", "id", "variant"].includes(key))) throw new Error("Unknown model field")
|
||||
return { agent, model: {
|
||||
providerID: text(model.providerID), id: text(model.id),
|
||||
...(model.variant === undefined ? {} : { variant: text(model.variant) }),
|
||||
} }
|
||||
}
|
||||
|
||||
export function sameExecution(left?: MissionExecution, right?: MissionExecution): boolean {
|
||||
return left?.agent === right?.agent && left?.model?.providerID === right?.model?.providerID
|
||||
&& left?.model?.id === right?.model?.id && left?.model?.variant === right?.model?.variant
|
||||
}
|
||||
|
||||
export function matchesExecution(request: MissionExecution | undefined, actual: MissionExecution): boolean {
|
||||
return (!request?.agent || request.agent === actual.agent)
|
||||
&& (!request?.model || sameExecution({ model: request.model }, { model: actual.model }))
|
||||
}
|
||||
24
packages/server/src/missions/inputs.ts
Normal file
24
packages/server/src/missions/inputs.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { stableToken } from "./journal"
|
||||
import { buildAssignmentPrompt } from "./recipes"
|
||||
import type { MissionMap, MissionReport, MissionTask } from "./model"
|
||||
import type { MissionSessionAdapter } from "./control-types"
|
||||
|
||||
export function assignmentInput(mission: MissionMap, task: MissionTask): Parameters<MissionSessionAdapter["prompt"]>[0] {
|
||||
if (!task.actorSessionId || !task.admissionId || !task.delivery) throw new Error("Incomplete mission dispatch")
|
||||
return {
|
||||
sessionID: task.actorSessionId, id: task.admissionId, text: buildAssignmentPrompt(mission, task),
|
||||
metadata: { "codenomad.mission": { version: 1, missionID: mission.id, kind: "assignment", taskKey: task.key, role: task.role } },
|
||||
delivery: task.delivery, resume: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function reportInput(mission: MissionMap, report: MissionReport): Parameters<MissionSessionAdapter["synthetic"]>[0] {
|
||||
return {
|
||||
sessionID: mission.coordinatorSessionId, id: `msg_${stableToken(`report\0${report.id}`, 28)}`,
|
||||
text: `Mission report received for “${report.taskKey}” from ${report.sessionId}. Outcome: ${report.outcome}.\n\n${report.summary}`,
|
||||
description: "CodeNomad mission report",
|
||||
metadata: { "codenomad.mission": { version: 1, missionID: mission.id, kind: "report", taskKey: report.taskKey,
|
||||
reportID: report.id, fromSessionID: report.sessionId } },
|
||||
delivery: "queue", resume: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type MissionTemplateId,
|
||||
} from "./model"
|
||||
import { runMissionExclusive } from "./exclusive"
|
||||
import { parseExecution } from "./execution"
|
||||
|
||||
const STORAGE_PREFIX = "codenomad-missions/v1"
|
||||
const PAGE_SIZE = 100
|
||||
|
|
@ -138,6 +139,8 @@ export function parseMissionEvent(input: unknown): MissionEvent | undefined {
|
|||
if (!record(input.task) || !text(input.task.id, MAX_SHORT_TEXT) || !text(input.task.key, MAX_SHORT_TEXT)
|
||||
|| !text(input.task.title, MAX_SHORT_TEXT) || !text(input.task.brief, MAX_TEXT)
|
||||
|| !text(input.task.role, MAX_SHORT_TEXT) || !stringArray(input.task.blockedBy, 24, MAX_SHORT_TEXT)) return undefined
|
||||
let execution
|
||||
try { execution = parseExecution(input.task.execution) } catch { return undefined }
|
||||
return {
|
||||
...eventBase(input),
|
||||
type: "task.created",
|
||||
|
|
@ -147,6 +150,7 @@ export function parseMissionEvent(input: unknown): MissionEvent | undefined {
|
|||
title: input.task.title,
|
||||
brief: input.task.brief,
|
||||
role: input.task.role,
|
||||
...(execution === undefined ? {} : { execution }),
|
||||
blockedBy: input.task.blockedBy,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { MissionExecution } from "./execution"
|
||||
|
||||
export const MISSION_SCHEMA_VERSION = 1 as const
|
||||
export const MISSION_MAX_ACTORS = 8
|
||||
export const MISSION_MAX_EVENTS = 2_000
|
||||
|
|
@ -55,6 +57,7 @@ export interface MissionTask {
|
|||
title: string
|
||||
brief: string
|
||||
role: string
|
||||
execution?: MissionExecution
|
||||
blockedBy: string[]
|
||||
status: MissionTaskStatus
|
||||
actorSessionId?: string
|
||||
|
|
@ -135,6 +138,7 @@ export interface MissionTaskCreatedEvent extends MissionEventBase {
|
|||
title: string
|
||||
brief: string
|
||||
role: string
|
||||
execution?: MissionExecution
|
||||
blockedBy: string[]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
packages/server/src/missions/native-catalog.test.ts
Normal file
32
packages/server/src/missions/native-catalog.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { parseExecution } from "./execution"
|
||||
import { readMissionCatalog, validateNativeExecution, type MissionCatalogClient } from "./native-catalog"
|
||||
|
||||
test("discovers native identities at the requested location and validates runnable selections", async () => {
|
||||
const locations: string[] = []
|
||||
const client: MissionCatalogClient = {
|
||||
agent: { list: async input => {
|
||||
locations.push(input.location.directory)
|
||||
return { data: [{ id: "review", mode: "all" }, { id: "explore", mode: "subagent" }, { id: "title", hidden: true }] } as never
|
||||
} },
|
||||
model: { list: async input => {
|
||||
locations.push(input.location.directory)
|
||||
return { data: [
|
||||
{ providerID: "fixture", id: "reasoner", enabled: true, capabilities: { tools: true }, variants: [{ id: "high" }] },
|
||||
{ providerID: "fixture", id: "disabled", enabled: false, capabilities: { tools: true }, variants: [] },
|
||||
] } as never
|
||||
} },
|
||||
}
|
||||
const catalog = await readMissionCatalog(client, "/worktree")
|
||||
assert.deepEqual(locations, ["/worktree", "/worktree"])
|
||||
assert.deepEqual(catalog.agents.map(agent => agent.id), ["review", "explore"])
|
||||
assert.equal(catalog.models.length, 1)
|
||||
const input = { taskKey: "review", title: "Review", brief: "Inspect", role: "reviewer", blockedBy: [], delivery: "queue" as const }
|
||||
await validateNativeExecution(client, "/worktree", { ...input, execution: { agent: "review", model: { providerID: "fixture", id: "reasoner", variant: "high" } } })
|
||||
await assert.rejects(validateNativeExecution(client, "/worktree", { ...input, execution: { agent: "explore" } }), /child-only/)
|
||||
await assert.rejects(validateNativeExecution(client, "/worktree", { ...input, execution: { model: { providerID: "fixture", id: "reasoner", variant: "missing" } } }), /variant/)
|
||||
await assert.rejects(validateNativeExecution(client, "/worktree", { ...input, execution: { model: { providerID: "fixture", id: "disabled" } } }), /enabled/)
|
||||
assert.throws(() => parseExecution({ model: { providerID: "fixture" } }), /identifier/)
|
||||
assert.throws(() => parseExecution({ model: { providerID: "fixture", id: "reasoner", apiKey: "not-allowed" } }), /Unknown/)
|
||||
})
|
||||
36
packages/server/src/missions/native-catalog.ts
Normal file
36
packages/server/src/missions/native-catalog.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { AgentListOutput, ModelListOutput } from "@opencode/client"
|
||||
import type { MissionDelegateInput } from "./control-types"
|
||||
|
||||
export interface MissionCatalogClient {
|
||||
agent: { list(input: { location: { directory: string } }): Promise<AgentListOutput> }
|
||||
model: { list(input: { location: { directory: string } }): Promise<ModelListOutput> }
|
||||
}
|
||||
|
||||
export async function readMissionCatalog(client: MissionCatalogClient, directory: string) {
|
||||
const [agents, models] = await Promise.all([
|
||||
client.agent.list({ location: { directory } }), client.model.list({ location: { directory } }),
|
||||
])
|
||||
return {
|
||||
agents: agents.data.filter(agent => !agent.hidden).map(({ id, mode, description }) => ({ id, mode, description })),
|
||||
models: models.data.filter(model => model.enabled && model.capabilities.tools)
|
||||
.map(({ providerID, id, variants }) => ({ providerID, id, variants: variants.map(variant => variant.id) })),
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateNativeExecution(client: MissionCatalogClient, directory: string, input: MissionDelegateInput) {
|
||||
if (!input.execution) return
|
||||
const catalog = await readMissionCatalog(client, directory)
|
||||
if (input.execution.agent) {
|
||||
const agent = catalog.agents.find(agent => agent.id === input.execution!.agent)
|
||||
if (!agent || agent.mode === "subagent") {
|
||||
throw new Error("Choose a visible primary/all agent for a mission actor; use native subagent for child-only profiles")
|
||||
}
|
||||
}
|
||||
if (input.execution.model) {
|
||||
const selected = input.execution.model
|
||||
const model = catalog.models.find(model => model.providerID === selected.providerID && model.id === selected.id)
|
||||
if (!model || (selected.variant !== undefined && !model.variants.includes(selected.variant))) {
|
||||
throw new Error("Choose an enabled tool-capable model and variant from the native catalog")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { executionSchema } from "./execution"
|
||||
|
||||
export const CODENOMAD_MISSIONS_RPC_ID = "codenomad.missions"
|
||||
export const CODENOMAD_MISSIONS_CHANGED_EVENT = `rpc.${CODENOMAD_MISSIONS_RPC_ID}.changed`
|
||||
|
||||
|
|
@ -73,6 +75,7 @@ const mission = {
|
|||
title: { type: "string" },
|
||||
brief: { type: "string" },
|
||||
role: { type: "string" },
|
||||
execution: executionSchema,
|
||||
blockedBy: stringArray,
|
||||
status: { type: "string", enum: ["blocked", "ready", "dispatching", "queued", "completed", "needs-input", "failed"] },
|
||||
actorSessionId: { type: "string" },
|
||||
|
|
|
|||
|
|
@ -462,6 +462,14 @@ async function probeBrowserBridges(
|
|||
return found.slice(0, 2)
|
||||
}
|
||||
|
||||
export async function sendMissionInput(sessionID: string, kind: "prompt" | "synthetic", input: unknown): Promise<unknown> {
|
||||
const targets = await probeBrowserBridges(await registrations(), sessionID, "browser-claim")
|
||||
if (targets.length !== 1) throw new Error("Mission dispatch requires exactly one owning CodeNomad backend")
|
||||
const response = await callBridge(targets[0], { mode: "mission-input", sessionID, command: { kind, input } }, REQUEST_TIMEOUT_MS)
|
||||
if (response.status !== 200) throw new Error("CodeNomad could not admit the mission input; inspect ownership/environment and retry the same task")
|
||||
return response.body.result
|
||||
}
|
||||
|
||||
export async function executeBrowserTool(sessionID: string, input: unknown) {
|
||||
const command = parseBrowserAction(input)
|
||||
const mode = command.action === "open" ? "browser-claim" : "browser-probe"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ test("the pinned status client follows the discovered route across legacy, moder
|
|||
return Response.json(discovery === "health" ? { healthy: true, version, pid: 123 } : { version, pid: 123, urls: [endpoint.url] })
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
|
||||
assert.deepEqual(await client.server.status(), { version, pid: 123, urls: [endpoint.url] })
|
||||
assert.deepEqual(await client.server.info(), { version, pid: 123, urls: [endpoint.url] })
|
||||
assert.deepEqual(seen, discovery === "info" ? ["/openapi.json", route] : [route])
|
||||
}
|
||||
})
|
||||
|
|
@ -43,7 +43,7 @@ test("info translation is GET-only and does not probe alternate routes on failur
|
|||
return path === "/openapi.json" ? Response.json(modernContractFixture) : new Response(null, { status: 404 })
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
|
||||
await assert.rejects(client.server.status())
|
||||
await assert.rejects(client.server.info())
|
||||
await fetch(`${endpoint.url}/api/status`, { method: "POST" })
|
||||
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/status"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,8 +48,10 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
|
|||
// Credentialed native requests must never follow a redirect to another host.
|
||||
const options: RequestInit = { method: request.method, headers, signal: AbortSignal.any([request.signal, lifetime]), redirect: "error" }
|
||||
const originalPath = url.pathname
|
||||
if (originalPath === "/api/status" && request.method === "GET" && identity?.discovery === "info") {
|
||||
url.pathname = "/api/info"
|
||||
const serverInfo = (originalPath === "/api/info" || originalPath === "/api/status") && request.method === "GET"
|
||||
if (serverInfo) {
|
||||
url.pathname = identity?.discovery === "info" ? "/api/info"
|
||||
: identity?.discovery === "health" || profile === "legacy" ? "/api/health" : "/api/status"
|
||||
}
|
||||
if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) {
|
||||
const text = request.body ? await request.text() : undefined
|
||||
|
|
@ -72,7 +74,7 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
|
|||
status: response.status, profile, method: request.method, path: originalPath,
|
||||
}, { status: response.status })
|
||||
}
|
||||
if (profile === "legacy" && response.ok && originalPath === "/api/status") {
|
||||
if (serverInfo && url.pathname === "/api/health" && response.ok) {
|
||||
const health = await response.json() as { version: string; pid: number }
|
||||
return Response.json({ version: health.version, pid: health.pid, urls: [endpoint.url] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface DesktopPluginPaths {
|
|||
// WSL uses Linux paths in the entry, UNC paths only for host filesystem I/O.
|
||||
nativeData?: string
|
||||
}
|
||||
export type DesktopPluginFeature = "session-pruning" | "automation"
|
||||
export type DesktopPluginFeature = "session-pruning" | "automation" | "missions"
|
||||
|
||||
// Only parse our exact generated entry shape. Older backend leases remain
|
||||
// readable during migration, but new heartbeats must leave the watched root.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export class DesktopPluginLifecycle {
|
|||
export async function prepareDesktopPluginPresence(
|
||||
paths: DesktopPluginPaths,
|
||||
assertCurrent: () => void,
|
||||
lifecycles: { pruning: DesktopPluginLifecycle; automation?: DesktopPluginLifecycle },
|
||||
lifecycles: { pruning: DesktopPluginLifecycle; automation?: DesktopPluginLifecycle; missions?: DesktopPluginLifecycle },
|
||||
): Promise<void> {
|
||||
const prepared: PreparedPlugin[] = []
|
||||
try {
|
||||
|
|
@ -110,6 +110,8 @@ export async function prepareDesktopPluginPresence(
|
|||
assertCurrent()
|
||||
if (lifecycles.automation) prepared.push(await lifecycles.automation.prepare(paths, assertCurrent))
|
||||
assertCurrent()
|
||||
if (lifecycles.missions) prepared.push(await lifecycles.missions.prepare(paths, assertCurrent))
|
||||
assertCurrent()
|
||||
for (const entry of prepared) entry.commit()
|
||||
} finally {
|
||||
await Promise.all(prepared.map(entry => entry.release()))
|
||||
|
|
|
|||
|
|
@ -91,4 +91,24 @@ test("registers three tools, typed snapshot RPC, and role context", async () =>
|
|||
assert.match(event.system[0]?.text ?? "", /Only this coordinator session/)
|
||||
assert.ok(event.tools.mission_delegate)
|
||||
await cleanup()
|
||||
await assert.rejects(inspect.execute({}, {
|
||||
sessionID: "ses_coordinator", id: "stale-tool", progress: async () => {},
|
||||
}), /no longer available/)
|
||||
})
|
||||
|
||||
test("failed presence activation disposes partial registrations and fences captured tools", async () => {
|
||||
const disposed: string[] = []
|
||||
const captured: Array<{ execute(input: unknown, context: unknown): Promise<unknown> }> = []
|
||||
await assert.rejects(setupMissionsPlugin({
|
||||
location: { directory: "/repo", project: { id: "project-1", canonical: "/repo" } },
|
||||
storage: {},
|
||||
session: { hook: async () => { throw new Error("hook failed") } },
|
||||
rpc: { register: async () => ({ dispose: async () => { disposed.push("rpc") } }) },
|
||||
tool: { transform: async (callback: (draft: unknown) => void) => {
|
||||
callback({ namespace: () => {}, add: (tool: typeof captured[number]) => captured.push(tool) })
|
||||
return { dispose: async () => { disposed.push("tools") } }
|
||||
} },
|
||||
} as never), /hook failed/)
|
||||
assert.deepEqual(disposed.sort(), ["rpc", "tools"])
|
||||
await assert.rejects(captured[0].execute({}, {}), /no longer available/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import type {
|
|||
MissionInspectInput,
|
||||
MissionReportInput,
|
||||
MissionSessionAdapter,
|
||||
MissionInputTransport,
|
||||
} from "../missions/control-types"
|
||||
import { MISSION_SCHEMA_VERSION, type MissionJsonValue, type MissionTemplateId } from "../missions/model"
|
||||
import { CODENOMAD_MISSIONS_RPC } from "../missions/rpc"
|
||||
import { executionSchema, parseExecution } from "../missions/execution"
|
||||
import { readMissionCatalog, validateNativeExecution, type MissionCatalogClient } from "../missions/native-catalog"
|
||||
|
||||
interface Registration {
|
||||
dispose(): Promise<void>
|
||||
|
|
@ -32,7 +35,7 @@ interface ToolDraft {
|
|||
}): void
|
||||
}
|
||||
|
||||
interface MissionsPluginContext {
|
||||
interface MissionsPluginContext extends MissionCatalogClient {
|
||||
location: {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
|
|
@ -65,7 +68,14 @@ interface MissionsPluginContext {
|
|||
}
|
||||
}
|
||||
|
||||
export async function setupMissionsPlugin(context: MissionsPluginContext): Promise<() => Promise<void>> {
|
||||
export async function setupMissionsPlugin(context: MissionsPluginContext, transport?: MissionInputTransport): Promise<() => Promise<void>> {
|
||||
let active = true
|
||||
const registrations: Registration[] = []
|
||||
const assertActive = () => { if (!active) throw new Error("CodeNomad Missions is no longer available") }
|
||||
const dispose = async () => {
|
||||
active = false
|
||||
await Promise.allSettled(registrations.map(registration => registration.dispose()))
|
||||
}
|
||||
let rpcRegistration: Awaited<ReturnType<MissionsPluginContext["rpc"]["register"]>> | undefined
|
||||
const control = new MissionControl({
|
||||
project: {
|
||||
|
|
@ -75,67 +85,85 @@ export async function setupMissionsPlugin(context: MissionsPluginContext): Promi
|
|||
},
|
||||
storage: context.storage,
|
||||
sessions: context.session,
|
||||
transport,
|
||||
validateExecution: async (input, coordinatorID) => {
|
||||
const session = await context.session.get({ sessionID: input.targetSessionID ?? coordinatorID })
|
||||
await validateNativeExecution(context, session.location.directory, input)
|
||||
},
|
||||
changed: (missionID, revision) => rpcRegistration?.events.emit("changed", { missionID, revision }) ?? Promise.resolve(),
|
||||
})
|
||||
|
||||
rpcRegistration = await context.rpc.register(CODENOMAD_MISSIONS_RPC, {
|
||||
snapshot: async () => JSON.parse(JSON.stringify(await control.snapshot())),
|
||||
})
|
||||
try {
|
||||
rpcRegistration = await context.rpc.register(CODENOMAD_MISSIONS_RPC, {
|
||||
snapshot: async () => JSON.parse(JSON.stringify(await control.snapshot())),
|
||||
})
|
||||
registrations.push(rpcRegistration)
|
||||
|
||||
const tools = await context.tool.transform((draft) => {
|
||||
draft.namespace({
|
||||
name: "mission",
|
||||
description: "Coordinate visible OpenCode root sessions through a durable, agent-driven mission map.",
|
||||
const tools = await context.tool.transform((draft) => {
|
||||
draft.namespace({
|
||||
name: "mission",
|
||||
description: "Coordinate visible OpenCode root sessions through a durable, agent-driven mission map.",
|
||||
})
|
||||
draft.add({
|
||||
name: "inspect",
|
||||
description: "Inspect the caller's durable mission map and optional native catalog, or start a custom, Pocock bug-fix, or Wayfinder mission.",
|
||||
input: inspectSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
assertActive()
|
||||
await tool.progress({ status: "Reading the mission map" })
|
||||
const result = await control.inspect(tool.sessionID, parseInspectInput(input), tool.id)
|
||||
if (object(input).catalog === true) {
|
||||
const session = await context.session.get({ sessionID: tool.sessionID })
|
||||
result.catalog = await readMissionCatalog(context, session.location.directory)
|
||||
}
|
||||
return textResult(result)
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "delegate",
|
||||
description: "Create or dispatch one dependency-aware task to a new or existing root session. Coordinator only. Native execution selection is independent of mission role; inspect the catalog before selecting IDs.",
|
||||
input: delegateSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
assertActive()
|
||||
await tool.progress({ status: "Delegating mission task" })
|
||||
return textResult(await control.delegate(tool.sessionID, parseDelegateInput(input)))
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "report",
|
||||
description: "Report an assigned task back to the coordinator, or let the coordinator finish the mission.",
|
||||
input: reportSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
assertActive()
|
||||
await tool.progress({ status: "Recording mission report" })
|
||||
return textResult(await control.report(tool.sessionID, parseReportInput(input)))
|
||||
},
|
||||
})
|
||||
})
|
||||
draft.add({
|
||||
name: "inspect",
|
||||
description: "Inspect the caller's durable mission map, or start a custom, Pocock bug-fix, or Wayfinder mission.",
|
||||
input: inspectSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
await tool.progress({ status: "Reading the mission map" })
|
||||
const result = await control.inspect(tool.sessionID, parseInspectInput(input), tool.id)
|
||||
return textResult(result)
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "delegate",
|
||||
description: "Create or dispatch one dependency-aware task to a new or existing root session. Coordinator only.",
|
||||
input: delegateSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
await tool.progress({ status: "Delegating mission task" })
|
||||
return textResult(await control.delegate(tool.sessionID, parseDelegateInput(input)))
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "report",
|
||||
description: "Report an assigned task back to the coordinator, or let the coordinator finish the mission.",
|
||||
input: reportSchema,
|
||||
options: { namespace: "mission", codemode: false },
|
||||
execute: async (input, tool) => {
|
||||
await tool.progress({ status: "Recording mission report" })
|
||||
return textResult(await control.report(tool.sessionID, parseReportInput(input)))
|
||||
},
|
||||
})
|
||||
})
|
||||
registrations.push(tools)
|
||||
|
||||
const contextHook = await context.session.hook("context", async (event) => {
|
||||
try {
|
||||
const instruction = await control.contextFor(event.sessionID)
|
||||
if (!instruction) return
|
||||
event.system.push({ type: "text", text: instruction })
|
||||
const snapshot = await control.snapshot()
|
||||
const mission = snapshot.missions.find((candidate) => candidate.status === "active"
|
||||
&& candidate.actors.some((actor) => actor.sessionId === event.sessionID))
|
||||
if (mission && mission.coordinatorSessionId !== event.sessionID) delete event.tools.mission_delegate
|
||||
} catch {
|
||||
// Mission context is additive. A damaged optional map must not block an otherwise valid model request.
|
||||
}
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await Promise.allSettled([contextHook.dispose(), tools.dispose(), rpcRegistration?.dispose() ?? Promise.resolve()])
|
||||
const contextHook = await context.session.hook("context", async (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const instruction = await control.contextFor(event.sessionID)
|
||||
if (!instruction) return
|
||||
event.system.push({ type: "text", text: instruction })
|
||||
const snapshot = await control.snapshot()
|
||||
const mission = snapshot.missions.find((candidate) => candidate.status === "active"
|
||||
&& candidate.actors.some((actor) => actor.sessionId === event.sessionID))
|
||||
if (mission && mission.coordinatorSessionId !== event.sessionID) delete event.tools.mission_delegate
|
||||
} catch {
|
||||
// Mission context is additive. A damaged optional map must not block an otherwise valid model request.
|
||||
}
|
||||
})
|
||||
registrations.push(contextHook)
|
||||
return dispose
|
||||
} catch (error) {
|
||||
await dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +171,7 @@ const inspectSchema = {
|
|||
type: "object",
|
||||
properties: {
|
||||
missionID: { type: "string", description: "Mission to inspect; omit to use the caller's active mission." },
|
||||
catalog: { type: "boolean", description: "Include the native agent, enabled model and variant catalog for this session's location." },
|
||||
start: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -165,6 +194,7 @@ const delegateSchema = {
|
|||
title: { type: "string", minLength: 1, maxLength: 240 },
|
||||
brief: { type: "string", minLength: 1, maxLength: 20_000 },
|
||||
role: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{1,63}$" },
|
||||
execution: { ...executionSchema, description: "Native agent/model selection, independent of mission role. New root actors support primary/all agents. Existing actors must already match; no busy-session switching." },
|
||||
blockedBy: { type: "array", maxItems: 24, items: { type: "string" } },
|
||||
targetSessionID: { type: "string", description: "Existing same-project root session to reuse; omit to create one." },
|
||||
delivery: { type: "string", enum: ["queue", "steer"], description: "queue is safe for a busy actor." },
|
||||
|
|
@ -216,6 +246,7 @@ export function parseDelegateInput(input: unknown): MissionDelegateInput {
|
|||
title: requiredText(value.title, "title", 240),
|
||||
brief: requiredText(value.brief, "brief", 20_000),
|
||||
role: identifier(value.role, "role"),
|
||||
...(value.execution === undefined ? {} : { execution: parseExecution(value.execution) }),
|
||||
blockedBy,
|
||||
targetSessionID: optionalText(value.targetSessionID, "targetSessionID", 240),
|
||||
delivery: value.delivery === undefined ? "queue" : delivery(value.delivery),
|
||||
|
|
|
|||
14
packages/server/src/opencode/missions/desktop-plugin.ts
Normal file
14
packages/server/src/opencode/missions/desktop-plugin.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Plugin } from "@opencode/plugin"
|
||||
import { followPresence } from "../desktop-plugin-presence"
|
||||
import { setupMissionsPlugin } from "../missions-plugin"
|
||||
import { sendMissionInput } from "../automation-plugin"
|
||||
|
||||
export function desktopPlugin(presenceDirectory: string | readonly string[]): Plugin.Plugin {
|
||||
return {
|
||||
id: "codenomad.missions",
|
||||
setup: ctx => followPresence(presenceDirectory, () => setupMissionsPlugin(ctx, {
|
||||
prompt: (coordinatorID, input) => sendMissionInput(coordinatorID, "prompt", input),
|
||||
synthetic: (coordinatorID, input) => sendMissionInput(coordinatorID, "synthetic", input),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -328,6 +328,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
registerAutomationPluginRoute(app, {
|
||||
authManager: deps.authManager,
|
||||
bridgeToken: deps.automationBridgeToken,
|
||||
worktreeDeletionFence,
|
||||
nativeParent: deps.nativeParent,
|
||||
developerCdp,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type { DeveloperCdpIdentity, DeveloperCdpSelection } from "../../develope
|
|||
import type { NativeParent } from "../../native-parent"
|
||||
import { AUTOMATION_BRIDGE_PATH, parseBrowserAction, parseDeveloperAction } from "../../opencode/automation-plugin"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { admitMissionInput } from "./mission-input"
|
||||
|
||||
interface AutomationPluginRouteDeps {
|
||||
authManager: AuthManager
|
||||
|
|
@ -13,6 +15,7 @@ interface AutomationPluginRouteDeps {
|
|||
nativeParent: NativeParent
|
||||
workspaceManager: WorkspaceManager
|
||||
developerCdp: DeveloperCdp
|
||||
worktreeDeletionFence?: WorktreeDeletionFence
|
||||
}
|
||||
|
||||
interface DeveloperNativeStatus {
|
||||
|
|
@ -40,10 +43,10 @@ export function isAutomationPluginRequest(
|
|||
}
|
||||
|
||||
export function registerAutomationPluginRoute(app: FastifyInstance, deps: AutomationPluginRouteDeps): void {
|
||||
app.post(AUTOMATION_BRIDGE_PATH, { bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
app.post(AUTOMATION_BRIDGE_PATH, { bodyLimit: 512 * 1024 }, async (request, reply) => {
|
||||
if (!isAutomationPluginRequest(request, deps)) return reply.code(401).send({ error: "Unauthorized automation bridge" })
|
||||
const body = request.body as { mode?: unknown; sessionID?: unknown; command?: unknown } | undefined
|
||||
if (!body || !["developer-probe", "developer-execute", "browser-claim", "browser-probe", "browser-execute"].includes(String(body.mode))
|
||||
if (!body || !["developer-probe", "developer-execute", "browser-claim", "browser-probe", "browser-execute", "mission-input"].includes(String(body.mode))
|
||||
|| typeof body.sessionID !== "string" || body.sessionID.length > 256) {
|
||||
return reply.code(400).send({ error: "Invalid automation bridge request" })
|
||||
}
|
||||
|
|
@ -59,6 +62,21 @@ export function registerAutomationPluginRoute(app: FastifyInstance, deps: Automa
|
|||
))).some(Boolean)
|
||||
if (!owned) return reply.code(404).send({ error: "Session is not owned by this CodeNomad instance" })
|
||||
|
||||
if (body.mode === "mission-input") {
|
||||
if (!deps.worktreeDeletionFence) return reply.code(503).send({ error: "Mission dispatch unavailable" })
|
||||
const disconnected = new AbortController()
|
||||
const close = () => disconnected.abort()
|
||||
reply.raw.once("close", close)
|
||||
try {
|
||||
const result = await admitMissionInput(deps.workspaceManager, deps.worktreeDeletionFence, body.sessionID, body.command,
|
||||
AbortSignal.any([disconnected.signal, AbortSignal.timeout(30_000)]))
|
||||
return reply.send({ result })
|
||||
} catch {
|
||||
// Native errors can contain environment snapshots or provider credentials.
|
||||
return reply.code(502).send({ error: "Mission admission failed" })
|
||||
} finally { reply.raw.off("close", close) }
|
||||
}
|
||||
|
||||
if (body.mode === "browser-claim") return reply.send({ result: { available: true } })
|
||||
if (body.mode === "browser-probe") {
|
||||
try {
|
||||
|
|
|
|||
121
packages/server/src/server/routes/mission-input.test.ts
Normal file
121
packages/server/src/server/routes/mission-input.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import type { MissionMap } from "../../missions/model"
|
||||
import { assignmentInput, reportInput } from "../../missions/inputs"
|
||||
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { admitMissionInput } from "./mission-input"
|
||||
import { registerAutomationPluginRoute } from "./automation-plugin"
|
||||
import { AUTOMATION_BRIDGE_PATH } from "../../opencode/automation-plugin"
|
||||
|
||||
function fixture() {
|
||||
const mission = {
|
||||
id: "msn_fixture", projectID: "project", objective: "Review", template: "custom", status: "active",
|
||||
coordinatorSessionId: "ses_coordinator", actors: [],
|
||||
tasks: [{ key: "review", title: "Review", brief: "Inspect", role: "reviewer", status: "dispatching", blockedBy: [],
|
||||
actorSessionId: "ses_actor", admissionId: "msg_assignment", delivery: "queue", execution: { agent: "review" } }],
|
||||
} as unknown as MissionMap
|
||||
const sessions = {
|
||||
ses_coordinator: { id: "ses_coordinator", projectID: "project", location: { directory: "/repo" } },
|
||||
ses_actor: { id: "ses_actor", projectID: "project", location: { directory: "/repo" }, agent: "review" },
|
||||
}
|
||||
const state = { current: true, owned: true, variables: { TEMP: "first" }, failEnvironment: false,
|
||||
preparing: () => {}, onEnvironment: () => {} }
|
||||
const calls: Array<{ kind: string; input: any }> = []
|
||||
const client = {
|
||||
rpc: () => ({ snapshot: async () => ({ projectID: "project", missions: [mission] }) }),
|
||||
session: {
|
||||
get: async ({ sessionID }: { sessionID: keyof typeof sessions }) => structuredClone(sessions[sessionID]),
|
||||
environment: async (input: unknown) => {
|
||||
calls.push({ kind: "environment", input })
|
||||
state.onEnvironment()
|
||||
if (state.failEnvironment) throw new Error("SECRET native environment request")
|
||||
},
|
||||
prompt: async (input: unknown) => { calls.push({ kind: "prompt", input }) },
|
||||
synthetic: async (input: unknown) => { calls.push({ kind: "synthetic", input }) },
|
||||
},
|
||||
}
|
||||
const manager = {
|
||||
list: () => [{ id: "workspace" }],
|
||||
getSharedServiceClient: async () => client,
|
||||
getSharedServiceConnection: async () => ({ client, assertCurrent: () => { if (!state.current) throw new Error("stale") } }),
|
||||
ownsLocation: async () => state.owned,
|
||||
getWorktreeIdentityForPath: async () => "/repo",
|
||||
getSessionEnvironment: async () => { state.preparing(); return { ...state.variables } },
|
||||
}
|
||||
const fence = new WorktreeDeletionFence()
|
||||
const command = { kind: "prompt" as const, input: assignmentInput(mission, mission.tasks[0]) }
|
||||
const send = (input: unknown = command, signal = new AbortController().signal) =>
|
||||
admitMissionInput(manager as never, fence, "ses_coordinator", input, signal)
|
||||
return { mission, sessions, state, calls, manager, fence, command, send }
|
||||
}
|
||||
|
||||
test("mission inputs refresh profile environment on every admission, including synthetic reports", async () => {
|
||||
const f = fixture()
|
||||
f.manager.list = () => [{ id: "workspace" }, { id: "duplicate-tab" }]
|
||||
await f.send()
|
||||
f.state.variables = { TEMP: "changed" }
|
||||
await f.send()
|
||||
f.mission.tasks[0].report = { id: "rpt_1", sessionId: "ses_actor", taskKey: "review", outcome: "completed",
|
||||
summary: "Done", evidence: [], next: [], createdAt: 1 }
|
||||
await f.send({ kind: "synthetic", input: reportInput(f.mission, f.mission.tasks[0].report) })
|
||||
assert.deepEqual(f.calls.map(call => call.kind), ["environment", "prompt", "environment", "prompt", "environment", "synthetic"])
|
||||
assert.equal(f.calls[0].input.variables.TEMP, "first")
|
||||
assert.equal(f.calls[2].input.variables.TEMP, "changed")
|
||||
assert.equal(f.calls[4].input.sessionID, "ses_coordinator")
|
||||
})
|
||||
|
||||
test("rejects foreign ownership, changed selection, forged contracts and deletion before native writes", async () => {
|
||||
for (const mutate of [
|
||||
(f: ReturnType<typeof fixture>) => { f.state.owned = false },
|
||||
(f: ReturnType<typeof fixture>) => { f.sessions.ses_actor.projectID = "foreign" },
|
||||
(f: ReturnType<typeof fixture>) => { f.sessions.ses_actor.agent = "other" },
|
||||
(f: ReturnType<typeof fixture>) => { f.command.input.text = "forged" },
|
||||
(f: ReturnType<typeof fixture>) => { f.mission.coordinatorSessionId = "ses_other" },
|
||||
(f: ReturnType<typeof fixture>) => { f.state.preparing = () => { f.sessions.ses_actor.location.directory = "/elsewhere" } },
|
||||
(f: ReturnType<typeof fixture>) => { f.state.preparing = () => { f.state.current = false } },
|
||||
]) {
|
||||
const f = fixture()
|
||||
mutate(f)
|
||||
await assert.rejects(f.send())
|
||||
assert.equal(f.calls.length, 0)
|
||||
}
|
||||
const f = fixture()
|
||||
await f.fence.run("repo", ["/repo"], async () => {
|
||||
await assert.rejects(f.send(), /deletion/)
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
await f.send() // Refused admission did not leak the worktree fence.
|
||||
})
|
||||
|
||||
test("environment errors, cancellation and connection retirement fail closed without prompt admission", async () => {
|
||||
for (const mode of ["environment", "stale", "abort"] as const) {
|
||||
const f = fixture()
|
||||
const abort = new AbortController()
|
||||
if (mode === "environment") f.state.failEnvironment = true
|
||||
f.state.onEnvironment = () => {
|
||||
if (mode === "stale") f.state.current = false
|
||||
if (mode === "abort") abort.abort()
|
||||
}
|
||||
await assert.rejects(f.send(f.command, abort.signal))
|
||||
assert.deepEqual(f.calls.map(call => call.kind), ["environment"])
|
||||
await f.fence.run("repo", ["/repo"], async () => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("desktop bridge authenticates mission dispatch and redacts native environment failures", async () => {
|
||||
const f = fixture()
|
||||
f.state.failEnvironment = true
|
||||
const app = Fastify()
|
||||
registerAutomationPluginRoute(app, { workspaceManager: f.manager, worktreeDeletionFence: f.fence,
|
||||
authManager: { isLoopbackRequest: () => true }, bridgeToken: "fixture", nativeParent: {}, developerCdp: {} } as never)
|
||||
try {
|
||||
const payload = { mode: "mission-input", sessionID: "ses_coordinator", command: f.command }
|
||||
assert.equal((await app.inject({ method: "POST", url: AUTOMATION_BRIDGE_PATH, payload })).statusCode, 401)
|
||||
const response = await app.inject({ method: "POST", url: AUTOMATION_BRIDGE_PATH, payload,
|
||||
headers: { "x-codenomad-automation-token": "fixture" } })
|
||||
assert.equal(response.statusCode, 502)
|
||||
assert.doesNotMatch(response.body, /SECRET|TEMP|first/)
|
||||
assert.deepEqual(f.calls.map(call => call.kind), ["environment"])
|
||||
} finally { await app.close() }
|
||||
})
|
||||
88
packages/server/src/server/routes/mission-input.ts
Normal file
88
packages/server/src/server/routes/mission-input.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { z } from "zod"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { CODENOMAD_MISSIONS_RPC } from "../../missions/rpc"
|
||||
import type { MissionSnapshot } from "../../missions/model"
|
||||
import { assignmentInput, reportInput } from "../../missions/inputs"
|
||||
import { matchesExecution } from "../../missions/execution"
|
||||
import { locationRequestOptions, sameLocation } from "../../opencode/compatibility/location"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
|
||||
const inputSchema = z.object({
|
||||
kind: z.enum(["prompt", "synthetic"]),
|
||||
input: z.object({
|
||||
sessionID: z.string().regex(/^ses/).max(240),
|
||||
id: z.string().regex(/^msg_/).max(240),
|
||||
text: z.string().min(1).max(100_000),
|
||||
description: z.string().max(240).optional(),
|
||||
metadata: z.object({ "codenomad.mission": z.object({
|
||||
version: z.literal(1), missionID: z.string().max(100), kind: z.enum(["assignment", "report"]),
|
||||
taskKey: z.string().max(100), role: z.string().max(100).optional(),
|
||||
reportID: z.string().max(100).optional(), fromSessionID: z.string().max(240).optional(),
|
||||
}).strict() }).strict(),
|
||||
delivery: z.enum(["queue", "steer"]), resume: z.literal(true),
|
||||
}).strict(),
|
||||
}).strict()
|
||||
|
||||
type Manager = Pick<WorkspaceManager, "list" | "getSharedServiceConnection" | "ownsLocation" | "getWorktreeIdentityForPath" | "getSessionEnvironment">
|
||||
|
||||
// Called only behind the loopback token-authenticated desktop bridge. No environment
|
||||
// values leave this backend; both native writes share ownership, connection and fence.
|
||||
export async function admitMissionInput(manager: Manager, fence: WorktreeDeletionFence, coordinatorID: string, command: unknown, signal: AbortSignal) {
|
||||
const { kind, input } = inputSchema.parse(command)
|
||||
const owners = []
|
||||
for (const workspace of manager.list()) {
|
||||
const connection = await manager.getSharedServiceConnection(workspace.id)
|
||||
if (!connection) continue
|
||||
const coordinator = await connection.client.session.get({ sessionID: coordinatorID })
|
||||
const target = await connection.client.session.get({ sessionID: input.sessionID })
|
||||
if (await manager.ownsLocation(workspace.id, coordinator.location, connection.client)
|
||||
&& await manager.ownsLocation(workspace.id, target.location, connection.client)) owners.push({ workspace, connection, coordinator, target })
|
||||
}
|
||||
// Duplicate logical tabs in one backend share the profile; bridge discovery
|
||||
// already rejects multiple backends that could supply different profiles.
|
||||
if (!owners.length) throw new Error("Missing mission owner")
|
||||
const { workspace, connection, coordinator, target } = owners[0]
|
||||
const client = connection.client
|
||||
if (coordinator.parentID || target.parentID || coordinator.projectID !== target.projectID
|
||||
|| !await manager.ownsLocation(workspace.id, target.location, client)) throw new Error("Foreign mission actor")
|
||||
const snapshot = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, {
|
||||
location: { directory: coordinator.location.directory }, ...locationRequestOptions(coordinator.location), signal,
|
||||
}) as MissionSnapshot
|
||||
const metadata = input.metadata["codenomad.mission"]
|
||||
const mission = snapshot.missions.find(mission => mission.id === metadata.missionID)
|
||||
if (snapshot.projectID !== coordinator.projectID || !mission || mission.coordinatorSessionId !== coordinatorID) {
|
||||
throw new Error("Foreign mission contract")
|
||||
}
|
||||
const task = mission.tasks.find(task => task.key === metadata.taskKey)
|
||||
if (!task) throw new Error("Missing mission task")
|
||||
const expected = kind === "prompt" && mission.status === "active" && task.status === "dispatching"
|
||||
? assignmentInput(mission, task)
|
||||
: kind === "synthetic" && task.report ? reportInput(mission, task.report) : undefined
|
||||
if (!expected || !isDeepStrictEqual(input, expected)) throw new Error("Mission input differs from its durable contract")
|
||||
if (kind === "prompt" && !matchesExecution(task.execution, target)) throw new Error("Mission execution selection changed")
|
||||
const identities = await Promise.all([target, coordinator].map(session =>
|
||||
manager.getWorktreeIdentityForPath(workspace.id, session.location.directory)))
|
||||
if (identities.some(identity => !identity)) throw new Error("Missing mission worktree")
|
||||
const release = fence.enter(identities as string[])
|
||||
if (!release) throw new Error("Worktree deletion in progress")
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
connection.assertCurrent()
|
||||
const variables = await manager.getSessionEnvironment(workspace.id, signal)
|
||||
const currentTarget = await client.session.get({ sessionID: target.id }, { signal })
|
||||
const currentCoordinator = await client.session.get({ sessionID: coordinatorID }, { signal })
|
||||
if (!sameLocation(currentTarget.location, target.location) || !sameLocation(currentCoordinator.location, coordinator.location)
|
||||
|| currentTarget.parentID || currentCoordinator.parentID
|
||||
|| currentTarget.projectID !== target.projectID || currentCoordinator.projectID !== coordinator.projectID
|
||||
|| (kind === "prompt" && !matchesExecution(task.execution, currentTarget))) throw new Error("Mission sessions changed during admission")
|
||||
signal.throwIfAborted()
|
||||
connection.assertCurrent()
|
||||
await client.session.environment({ sessionID: target.id, variables }, { signal })
|
||||
signal.throwIfAborted()
|
||||
connection.assertCurrent()
|
||||
if (kind === "prompt") await client.session.prompt(input, { signal })
|
||||
else await client.session.synthetic(input, { signal })
|
||||
return { admitted: true }
|
||||
} finally { release() }
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode/client": "2.0.4",
|
||||
"@opencode/client": "2.0.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
|
|
|
|||
|
|
@ -217,6 +217,10 @@ const MissionRoute: Component<{
|
|||
</div>
|
||||
<div class="mission-route-meta">
|
||||
<span>{task.role}</span>
|
||||
<Show when={task.execution?.agent}>{agent => <code>{agent()}</code>}</Show>
|
||||
<Show when={task.execution?.model}>
|
||||
{model => <code>{model().providerID}/{model().id}{model().variant ? `#${model().variant}` : ""}</code>}
|
||||
</Show>
|
||||
<Show when={task.blockedBy.length > 0}>
|
||||
<span>{props.t("missions.control.task.blockedBy", { tasks: task.blockedBy.join(", ") })}</span>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Missionskarte wird gelesen…",
|
||||
"missions.control.unavailable.title": "Missionszentrale ist nicht verfügbar",
|
||||
"missions.control.unavailable.workspace": "Der OpenCode-Arbeitsbereich ist nicht bereit. Erneut verbinden und aktualisieren.",
|
||||
"missions.control.unavailable.plugin": "Dieses Projekt hat das geprüfte Plugin codenomad.missions nicht aktiviert.",
|
||||
"missions.control.unavailable.plugin": "Das mitgelieferte Plugin codenomad.missions ist nicht verfügbar. CodeNomad erneut verbinden und aktualisieren.",
|
||||
"missions.control.error.title": "Die Missionskarte konnte nicht aktualisiert werden",
|
||||
"missions.control.error.detail": "Arbeitsbereichsverbindung prüfen und erneut versuchen.",
|
||||
"missions.control.error.stale": "Die letzte dauerhafte Karte wird während der Wiederverbindung angezeigt.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Reading the mission map…",
|
||||
"missions.control.unavailable.title": "Mission Control is unavailable",
|
||||
"missions.control.unavailable.workspace": "The OpenCode workspace is not ready. Reconnect it, then refresh.",
|
||||
"missions.control.unavailable.plugin": "This project has not activated the reviewed codenomad.missions plugin.",
|
||||
"missions.control.unavailable.plugin": "The bundled codenomad.missions plugin is not available. Reconnect CodeNomad, then refresh.",
|
||||
"missions.control.error.title": "The mission map could not be refreshed",
|
||||
"missions.control.error.detail": "Check the workspace connection, then try again.",
|
||||
"missions.control.error.stale": "Showing the last durable map while reconnection is pending.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Leyendo el mapa de misión…",
|
||||
"missions.control.unavailable.title": "Control de misión no está disponible",
|
||||
"missions.control.unavailable.workspace": "El espacio de trabajo de OpenCode no está listo. Vuelve a conectarlo y actualiza.",
|
||||
"missions.control.unavailable.plugin": "Este proyecto no ha activado el plugin revisado codenomad.missions.",
|
||||
"missions.control.unavailable.plugin": "El plugin incluido codenomad.missions no está disponible. Vuelve a conectar CodeNomad y actualiza.",
|
||||
"missions.control.error.title": "No se pudo actualizar el mapa de misión",
|
||||
"missions.control.error.detail": "Comprueba la conexión del espacio de trabajo e inténtalo de nuevo.",
|
||||
"missions.control.error.stale": "Se muestra el último mapa duradero mientras se restablece la conexión.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Lecture de la carte de mission…",
|
||||
"missions.control.unavailable.title": "Le centre de mission est indisponible",
|
||||
"missions.control.unavailable.workspace": "L’espace de travail OpenCode n’est pas prêt. Reconnectez-le, puis actualisez.",
|
||||
"missions.control.unavailable.plugin": "Ce projet n’a pas activé le plugin vérifié codenomad.missions.",
|
||||
"missions.control.unavailable.plugin": "Le plugin intégré codenomad.missions est indisponible. Reconnectez CodeNomad, puis actualisez.",
|
||||
"missions.control.error.title": "La carte de mission n’a pas pu être actualisée",
|
||||
"missions.control.error.detail": "Vérifiez la connexion à l’espace de travail, puis réessayez.",
|
||||
"missions.control.error.stale": "La dernière carte durable reste affichée pendant la reconnexion.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "מפת המשימה נטענת…",
|
||||
"missions.control.unavailable.title": "בקרת המשימה אינה זמינה",
|
||||
"missions.control.unavailable.workspace": "סביבת העבודה של OpenCode אינה מוכנה. יש להתחבר מחדש ולרענן.",
|
||||
"missions.control.unavailable.plugin": "התוסף המאומת codenomad.missions לא הופעל בפרויקט זה.",
|
||||
"missions.control.unavailable.plugin": "התוסף המובנה codenomad.missions אינו זמין. יש לחבר מחדש את CodeNomad ולרענן.",
|
||||
"missions.control.error.title": "לא ניתן לרענן את מפת המשימה",
|
||||
"missions.control.error.detail": "יש לבדוק את החיבור לסביבת העבודה ולנסות שוב.",
|
||||
"missions.control.error.stale": "המפה המתמשכת האחרונה מוצגת בזמן ההתחברות מחדש.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "ミッションマップを読み込み中…",
|
||||
"missions.control.unavailable.title": "ミッションコントロールを利用できません",
|
||||
"missions.control.unavailable.workspace": "OpenCode ワークスペースの準備ができていません。再接続して更新してください。",
|
||||
"missions.control.unavailable.plugin": "このプロジェクトでは、確認済みの codenomad.missions プラグインが有効になっていません。",
|
||||
"missions.control.unavailable.plugin": "同梱の codenomad.missions プラグインを利用できません。CodeNomad に再接続して更新してください。",
|
||||
"missions.control.error.title": "ミッションマップを更新できませんでした",
|
||||
"missions.control.error.detail": "ワークスペース接続を確認して、もう一度お試しください。",
|
||||
"missions.control.error.stale": "再接続中のため、最後の永続マップを表示しています。",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "मिसन नक्सा पढिँदै छ…",
|
||||
"missions.control.unavailable.title": "मिसन नियन्त्रण उपलब्ध छैन",
|
||||
"missions.control.unavailable.workspace": "OpenCode कार्यक्षेत्र तयार छैन। फेरि जडान गरेर ताजा गर्नुहोस्।",
|
||||
"missions.control.unavailable.plugin": "यस परियोजनाले जाँचिएको codenomad.missions प्लगइन सक्रिय गरेको छैन।",
|
||||
"missions.control.unavailable.plugin": "समावेश गरिएको codenomad.missions प्लगइन उपलब्ध छैन। CodeNomad फेरि जडान गरेर ताजा गर्नुहोस्।",
|
||||
"missions.control.error.title": "मिसन नक्सा ताजा गर्न सकिएन",
|
||||
"missions.control.error.detail": "कार्यक्षेत्र जडान जाँचेर फेरि प्रयास गर्नुहोस्।",
|
||||
"missions.control.error.stale": "पुनः जडान हुँदा पछिल्लो दिगो नक्सा देखाइँदै छ।",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Чтение карты миссии…",
|
||||
"missions.control.unavailable.title": "Центр миссии недоступен",
|
||||
"missions.control.unavailable.workspace": "Рабочая область OpenCode не готова. Переподключите её и обновите карту.",
|
||||
"missions.control.unavailable.plugin": "В этом проекте не активирован проверенный плагин codenomad.missions.",
|
||||
"missions.control.unavailable.plugin": "Встроенный плагин codenomad.missions недоступен. Переподключите CodeNomad и обновите карту.",
|
||||
"missions.control.error.title": "Не удалось обновить карту миссии",
|
||||
"missions.control.error.detail": "Проверьте подключение рабочей области и повторите попытку.",
|
||||
"missions.control.error.stale": "Во время переподключения показана последняя постоянная карта.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "Görev haritası okunuyor…",
|
||||
"missions.control.unavailable.title": "Görev Kontrolü kullanılamıyor",
|
||||
"missions.control.unavailable.workspace": "OpenCode çalışma alanı hazır değil. Yeniden bağlanıp yenileyin.",
|
||||
"missions.control.unavailable.plugin": "Bu proje, incelenmiş codenomad.missions eklentisini etkinleştirmedi.",
|
||||
"missions.control.unavailable.plugin": "Yerleşik codenomad.missions eklentisi kullanılamıyor. CodeNomad'ı yeniden bağlayıp yenileyin.",
|
||||
"missions.control.error.title": "Görev haritası yenilenemedi",
|
||||
"missions.control.error.detail": "Çalışma alanı bağlantısını kontrol edip tekrar deneyin.",
|
||||
"missions.control.error.stale": "Yeniden bağlantı beklenirken son kalıcı harita gösteriliyor.",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const missionMessages = {
|
|||
"missions.control.loading": "正在读取任务地图…",
|
||||
"missions.control.unavailable.title": "任务控制台不可用",
|
||||
"missions.control.unavailable.workspace": "OpenCode 工作区尚未就绪。请重新连接后刷新。",
|
||||
"missions.control.unavailable.plugin": "此项目尚未启用经过审核的 codenomad.missions 插件。",
|
||||
"missions.control.unavailable.plugin": "内置的 codenomad.missions 插件不可用。请重新连接 CodeNomad 后刷新。",
|
||||
"missions.control.error.title": "无法刷新任务地图",
|
||||
"missions.control.error.detail": "请检查工作区连接,然后重试。",
|
||||
"missions.control.error.stale": "等待重新连接时显示上次保存的持久地图。",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
min-height: 100%;
|
||||
padding: var(--space-md);
|
||||
color: var(--text-primary);
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), var(--border-base) calc(100% - 1px)),
|
||||
var(--surface-base);
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.mission-control-header {
|
||||
|
|
|
|||
|
|
@ -59,15 +59,15 @@ test("resolves a macOS ARM64 esbuild binary nested under esbuild", (t) => {
|
|||
})
|
||||
})
|
||||
|
||||
test("both desktop resource layouts retain a self-contained unified automation bundle", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-automation-resources-"))
|
||||
for (const feature of ["automation", "missions"]) test(`both desktop resource layouts retain a self-contained ${feature} bundle`, async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `codenomad-${feature}-resources-`))
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
const serverRoot = path.join(root, "source")
|
||||
for (const name of ["public", "node_modules"]) fs.mkdirSync(path.join(serverRoot, name), { recursive: true })
|
||||
fs.writeFileSync(path.join(serverRoot, "package.json"), JSON.stringify({ type: "module" }))
|
||||
const relative = path.join("dist", "plugins", "automation", "plugin.mjs")
|
||||
const relative = path.join("dist", "plugins", feature, "plugin.mjs")
|
||||
await require("esbuild").build({
|
||||
entryPoints: [path.join(__dirname, "../packages/server/src/opencode/automation/desktop-plugin.ts")],
|
||||
entryPoints: [path.join(__dirname, `../packages/server/src/opencode/${feature}/desktop-plugin.ts`)],
|
||||
outfile: path.join(serverRoot, relative), bundle: true, platform: "node", format: "esm", target: "node22",
|
||||
})
|
||||
for (const host of ["electron", "tauri"]) {
|
||||
|
|
@ -77,7 +77,7 @@ test("both desktop resource layouts retain a self-contained unified automation b
|
|||
assert.deepEqual(fs.readFileSync(target), fs.readFileSync(path.join(serverRoot, relative)))
|
||||
const { desktopPlugin } = await import(require("node:url").pathToFileURL(target).href)
|
||||
const plugin = desktopPlugin(path.join(root, "absent-presence"))
|
||||
assert.equal(plugin.id, "codenomad.automation")
|
||||
assert.equal(plugin.id, `codenomad.${feature}`)
|
||||
const cleanup = await plugin.setup({})
|
||||
await cleanup()
|
||||
}
|
||||
|
|
|
|||
201
scripts/test-missions-native.mjs
Normal file
201
scripts/test-missions-native.mjs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Explicit isolated CLI + local deterministic provider. Never discovers the shared service.
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn, execFileSync } from "node:child_process"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import Fastify from "fastify"
|
||||
import pino from "pino"
|
||||
|
||||
const cli = process.argv[2]
|
||||
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute isolated CLI executable")
|
||||
const version = execFileSync(cli, ["--version"], { encoding: "utf8" }).trim()
|
||||
const temporary = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(temporary, { recursive: true })
|
||||
const root = await mkdtemp(path.join(temporary, "missions-native-"))
|
||||
for (const key of Object.keys(process.env)) if (/^(OPENCODE_|XDG_|CODENOMAD_)/i.test(key)) delete process.env[key]
|
||||
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) process.env[key] = path.join(root, key)
|
||||
Object.assign(process.env, {
|
||||
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, LOCALAPPDATA: root, XDG_RUNTIME_DIR: root,
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
||||
OPENCODE_SERVER_PASSWORD: "isolated-missions", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
})
|
||||
delete process.env.WSL_DISTRO_NAME
|
||||
const project = path.join(root, "project")
|
||||
await mkdir(project)
|
||||
await mkdir(process.env.OPENCODE_CONFIG_DIR)
|
||||
await writeFile(path.join(process.env.OPENCODE_CONFIG_DIR, "opencode.json"), "{}\n")
|
||||
const { WorkspaceManager } = await tsImport("../packages/server/src/workspaces/manager.ts", import.meta.url)
|
||||
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
|
||||
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
||||
const { DesktopPluginLifecycle } = await tsImport("../packages/server/src/opencode/desktop-plugin-lifecycle.ts", import.meta.url)
|
||||
const { resolveDesktopPluginPaths } = await tsImport("../packages/server/src/opencode/desktop-plugin-paths.ts", import.meta.url)
|
||||
const { createAutomationBridgeRegistration, publishAutomationBridge } = await tsImport("../packages/server/src/opencode/automation-plugin.ts", import.meta.url)
|
||||
const { registerAutomationPluginRoute } = await tsImport("../packages/server/src/server/routes/automation-plugin.ts", import.meta.url)
|
||||
const { WorktreeDeletionFence } = await tsImport("../packages/server/src/workspaces/worktree-session-evacuation.ts", import.meta.url)
|
||||
const { CODENOMAD_MISSIONS_RPC } = await tsImport("../packages/server/src/missions/rpc.ts", import.meta.url)
|
||||
let child, stopped, manager, plugin, removeBridge, output = "", failure, held, hold = false
|
||||
let requests = []
|
||||
const provider = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = ""
|
||||
for await (const chunk of request) raw += chunk
|
||||
const body = JSON.parse(raw)
|
||||
if (request.headers["x-fixture-kind"] === "primary") {
|
||||
requests.push({ session: request.headers["x-fixture-session"], model: body.model,
|
||||
tools: (body.tools ?? []).map(tool => tool.function?.name) })
|
||||
if (hold) { hold = false; await new Promise(resolve => { held = resolve }) }
|
||||
}
|
||||
if (!body.stream) {
|
||||
response.setHeader("content-type", "application/json")
|
||||
response.end(JSON.stringify({ id: "fixture", choices: [{ message: { role: "assistant", content: "Fixture" }, finish_reason: "stop" }] }))
|
||||
} else {
|
||||
response.setHeader("content-type", "text/event-stream")
|
||||
for (const [delta, finish_reason] of [[{ role: "assistant", content: "Done" }, null], [{}, "stop"]]) {
|
||||
response.write(`data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", model: "fixture",
|
||||
choices: [{ index: 0, delta, finish_reason }] })}\n\n`)
|
||||
}
|
||||
response.end("data: [DONE]\n\n")
|
||||
}
|
||||
} catch (error) { failure = error; response.destroy(error) }
|
||||
})
|
||||
const bridge = Fastify()
|
||||
async function until(predicate) {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (failure) throw failure
|
||||
if (await predicate()) return
|
||||
if (child && child.exitCode !== null) throw new Error(output.slice(-6000))
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error(`Isolated fixture timed out: ${output.slice(-6000)}`)
|
||||
}
|
||||
const invocation = {
|
||||
id: "missions.fixture",
|
||||
methods: { invoke: { input: { type: "object" }, output: { type: "string" } } },
|
||||
events: {},
|
||||
}
|
||||
try {
|
||||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
const fixturePlugin = path.join(root, "fixture-plugin")
|
||||
await mkdir(fixturePlugin)
|
||||
// Test-only RPC drives the actual registered tool executors without relying on LLM decisions.
|
||||
await writeFile(path.join(fixturePlugin, "index.ts"), `export default { id: 'missions.fixture', async setup(ctx) {
|
||||
await ctx.session.hook('http.request', event => {
|
||||
event.request.headers.set('x-fixture-kind', event.kind)
|
||||
event.request.headers.set('x-fixture-session', event.sessionID)
|
||||
})
|
||||
await ctx.rpc.register(${JSON.stringify(invocation)}, { invoke: async input => {
|
||||
const tool = (await ctx.tool.list()).find(tool => tool.id === 'mission_' + input.tool)
|
||||
if (!tool) throw new Error('Mission tool unavailable')
|
||||
const result = await tool.execute(input.input, { sessionID: input.sessionID, messageID: 'msg_fixture',
|
||||
id: input.callID, progress: async () => {}, signal: new AbortController().signal })
|
||||
return result.content
|
||||
} })
|
||||
} }`)
|
||||
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
model: "fixture/fixture", permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
||||
agents: { reviewer: { mode: "all", description: "Fixture reviewer" } },
|
||||
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture" },
|
||||
models: { fixture: {}, selected: { variants: [{ id: "careful" }] } } } }, plugins: [fixturePlugin],
|
||||
})
|
||||
child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env: process.env, windowsHide: true })
|
||||
stopped = new Promise(resolve => child.once("close", resolve))
|
||||
child.stdout.on("data", data => { output += data })
|
||||
child.stderr.on("data", data => { output += data })
|
||||
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
||||
const endpoint = { url, auth: { type: "basic", username: "opencode", password: process.env.OPENCODE_SERVER_PASSWORD } }
|
||||
const info = await (await fetch(`${url}/api/info`, { headers: { authorization: `Basic ${Buffer.from(`opencode:${process.env.OPENCODE_SERVER_PASSWORD}`).toString("base64")}` } })).json()
|
||||
rememberRuntime(endpoint, { version: info.version, pid: info.pid, discovery: "info" })
|
||||
let variables = { MISSION_FIXTURE: "first" }
|
||||
manager = new WorkspaceManager({ rootDir: root, logger: pino({ level: "silent" }), eventBus: new EventBus(),
|
||||
settings: { getOwner: () => ({ environmentVariables: variables }) },
|
||||
binaryResolver: { resolveDefault: () => ({ path: cli, label: "Isolated missions" }) },
|
||||
hostServiceLifecycleFactory: () => ({ discover: async () => endpoint, ensure: async () => endpoint }),
|
||||
})
|
||||
const { workspace } = await manager.create(project)
|
||||
const client = await manager.getSharedServiceClient()
|
||||
const connection = await manager.getSharedServiceConnection(workspace.id)
|
||||
const paths = await resolveDesktopPluginPaths(connection, { kind: "host", platform: process.platform, binary: cli })
|
||||
assert.equal(paths.config, process.env.OPENCODE_CONFIG_DIR)
|
||||
const registration = createAutomationBridgeRegistration("http://127.0.0.1:1")
|
||||
registerAutomationPluginRoute(bridge, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence(),
|
||||
authManager: { isLoopbackRequest: () => true }, bridgeToken: registration.token, nativeParent: {}, developerCdp: {} })
|
||||
await bridge.listen({ host: "127.0.0.1", port: 0 })
|
||||
registration.url = `http://127.0.0.1:${bridge.server.address().port}/api/opencode-plugin/automation`
|
||||
removeBridge = await publishAutomationBridge(registration)
|
||||
plugin = new DesktopPluginLifecycle("missions")
|
||||
await plugin.start(paths)
|
||||
const location = { directory: project }
|
||||
await until(async () => (await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad.missions" && plugin.state.status === "active"))
|
||||
const coordinator = await client.session.create({ location, title: "Coordinator" })
|
||||
const invoke = async (tool, input, sessionID = coordinator.id, callID = `fixture-${tool}`) =>
|
||||
JSON.parse(await client.rpc(invocation).invoke({ tool, input, sessionID, callID }, { location }))
|
||||
const inspected = await invoke("inspect", { catalog: true, start: { objective: "Native integration", template: "custom" } })
|
||||
assert(inspected.catalog.agents.some(agent => agent.id === "reviewer"))
|
||||
assert(inspected.catalog.models.some(model => model.id === "selected" && model.variants.includes("careful")))
|
||||
const task = { taskKey: "native-review", title: "Review", brief: "Conclude briefly", role: "reviewer",
|
||||
execution: { agent: "reviewer", model: { providerID: "fixture", id: "selected", variant: "careful" } } }
|
||||
const delegated = await invoke("delegate", task)
|
||||
const actorID = delegated.mission.tasks[0].actorSessionId
|
||||
const actor = await client.session.get({ sessionID: actorID })
|
||||
assert.equal(actor.parentID, undefined)
|
||||
assert.equal(actor.agent, "reviewer")
|
||||
assert.deepEqual(actor.model, task.execution.model)
|
||||
await client.session.wait({ sessionID: actorID }, { signal: AbortSignal.timeout(20_000) })
|
||||
assert(requests.some(request => request.session === actorID && request.model === "selected"))
|
||||
const probe = async (sessionID, expected) => {
|
||||
const file = path.join(project, "environment.json")
|
||||
await writeFile(path.join(project, "probe.cjs"), `require('node:fs').writeFileSync(${JSON.stringify(file)}, JSON.stringify(process.env.MISSION_FIXTURE))`)
|
||||
await client.session.shell({ sessionID, command: "node probe.cjs" })
|
||||
assert.equal(JSON.parse(await readFile(file, "utf8")), expected)
|
||||
}
|
||||
await probe(actorID, "first")
|
||||
variables = { MISSION_FIXTURE: "changed" }
|
||||
hold = true
|
||||
await client.session.prompt({ sessionID: actorID, text: "Busy actor" })
|
||||
await until(() => Boolean(held))
|
||||
const queued = { ...task, taskKey: "queued-review", targetSessionID: actorID }
|
||||
const queueResult = await invoke("delegate", queued)
|
||||
const inbox = await client.session.inbox.list({ sessionID: actorID })
|
||||
assert(inbox.some(item => item.id === queueResult.mission.tasks.find(task => task.key === queued.taskKey).admissionId),
|
||||
"Busy actor retains the new durable assignment")
|
||||
const before = await client.session.get({ sessionID: actorID })
|
||||
await assert.rejects(invoke("delegate", { ...queued, taskKey: "conflict", execution: { agent: "build" } }))
|
||||
assert.deepEqual((await client.session.get({ sessionID: actorID })).model, before.model)
|
||||
held(); held = undefined
|
||||
await client.session.wait({ sessionID: actorID }, { signal: AbortSignal.timeout(20_000) })
|
||||
await probe(actorID, "changed")
|
||||
await invoke("report", { taskKey: task.taskKey, outcome: "completed", summary: "Native verified" }, actorID)
|
||||
await client.session.wait({ sessionID: coordinator.id }, { signal: AbortSignal.timeout(20_000) })
|
||||
await probe(coordinator.id, "changed")
|
||||
const beforeRestart = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })
|
||||
await plugin.stop()
|
||||
await until(async () => { try { await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location }); return false } catch { return true } })
|
||||
plugin = new DesktopPluginLifecycle("missions")
|
||||
await plugin.start(paths)
|
||||
await until(async () => { try { return Boolean(await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })) } catch { return false } })
|
||||
assert.equal((await invoke("delegate", task)).disposition, "existing")
|
||||
const afterRestart = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })
|
||||
assert.deepEqual(afterRestart.missions, beforeRestart.missions)
|
||||
assert.equal((await client.server.info()).pid, info.pid)
|
||||
console.log(`PASS ${version}: native catalog, selection, busy queue, conflict, environment, reports, presence restart and idempotence; ${root}`)
|
||||
} catch (error) {
|
||||
console.error(`Fixture failed at ${root}: ${output.slice(-8000)}`)
|
||||
throw error
|
||||
} finally {
|
||||
held?.()
|
||||
await removeBridge?.()
|
||||
await plugin?.stop()
|
||||
await bridge.close()
|
||||
await manager?.shutdown()
|
||||
child?.kill()
|
||||
if (stopped) await stopped
|
||||
provider.closeAllConnections()
|
||||
await new Promise(resolve => provider.close(resolve))
|
||||
}
|
||||
|
|
@ -230,8 +230,8 @@ async function runIsolated(cli) {
|
|||
const client = OpenCode.make({ baseUrl, fetch: transport.fetch })
|
||||
const connection = { endpoint, client, ...transport, assertCurrent() {}, invalidate() {} }
|
||||
console.log(`Testing official runtime ${identity.version}`)
|
||||
assert.equal((await client.server.status()).version, identity.version)
|
||||
console.log(`PASS: production ${identity.discovery} discovery and canonical client.server.status()`)
|
||||
assert.equal((await client.server.info()).version, identity.version)
|
||||
console.log(`PASS: production ${identity.discovery} discovery and canonical client.server.info()`)
|
||||
await testNativeLocationIdentity({ client, connection, root })
|
||||
} finally {
|
||||
child.kill()
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ try {
|
|||
}
|
||||
variables = {}
|
||||
assert.equal((await probe(proxy, a, "removed-overrides")).tmpdir, path.join(root, "base"))
|
||||
assert.equal((await native.server.status()).pid, info.pid)
|
||||
assert.equal((await native.server.info()).pid, info.pid)
|
||||
console.log(`PASS OpenCode ${info.version}: real manager + proxy + native shells; next-send updates, both conversations, removal, no writes on read, unchanged daemon PID`)
|
||||
} finally {
|
||||
await app?.close()
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ try {
|
|||
const makeClient = () => OpenCode.make({ baseUrl, headers: {
|
||||
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
||||
}, fetch: runtimeFetch })
|
||||
assert.equal((await client.server.status()).version, runtimeVersion)
|
||||
assert.equal((await client.server.info()).version, runtimeVersion)
|
||||
console.log(`Testing official runtime ${runtimeVersion}`)
|
||||
const { testNativeLocationIdentity } = await import("./test-opencode-location-native.mjs")
|
||||
await testNativeLocationIdentity({ client, connection, root })
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function testPruningUI({ client, baseUrl, root, location, generate,
|
|||
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
||||
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url)
|
||||
const endpoint = connection?.endpoint ?? { url: baseUrl, auth: { type: "basic", username: "opencode", password: "isolated-pruning-fixture" } }
|
||||
if (!connection) rememberRuntime(endpoint, { ...await client.server.status(), discovery: "status" })
|
||||
if (!connection) rememberRuntime(endpoint, { ...await client.server.info(), discovery: "info" })
|
||||
const runtimeFetch = connection?.fetch ?? createRuntimeFetch(endpoint)
|
||||
const owns = candidate => path.resolve(candidate) === path.resolve(location.directory)
|
||||
const workspaceManager = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue