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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ body:
id: plugin-version
attributes:
label: Plugin version
placeholder: 1.1.0
placeholder: 1.2.0
validations:
required: true
- type: input
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ Thumbs.db
*.tsbuildinfo

/docs/

# Local agent workspaces
.claude/
.superpowers/
54 changes: 49 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ This is an unofficial third-party plugin and is not affiliated with the OpenCode

- Loads dedicated global and project command files in global-first order without blocking on child readiness.
- Deduplicates launches for each OpenCode process and normalized project root.
- Remembers `stopOnExit: false` processes across OpenCode restarts and re-adopts them instead of starting duplicates.
- Spawns the executable with its ordered arguments directly, without an implicit shell.
- Supports Linux, macOS, and Windows with sanitized console and rotating file logs.

## Compatibility

Supports OpenCode `1.18.x` and is compiled and directly tested against `1.18.25`.
Supports OpenCode `1.18.x` and is compiled and directly tested against `1.18.28`.

## Installation

Expand All @@ -24,7 +25,7 @@ Register the exact npm release in `opencode.json` or `opencode.jsonc`:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-startup-commands@1.1.0"]
"plugin": ["opencode-startup-commands@1.2.0"]
}
```

Expand All @@ -33,7 +34,7 @@ Alternatively, register the immutable Git tag:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.1.0"]
"plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.2.0"]
}
```

Expand Down Expand Up @@ -80,10 +81,10 @@ Project commands receive the original OpenCode worktree root as `cwd`; only the
- `stopOnExit: false` records remain tracked when ownerless for later `skip` or `restart`; `stopOnExit: true` records wait for the final owner, then stop. A same-process reopen can attach a new owner to a still-tracked record. Confirmed successful final cleanup instead releases the identity, so reopening in the same OpenCode process starts a new process.
- Failed launches are not retried in that process; launch failures, final natural exits, and unconfirmed stale cleanup create tombstones and blockers that block same-process retry as applicable.
- A partial restart leaves safely addressable survivors in degraded state, transfers orphaned owners to the oldest survivor, and starts no replacement.
- Tracking covers only processes the plugin launched during the current OpenCode process; it does not scan the OS to discover or adopt other processes.
- Tracking covers processes the plugin launched during the current OpenCode process, plus the `stopOnExit: false` processes it recorded in its own registry; it does not scan the OS to discover or adopt processes it did not launch.
- Bare `opencode serve` does not launch commands until a project or directory initializes the plugin.

A full OpenCode restart is required after updating plugin code or changing registration, revision, or command files; the plugin cannot rediscover previously launched processes after that restart because tracking is in memory.
A full OpenCode restart is required after updating plugin code or changing registration, revision, or command files; after that restart the plugin cannot rediscover `stopOnExit: true` processes, which are tracked only in memory, and re-adopts only the `stopOnExit: false` processes recorded in its registry whose `(pid, startToken)` pair still matches.

### Process stopping

Expand All @@ -92,6 +93,49 @@ A full OpenCode restart is required after updating plugin code or changing regis
- On Windows, if the tracked root exits before cleanup, its descendants cannot be addressed safely. The plugin fails closed, which may leave descendants running, blocks restart for that identity, and recovery requires a full OpenCode restart.
- Cleanup is best-effort for deliberately detached or escaped descendants, forced OpenCode termination, OS crash, and power loss.

## Process lifetime across OpenCode restarts

A `stopOnExit: false` process survives an OpenCode exit, and the next OpenCode process re-adopts it from a registry file instead of starting a second copy. A `stopOnExit: true` process is stopped when its final owner disposes, is never recorded, and is never re-adopted.

The table covers the first activation after a full OpenCode exit, with the command's configuration unchanged since the previous start; within one OpenCode process the policies behave as described above.

| `onExistingProcess` | `stopOnExit` | Behavior at the next OpenCode start |
| --- | --- | --- |
| `skip` | `false` | Reuses the surviving process and starts nothing. |
| `start` | `false` | Keeps the surviving process and starts one more beside it; both are remembered. |
| `restart` | `false` | Stops the surviving process, waits for confirmed cleanup, then starts exactly one replacement. |
| `skip` | `true` | Nothing survived, so one process starts and is stopped again on exit. |
| `start` | `true` | Nothing survived, so one process starts and is stopped again on exit. |
| `restart` | `true` | Nothing survived, so one process starts and is stopped again on exit. |

