Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,15 @@ jobs:
sudo apt-get install -y tmux
npm install --global @opencode/cli@2.2.6 --no-audit --no-fund
npm ci --prefix plugin/gateway-sidebar-v2 --ignore-scripts --no-audit --no-fund
npm ci --prefix plugin/gateway-core --ignore-scripts --no-audit --no-fund
npm --prefix plugin/gateway-core run build

- name: Validate V2 sidebar and live terminal behavior
run: |
npm --prefix plugin/gateway-sidebar-v2 run typecheck
npm --prefix plugin/gateway-sidebar-v2 test
npm --prefix plugin/gateway-sidebar-v2 run test:live -- --opencode-bin "$(command -v opencode)"
python plugin/gateway-core/test/v2/execution_status_live_smoke.py --opencode-bin "$(command -v opencode)" --with-tui

gateway-v2-permission:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -145,6 +148,7 @@ jobs:
python scripts/gateway_v2_permission_rejection_live_smoke.py
python scripts/gateway_v2_tool_error_live_smoke.py
python scripts/gateway_v2_shell_live_smoke.py
python plugin/gateway-core/test/v2/execution_status_live_smoke.py

windows-bounded-subprocess:
runs-on: windows-latest
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ OPENCODE_RESUME_E2E_VERSION := 1.18.18
help: ## Show available targets
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

PYTHON_TARGETS := data-lifecycle-help validate build-agents build-agents-check build-agents-prune release-index-update docs-automation-summary-update docs-automation-check pages-readiness-check release-note-validation-check release-note-quality-check plan-hygiene-check wave-linkage-check wave-handoff-summary wave-completion-update quality-fast quality-strict quality-off quality-status tasker-production-live-smoke rolling-decomposition-live gateway-status gateway-enable gateway-disable gateway-doctor gateway-secret-redaction-smoke gateway-v2-dependency-probe gateway-v2-dependency-profile-probe gateway-v2-auxiliary-redaction-probe gateway-v2-slice-smoke gateway-resume-redaction-e2e gateway-resume-redaction-e2e-prebuilt gateway-execution-status-live-smoke gateway-turn-watch gateway-turn-watch-webhook harness-wave2-task4-smoke notify-icons-generate notify-icons-select reservation-status task-lease-status selftest doctor doctor-json sqlite-doctor sqlite-doctor-json devtools-status hooks-install install-test install-test-full release-check release
PYTHON_TARGETS := data-lifecycle-help validate build-agents build-agents-check build-agents-prune release-index-update docs-automation-summary-update docs-automation-check pages-readiness-check release-note-validation-check release-note-quality-check plan-hygiene-check wave-linkage-check wave-handoff-summary wave-completion-update quality-fast quality-strict quality-off quality-status tasker-production-live-smoke rolling-decomposition-live gateway-status gateway-enable gateway-disable gateway-doctor gateway-secret-redaction-smoke gateway-v2-dependency-probe gateway-v2-dependency-profile-probe gateway-v2-auxiliary-redaction-probe gateway-v2-execution-status-live-smoke gateway-v2-slice-smoke gateway-resume-redaction-e2e gateway-resume-redaction-e2e-prebuilt gateway-execution-status-live-smoke gateway-turn-watch gateway-turn-watch-webhook harness-wave2-task4-smoke notify-icons-generate notify-icons-select reservation-status task-lease-status selftest doctor doctor-json sqlite-doctor sqlite-doctor-json devtools-status hooks-install install-test install-test-full release-check release

$(PYTHON_TARGETS): python-check

Expand Down Expand Up @@ -118,6 +118,11 @@ gateway-v2-auxiliary-redaction-probe: python-check ## Measure four V2 request ki
npm --prefix plugin/gateway-core run build
$(PYTHON) scripts/gateway_v2_auxiliary_redaction_probe.py --opencode-bin "$(OPENCODE_BIN)"

.PHONY: gateway-v2-execution-status-live-smoke
gateway-v2-execution-status-live-smoke: python-check ## Test the experimental V2 status writer and sidebar
npm --prefix plugin/gateway-core run build
$(PYTHON) plugin/gateway-core/test/v2/execution_status_live_smoke.py --opencode-bin "$(OPENCODE_BIN)" --with-tui

