@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1
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 +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +114 -0
- package/install +75 -0
- package/package.json +94 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1047 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +654 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1304 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for the host boundary.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS FOR. OpenCode does not protect itself from a plugin: a hook that
|
|
5
|
+
* throws fails the request, and a hook that never settles leaves the editor
|
|
6
|
+
* waiting forever. Verified against a real OpenCode process on 2026-11-06, in
|
|
7
|
+
* `scripts/isolated-e2e.sh` -- see the `hook-throws` and `hook-hangs`
|
|
8
|
+
* scenarios.
|
|
9
|
+
*
|
|
10
|
+
* That makes "the host is robust" an assumption this plugin cannot rely on.
|
|
11
|
+
* The only place the guarantee can come from is here: whatever the router does
|
|
12
|
+
* internally, the function OpenCode calls must return, and must not throw.
|
|
13
|
+
*
|
|
14
|
+
* TWO RULES.
|
|
15
|
+
*
|
|
16
|
+
* 1. A hook never propagates an exception. The prompt is served unrouted, which
|
|
17
|
+
* is exactly what would have happened if the plugin were not installed.
|
|
18
|
+
*
|
|
19
|
+
* 2. A hook never waits forever. Routing runs against a deadline; if the router
|
|
20
|
+
* is still working when it expires, the prompt proceeds without it. The
|
|
21
|
+
* router is an optimization, and an optimization that cannot finish is
|
|
22
|
+
* worth nothing -- but only if it gets out of the way.
|
|
23
|
+
*
|
|
24
|
+
* This module has no dependencies and no state, so it cannot be part of the
|
|
25
|
+
* thing that fails.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The narrow shape a contained hook needs from the plugin's own error type. */
|
|
29
|
+
interface BlockedLike {
|
|
30
|
+
readonly name?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isBlocked = (error: unknown): boolean =>
|
|
34
|
+
typeof error === "object" &&
|
|
35
|
+
error !== null &&
|
|
36
|
+
((error as BlockedLike).name === "RouterBlockedError" || (error as any).routerBlocked === true)
|
|
37
|
+
|
|
38
|
+
export interface ContainOptions {
|
|
39
|
+
/** What to call this hook in a diagnostic. Never user-facing. */
|
|
40
|
+
readonly label: string
|
|
41
|
+
/** Called with the reason a hook was contained. Must not throw. */
|
|
42
|
+
readonly onContained?: (message: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* Whether an exception is a deliberate refusal that must reach the host.
|
|
45
|
+
* Deliberate refusals are the one thing that may still stop a prompt,
|
|
46
|
+
* because they are a decision rather than a defect.
|
|
47
|
+
*/
|
|
48
|
+
readonly allowBlocked?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const describe = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a hook so it can never throw and never hang.
|
|
55
|
+
*
|
|
56
|
+
* The returned function always resolves. A `RouterBlockedError` is re-thrown
|
|
57
|
+
* when `allowBlocked` is set, because that is the router saying "I refuse",
|
|
58
|
+
* which is a supported outcome. Everything else is a defect, and a defect
|
|
59
|
+
* leaves the prompt alone.
|
|
60
|
+
*/
|
|
61
|
+
export const containHook = <TEvent>(
|
|
62
|
+
handler: (event: TEvent) => Promise<unknown> | unknown,
|
|
63
|
+
options: ContainOptions,
|
|
64
|
+
): ((event: TEvent) => Promise<void>) => {
|
|
65
|
+
const report = (message: string) => {
|
|
66
|
+
try {
|
|
67
|
+
options.onContained?.(message)
|
|
68
|
+
} catch {
|
|
69
|
+
// A diagnostic sink that throws must not resurrect the failure.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return async (event: TEvent): Promise<void> => {
|
|
74
|
+
try {
|
|
75
|
+
await handler(event)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (options.allowBlocked && isBlocked(error)) throw error
|
|
78
|
+
report(`${options.label}: ${describe(error)}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run work against a deadline, resolving to `undefined` when it is exceeded.
|
|
85
|
+
*
|
|
86
|
+
* `Promise.race` would leave the slow work running and unobserved, which can
|
|
87
|
+
* produce an unhandled rejection later and, worse, keep doing work whose result
|
|
88
|
+
* nobody will read. This variant attaches handlers to the original promise so a
|
|
89
|
+
* late rejection is swallowed deliberately, and reports the timeout so the
|
|
90
|
+
* owner can see that routing was skipped.
|
|
91
|
+
*
|
|
92
|
+
* The timer is cleared on the normal path: a deadline that has already been met
|
|
93
|
+
* must not keep the process alive or fire against a later request.
|
|
94
|
+
*/
|
|
95
|
+
export const withDeadline = async <T>(input: {
|
|
96
|
+
readonly work: () => Promise<T> | T
|
|
97
|
+
readonly ms: number
|
|
98
|
+
readonly onTimeout: () => void
|
|
99
|
+
}): Promise<T | undefined> => {
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
101
|
+
let settled = false
|
|
102
|
+
|
|
103
|
+
const deadline = new Promise<undefined>((resolve) => {
|
|
104
|
+
timer = setTimeout(() => {
|
|
105
|
+
settled = true
|
|
106
|
+
try {
|
|
107
|
+
input.onTimeout()
|
|
108
|
+
} catch {
|
|
109
|
+
// Reporting the timeout must not replace it with another failure.
|
|
110
|
+
}
|
|
111
|
+
resolve(undefined)
|
|
112
|
+
}, input.ms)
|
|
113
|
+
// Never hold the event loop open on a deadline alone.
|
|
114
|
+
;(timer as any)?.unref?.()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const work = Promise.resolve()
|
|
118
|
+
.then(input.work)
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
// The caller's handler is responsible for the failure; this only stops a
|
|
121
|
+
// rejection from escaping after the deadline has already won the race.
|
|
122
|
+
if (settled) return undefined
|
|
123
|
+
throw error
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
return (await Promise.race([work, deadline])) as T | undefined
|
|
128
|
+
} finally {
|
|
129
|
+
if (timer) clearTimeout(timer)
|
|
130
|
+
}
|
|
131
|
+
}
|