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.0.0
placeholder: 1.1.0
validations:
required: true
- type: input
Expand Down
36 changes: 25 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Register the exact npm release in `opencode.json` or `opencode.jsonc`:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-startup-commands@1.0.0"]
"plugin": ["opencode-startup-commands@1.1.0"]
}
```

Expand All @@ -33,7 +33,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.0.0"]
"plugin": ["opencode-startup-commands@git+https://github.com/PixelWinner/opencode-startup-commands.git#v1.1.0"]
}
```

Expand All @@ -55,28 +55,42 @@ Each file is strict JSON; comments and trailing commas are not accepted. A missi
{
"name": "Start helper",
"executable": "/absolute/path/to/helper",
"args": ["--watch"]
"args": ["--watch"],
"onExistingProcess": "skip",
"stopOnExit": true
}
]
}
```

`commands` must be an array. Each valid entry needs a non-empty `name`, a non-empty `executable`, and a string array named `args`; invalid entries are skipped without blocking later valid entries.
`commands` must be an array. Each valid entry needs a non-empty `name`, a non-empty `executable`, and a string array named `args`. `onExistingProcess` accepts only `start`, `skip`, or `restart` and defaults to `skip`; `stopOnExit` must be a boolean and defaults to `true`. Invalid entries are skipped without blocking later valid entries.

Project commands receive the original OpenCode worktree root as `cwd`; only the deduplication key normalizes that root. Global commands inherit OpenCode's current directory. Both inherit its environment and `PATH`.

## Lifecycle and deduplication

- Global commands are loaded before project commands; array order is preserved within each scope.
- One missing or invalid scope does not block valid commands from the other scope.
- Duplicate identity is the exact `executable` plus the ordered `args`; `name` is not part of identity.
- An exact global duplicate takes precedence over a project command.
- A global identity starts once per OpenCode process.
- A project identity starts once per normalized project root per OpenCode process.
- Identity is recorded before spawning, so failed launches are not retried in that process.
- Process identity is the exact `executable` plus the ordered `args`; project identities also include the normalized root, while global identities are shared by OpenCode instances in one process. `name`, `onExistingProcess`, and `stopOnExit` are not part of identity.
- Same-batch duplicate removal is global-first and first within each scope: the global/first duplicate winner supplies both policies before policy evaluation.
- `start` appends one additional, separately owned process record.
- `skip` attaches the current owner to and reuses the oldest active process record without changing its creation-time `stopOnExit`.
- `restart` stops all plugin-owned tracked records, regardless of `stopOnExit`, and starts one replacement only after complete confirmed cleanup.
- For `stopOnExit: true`, project ownership uses the normalized root and cleanup waits for that project's final owner; global cleanup waits for the final OpenCode plugin-instance owner.
- `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.
- Bare `opencode serve` does not launch commands until a project or directory initializes the plugin.

Fully restart OpenCode after changing registration, revision, or command files. Restarting reloads configuration and clears process-wide launch identities.
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.

### Process stopping

- On POSIX, process-group cleanup sends `SIGTERM`, waits 5 seconds, then sends `SIGKILL`.
- On Windows, the plugin directly invokes trusted `taskkill.exe /T` and waits 5 seconds; only if the tree still remains does it invoke `taskkill.exe /T /F` for forced cleanup.
- 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.

## Security

Expand All @@ -99,7 +113,7 @@ Events appear as `startup-commands:` messages on OpenCode's stderr and in:
| macOS | `~/Library/Logs/OpenCode/opencode-startup-commands.log` |
| Linux | `$XDG_STATE_HOME/opencode/log/opencode-startup-commands.log`, or `~/.local/state/opencode/log/opencode-startup-commands.log` |

The active file rotates at 1 MiB and retains one `.1` archive. Logging failures do not prevent command startup. Logs omit configuration and executable paths, arguments, environment variables, raw configuration, and raw errors; they retain only sanitized lifecycle context such as scope, safe display name, PID, exit code, and signal.
The active file rotates at 1 MiB and retains one `.1` archive. Logging failures do not prevent command startup. Logs omit configuration and executable paths, arguments, environment variables, raw configuration, and raw errors; they retain only sanitized lifecycle context such as scope, safe display name, PID, exit code, and signal. Stop logging uses sanitized requested, forced, and failed stop events without process commands or raw errors.

## Development

Expand Down
3 changes: 3 additions & 0 deletions dist/config.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export type ConfigScope = "global" | "project";
export type OnExistingProcessPolicy = "start" | "skip" | "restart";
interface ConfiguredCommandFields {
name: string;
executable: string;
args: string[];
onExistingProcess: OnExistingProcessPolicy;
stopOnExit: boolean;
index: number;
}
export type ConfiguredCommand = (ConfiguredCommandFields & {
Expand Down
2 changes: 1 addition & 1 deletion dist/config.d.ts.map

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

11 changes: 10 additions & 1 deletion dist/config.js

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

2 changes: 1 addition & 1 deletion dist/config.js.map

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

46 changes: 41 additions & 5 deletions dist/core.d.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,63 @@
import type { ConfiguredCommand } from "./config.js";
import type { Logger } from "./logger.js";
export interface StartupState {
started: Set<string>;
}
import type { ProcessTreeController, ProcessTreeStopResult } from "./process-tree.js";
export interface SpawnedChild {
readonly pid?: number;
once(event: "error", listener: (error: Error) => void): SpawnedChild;
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): SpawnedChild;
unref(): void;
}
type OwnerToken = symbol;
type RetryTombstone = "spawn-failed" | "natural-exit";
type IdentityStatus = "stable" | "restarting" | "degraded";
type ProcessRecordStatus = "active" | "stopping";
interface ManagedCommandContext {
scope: "global" | "project";
index: number;
name: string;
}
interface ProcessRecord {
child: SpawnedChild;
pid?: number;
context: ManagedCommandContext;
creationOrder: number;
owners: Set<OwnerToken>;
stopOnExit: boolean;
status: ProcessRecordStatus;
stopPromise?: Promise<ProcessTreeStopResult>;
rootExited: boolean;
}
interface IdentityEntry {
processKey: string;
records: ProcessRecord[];
retryTombstone?: RetryTombstone;
cleanupUnconfirmed: boolean;
status: IdentityStatus;
transitionTail: Promise<void>;
pendingTransitions: number;
nextCreationOrder: number;
}
export type StartupState = Map<string, IdentityEntry>;
export interface StartupSpawnOptions {
cwd?: string;
detached: false;
detached: true;
shell: false;
stdio: "ignore";
windowsHide: true;
}
export type SpawnFunction = (command: string, args: string[], options: StartupSpawnOptions) => SpawnedChild;
export interface StartupDependencies {
spawn: SpawnFunction;
state: StartupState;
processTree: ProcessTreeController;
logger: Logger;
}
export interface StartupActivation {
dispose(): Promise<void>;
}
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): void;
export declare function runStartupCommands(commands: readonly ConfiguredCommand[], dependencies: StartupDependencies): Promise<StartupActivation>;
export {};
//# sourceMappingURL=core.d.ts.map
Loading
Loading