gateway-v2-slice-smoke: ## Test experimental V2 security adapters against localhost (not full gateway parity)
npm --prefix plugin/gateway-core run build
$(PYTHON) scripts/gateway_v2_context_live_smoke.py
Expand Down
14 changes: 12 additions & 2 deletions docs/plan/doing/opencode-v2-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,24 @@ The experimental V2 execution sidebar is a separate, unconfigured TUI plugin
under `plugin/gateway-sidebar-v2`. It reads the existing private, bounded status
format for the displayed session's location. Its unit tests and isolated tmux
smoke cover status updates and plugin unload/reload on OpenCode 2.2.6. It does
not provide a V2 status writer or establish gateway security parity; leave it
unconfigured until the V2 gateway and its activation gates are complete.
not establish gateway security parity; leave it unconfigured until the V2
gateway and its activation gates are complete.

The separate, unconfigured `plugin/gateway-core/src/v2/execution-status.ts`
adapter translates V2 tool and session events into the existing bounded status
ledger. It checks each session's actual location against its own plugin
location and records generic milestones rather than tool output. Deletion and
move cleanup are best-effort for sessions observed during that plugin lifetime;
old entries remain bounded. A private-server smoke exercises a real V2 shell,
session deletion and, on 2.2.6, the rendered sidebar. This adapter is not a V2
gateway entrypoint and does not enforce or qualify security policy.

```sh
npm ci --prefix plugin/gateway-sidebar-v2 --ignore-scripts
npm --prefix plugin/gateway-sidebar-v2 run typecheck
npm --prefix plugin/gateway-sidebar-v2 test
npm --prefix plugin/gateway-sidebar-v2 run test:live -- --opencode-bin /path/to/opencode-2.2.6
make gateway-v2-execution-status-live-smoke OPENCODE_BIN=/path/to/opencode-2.2.6
```