- Changing `stopOnExit` from `false` to `true` takes effect at the next activation, not the running one: the surviving process is stopped, one replacement starts in per-session mode, and that replacement is stopped when its session closes.
- Removing a command from a cleanly loaded configuration stops its durable process. The record is kept unless that stop is confirmed, so a process that cannot be confirmed stopped is retried on every later cleanly loaded activation, and each retry waits out the stop budget before other durable work proceeds. A configuration error stops nothing: adoption still runs, because refusing to adopt over a typo would create the duplicate processes this section exists to prevent, while stopping is destructive and requires both the global and the project file to load with no error at all, including a single invalid entry.
- If the plugin cannot determine whether a remembered process is alive, such as when a probe is denied permission, it fails closed: it neither stops that process nor starts a duplicate, and retries at a later activation.
- No external runtime, daemon, or supervisor process is added, and the plugin declares no runtime dependencies.

### What is remembered

A registry file records, for each surviving process, a hash of the command identity, its PID, and a start token:

| Platform | Registry file |
| --- | --- |
| Windows | `%LOCALAPPDATA%\opencode\startup-commands\<hostname>\registry.json` |
| macOS | `~/Library/Application Support/OpenCode/startup-commands/<hostname>/registry.json` |
| Linux | `$XDG_STATE_HOME/opencode/startup-commands/<hostname>/registry.json`, or `~/.local/state/opencode/startup-commands/<hostname>/registry.json` |

The registry is kept per host, under a sanitized `<hostname>` directory, so a home directory shared between machines keeps each machine's processes separate. Because that key is the hostname, renaming the machine starts a new registry: helpers recorded under the old name are forgotten, never stopped, and the next start launches fresh ones beside them, so stop the old ones once by hand after a rename. A remembered process is re-identified by the `(pid, startToken)` pair, never by the token alone. A start token is only a start timestamp at the platform's own granularity: 100-nanosecond ticks on Windows, one kernel clock tick on Linux (10 ms on the kernels measured), and one second on macOS. No token contains a PID, so two processes started inside one such window can carry the same token; that is safe because every comparison the plugin makes is keyed by PID as well.

A deleted registry file, or a record whose `(pid, startToken)` pair no longer matches, for example after a reboot, means the process it described is forgotten rather than stopped, and the command starts fresh. A registry file that cannot be parsed is set aside under a `.corrupt-` name and the plugin continues with an empty registry, so the processes it described are likewise forgotten rather than stopped. A registry file that cannot be read at all is left in place instead, and `stopOnExit: false` commands are skipped for that activation rather than started. Executable paths, arguments, environment variables, and project paths are never recorded.

### Upgrading from 1.1.0

Version 1.1.0 kept no record of the processes it left running, so 1.2.0 cannot identify them. Once, before upgrading:

1. Stop any helpers left running by `stopOnExit: false`.
2. Fully exit every OpenCode process.
3. Install 1.2.0.
4. Start OpenCode. New `stopOnExit: false` commands are now remembered.

## Security

> [!WARNING]
Expand Down
24 changes: 20 additions & 4 deletions dist/core.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ConfiguredCommand } from "./config.js";
import type { DurableLockHolder, DurableRegistry } from "./durable-registry.js";
import type { Logger } from "./logger.js";
import type { ProcessIdentityController } from "./process-identity.js";
import type { ProcessTreeController, ProcessTreeStopResult } from "./process-tree.js";
export interface SpawnedChild {
readonly pid?: number;
Expand All @@ -16,8 +18,7 @@ interface ManagedCommandContext {
index: number;
name: string;
}
interface ProcessRecord {
child: SpawnedChild;
interface ProcessRecordBase {
pid?: number;
context: ManagedCommandContext;
creationOrder: number;
Expand All @@ -27,6 +28,13 @@ interface ProcessRecord {
stopPromise?: Promise<ProcessTreeStopResult>;
rootExited: boolean;
}
type ProcessRecord = (ProcessRecordBase & {
origin: "spawned";
child: SpawnedChild;
}) | (ProcessRecordBase & {
origin: "adopted";
startToken: string;
});
interface IdentityEntry {
processKey: string;
records: ProcessRecord[];
Expand All @@ -51,13 +59,21 @@ export interface StartupDependencies {
state: StartupState;
processTree: ProcessTreeController;
logger: Logger;
identity?: ProcessIdentityController;
registry?: DurableRegistry;
}
export interface StartupActivation {
dispose(): Promise<void>;
}
export interface StartupRunOptions {
authoritative: boolean;
projectRootHash: string;
resolveOwner?: () => Promise<DurableLockHolder | undefined>;
}
export declare function createStartupState(): StartupState;
export declare function getOrCreateProcessState(registry: Record<symbol, unknown>): StartupState;
export declare const processState: StartupState;
export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies): Promise<StartupActivation>;
export {};
declare function getNormalizedProjectRoot(projectRoot: string): string;
export { getNormalizedProjectRoot as normalizeProjectRoot };
export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies, options?: StartupRunOptions): Promise<StartupActivation>;
//# sourceMappingURL=core.d.ts.map
2 changes: 1 addition & 1 deletion dist/core.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading