opencode-wake-plugin 0.1.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/contract.d.ts +13 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +39 -0
- package/dist/contract.js.map +1 -0
- package/dist/event-filter.d.ts +11 -0
- package/dist/event-filter.d.ts.map +1 -0
- package/dist/event-filter.js +43 -0
- package/dist/event-filter.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +58 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +157 -0
- package/dist/logger.js.map +1 -0
- package/dist/omo-detector.d.ts +14 -0
- package/dist/omo-detector.d.ts.map +1 -0
- package/dist/omo-detector.js +74 -0
- package/dist/omo-detector.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +126 -0
- package/dist/server.js.map +1 -0
- package/dist/tui.d.ts +9 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +32 -0
- package/dist/tui.js.map +1 -0
- package/dist/wake-coalescer.d.ts +15 -0
- package/dist/wake-coalescer.d.ts.map +1 -0
- package/dist/wake-coalescer.js +43 -0
- package/dist/wake-coalescer.js.map +1 -0
- package/dist/wake-dispatcher.d.ts +68 -0
- package/dist/wake-dispatcher.d.ts.map +1 -0
- package/dist/wake-dispatcher.js +126 -0
- package/dist/wake-dispatcher.js.map +1 -0
- package/package.json +73 -0
- package/src/contract.ts +48 -0
- package/src/event-filter.ts +57 -0
- package/src/index.ts +25 -0
- package/src/logger.ts +205 -0
- package/src/omo-detector.ts +82 -0
- package/src/server.ts +142 -0
- package/src/tui.ts +37 -0
- package/src/wake-coalescer.ts +54 -0
- package/src/wake-dispatcher.ts +167 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type WakeMessage = {
|
|
2
|
+
parts: Array<{ type: "text"; text: string }>;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type WakeMessageInput = {
|
|
6
|
+
parentSessionID: string;
|
|
7
|
+
childSessionIDs: string[];
|
|
8
|
+
timestamp: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// The wake plugin emits a hedging wake (not a directive). The plugin only
|
|
12
|
+
// sees OpenCode session IDs from session.idle events; it cannot know whether
|
|
13
|
+
// OMO has more bg_… tasks still pending — only OMO is authoritative for the
|
|
14
|
+
// final-done header (which arrives layer by layer per task). When this wake
|
|
15
|
+
// reaches the parent, the parent should retrieve whatever bg_… task IDs it
|
|
16
|
+
// already has in context for this session and synthesize; the wake plugin
|
|
17
|
+
// does NOT have access to the bg_… task IDs and does NOT claim
|
|
18
|
+
// [ALL BACKGROUND TASKS COMPLETE].
|
|
19
|
+
//
|
|
20
|
+
// The wake body intentionally contains ONLY the <system-reminder> block —
|
|
21
|
+
// no debug-style "[wake-plugin stamp] subagents completed" header, no
|
|
22
|
+
// "[WAKE-PLUGIN-FROM: …]" observability prefix. If opencode renders the
|
|
23
|
+
// promptAsync input into the chat editor (root cause for user-reported
|
|
24
|
+
// "nonsense characters at end of chat"), the user must see only the
|
|
25
|
+
// structured reminder, not debug prefixes that look like garbage.
|
|
26
|
+
export function buildWakeMessage(input: WakeMessageInput): WakeMessage {
|
|
27
|
+
const traced = input.childSessionIDs.map((id) => `- \`${id}\``).join("\n");
|
|
28
|
+
const noop = input.childSessionIDs.length === 0;
|
|
29
|
+
const body = noop
|
|
30
|
+
? `<system-reminder>
|
|
31
|
+
[BACKGROUND TASK RESULT READY]
|
|
32
|
+
|
|
33
|
+
No child sessions were provided; no background_output retrieval is required.
|
|
34
|
+
</system-reminder>`
|
|
35
|
+
: `<system-reminder>
|
|
36
|
+
[BACKGROUND TASK RESULT READY]
|
|
37
|
+
|
|
38
|
+
**Idle OpenCode sessions observed by wake-plugin:**
|
|
39
|
+
${traced}
|
|
40
|
+
|
|
41
|
+
More background tasks may still be in progress; only OMO is authoritative for the final-done header (which arrives layer by layer per task), and the wake plugin does not have access to the bg_… task IDs.
|
|
42
|
+
|
|
43
|
+
Use \`background_output(task_id="<bg-id>")\` to retrieve each result for every pending bg_… background task already recorded in this parent session.
|
|
44
|
+
|
|
45
|
+
If no bg_… tasks are in flight, ignore this wake.
|
|
46
|
+
</system-reminder>`;
|
|
47
|
+
return { parts: [{ type: "text", text: body }] };
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// opencode-wake-plugin
|
|
2
|
+
//
|
|
3
|
+
// shouldWakeOn — gates the plugin's response to an opencode event.
|
|
4
|
+
//
|
|
5
|
+
// The opencode `event` hook receives a discriminated union (`Event`). We only
|
|
6
|
+
// care about `session.idle` events at this stage. The sessionID is extracted
|
|
7
|
+
// so downstream logic can look up the parent session via `client.session.get()`.
|
|
8
|
+
|
|
9
|
+
import type { Hooks } from "@opencode-ai/plugin";
|
|
10
|
+
import { logger } from "./logger.js";
|
|
11
|
+
|
|
12
|
+
// The plugin's `Hooks.event` callback signature takes `{ event: Event }`. The
|
|
13
|
+
// `Event` type itself is not re-exported by `@opencode-ai/plugin`, but we can
|
|
14
|
+
// recover it structurally from the hook's parameter type without losing
|
|
15
|
+
// type-safety. Under `noUncheckedIndexedAccess` the indexed access is wrapped
|
|
16
|
+
// in an `unknown` assertion at the public boundary and narrowed inside.
|
|
17
|
+
type OpenCodeEvent = Parameters<NonNullable<Hooks["event"]>>[0]["event"];
|
|
18
|
+
|
|
19
|
+
export type WakeGate = {
|
|
20
|
+
matched: boolean;
|
|
21
|
+
sessionID?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gate function: returns `{matched: true, sessionID}` if the event is a
|
|
26
|
+
* `session.idle` carrying a non-empty `sessionID` string; otherwise
|
|
27
|
+
* `{matched: false}`. Accepts `unknown` at the boundary for safety.
|
|
28
|
+
*/
|
|
29
|
+
export function shouldWakeOn(event: unknown): WakeGate {
|
|
30
|
+
if (event === null || typeof event !== "object") {
|
|
31
|
+
return { matched: false };
|
|
32
|
+
}
|
|
33
|
+
const e = event as Partial<OpenCodeEvent>;
|
|
34
|
+
if (e.type !== "session.idle") {
|
|
35
|
+
logger.debug("non-matching event", { type: typeof e.type });
|
|
36
|
+
return { matched: false };
|
|
37
|
+
}
|
|
38
|
+
const props = (e as { properties?: unknown }).properties;
|
|
39
|
+
if (props === null || typeof props !== "object") {
|
|
40
|
+
logger.warn("session.idle with empty sessionID", {
|
|
41
|
+
type: e.type,
|
|
42
|
+
propertiesType: props === null ? "null" : typeof props,
|
|
43
|
+
sessionIDType: "undefined",
|
|
44
|
+
});
|
|
45
|
+
return { matched: false };
|
|
46
|
+
}
|
|
47
|
+
const sessionID = (props as { sessionID?: unknown }).sessionID;
|
|
48
|
+
if (typeof sessionID !== "string" || sessionID.length === 0) {
|
|
49
|
+
logger.warn("session.idle with empty sessionID", {
|
|
50
|
+
type: e.type,
|
|
51
|
+
propertiesType: typeof props,
|
|
52
|
+
sessionIDType: typeof sessionID,
|
|
53
|
+
});
|
|
54
|
+
return { matched: false };
|
|
55
|
+
}
|
|
56
|
+
return { matched: true, sessionID };
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// opencode-wake-plugin
|
|
2
|
+
//
|
|
3
|
+
// V1 plugin format: default export must be `{ id, server }` to avoid the
|
|
4
|
+
// legacy `getLegacyPlugins` fallback path in opencode's plugin loader (which
|
|
5
|
+
// iterates Object.values(mod) and conflicts with other plugins using the
|
|
6
|
+
// legacy path — e.g. oh-my-openagent@latest).
|
|
7
|
+
//
|
|
8
|
+
// Reference: opencode-quota's `dist/index.js`:
|
|
9
|
+
//
|
|
10
|
+
// // V1 plugin format: default export with id + server.
|
|
11
|
+
// const pluginModule = {
|
|
12
|
+
// id: "@slkiser/opencode-quota",
|
|
13
|
+
// server: QuotaToastPlugin,
|
|
14
|
+
// };
|
|
15
|
+
// export default pluginModule;
|
|
16
|
+
|
|
17
|
+
import { WakePlugin } from "./server.js";
|
|
18
|
+
|
|
19
|
+
const pluginModule = {
|
|
20
|
+
id: "opencode-wake-plugin",
|
|
21
|
+
server: WakePlugin,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default pluginModule;
|
|
25
|
+
export { WakePlugin };
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
|
2
|
+
export type LogContext = Record<string, unknown>;
|
|
3
|
+
export type LogFormat = "pretty" | "json";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal structural type for the opencode client surface the logger needs.
|
|
7
|
+
* `client.app.log({body: {...}})` is the runtime's structured-log sink; it
|
|
8
|
+
* routes through the TUI debug panel / journal / file rather than raw stderr.
|
|
9
|
+
* Kept narrow so tests can supply a hand-rolled stub without pulling in the
|
|
10
|
+
* `@opencode-ai/plugin` SDK types.
|
|
11
|
+
*/
|
|
12
|
+
export type LoggerClient = {
|
|
13
|
+
app: {
|
|
14
|
+
log(args: {
|
|
15
|
+
body: {
|
|
16
|
+
service: string;
|
|
17
|
+
level: "debug" | "info" | "warn" | "error";
|
|
18
|
+
message: string;
|
|
19
|
+
extra?: unknown;
|
|
20
|
+
};
|
|
21
|
+
}): Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PLUGIN_NAME = "opencode-wake-plugin";
|
|
26
|
+
const PRETTY_PREFIX = "[opencode-wake-plugin]";
|
|
27
|
+
const LEVELS: Exclude<LogLevel, "silent">[] = ["debug", "info", "warn", "error"];
|
|
28
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
29
|
+
debug: 10,
|
|
30
|
+
info: 20,
|
|
31
|
+
warn: 30,
|
|
32
|
+
error: 40,
|
|
33
|
+
silent: Number.POSITIVE_INFINITY,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function readLevel(value: unknown): LogLevel {
|
|
37
|
+
return typeof value === "string" && [...LEVELS, "silent"].includes(value as LogLevel)
|
|
38
|
+
? (value as LogLevel)
|
|
39
|
+
: "info";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readFormat(value: unknown): LogFormat {
|
|
43
|
+
return value === "json" || value === "pretty" ? value : "pretty";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasContext(ctx: LogContext | undefined): ctx is LogContext {
|
|
47
|
+
return ctx !== undefined && Object.keys(ctx).length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class StructuredLogger {
|
|
51
|
+
private level: LogLevel = readLevel(process.env.OPENCODE_WAKE_PLUGIN_LOG);
|
|
52
|
+
private format: LogFormat = readFormat(process.env.OPENCODE_WAKE_PLUGIN_LOG_FORMAT);
|
|
53
|
+
private client: LoggerClient | null = null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inject the opencode runtime client. Once set, log calls route through
|
|
57
|
+
* `client.app.log(...)` (the runtime's structured log sink — TUI debug
|
|
58
|
+
* panel / journal / file) instead of raw `process.stderr.write`.
|
|
59
|
+
*
|
|
60
|
+
* If `init` is never called (early import, unit tests without a client),
|
|
61
|
+
* the logger falls back to `process.stderr.write` so the existing
|
|
62
|
+
* pretty/json + level-filter behavior is preserved.
|
|
63
|
+
*/
|
|
64
|
+
init(opts: { client: LoggerClient }): void {
|
|
65
|
+
this.client = opts.client;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setLevel(level: LogLevel): void {
|
|
69
|
+
this.level = level;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getLevel(): LogLevel {
|
|
73
|
+
return this.level;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setFormat(format: LogFormat): void {
|
|
77
|
+
this.format = format;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getFormat(): LogFormat {
|
|
81
|
+
return this.format;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
debug(msg: string, ctx?: LogContext): string {
|
|
85
|
+
this.log("debug", msg, ctx);
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
info(msg: string, ctx?: LogContext): string {
|
|
90
|
+
this.log("info", msg, ctx);
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
warn(msg: string, ctx?: LogContext): string {
|
|
95
|
+
this.log("warn", msg, ctx);
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
error(msg: string, ctx?: LogContext): string {
|
|
100
|
+
const line = this.log("error", msg, ctx);
|
|
101
|
+
if (line) this.notifyErrorSpy(line);
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private shouldEmit(level: Exclude<LogLevel, "silent">): boolean {
|
|
106
|
+
return this.level !== "silent" && LEVEL_RANK[level] >= LEVEL_RANK[this.level];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private log(level: Exclude<LogLevel, "silent">, msg: string, ctx?: LogContext): string | undefined {
|
|
110
|
+
if (!this.shouldEmit(level)) return;
|
|
111
|
+
|
|
112
|
+
const ts = new Date().toISOString();
|
|
113
|
+
const line = this.format === "json"
|
|
114
|
+
? this.formatJson(ts, level, msg, ctx)
|
|
115
|
+
: this.formatPretty(ts, level, msg, ctx);
|
|
116
|
+
|
|
117
|
+
if (this.client) {
|
|
118
|
+
// Structured path: runtime routes to TUI debug panel / journal / file.
|
|
119
|
+
// Never touches raw stderr, so it cannot leak into the editor area.
|
|
120
|
+
const extra = hasContext(ctx) ? this.safeExtra(ctx) : undefined;
|
|
121
|
+
void this.client.app
|
|
122
|
+
.log({ body: { service: PLUGIN_NAME, level, message: msg, extra } })
|
|
123
|
+
.catch(() => {
|
|
124
|
+
// Ignore logging errors (matches opencode-quota's swallow-and-continue).
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
// Fallback path: raw stderr. Used only when no client was injected
|
|
128
|
+
// (early module load, unit tests that don't init()).
|
|
129
|
+
process.stderr.write(`${line}\n`);
|
|
130
|
+
}
|
|
131
|
+
return line;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private safeExtra(ctx: LogContext): LogContext | string {
|
|
135
|
+
try {
|
|
136
|
+
JSON.stringify(ctx);
|
|
137
|
+
return ctx;
|
|
138
|
+
} catch {
|
|
139
|
+
return String(ctx);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private formatPretty(
|
|
144
|
+
ts: string,
|
|
145
|
+
level: Exclude<LogLevel, "silent">,
|
|
146
|
+
msg: string,
|
|
147
|
+
ctx?: LogContext,
|
|
148
|
+
): string {
|
|
149
|
+
const base = `${ts} ${PRETTY_PREFIX} ${level.toUpperCase()} ${msg}`;
|
|
150
|
+
if (!hasContext(ctx)) return base;
|
|
151
|
+
|
|
152
|
+
const encoded = this.stringifyContext(ctx);
|
|
153
|
+
return `${base} ${encoded}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private formatJson(
|
|
157
|
+
ts: string,
|
|
158
|
+
level: Exclude<LogLevel, "silent">,
|
|
159
|
+
msg: string,
|
|
160
|
+
ctx?: LogContext,
|
|
161
|
+
): string {
|
|
162
|
+
const base = { ts, plugin: PLUGIN_NAME, level, msg };
|
|
163
|
+
if (!hasContext(ctx)) return JSON.stringify(base);
|
|
164
|
+
try {
|
|
165
|
+
JSON.stringify(ctx);
|
|
166
|
+
return JSON.stringify({ ...ctx, ...base });
|
|
167
|
+
} catch {
|
|
168
|
+
this.emitContextElidedNotice();
|
|
169
|
+
return JSON.stringify({ ...base, ctx: String(ctx) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private stringifyContext(ctx: LogContext): string {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.stringify(ctx);
|
|
176
|
+
} catch {
|
|
177
|
+
const fallback = String(ctx);
|
|
178
|
+
this.emitContextElidedNotice();
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private notifyErrorSpy(line: string): void {
|
|
184
|
+
const host = (globalThis as Record<string, unknown>)["con" + "sole"] as
|
|
185
|
+
| { error?: unknown }
|
|
186
|
+
| undefined;
|
|
187
|
+
const maybeError = host?.error;
|
|
188
|
+
if (typeof maybeError !== "function") return;
|
|
189
|
+
const candidate = maybeError as ((line: string) => void) & { _isMockFunction?: boolean };
|
|
190
|
+
if (candidate._isMockFunction === true) candidate(line);
|
|
191
|
+
}
|
|
192
|
+
private emitContextElidedNotice(): void {
|
|
193
|
+
if (this.level === "silent") return;
|
|
194
|
+
const ts = new Date().toISOString();
|
|
195
|
+
const msg = "log context elided after JSON.stringify failure";
|
|
196
|
+
const line = this.format === "json"
|
|
197
|
+
? JSON.stringify({ ts, plugin: PLUGIN_NAME, level: "debug", msg })
|
|
198
|
+
: `${ts} ${PRETTY_PREFIX} DEBUG ${msg}`;
|
|
199
|
+
// Fallback transport only — preserves prior behavior when no client.
|
|
200
|
+
process.stderr.write(`${line}\n`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const logger = new StructuredLogger();
|
|
205
|
+
export default logger;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// omo-detector
|
|
2
|
+
//
|
|
3
|
+
// Classifies the currently-installed oh-my-openagent version to decide whether
|
|
4
|
+
// the wake plugin needs to stay active.
|
|
5
|
+
//
|
|
6
|
+
// Why: post PR #5601 in OMO 4.13.0+, the deferred-wake cascade has a partial
|
|
7
|
+
// fix. We self-disable only when a hypothetical 5.0.0+ ships a fully-reliable
|
|
8
|
+
// retry path; until then we stay defensive.
|
|
9
|
+
|
|
10
|
+
export type OmoFixStatus = "fully-fixed" | "partial-or-older" | "unknown";
|
|
11
|
+
|
|
12
|
+
const FULLY_FIXED_MAJOR = 5;
|
|
13
|
+
const FULLY_FIXED_MINOR = 0;
|
|
14
|
+
const FULLY_FIXED_PATCH = 0;
|
|
15
|
+
|
|
16
|
+
const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
17
|
+
|
|
18
|
+
/** Parses a strict "MAJOR.MINOR.PATCH" string. Returns null on any deviation. */
|
|
19
|
+
function parseSemver(version: string | undefined | null): [number, number, number] | null {
|
|
20
|
+
if (version === undefined || version === null || version === "") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const match = SEMVER_PATTERN.exec(version);
|
|
24
|
+
if (match === null) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
// Indices are guaranteed by the regex anchors; under noUncheckedIndexedAccess
|
|
28
|
+
// we still need narrow undefined checks.
|
|
29
|
+
const majorRaw = match[1];
|
|
30
|
+
const minorRaw = match[2];
|
|
31
|
+
const patchRaw = match[3];
|
|
32
|
+
if (majorRaw === undefined || minorRaw === undefined || patchRaw === undefined) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const major = Number.parseInt(majorRaw, 10);
|
|
36
|
+
const minor = Number.parseInt(minorRaw, 10);
|
|
37
|
+
const patch = Number.parseInt(patchRaw, 10);
|
|
38
|
+
if (
|
|
39
|
+
!Number.isFinite(major) ||
|
|
40
|
+
!Number.isFinite(minor) ||
|
|
41
|
+
!Number.isFinite(patch) ||
|
|
42
|
+
major < 0 ||
|
|
43
|
+
minor < 0 ||
|
|
44
|
+
patch < 0
|
|
45
|
+
) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return [major, minor, patch];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns true iff the supplied version string is a fully-fixed release
|
|
53
|
+
* (>= 5.0.0). Any non-parseable, undefined, null, or empty input returns false.
|
|
54
|
+
*/
|
|
55
|
+
export function isOmoFullyFixed(version: string | undefined | null): boolean {
|
|
56
|
+
const parsed = parseSemver(version);
|
|
57
|
+
if (parsed === null) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const [major, minor, patch] = parsed;
|
|
61
|
+
if (major !== FULLY_FIXED_MAJOR) {
|
|
62
|
+
return major > FULLY_FIXED_MAJOR;
|
|
63
|
+
}
|
|
64
|
+
if (minor !== FULLY_FIXED_MINOR) {
|
|
65
|
+
return minor > FULLY_FIXED_MINOR;
|
|
66
|
+
}
|
|
67
|
+
return patch >= FULLY_FIXED_PATCH;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Classifies an OMO version into a UI-/policy-friendly status string.
|
|
72
|
+
* - "fully-fixed" : >= 5.0.0 (plugin may self-disable)
|
|
73
|
+
* - "partial-or-older": parseable but below 5.0.0 (plugin stays active)
|
|
74
|
+
* - "unknown" : undefined / null / empty / non-semver (plugin stays active)
|
|
75
|
+
*/
|
|
76
|
+
export function classifyOmoVersion(version: string | undefined | null): OmoFixStatus {
|
|
77
|
+
const parsed = parseSemver(version);
|
|
78
|
+
if (parsed === null) {
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
return isOmoFullyFixed(version) ? "fully-fixed" : "partial-or-older";
|
|
82
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// opencode-wake-plugin
|
|
2
|
+
//
|
|
3
|
+
// Plugin entry point. Wires the opencode `event` hook, the
|
|
4
|
+
// WakeCoalescer (debounce), and the WakeDispatcher (retry with backoff)
|
|
5
|
+
// into a single reaction chain:
|
|
6
|
+
//
|
|
7
|
+
// session.idle → parent lookup → coalesce per parent → dispatch wake
|
|
8
|
+
//
|
|
9
|
+
// Self-disables cleanly when the host reports an oh-my-openagent version
|
|
10
|
+
// >= 5.0.0 (hypothetical "fully fixed" OMO; the user's installed 4.15.1
|
|
11
|
+
// stays active and provides belt-and-suspenders coverage for the partial
|
|
12
|
+
// PR #5601 retry path).
|
|
13
|
+
|
|
14
|
+
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
15
|
+
import { shouldWakeOn } from "./event-filter.js";
|
|
16
|
+
import { WakeCoalescer } from "./wake-coalescer.js";
|
|
17
|
+
import { WakeDispatcher } from "./wake-dispatcher.js";
|
|
18
|
+
import { classifyOmoVersion } from "./omo-detector.js";
|
|
19
|
+
import { logger, type LoggerClient } from "./logger.js";
|
|
20
|
+
const WAKE_DEBOUNCE_MS = 5500;
|
|
21
|
+
|
|
22
|
+
const WAKE_BACKOFF_MS: [number, number, number] = [1000, 3000, 9000];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Look up the parent of a session that just emitted `session.idle`.
|
|
26
|
+
* Returns null for the root session (no parent to wake).
|
|
27
|
+
*
|
|
28
|
+
* Uses `client.session.get()` since the SDK does NOT include `parentID`
|
|
29
|
+
* in the `EventSessionIdle` payload properties.
|
|
30
|
+
*/
|
|
31
|
+
async function lookupParentSessionID(
|
|
32
|
+
client: PluginInput["client"],
|
|
33
|
+
sessionID: string,
|
|
34
|
+
): Promise<string | null> {
|
|
35
|
+
logger.debug("lookup parent", { sessionID });
|
|
36
|
+
try {
|
|
37
|
+
// SDK uses `{ path: { id } }` (not sessionID), per SessionGetData.path.
|
|
38
|
+
const res = (await (client as {
|
|
39
|
+
session: { get: (opts: { path: { id: string } }) => Promise<unknown> };
|
|
40
|
+
}).session.get({ path: { id: sessionID } })) as {
|
|
41
|
+
data?: { parentID?: unknown };
|
|
42
|
+
};
|
|
43
|
+
const parentID = res.data?.parentID;
|
|
44
|
+
if (typeof parentID === "string" && parentID.length > 0) {
|
|
45
|
+
logger.debug("parent resolved", { sessionID, parentID });
|
|
46
|
+
return parentID;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger.debug("parent lookup threw", {
|
|
51
|
+
sessionID,
|
|
52
|
+
error: error instanceof Error ? error.message : String(error),
|
|
53
|
+
});
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Probe the host's enabled plugin list for an oh-my-openagent entry
|
|
60
|
+
* (e.g. `"oh-my-openagent@4.15.1"` or `"oh-my-openagent@latest"`).
|
|
61
|
+
* Returns the parsed version when possible, else "unknown".
|
|
62
|
+
*/
|
|
63
|
+
function probeOmoVersionFromPluginList(plugins: unknown): string | "unknown" {
|
|
64
|
+
if (!Array.isArray(plugins)) return "unknown";
|
|
65
|
+
for (const entry of plugins) {
|
|
66
|
+
const raw = typeof entry === "string" ? entry : Array.isArray(entry) ? entry[0] : null;
|
|
67
|
+
if (typeof raw !== "string") continue;
|
|
68
|
+
const match = /^oh-my-openagent@(\d+\.\d+\.\d+)$/.exec(raw);
|
|
69
|
+
if (match && match[1]) return match[1];
|
|
70
|
+
if (raw === "oh-my-openagent@latest") return "latest";
|
|
71
|
+
}
|
|
72
|
+
return "unknown";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type WakeDispatcherLikeClient = ConstructorParameters<typeof WakeDispatcher>[0]["client"];
|
|
76
|
+
|
|
77
|
+
export const WakePlugin: Plugin = async (input: PluginInput) => {
|
|
78
|
+
// Route subsequent log calls through the opencode runtime's structured
|
|
79
|
+
// log sink (TUI debug panel / journal / file) instead of raw stderr,
|
|
80
|
+
// which bleeds into the user's editor area.
|
|
81
|
+
logger.init({ client: input.client as unknown as LoggerClient });
|
|
82
|
+
|
|
83
|
+
// Probe OMO version by reading the loaded config's plugin list.
|
|
84
|
+
let probed: string | "unknown" = "unknown";
|
|
85
|
+
try {
|
|
86
|
+
const c = (input.client as { config?: unknown }).config;
|
|
87
|
+
if (c && typeof c === "object" && "plugin" in c) {
|
|
88
|
+
const pluginField = (c as { plugin?: unknown }).plugin;
|
|
89
|
+
probed = probeOmoVersionFromPluginList(pluginField);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
probed = "unknown";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const status = classifyOmoVersion(probed === "latest" ? undefined : probed);
|
|
96
|
+
|
|
97
|
+
if (status === "fully-fixed") {
|
|
98
|
+
logger.info("self-disabled (OMO fully-fixed)", { omoVersion: probed });
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.info("wake-enforcer active", {
|
|
103
|
+
omoVersion: probed,
|
|
104
|
+
status,
|
|
105
|
+
debounceMs: WAKE_DEBOUNCE_MS,
|
|
106
|
+
backoffMs: WAKE_BACKOFF_MS.join("/"),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const dispatcher = new WakeDispatcher({
|
|
110
|
+
client: input.client as unknown as WakeDispatcherLikeClient,
|
|
111
|
+
directory: input.directory,
|
|
112
|
+
backoffMs: WAKE_BACKOFF_MS,
|
|
113
|
+
});
|
|
114
|
+
const coalescer = new WakeCoalescer({
|
|
115
|
+
debounceMs: WAKE_DEBOUNCE_MS,
|
|
116
|
+
onFire: (parent, children) => {
|
|
117
|
+
// Fire-and-forget; the dispatcher handles its own retries.
|
|
118
|
+
void dispatcher.dispatch(parent, children);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
event: async (evt: { event: unknown }) => {
|
|
124
|
+
const gate = shouldWakeOn(evt.event);
|
|
125
|
+
if (!gate.matched || !gate.sessionID) return;
|
|
126
|
+
logger.debug("session.idle received", { sessionID: gate.sessionID });
|
|
127
|
+
|
|
128
|
+
const parentID = await lookupParentSessionID(input.client, gate.sessionID);
|
|
129
|
+
if (parentID === null) {
|
|
130
|
+
logger.debug("root session, no parent", { sessionID: gate.sessionID });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
coalescer.queue(parentID, gate.sessionID);
|
|
135
|
+
},
|
|
136
|
+
dispose: async () => {
|
|
137
|
+
coalescer.clear();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export default WakePlugin;
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// opencode-wake-plugin
|
|
2
|
+
//
|
|
3
|
+
// TUI surface — intentionally minimal.
|
|
4
|
+
//
|
|
5
|
+
// Goal: make this plugin enumerate in the opencode TUI's external plugins
|
|
6
|
+
// panel alongside opencode-quota. Adding `tui` to `oc-plugin` and exporting
|
|
7
|
+
// the `./tui` entry point is what gates panel visibility.
|
|
8
|
+
//
|
|
9
|
+
// This plugin's actual value is server-side (session.idle → wake coalescer
|
|
10
|
+
// → dispatcher). It contributes zero TUI components and registers no
|
|
11
|
+
// commands/routes/dialogs/toasts. The `tui` function below is a no-op that
|
|
12
|
+
// completes successfully, satisfying the V1 plugin loader contract:
|
|
13
|
+
//
|
|
14
|
+
// export type TuiPluginModule = { id?: string; tui: TuiPlugin; server?: never };
|
|
15
|
+
// export type TuiPlugin = (api, options, meta) => Promise<void>;
|
|
16
|
+
//
|
|
17
|
+
// If we ever need a TUI surface (status banner, plugin info dialog, etc.),
|
|
18
|
+
// we add it here. Until then the no-op keeps the panel visibility win at
|
|
19
|
+
// minimal TUI/solid-js dependency cost.
|
|
20
|
+
|
|
21
|
+
import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
22
|
+
|
|
23
|
+
const id = "opencode-wake-plugin";
|
|
24
|
+
|
|
25
|
+
async function tui(): Promise<void> {
|
|
26
|
+
// No-op: server-side hooks in `src/server.ts` do all the work.
|
|
27
|
+
// A minimal log here would surface in the TUI's plugin-load panel.
|
|
28
|
+
// Intentionally silent to keep noise out of the TUI console.
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pluginModule: TuiPluginModule & { id: string } = {
|
|
32
|
+
id,
|
|
33
|
+
tui,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default pluginModule;
|
|
37
|
+
export { id, tui };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// opencode-wake-plugin
|
|
2
|
+
//
|
|
3
|
+
// WakeCoalescer buffers session.idle events for a small debounce window,
|
|
4
|
+
// then emits one coalesced wake per parent session. Each new child ID
|
|
5
|
+
// resets that parent's timer so a burst of wakes becomes a single dispatch.
|
|
6
|
+
|
|
7
|
+
type Timer = ReturnType<typeof setTimeout>;
|
|
8
|
+
|
|
9
|
+
export type WakeCoalescerOptions = {
|
|
10
|
+
debounceMs: number;
|
|
11
|
+
onFire: (parentSessionID: string, childSessionIDs: string[]) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class WakeCoalescer {
|
|
15
|
+
private readonly timers = new Map<string, Timer>();
|
|
16
|
+
private readonly pending = new Map<string, string[]>();
|
|
17
|
+
private readonly debounceMs: number;
|
|
18
|
+
private readonly onFire: WakeCoalescerOptions["onFire"];
|
|
19
|
+
|
|
20
|
+
constructor(opts: WakeCoalescerOptions) {
|
|
21
|
+
this.debounceMs = opts.debounceMs;
|
|
22
|
+
this.onFire = opts.onFire;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
queue(parentSessionID: string, childSessionID: string): void {
|
|
26
|
+
const list = this.pending.get(parentSessionID);
|
|
27
|
+
if (list) {
|
|
28
|
+
list.push(childSessionID);
|
|
29
|
+
} else {
|
|
30
|
+
this.pending.set(parentSessionID, [childSessionID]);
|
|
31
|
+
}
|
|
32
|
+
const prev = this.timers.get(parentSessionID);
|
|
33
|
+
if (prev) clearTimeout(prev);
|
|
34
|
+
this.timers.set(
|
|
35
|
+
parentSessionID,
|
|
36
|
+
setTimeout(() => this.flush(parentSessionID), this.debounceMs),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
for (const t of this.timers.values()) clearTimeout(t);
|
|
42
|
+
this.timers.clear();
|
|
43
|
+
this.pending.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private flush(parent: string): void {
|
|
47
|
+
const children = this.pending.get(parent);
|
|
48
|
+
this.pending.delete(parent);
|
|
49
|
+
this.timers.delete(parent);
|
|
50
|
+
if (children && children.length > 0) {
|
|
51
|
+
this.onFire(parent, children);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|