Releases: marcusrbrown/opencode-copilot-delegate
v0.12.0
Minor Changes
-
2667712: Add
copilot_resumetool for resuming prior Copilot sessions by ID, name, or prefix.The new tool wraps
copilot --resume=<target>and integrates fully with the existing task lifecycle — output, cancel, and completion notifications all work the same way as delegated tasks.Key behaviors:
- UUID targets are validated against the local Copilot session store before spawning; missing sessions return a structured error without touching the CLI.
- When a prior plugin task's session ID matches the target, its workspace paths (
--add-dirset) are reused automatically when the caller omitsaddDirs. - CLI no-match errors (
Error: No session, task, or name matched '...') are normalized to a cleanSession not foundresponse. - All path inputs (
cwd,addDirs) are validated against allowed roots before spawn; argv-injection-shaped values are rejected.
The tool catalog grows from 3 to 4. Resume + new prompt (fork) is not part of this release.
-
a02c3e7: Extract the post-completion pipeline (
SpawnCopilotResult→TaskStateback-patch + completion notification) into a sharedattachCompletionPipelinehelper insrc/runtime/notify.ts.copilot_delegatenow delegates itsvoid task.completionPromise.then(...)block to this helper; the upcomingcopilot_resumetool will reuse it without duplicating the load-bearing field sync.Notification headers are now origin-aware: tasks created with
origin: 'spawn'(today's only call site) keep the existing[COPILOT DELEGATION COMPLETED]header. Tasks withorigin: 'resume'will surface[COPILOT RESUME COMPLETED]so users can distinguish a resumed Copilot session from a fresh delegation. Theconnectheader is wired for forward compatibility but noconnect-origin tasks are constructed in this slice. -
217a69e: Harden the plugin entry's public surface and Node-loadability so the build artifact survives the same class of regression that bit Systematic in v2.5.0 and v2.12.1.
OpenCode's plugin loader treats every named export from a plugin entry point as a separate plugin factory and invokes it with
undefinedinput. That contract has bitten upstream plugins twice with hours of downtime each; this PR institutionalizes the fix in three coordinated changes:- Helper moved out of the entry point.
wireRpcServerCleanupnow lives insrc/lib/rpc-cleanup.ts. The plugin entry exports onlydefaultand re-imports the helper internally. No behavior change — the helper's identity-once semantics and its single caller are byte-identical. - Build target switched to Node for the plugin entry.
scripts/build.tsnow buildssrc/index.tswithtarget: 'node'sodist/index.jsloads under plain Node ESM. The TUI entry stays ontarget: 'bun'because@opentui/solidis Bun-specific. The Node-loadable build is the prerequisite for the new CI gate (next bullet). - CI gate asserts the export shape on every PR. A new step in
.github/workflows/ci.yamlbetweenBuildandUnit testsrunsnode --input-type=module -e "import('./dist/index.js').then(m => …)"and exits non-zero if the entry exposes any export other thandefaultor ifdefaultis not a function. The local test surface gets a matching assertion intests/package-exports.test.ts. The gate's failure message references the v2.5.0/v2.12.1 regression class so future contributors can find the rationale.
Also documents the divergence rationale: this plugin keeps the
plugInOncesingleton pattern (returns empty hooks on duplicate invocations) even though Systematic's PR #352 replaced that pattern with per-load registration. The constraint inverts here because this plugin'sdoInitbinds a TCP port and writes a PID file — runningdoInittwice in the same process would race on those exclusive resources.src/runtime/plugin-singleton.tsandsrc/lib/rpc-cleanup.tscarry top-of-file JSDoc explaining the divergence with cross-references to marcusrbrown/systematic#352.No user-visible behavior change.
- Helper moved out of the entry point.
-
66f97f4: Add
origindiscriminator ('spawn' | 'resume' | 'connect') toTaskState, theOutputEnvelopereturned bycopilot_output, and theEnvelopeInputbuilder. Capture the upstream Copilot session UUID from the JSONLresultevent and surface it ascopilot_session_idon the envelope (omitted when the subprocess never emitted aresultevent).Existing
copilot_delegatecalls receiveorigin: 'spawn'automatically and continue to behave the same way. The new fields are the substrate the upcomingcopilot_resumetool builds on; they do not change today's tool surface. -
82a1026: Make the TUI plugin survive OpenCode's
api.command.registermigration.OpenCode 1.14.42 removed
api.command.registerin favor of the new keymap engine. 1.14.44+ restoredapi.command.registeras a deprecated shim that translates toapi.keymap.registerLayerinternally. The TUI plugin's/copilot-statuscommand was unconditionally callingapi.command.register, which would silently disappear on OpenCode versions where the call path went away.The TUI entry now runtime-feature-detects:
- OpenCode 1.14.44+ (canonical): registers via
api.keymap.registerLayer({ commands: [{ namespace: 'palette', name: 'copilot-status', title: 'Copilot Status', category: 'Copilot', run() }], bindings: [] }). Mirrors the dual-path pattern Magic Context established in commit5fe1c4f. - OpenCode 1.14.41 (the prior pinned version): falls back to
api.command.register(...)with the original command shape, so the slash menu continues to surface/copilot-statusexactly as before. - Neither API present (defensive): logs a warning and continues to load the plugin without the slash command. The status modal remains available via direct API consumers.
Other surfaces:
devDependencies['@opencode-ai/plugin']moves from1.14.41to1.14.48so tests run against the canonical keymap API.peerDependencies['@opencode-ai/plugin']narrows from>=1.14.0to>=1.14.41to align advertised compatibility with what's actually tested.
- OpenCode 1.14.44+ (canonical): registers via
v0.11.0
Minor Changes
-
31dfb28: Fix host-side double registration of
copilot_delegate,copilot_output, andcopilot_cancelwhen the plugin is listed in both a user-level and project-levelopencode.json.Previously, the per-process register-once guard returned the cached real hooks to every duplicate factory invocation in the same PID. The OpenCode host iterates each plugin source's returned hook surface and registers every tool entry it finds, even when two sources return the same JS reference. That meant each tool appeared twice in the LLM-visible tool catalog under dual-source configurations.
The guard now returns empty hooks (
{}) on duplicate invocations so the host has nothing to register a second time. The first invocation still runsdoInitonce and receives the real hooks; subsequent invocations in the same PID receive{}and emit a one-time warning. Heavy initialization (agent discovery, orphan reaping, RPC server startup) still runs at most once per process.Single-source configurations are unaffected.
Patch Changes
- e019e86: Stabilize the cancel-process-tree test against CI runner load by polling
process.kill(pid, 0)with a 2s deadline instead of asserting synchronously. Test-only change; no plugin behavior or consumer surface affected.
v0.10.1
v0.10.0
v0.9.0
Minor Changes
-
80b549e: Improve runtime observability and fix a silent failure in completion notifications.
killProcessTreenow classifies fkill failures by probing the process group withprocess.kill(-pid, 0). ESRCH is reported as an already-gone group and the failure is suppressed; alive or unknown states preserve the original throw with a more accurate warning. The probe targets-pid(the process group) rather than the leader so children leaking after a leader exit are no longer misclassified as benign.notifyCompletion's fallbackclient.app.logcall now uses the structured SDK shape ({ body: { service, level, message } }) and is wrapped intry/catchso synchronous SDK throws can no longer escape the documented "never throws" contract.- All runtime warnings now share the
[copilot-delegate]prefix acrosskill-tree,orphan-reaper,pid-file,task-registry, andtask-status, making operator log filtering predictable. - Internal contract documentation:
setStatusandwriteChainscarry JSDoc covering terminal-state transitions and the per-file serialize chain.
-
4f2e8b5: Harden PID file and orphan-reaper state path handling against same-user symlink attacks.
- Use no-follow PID file opens for read and truncate paths so symlinked
.pidsfiles are rejected instead of read or truncated. - Reject symlinked PID file parent directories before orphan reaping, cleanup, and plugin init state-directory creation so
orphans/scans and cleanup do not follow attacker-controlled links.
- Use no-follow PID file opens for read and truncate paths so symlinked
v0.8.0
Minor Changes
-
0c21f43: Make the plugin factory idempotent across multiple OpenCode config sources within the same process. When
~/.config/opencode/opencode.jsonAND a project-levelopencode.json(or any other combination of config sources) both listopencode-copilot-delegate, OpenCode previously invoked the factory once per source — each invocation evaluating the plugin module fresh, running orphan reaping, and registering its own copy of the three tools. The result was duplicated tools in the catalog, duplicated init side effects (PID-filemkdir,reapOrphans), and per-invocation closure state that could diverge across registrations.The factory now resolves at most once per process via a
globalThissymbol singleton (Symbol.for('opencode-copilot-delegate.singleton.v1')). Subsequent invocations within the same PID return the cached hooks promise, skip the heavy init, and emit a single one-shot warning (viaconsole.warnANDclient.app.logunderservice=copilot-delegate) so duplicate-config situations remain observable in logs. Across distinct OpenCode processes the singleton is fresh — each process initializes normally.No configuration change is required for users with a single config source. Users with duplicate registrations will see one warning per process and a single set of tools in the catalog.
-
1fc343d: Tighten orphan-reaper internals and add two new exported helpers from
src/runtime/pid-file.ts:truncatePidFile(filePath)— truncate under the per-fileserializeWritelock; ENOENT (file or parent missing) silently swallowed.unlinkPidFile(filePath)— unlink under the per-fileserializeWritelock; ENOENT silently swallowed.
Also export
ReapOptionsfromsrc/runtime/orphan-reaper.ts(consolidatesReapDeps+ reap-specific opts via interface extension), and consolidatereapOneFileto a single opts-bag parameter.cleanupAfterReapnow routes its truncate-on-current and unlink-on-foreign paths through the new helpers so any future change that runs reap concurrent with task spawns is automatically race-safe.No user-visible behavior change in default usage; the bump reflects the new exported surface.
-
42c5239: Add
timedOut: booleantoReapResultso consumers can distinguish a successful no-op reap (nothing to do) from a timeout-aborted reap (gave up; orphans may remain). The flag isfalseon every success path andtrueonly when the overallreapOrphanstimeout fires.When
timedOutistrue, the count fields are zero placeholders, not partial-progress accounting — in-flight workers may have already invokedkillProcessTreeor scanned files before the abort signal landed, but those side effects are not reflected in the returned counts. Treat a timed-out result as "no reliable count signal" and warn or retry.Note for TypeScript consumers:
ReapResultis exported from the package's runtime types and gains a required field. Any caller constructing aReapResultliteral will need to addtimedOutexplicitly. Internal callers in this repo are already updated. -
3491e63: Tighten the
setStatuslifecycle helper to forbid terminal → non-terminal transitions. Once a task reachescomplete,failed, orcancelled, every subsequentsetStatuscall is a no-op. Previously the helper short-circuited only when both old and new status were terminal, leaving an unintended resurrection path that no caller exercises but the contract permitted. This narrows the public API contract; no caller behavior changes.
v0.7.0
Minor Changes
-
3f8b78e: Surface per-parameter
.describe()text to the host runtime by patching each tool arg schema with a_zod.toJSONSchemaoverride.OpenCode's tool catalog renders plugin schemas via the host's bundled zod, which lives in a different module instance from the plugin's zod and cannot see the plugin-side
.describe()metadata registry. The override delegates serialization back to the plugin-local zod so descriptions survive intact, mirroring the pattern shipped by@cortexkit/opencode-magic-contextand@cortexkit/aft-opencode.Also pins
zodas a direct dependency (^4.3.0) with a matchingoverridesentry to keep this repo's dependency tree on a single zod version, resolving a TS2883 unportable-inferred-type error from two zod trees coexisting at build time. Theoverridesfield is local to this repo's installs only; npm ignores it for downstream consumers, so external plugin authors importing this package may still see a different transitive zod from their OpenCode host. Also reverts the prior schema-chain reorder since the override makes ordering irrelevant.
v0.6.0
Minor Changes
-
e0480fe: Surface per-parameter descriptions to host LLMs and drop the unused
@opencode-ai/sdkpeer dependency.OpenCode's tool-list endpoint serializes plugin schemas with Zod's
toJSONSchema(..., { io: 'input' })mode, which unwraps.optional()wrappers and drops any.describe()text attached to the wrapper. The 5 optional parameters acrosscopilot_delegateandcopilot_outputchained.optional().describe(...)and lost their descriptions in the rendered tool catalog. Reordering to.describe(...).optional()places the description on the leaf type so it survives the unwrap.Also drops
@opencode-ai/sdkfrompeerDependenciesand the build externals — the package was never imported from any source file, so the peer requirement was dead config that forced consumers to install an unused dependency.
v0.5.0
Minor Changes
-
e940a28: Drop
BUILTIN_AGENTSand enrich tool schema descriptionsBUILTIN_AGENTSremoval. The plugin previously advertised six "built-in" agent names —default,explore,task,general-purpose,code-review,research— inherited from VS Code Copilot Chat / OpenCode conventions. The standalone@github/copilotCLI v1.0.36 ships zero of these; passing any of them as--agent <name>causes Copilot CLI to fail at spawn time withError: No such agent: <name>, available:. The constant has been removed.discoverAgentsnow returns user agents (filtered by repo override) followed by repo agents;Agent.sourceis'user' | 'repo'.buildDescriptionhandles the empty-discovery case by emitting an actionable hint pointing at~/.copilot/agentsand.github/agents.Tool schema enrichment. The three exposed tools —
copilot_delegate,copilot_output,copilot_cancel— now ship multi-paragraph tool descriptions covering what they do, when to use them, lifecycle, common pitfalls, and return-value shape. Per-parameter.describe()text spells out type, default, when-required, example values, and constraints. Examples follow the patterns Magic Context'sctx_*tools established. The new descriptions surface in OpenCode's tool registry exposure to LLMs and viamise run opencode:doctor --only tools.No runtime behavior change beyond the agent discovery fix. Tool argument shapes are unchanged; existing callers continue to work without modification.
v0.4.0
Minor Changes
-
f3f21c7: Configurable orphan-reaper timeouts and parser extraction
Three medium-priority improvements to the plugin-init orphan-subprocess reaper, all from the runtime hardening backlog:
- Configurable per-probe
pstimeout with degradation warning:getPidIdentityand the underlyingrunPshelper now accept an optionaltimeoutMsparameter (default 1000). On loaded systems where legitimatepsresponses can exceed 1s, callers can pass a longer timeout to avoid identity-gate misses that leave orphans alive. When the timeout fires,runPsemits aconsole.warnso operators can detect probe degradation and tune the budget up. - Overall
reapOrphanstimeout with cooperative cancellation:reapOrphansnow accepts an optionalreapTimeoutMsparameter (default 15000). When the timeout fires, the reap aborts viaAbortSignal: in-flight workers cooperate by skipping their next mutating step (kill, truncate, unlink) so no dangerous side effects can occur afterreapOrphansreturns. The call resolves with an empty result and emits aconsole.warn. Prevents pathological cases (NFSreaddirhang, all probes timing out simultaneously) from blocking plugin init indefinitely, and prevents lingering reap workers from wiping current-instance pid file entries that live tasks have appended after the timeout fired. - Parser extraction: the comm/lstart parsing logic is lifted out of
getPidIdentityinto a new exportedparsePsIdentity(raw)pure function with full edge-case coverage (empty input, single-token input, leading-whitespace input, the 15/16-charcommboundary, and multi-whitespace separator).
All new parameters are additive optionals; existing callers continue to work without modification. Behavior at default settings is unchanged for normal-load operation, but the new overall reap timeout introduces a conditional control-flow branch (warn + empty return) that activates only under pathological conditions where a reap exceeds the 15s default budget.
- Configurable per-probe