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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { buildWakeMessage } from "./contract.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
type PromptPart = { type: "text"; text: string };
|
|
5
|
+
|
|
6
|
+
export type PathBodyPromptAsyncArgs = {
|
|
7
|
+
path: { id: string };
|
|
8
|
+
body: {
|
|
9
|
+
parts: PromptPart[];
|
|
10
|
+
noReply: false;
|
|
11
|
+
model?: { providerID: string; modelID: string };
|
|
12
|
+
};
|
|
13
|
+
query: { directory: string };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PromptAsyncArgs = PathBodyPromptAsyncArgs;
|
|
17
|
+
|
|
18
|
+
type PromptAsyncResult = { error?: unknown } | undefined;
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return value !== null && typeof value === "object";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WakeDispatcherClient = {
|
|
25
|
+
session: {
|
|
26
|
+
messages(args: {
|
|
27
|
+
path: { id: string };
|
|
28
|
+
query: { directory: string };
|
|
29
|
+
}): Promise<unknown>;
|
|
30
|
+
promptAsync(args: PromptAsyncArgs): Promise<PromptAsyncResult>;
|
|
31
|
+
status?(args: { query: { directory: string } }): Promise<unknown>;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type WakeDispatcherOpts = {
|
|
36
|
+
client: WakeDispatcherClient;
|
|
37
|
+
directory: string;
|
|
38
|
+
/** Delays applied before retry attempts 1, 2, 3 (attempt 0 has zero delay). */
|
|
39
|
+
backoffMs: [number, number, number];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WakeDispatcher sends a chat.message-style wake to the parent session
|
|
44
|
+
* when subagent activity has been observed.
|
|
45
|
+
*
|
|
46
|
+
* Retries with exponential backoff (1s / 3s / 9s default) up to 3 retries
|
|
47
|
+
* (4 attempts total) before giving up. Survives `promptAsync` network
|
|
48
|
+
* failures without propagating the error to the plugin runtime.
|
|
49
|
+
*/
|
|
50
|
+
export class WakeDispatcher {
|
|
51
|
+
private readonly client: WakeDispatcherClient;
|
|
52
|
+
private readonly directory: string;
|
|
53
|
+
private readonly backoffMs: [number, number, number];
|
|
54
|
+
|
|
55
|
+
constructor(opts: WakeDispatcherOpts) {
|
|
56
|
+
this.client = opts.client;
|
|
57
|
+
this.directory = opts.directory;
|
|
58
|
+
this.backoffMs = opts.backoffMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async prompt(
|
|
62
|
+
parentSessionID: string,
|
|
63
|
+
parts: PromptPart[],
|
|
64
|
+
model?: { providerID: string; modelID: string },
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const response = await this.client.session.promptAsync({
|
|
67
|
+
path: { id: parentSessionID },
|
|
68
|
+
body: { parts, noReply: false, ...(model ? { model } : {}) },
|
|
69
|
+
query: { directory: this.directory },
|
|
70
|
+
});
|
|
71
|
+
if (response?.error !== undefined && response.error !== null) {
|
|
72
|
+
throw response.error instanceof Error ? response.error : new Error(String(response.error));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async resolveParentModel(
|
|
77
|
+
parentSessionID: string,
|
|
78
|
+
): Promise<{ providerID: string; modelID: string } | undefined> {
|
|
79
|
+
try {
|
|
80
|
+
const response = await this.client.session.messages({
|
|
81
|
+
path: { id: parentSessionID },
|
|
82
|
+
query: { directory: this.directory },
|
|
83
|
+
});
|
|
84
|
+
const messages = isRecord(response) && Array.isArray(response["data"])
|
|
85
|
+
? response["data"]
|
|
86
|
+
: Array.isArray(response)
|
|
87
|
+
? response
|
|
88
|
+
: [];
|
|
89
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
90
|
+
const message = messages[index];
|
|
91
|
+
const info = isRecord(message) ? message["info"] : undefined;
|
|
92
|
+
if (!isRecord(info) || info["role"] !== "assistant") continue;
|
|
93
|
+
const providerID = info["providerID"];
|
|
94
|
+
const modelID = info["modelID"];
|
|
95
|
+
if (typeof providerID === "string" && typeof modelID === "string") {
|
|
96
|
+
return { providerID, modelID };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
logger.debug("parent model lookup failed; preserving fallback wake", {
|
|
101
|
+
parentID: parentSessionID,
|
|
102
|
+
error: error instanceof Error ? error.message : String(error),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async parentIsActive(parentSessionID: string): Promise<boolean> {
|
|
109
|
+
if (!this.client.session.status) return false;
|
|
110
|
+
try {
|
|
111
|
+
const response = await this.client.session.status({ query: { directory: this.directory } });
|
|
112
|
+
const statuses = isRecord(response) && isRecord(response["data"])
|
|
113
|
+
? response["data"]
|
|
114
|
+
: response;
|
|
115
|
+
const status = isRecord(statuses) ? statuses[parentSessionID] : undefined;
|
|
116
|
+
const type = isRecord(status) ? status["type"] : undefined;
|
|
117
|
+
return type === "busy" || type === "retry" || type === "running";
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.debug("parent status lookup failed; preserving fallback wake", {
|
|
120
|
+
parentID: parentSessionID,
|
|
121
|
+
error: error instanceof Error ? error.message : String(error),
|
|
122
|
+
});
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async dispatch(parentSessionID: string, childSessionIDs: string[]): Promise<void> {
|
|
128
|
+
if (await this.parentIsActive(parentSessionID)) {
|
|
129
|
+
logger.debug("skipping fallback wake; parent already active", { parentID: parentSessionID });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
logger.info("wake fired", {
|
|
133
|
+
parentID: parentSessionID,
|
|
134
|
+
childCount: childSessionIDs.length,
|
|
135
|
+
sessionIDs: childSessionIDs,
|
|
136
|
+
});
|
|
137
|
+
const msg = buildWakeMessage({
|
|
138
|
+
parentSessionID,
|
|
139
|
+
childSessionIDs,
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
const model = await this.resolveParentModel(parentSessionID);
|
|
143
|
+
const delays = [0, this.backoffMs[0], this.backoffMs[1], this.backoffMs[2]] as const;
|
|
144
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
145
|
+
if (delays[attempt]! > 0) {
|
|
146
|
+
await new Promise<void>((r) => setTimeout(r, delays[attempt]!));
|
|
147
|
+
}
|
|
148
|
+
logger.debug("dispatch attempt", {
|
|
149
|
+
parentID: parentSessionID,
|
|
150
|
+
attempt,
|
|
151
|
+
childCount: childSessionIDs.length,
|
|
152
|
+
willRetry: attempt < delays.length - 1,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
await this.prompt(parentSessionID, msg.parts, model);
|
|
156
|
+
return;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (attempt === delays.length - 1) {
|
|
159
|
+
logger.error("dispatch gave up after 4 attempts", {
|
|
160
|
+
parentID: parentSessionID,
|
|
161
|
+
error: e instanceof Error ? e.message : String(e),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|