opencode-herdr-orchestration 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/orchestration.js +6 -1
- package/package.json +2 -2
- package/src/agents.js +25 -4
- package/src/index.js +48 -30
- package/src/installer.js +75 -1
- package/src/steer.js +124 -0
package/README.md
CHANGED
|
@@ -595,11 +595,11 @@ This restriction is not hard-enforced by Herdr. Matchers are string globs and ca
|
|
|
595
595
|
|
|
596
596
|
## Developer steering
|
|
597
597
|
|
|
598
|
-
Developer steering is a trusted-Developer queue scoped per Plan ID target under `<git-common-dir>/flocky/steering/<planId>/`. There is exactly one submission tool, `herdr_steering_submit`,
|
|
598
|
+
Developer steering is a trusted-Developer queue scoped per Plan ID target under `<git-common-dir>/flocky/steering/<planId>/`. There is exactly one submission tool, `herdr_steering_submit`, plus one native `/steer` command hook as a second enforcement point with parity: both share the single `STEERING_TOOL_ACCESS` allowlist and both write directly through the existing state service with identical validation plus journaling and no parallel store. The submission allowlist holds only the explicit non-flock `developer` context; `shepherd`, `shepherd-governor`, `sheepdog`, `grazer`, `sheep`, `shearer-low`, `shearer-medium`, `unknown`, `ambiguous`, `none`, and unset contexts are denied fail-closed with no filesystem write. Developer is never inferred from session mode, directory, environment text, or prompt content. Static per-agent permissions deny the tool for all seven orchestration roles as defense in depth; the runtime `context.agent` check plus the `/steer` SDK session-agent check stays authoritative over static overrides.
|
|
599
599
|
|
|
600
600
|
### Steering UX through the existing integration
|
|
601
601
|
|
|
602
|
-
There is no `herdr steer` command and no package CLI steering command. The Developer submits
|
|
602
|
+
There is no `herdr steer` command and no package CLI steering command; the native `/steer` opencode command is installer-owned (`command.steer` with agent pin `developer`, `$ARGUMENTS` template, and description) and intercepted by `command.execute.before`, which writes directly via the existing state service then throws to abort before any model turn with confirmation carrying the steering id plus resolved target and a minimal text-parts fallback. The Developer submits either through `/steer` or through the `herdr_steering_submit` plugin tool in a Developer context:
|
|
603
603
|
|
|
604
604
|
```json
|
|
605
605
|
{
|
|
@@ -627,7 +627,7 @@ The recorded `developer` submitter is integration-asserted by the OpenCode plugi
|
|
|
627
627
|
}
|
|
628
628
|
```
|
|
629
629
|
|
|
630
|
-
Any session that can present as `developer` — including
|
|
630
|
+
Any session that can present as `developer` — including the registered `developer` profile — can submit. Flock roles cannot present as Developer: the `developer` profile holds only `herdr_steering_submit` with no spawn rights and no spawn matrix entry creates or targets it, static per-agent permissions deny `herdr_steering_submit` for all seven orchestration roles, and the runtime allowlist holds only `developer`. The runtime check plus the `/steer` SDK session-agent resolution stays authoritative over static overrides: even a local config that flips a flock role to `allow` still fails with `UNAUTHORIZED_AGENT` and writes nothing, and an unresolvable session agent fails closed with no write. Developer is mapped to an explicit non-flock `SHEPHERD_MODE` value `developer`, distinct from `shepherd`, `governor`, `sheepdog`, `grazer`, `sheep`, `shearer`, and `none`. Keep remote authorization and human review as the authoritative controls for anything consequential. There is still no `herdr steer` command and no package CLI steering command; `bin/orchestration.js` and `package.json` expose only `opencode-herdr-orchestration` with `install`, `update`, `configure-agents`, `status`, `uninstall`, `install-hooks`, and `uninstall-hooks`, while the installer owns `command.steer` in the OpenCode config. `/steer` arguments are `<content>` or `<planId> :: <content>`; omit `planId` only for a single active target.
|
|
631
631
|
|
|
632
632
|
## Shepherd ownership and lifecycle synchronization
|
|
633
633
|
|
|
@@ -715,7 +715,7 @@ Live-verified on `herdr 0.8.2` plus `opencode 1.18.29` plus package `0.2.1` with
|
|
|
715
715
|
|
|
716
716
|
**Cause:** On Windows PowerShell `opencode` may resolve to the direct exe (`node_modules/opencode-ai/bin/opencode.exe`) or to a shim (`opencode.cmd`, `opencode.ps1`, `opencode.exe` in the npm prefix) depending on `PATH` order. `PATH` here means the session `PATH` inspected read-only with `$env:PATH`. File-version text is not authoritative next to CLI `opencode --version` output `1.18.29`. Herdr panes inherit the Herdr server environment, not the current session `PATH`. A global `opencode upgrade` recreates the npm shims, so a previously durable order can flap back to shim-first with a bumped `opencode --version` versus `npm view opencode-ai version`.
|
|
717
717
|
|
|
718
|
-
**Check:** Inspect the resolver without changing it. `(Get-Command opencode).Source` shows the winning launcher. `(Get-Command opencode -All).Source` lists every candidate in order and reveals a reappearing shim after a global update. `(Get-Command herdr).Source` plus `(Get-Command herdr -All).Source` confirms Herdr resolves to a single `bin` exe with no shim confusion. Compare `opencode --version` against the full-path exe and shim forms; healthy hosts report the same `1.18.29` from each, while a bumped `opencode --version` against `npm view opencode-ai version` signals a recent global update. Confirm the process-spawn path with `Start-Process` using `-FilePath` from the resolved launcher plus `-ArgumentList --version -NoNewWindow -Wait`; live the extensionless shim fails with `%1 is not a valid Win32 application` while the direct exe reports `1.18.29`. Inspect order read-only with `$env:PATH`. Confirm the server split with `herdr status` showing `status: running` for `server` separate from client. This package never overwrites the global environment. Config resolution only reads the environment in `src/installer.js:
|
|
718
|
+
**Check:** Inspect the resolver without changing it. `(Get-Command opencode).Source` shows the winning launcher. `(Get-Command opencode -All).Source` lists every candidate in order and reveals a reappearing shim after a global update. `(Get-Command herdr).Source` plus `(Get-Command herdr -All).Source` confirms Herdr resolves to a single `bin` exe with no shim confusion. Compare `opencode --version` against the full-path exe and shim forms; healthy hosts report the same `1.18.29` from each, while a bumped `opencode --version` against `npm view opencode-ai version` signals a recent global update. Confirm the process-spawn path with `Start-Process` using `-FilePath` from the resolved launcher plus `-ArgumentList --version -NoNewWindow -Wait`; live the extensionless shim fails with `%1 is not a valid Win32 application` while the direct exe reports `1.18.29`. Inspect order read-only with `$env:PATH`. Confirm the server split with `herdr status` showing `status: running` for `server` separate from client. This package never overwrites the global environment. Config resolution only reads the environment in `src/installer.js:48` (`configDirectory`) and validation runs isolated with `OPENCODE_DISABLE_PROJECT_CONFIG` in `src/installer.js:594` (`validateOpenCode`), with status reporting in `src/installer.js:617` (`status`). Autoupdate context is `npm view opencode-herdr-orchestration version` reporting `0.2.1` matching `node bin/orchestration.js status` plus `herdr channel show` reporting `stable` plus `herdr update --help` describing the handoff option plus `opencode upgrade --help` listing upgrade targets and methods.
|
|
719
719
|
|
|
720
720
|
**Fix:** For current-session relief use the winning full exe path directly. For a durable fix apply a persistent user-chosen `PATH` reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready, then follow [Upgrade](#upgrade) for the package update path. Session-local changes alone never fix Herdr spawns because panes inherit the server environment. This package never reorders global state automatically and never sets global environment values automatically; the persistent reorder is an operator-chosen Windows user environment change, not a package write.
|
|
721
721
|
|
package/bin/orchestration.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
validateOpenCode,
|
|
23
23
|
writeAgentFilesManifest,
|
|
24
24
|
writePluginConfig,
|
|
25
|
+
writeSteerCommand,
|
|
25
26
|
} from "../src/installer.js";
|
|
26
27
|
|
|
27
28
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -127,12 +128,14 @@ async function installOrUpdate(useLatest) {
|
|
|
127
128
|
restoreBackup(result.file, result.backup, result.existed);
|
|
128
129
|
throw new Error(`OpenCode validation failed; restored the previous config. ${error.message}`);
|
|
129
130
|
}
|
|
131
|
+
const steerResult = writeSteerCommand(configDir, false);
|
|
130
132
|
const reconciliation = reconcileAgentFiles(configDir, { remove: false });
|
|
131
133
|
writeAgentFilesManifest(configDir, reconciliation.manifestEntries, version);
|
|
132
134
|
if (flags.includes("--with-hooks")) installHooks();
|
|
133
135
|
process.stdout.write(`Configured ${PACKAGE_NAME}@${version} in ${result.file}\n`);
|
|
134
136
|
reportReconciliation(reconciliation);
|
|
135
137
|
if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
|
|
138
|
+
if (steerResult.backup && steerResult.backup !== result.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
|
|
136
139
|
for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
|
|
137
140
|
process.stdout.write("Restart OpenCode intentionally to load the new configuration.\n");
|
|
138
141
|
}
|
|
@@ -157,6 +160,7 @@ async function configureAgents() {
|
|
|
157
160
|
|
|
158
161
|
function uninstallOrchestration() {
|
|
159
162
|
const configDir = configDirectory();
|
|
163
|
+
const steerResult = writeSteerCommand(configDir, true);
|
|
160
164
|
const result = writePluginConfig(configDir, true);
|
|
161
165
|
const packageBackups = uninstallPackage(configDir);
|
|
162
166
|
const reconciliation = reconcileAgentFiles(configDir, { remove: true });
|
|
@@ -167,7 +171,8 @@ function uninstallOrchestration() {
|
|
|
167
171
|
process.stdout.write(`Removed package-owned agent file: ${relPath}\n`);
|
|
168
172
|
}
|
|
169
173
|
reportReconciliation(reconciliation);
|
|
170
|
-
if (
|
|
174
|
+
if (steerResult.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
|
|
175
|
+
if (result.backup && result.backup !== steerResult.backup) process.stdout.write(`Backup: ${result.backup}\n`);
|
|
171
176
|
for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
|
|
172
177
|
}
|
|
173
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-herdr-orchestration",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Capability-separated Herdr orchestration agents for OpenCode",
|
|
5
5
|
"author": "CodingJinxx",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --test",
|
|
31
|
-
"check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check bin/orchestration.js"
|
|
31
|
+
"check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check bin/orchestration.js"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=20"
|
package/src/agents.js
CHANGED
|
@@ -105,11 +105,15 @@ function stateToolPermissions(allowedTools) {
|
|
|
105
105
|
// Developer steering submission (M2, Option A, trusted Developer only).
|
|
106
106
|
// The explicit non-flock `developer` context is the sole submitter; all
|
|
107
107
|
// seven orchestration roles are denied as defense in depth. The runtime
|
|
108
|
-
// context-agent check in src/index.js
|
|
109
|
-
// entries: even a user override
|
|
110
|
-
//
|
|
111
|
-
//
|
|
108
|
+
// context-agent check in src/index.js plus the /steer hook in src/steer.js
|
|
109
|
+
// stays authoritative over these static entries: even a user override
|
|
110
|
+
// flipping one to "allow" must not bypass the allowlist. No flock role may
|
|
111
|
+
// present as Developer: the developer profile below holds only the submit
|
|
112
|
+
// tool and no spawn matrix entry creates or targets it.
|
|
112
113
|
export const DEVELOPER_AGENT = "developer";
|
|
114
|
+
export const DEVELOPER_PROMPT = String.raw`
|
|
115
|
+
You are developer, the trusted steering submitter. Submit bounded steering via /steer as <content> or <planId> :: <content>; omit planId only when exactly one active steering target exists. You hold only herdr_steering_submit; you never implement flock work, never spawn workers, and never read raw steering.
|
|
116
|
+
`.trim();
|
|
113
117
|
export const ORCHESTRATION_ROLES = Object.freeze([
|
|
114
118
|
"shepherd",
|
|
115
119
|
"shepherd-governor",
|
|
@@ -629,6 +633,23 @@ export function createAgents(options = {}) {
|
|
|
629
633
|
|
|
630
634
|
"shearer-low": reviewerAgent(reviewerModel, "low"),
|
|
631
635
|
"shearer-medium": reviewerAgent(reviewerModel, "medium"),
|
|
636
|
+
|
|
637
|
+
developer: {
|
|
638
|
+
mode: "primary",
|
|
639
|
+
description: "Trusted Developer steering submitter; submits bounded steering via /steer; never implements flock work.",
|
|
640
|
+
prompt: DEVELOPER_PROMPT,
|
|
641
|
+
permission: {
|
|
642
|
+
...stateToolPermissions([]),
|
|
643
|
+
...steeringToolPermissions([STEERING_TOOLS.submit]),
|
|
644
|
+
...rawSteeringToolPermissions([]),
|
|
645
|
+
...ownershipToolPermissions([]),
|
|
646
|
+
herdr_agent_response: "deny",
|
|
647
|
+
task: "deny",
|
|
648
|
+
edit: "deny",
|
|
649
|
+
apply_patch: "deny",
|
|
650
|
+
bash: { "*": "deny", ...separatorDenials },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
632
653
|
};
|
|
633
654
|
}
|
|
634
655
|
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./agents.js";
|
|
17
17
|
import { createResponseTool } from "./response.js";
|
|
18
18
|
import { createStateService } from "./state.js";
|
|
19
|
+
import { createSteerCommandHook } from "./steer.js";
|
|
19
20
|
|
|
20
21
|
const SESSION_MODES = new Map();
|
|
21
22
|
|
|
@@ -344,39 +345,56 @@ export function createOwnershipTools(stateOptions = {}) {
|
|
|
344
345
|
return tools;
|
|
345
346
|
}
|
|
346
347
|
|
|
347
|
-
export const HerdrOrchestrationPlugin = async (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
348
|
+
export const HerdrOrchestrationPlugin = async (pluginInput = {}, options = {}) => {
|
|
349
|
+
const stateOptions = { ...(options.state ?? {}) };
|
|
350
|
+
if (stateOptions.cwd === undefined) {
|
|
351
|
+
const fallback = pluginInput?.directory ?? pluginInput?.worktree;
|
|
352
|
+
if (typeof fallback === "string" && fallback.length > 0) stateOptions.cwd = fallback;
|
|
353
|
+
}
|
|
354
|
+
const resolveAgent = options.steerResolveAgent ?? options.steer?.resolveAgent;
|
|
355
|
+
const steerBefore = createSteerCommandHook({
|
|
356
|
+
client: pluginInput?.client,
|
|
357
|
+
stateOptions,
|
|
358
|
+
...(resolveAgent ? { resolveAgent } : {}),
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
tool: {
|
|
362
|
+
herdr_agent_response: createResponseTool(options.response),
|
|
363
|
+
...createStateTools(options.state),
|
|
364
|
+
...createSteeringTools(options.state),
|
|
365
|
+
...createRawSteeringTools(options.state),
|
|
366
|
+
...createOwnershipTools(options.state),
|
|
367
|
+
},
|
|
368
|
+
config(config) {
|
|
369
|
+
config.agent ??= {};
|
|
370
|
+
const agents = createAgents(options);
|
|
371
|
+
for (const [name, defaults] of Object.entries(agents)) {
|
|
372
|
+
config.agent[name] = mergeAgent(defaults, config.agent[name]);
|
|
373
|
+
}
|
|
374
|
+
},
|
|
362
375
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
376
|
+
async "command.execute.before"(input, output) {
|
|
377
|
+
return steerBefore(input, output);
|
|
378
|
+
},
|
|
366
379
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
: "none";
|
|
371
|
-
},
|
|
380
|
+
async "chat.message"(input) {
|
|
381
|
+
if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
|
|
382
|
+
},
|
|
372
383
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
})
|
|
384
|
+
async "shell.env"(input, output) {
|
|
385
|
+
output.env.SHEPHERD_MODE = input.sessionID
|
|
386
|
+
? (SESSION_MODES.get(input.sessionID) ?? "none")
|
|
387
|
+
: "none";
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
async event({ event }) {
|
|
391
|
+
if (event?.type === "session.deleted") {
|
|
392
|
+
const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
|
|
393
|
+
if (sessionID) SESSION_MODES.delete(sessionID);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
};
|
|
380
398
|
|
|
381
399
|
export default HerdrOrchestrationPlugin;
|
|
382
400
|
export {
|
package/src/installer.js
CHANGED
|
@@ -5,6 +5,13 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import spawn from "cross-spawn";
|
|
7
7
|
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
8
|
+
import {
|
|
9
|
+
STEER_COMMAND_AGENT as STEER_AGENT,
|
|
10
|
+
STEER_COMMAND_DESCRIPTION as STEER_DESCRIPTION,
|
|
11
|
+
STEER_COMMAND_NAME as STEER_NAME,
|
|
12
|
+
STEER_COMMAND_TEMPLATE as STEER_TEMPLATE,
|
|
13
|
+
steerCommandEntry as steerEntry,
|
|
14
|
+
} from "./steer.js";
|
|
8
15
|
|
|
9
16
|
export const PACKAGE_NAME = "opencode-herdr-orchestration";
|
|
10
17
|
const NPM_COMMAND = "npm";
|
|
@@ -17,6 +24,7 @@ export const AGENT_NAMES = [
|
|
|
17
24
|
"sheep",
|
|
18
25
|
"shearer-low",
|
|
19
26
|
"shearer-medium",
|
|
27
|
+
"developer",
|
|
20
28
|
];
|
|
21
29
|
|
|
22
30
|
// Agents register dynamically through the plugin, so the package owns no agent
|
|
@@ -391,6 +399,64 @@ export function writePluginConfig(configDir, remove = false, models) {
|
|
|
391
399
|
return { file, backup, changed: true, existed };
|
|
392
400
|
}
|
|
393
401
|
|
|
402
|
+
// Native /steer command owned by the installer. Single source for the shape
|
|
403
|
+
// lives in src/steer.js; these helpers persist it with the same JSONC
|
|
404
|
+
// preserve-comments plus backup discipline as the plugin config. The entry
|
|
405
|
+
// pins agent developer, carries an $ARGUMENTS template, and describes the
|
|
406
|
+
// Developer-only direct-write hook.
|
|
407
|
+
export {
|
|
408
|
+
STEER_COMMAND_AGENT,
|
|
409
|
+
STEER_COMMAND_DESCRIPTION,
|
|
410
|
+
STEER_COMMAND_NAME,
|
|
411
|
+
STEER_COMMAND_TEMPLATE,
|
|
412
|
+
steerCommandEntry,
|
|
413
|
+
} from "./steer.js";
|
|
414
|
+
|
|
415
|
+
export function updateCommandConfig(text, commandName, entry, remove = false) {
|
|
416
|
+
const errors = [];
|
|
417
|
+
const parsed = parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
|
|
418
|
+
if (errors.length) {
|
|
419
|
+
const first = errors[0];
|
|
420
|
+
throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
|
|
421
|
+
}
|
|
422
|
+
if (typeof commandName !== "string" || commandName.length === 0) {
|
|
423
|
+
throw new Error("Command name must be a non-empty string.");
|
|
424
|
+
}
|
|
425
|
+
const source = text || "{}";
|
|
426
|
+
if (remove && parsed?.command?.[commandName] === undefined) return source;
|
|
427
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes("\r\n") ? "\r\n" : "\n" };
|
|
428
|
+
const value = remove ? undefined : entry;
|
|
429
|
+
return applyEdits(source, modify(source, ["command", commandName], value, { formattingOptions }));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function updateSteerCommand(text, entry = steerEntry(), remove = false) {
|
|
433
|
+
return updateCommandConfig(text, STEER_NAME, entry, remove);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function isSteerCommandConfigured(text) {
|
|
437
|
+
const config = parse(text || "{}", [], { allowTrailingComma: true, disallowComments: false });
|
|
438
|
+
const entry = config?.command?.[STEER_NAME];
|
|
439
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
440
|
+
return (
|
|
441
|
+
entry.agent === STEER_AGENT &&
|
|
442
|
+
typeof entry.template === "string" &&
|
|
443
|
+
entry.template.includes("$ARGUMENTS") &&
|
|
444
|
+
entry.description === STEER_DESCRIPTION
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function writeSteerCommand(configDir, remove = false) {
|
|
449
|
+
mkdirSync(configDir, { recursive: true });
|
|
450
|
+
const file = findConfigFile(configDir);
|
|
451
|
+
const existed = existsSync(file);
|
|
452
|
+
const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
|
|
453
|
+
const next = updateSteerCommand(previous, steerEntry(), remove);
|
|
454
|
+
if (next === previous) return { file, backup: null, changed: false, existed };
|
|
455
|
+
const backup = backupFile(file);
|
|
456
|
+
writeFileSync(file, next, "utf8");
|
|
457
|
+
return { file, backup, changed: true, existed };
|
|
458
|
+
}
|
|
459
|
+
|
|
394
460
|
export function restoreBackup(file, backup, existed = true) {
|
|
395
461
|
if (backup && existsSync(backup)) copyFileSync(backup, file);
|
|
396
462
|
else if (!existed) rmSync(file, { force: true });
|
|
@@ -552,9 +618,16 @@ export function validateOpenCode(configDir) {
|
|
|
552
618
|
export function status(configDir, packageRoot) {
|
|
553
619
|
const file = findConfigFile(configDir);
|
|
554
620
|
let configured = false;
|
|
621
|
+
let steerCommandConfigured = false;
|
|
555
622
|
if (existsSync(file)) {
|
|
556
|
-
const
|
|
623
|
+
const text = readFileSync(file, "utf8");
|
|
624
|
+
const config = parse(text, [], { allowTrailingComma: true, disallowComments: false });
|
|
557
625
|
configured = Array.isArray(config?.plugin) && config.plugin.some(isOrchestrationPlugin);
|
|
626
|
+
try {
|
|
627
|
+
steerCommandConfigured = isSteerCommandConfigured(text);
|
|
628
|
+
} catch {
|
|
629
|
+
steerCommandConfigured = false;
|
|
630
|
+
}
|
|
558
631
|
}
|
|
559
632
|
const installed = installedVersion(configDir);
|
|
560
633
|
let detectedAgents = [];
|
|
@@ -586,6 +659,7 @@ export function status(configDir, packageRoot) {
|
|
|
586
659
|
updateAvailable: Boolean(installed && latest && installed !== latest),
|
|
587
660
|
configFile: file,
|
|
588
661
|
pluginConfigured: configured,
|
|
662
|
+
steerCommandConfigured,
|
|
589
663
|
detectedAgents,
|
|
590
664
|
agentsReady: detectedAgents.length === AGENT_NAMES.length,
|
|
591
665
|
obsoleteAgentFiles,
|
package/src/steer.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { DEVELOPER_AGENT, STEERING_TOOL_ACCESS, STEERING_TOOLS } from "./agents.js";
|
|
2
|
+
import { createStateService } from "./state.js";
|
|
3
|
+
|
|
4
|
+
// Native /steer command owned by the installer. The hook in this module is
|
|
5
|
+
// the sole writer: it calls the existing state service directly so validation
|
|
6
|
+
// plus journaling stay identical to the submit tool with no parallel store.
|
|
7
|
+
export const STEER_COMMAND_NAME = "steer";
|
|
8
|
+
export const STEER_COMMAND_AGENT = DEVELOPER_AGENT;
|
|
9
|
+
export const STEER_COMMAND_DESCRIPTION =
|
|
10
|
+
"Submit bounded Developer steering for one Plan ID target (Developer only; flock roles denied).";
|
|
11
|
+
export const STEER_COMMAND_TEMPLATE =
|
|
12
|
+
"Submit Developer steering with arguments: $ARGUMENTS\n\nFormat $ARGUMENTS as `<content>` or `<planId> :: <content>`; omit planId only when exactly one active steering target exists.";
|
|
13
|
+
|
|
14
|
+
export function steerCommandEntry() {
|
|
15
|
+
return {
|
|
16
|
+
template: STEER_COMMAND_TEMPLATE,
|
|
17
|
+
description: STEER_COMMAND_DESCRIPTION,
|
|
18
|
+
agent: STEER_COMMAND_AGENT,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Parse /steer arguments into the exact { content plus optional planId }
|
|
23
|
+
// shape the state service validates. `::` separates an explicit planId
|
|
24
|
+
// candidate from content when the left side is a single token (no whitespace);
|
|
25
|
+
// the candidate is passed through so the state service reports
|
|
26
|
+
// INVALID_PLAN_ID identically to the submit tool. A left side with whitespace
|
|
27
|
+
// is prose, so the whole string stays content and `::` inside sentences never
|
|
28
|
+
// misroutes.
|
|
29
|
+
export function parseSteerArguments(rawArguments) {
|
|
30
|
+
const raw = typeof rawArguments === "string" ? rawArguments : "";
|
|
31
|
+
const separator = raw.indexOf("::");
|
|
32
|
+
if (separator !== -1) {
|
|
33
|
+
const left = raw.slice(0, separator).trim();
|
|
34
|
+
const right = raw.slice(separator + 2).trim();
|
|
35
|
+
if (left.length > 0 && !/\s/.test(left)) {
|
|
36
|
+
return { planId: left, content: right };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { content: raw.trim() };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Single shared allowlist with the submit tool: only the explicit developer
|
|
43
|
+
// context passes. All seven orchestration roles plus unknown plus none plus
|
|
44
|
+
// unset fail closed.
|
|
45
|
+
export function isSteerAllowedAgent(agent) {
|
|
46
|
+
return STEERING_TOOL_ACCESS.get(STEERING_TOOLS.submit)?.has(agent) === true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve the session agent via the SDK client, fail closed to undefined on
|
|
50
|
+
// any unresolvable shape. Prefers session messages (UserMessage.agent) then
|
|
51
|
+
// falls back to session get; any throw or missing agent is unresolvable.
|
|
52
|
+
export async function resolveSessionAgentViaClient(client, sessionID) {
|
|
53
|
+
if (!client || typeof sessionID !== "string" || sessionID.length === 0) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const session = client?.session;
|
|
56
|
+
if (!session) return undefined;
|
|
57
|
+
if (typeof session.messages === "function") {
|
|
58
|
+
const result = await session.messages({ path: { id: sessionID } });
|
|
59
|
+
const data = result?.data ?? result;
|
|
60
|
+
if (Array.isArray(data)) {
|
|
61
|
+
for (let index = data.length - 1; index >= 0; index -= 1) {
|
|
62
|
+
const agent = data[index]?.info?.agent;
|
|
63
|
+
if (typeof agent === "string" && agent.length > 0) return agent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof session.get === "function") {
|
|
68
|
+
const result = await session.get({ path: { id: sessionID } });
|
|
69
|
+
const data = result?.data ?? result;
|
|
70
|
+
const agent = data?.agent ?? data?.info?.agent;
|
|
71
|
+
if (typeof agent === "string" && agent.length > 0) return agent;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushPart(output, text) {
|
|
80
|
+
const part = { type: "text", text };
|
|
81
|
+
if (output && Array.isArray(output.parts)) {
|
|
82
|
+
output.parts.push(part);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (output && output.parts === undefined) {
|
|
86
|
+
output.parts = [part];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// command.execute.before intercept for /steer. Performs the write directly
|
|
91
|
+
// through the existing state service, enforces the shared Developer-only
|
|
92
|
+
// allowlist, then throws to abort before any model turn. The throw carries
|
|
93
|
+
// the confirmation (steering id plus resolved target); output.parts carries
|
|
94
|
+
// the same text as a minimal fallback if error styling is unacceptable.
|
|
95
|
+
export function createSteerCommandHook({ client, stateOptions = {}, resolveAgent } = {}) {
|
|
96
|
+
const resolve = resolveAgent ?? ((sessionID) => resolveSessionAgentViaClient(client, sessionID));
|
|
97
|
+
return async function steerCommandBefore(input, output) {
|
|
98
|
+
if (!input || input.command !== STEER_COMMAND_NAME) return;
|
|
99
|
+
let agent;
|
|
100
|
+
try {
|
|
101
|
+
agent = await resolve(input.sessionID);
|
|
102
|
+
} catch {
|
|
103
|
+
agent = undefined;
|
|
104
|
+
}
|
|
105
|
+
if (typeof agent !== "string" || !isSteerAllowedAgent(agent)) {
|
|
106
|
+
const denial = `Steering denied: agent ${JSON.stringify(agent ?? "unknown")} may not use /${STEER_COMMAND_NAME}. Developer context only.`;
|
|
107
|
+
pushPart(output, denial);
|
|
108
|
+
throw new Error(denial);
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseSteerArguments(input.arguments);
|
|
111
|
+
const state = createStateService(stateOptions);
|
|
112
|
+
const payload =
|
|
113
|
+
parsed.planId === undefined ? { content: parsed.content } : { planId: parsed.planId, content: parsed.content };
|
|
114
|
+
const result = await state.submitSteering(payload);
|
|
115
|
+
if (!result.ok) {
|
|
116
|
+
const failure = `Steering failed [${result.error.code}]: ${result.error.message}`;
|
|
117
|
+
pushPart(output, failure);
|
|
118
|
+
throw new Error(failure);
|
|
119
|
+
}
|
|
120
|
+
const confirmation = `Steering recorded: ${result.entry.id} for target ${result.entry.planId}#${result.entry.sequence}`;
|
|
121
|
+
pushPart(output, confirmation);
|
|
122
|
+
throw new Error(confirmation);
|
|
123
|
+
};
|
|
124
|
+
}
|