The separate [native dependency-boundary probe](../../specs/opencode-v2-dependency-boundary-contract.md)
Expand Down
42 changes: 42 additions & 0 deletions plugin/gateway-core/dist/v2/execution-status.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Registration } from "./types.js";
type ToolEvent = {
tool: string;
sessionID: string;
input: unknown;
status?: string;
result?: unknown;
};
interface StatusToolHooks {
hook(name: "execute.before" | "execute.after", callback: (event: ToolEvent) => Promise<void>): Promise<Registration>;
}
interface StatusContext {
app: {
version: string;
};
location: {
directory: string;
};
session: {
get(input: {
sessionID: string;
}): Promise<{
location?: {
directory?: unknown;
};
}>;
};
tool: StatusToolHooks;
event: {
subscribe(options: {
types: string[];
signal: AbortSignal;
}): AsyncIterable<unknown>;
};
}
/** Unconfigured V2 adapter; status reporting is never an enforcement boundary. */
export declare function registerV2ExecutionStatus(context: StatusContext, options: {
enabled: boolean;
maxSessions: number;
maxLabelChars: number;
}): Promise<() => Promise<void>>;
export {};
181 changes: 181 additions & 0 deletions plugin/gateway-core/dist/v2/execution-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { isAbsolute, resolve } from "node:path";
import { createExecutionStatusHook } from "../hooks/execution-status/index.js";
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
const TOOLS = new Map([
["shell", "bash"],
["edit", "edit"],
["write", "write"],
["patch", "apply_patch"],
["subagent", "task"],
]);
const MAX_PENDING = 256;
const MAX_SESSIONS = 256;
const MAX_LOOKUPS = 8;
const LOOKUP_TIMEOUT_MS = 1000;
const SUPPORTED_VERSIONS = ["2.2.5", "2.2.6"];
function isRecord(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function validSessionID(value) {
return typeof value === "string" && value.length > 0 && value.length <= 160 &&
value === value.trim() && !CONTROL_CHARACTER.test(value);
}
/** Unconfigured V2 adapter; status reporting is never an enforcement boundary. */
export async function registerV2ExecutionStatus(context, options) {
if (!options.enabled)
return async () => { };
if (!SUPPORTED_VERSIONS.includes(context.app.version) ||
typeof context.location.directory !== "string" || !isAbsolute(context.location.directory) ||
!Number.isInteger(options.maxSessions) || options.maxSessions < 1 || options.maxSessions > 64 ||
!Number.isInteger(options.maxLabelChars) || options.maxLabelChars < 24 || options.maxLabelChars > 160) {
throw new Error("Invalid V2 execution-status configuration");
}
const directory = resolve(context.location.directory);
const status = createExecutionStatusHook({ directory, ...options });
const known = new Set();
const tails = new Map();
const abort = new AbortController();
const registrations = [];
let pending = 0;
let activeLookups = 0;
let disposed = false;
const payload = (id) => ({ input: { sessionID: id } });
const remember = (id) => {
known.delete(id);
known.add(id);
if (known.size > MAX_SESSIONS) {
const first = known.values().next().value;
if (first !== undefined)
known.delete(first);
}
};
const enqueue = (id, work) => {
if (disposed || !validSessionID(id) || pending >= MAX_PENDING)
return Promise.resolve();
const previous = tails.get(id) ?? Promise.resolve();
pending += 1;
const current = previous.then(async () => {
if (!disposed)
await work(id);
}).catch(() => { });
tails.set(id, current);
return current.finally(() => {
pending -= 1;
if (tails.get(id) === current)
tails.delete(id);
});
};
const belongsHere = async (id) => {
if (disposed || activeLookups >= MAX_LOOKUPS)
return false;
activeLookups += 1;
const lookup = Promise.resolve().then(() => context.session.get({ sessionID: id }))
.finally(() => { activeLookups -= 1; });
let timer;
try {
const timeout = new Promise((done) => {
timer = setTimeout(() => done(undefined), LOOKUP_TIMEOUT_MS);
});
const session = await Promise.race([lookup, timeout]);
if (disposed || !session || !isRecord(session.location))
return false;
const location = session.location.directory;
if (typeof location !== "string" || !isAbsolute(location))
return false;
if (resolve(location) !== directory) {
if (known.delete(id))
await status.event("session.deleted", payload(id));
return false;
}
if (!known.has(id))
await status.event("session.created", payload(id));
if (disposed)
return false;
remember(id);
return true;
}
catch {
return false;
}
finally {
if (timer)
clearTimeout(timer);
}
};
const toolEvent = (phase, event) => {
try {
const name = typeof event.tool === "string" ? TOOLS.get(event.tool.toLowerCase()) : undefined;
if (!name)
return Promise.resolve();
const args = isRecord(event.input) && typeof event.input.command === "string"
? { command: event.input.command }
: {};
const metadata = isRecord(event.result) && isRecord(event.result.metadata) &&
typeof event.result.metadata.exit === "number" && Number.isFinite(event.result.metadata.exit)
? { exit: event.result.metadata.exit }
: {};
const input = { tool: name, sessionID: event.sessionID, args };
const kind = phase === "execute.before"
? "tool.execute.before"
: event.status === "error" ? "tool.execute.before.error" : "tool.execute.after";
if (phase === "execute.after" && event.status !== "completed" && event.status !== "error")
return Promise.resolve();
return enqueue(event.sessionID, async (id) => {
if (!(await belongsHere(id)) || disposed)
return;
await status.event(kind, { input, output: { metadata } });
});
}
catch {
return Promise.resolve();
}
};
const sessionEvent = async (event) => {
if (!isRecord(event) || !isRecord(event.data))
return;
const type = event.type;
if (type !== "session.created" && type !== "session.moved" && type !== "session.deleted")
return;
await enqueue(event.data.sessionID, async (id) => {
if (type === "session.deleted") {
if (known.delete(id))
await status.event("session.deleted", payload(id));
return;
}
await belongsHere(id);
});
};
try {
registrations.push(await context.tool.hook("execute.before", (event) => toolEvent("execute.before", event)));
registrations.push(await context.tool.hook("execute.after", (event) => toolEvent("execute.after", event)));
const stream = context.event.subscribe({ types: ["session.created", "session.moved", "session.deleted"], signal: abort.signal });
void (async () => {
try {
for await (const event of stream) {
if (disposed)
break;
try {
await sessionEvent(event);
}
catch {
// A malformed event must not end the subscription.
}
}
}
catch {
// Status reporting is best-effort; tool execution must continue.
}
})();
}
catch {
disposed = true;
abort.abort();
await Promise.allSettled(registrations.map(async (registration) => registration.dispose()));
throw new Error("Unable to register V2 execution status");
}
return async () => {
disposed = true;
abort.abort();
await Promise.allSettled(registrations.map(async (registration) => registration.dispose()));
};
}
Loading
Loading