@kisev/skills-opencode 1.1.1 → 1.2.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/README.md +8 -4
- package/dist/agent-profiles.js +3 -1
- package/dist/cli.js +120 -49
- package/dist/index.d.ts +6 -1
- package/dist/index.js +12 -4
- package/dist/plugins/background-attempts.js +15 -4
- package/dist/plugins/goal-loop.d.ts +11 -0
- package/dist/plugins/goal-loop.js +12 -0
- package/dist/routing.d.ts +59 -2
- package/dist/routing.js +147 -7
- package/dist/runtime/state.d.ts +1 -0
- package/dist/runtime/state.js +12 -5
- package/dist/terminal-wizard.d.ts +2 -0
- package/dist/terminal-wizard.js +125 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,10 +91,14 @@ npm exec -- skills-opencode agent model-set worker --scope global \
|
|
|
91
91
|
npm exec -- skills-opencode agent reconcile --scope global --dry-run
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
`agent configure` предлагает terminal
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
`agent configure` предлагает настоящий terminal wizard: стрелками выбираются
|
|
95
|
+
agent из inventory, затем provider, только его models и, если metadata выбранной
|
|
96
|
+
модели публикует variants, variant. Текущие model/variant и target показываются
|
|
97
|
+
перед выбором; доступны `keep`, `change`, `clear variant`, `back` и `cancel`.
|
|
98
|
+
Wizard не вызывает LLM, OpenCode Question или refresh catalog. Если catalog
|
|
99
|
+
недоступен, он завершается без записи и печатает инструкцию для exact
|
|
100
|
+
`--model <provider/model>` с optional `--variant`. Для модели без variants
|
|
101
|
+
дополнительный selector не показывается.
|
|
98
102
|
|
|
99
103
|
Additional critic имеет имя `critic-<safe-suffix>`. Стандартный `critic` и fixed
|
|
100
104
|
roles нельзя удалить или переименовать:
|
package/dist/agent-profiles.js
CHANGED
|
@@ -719,10 +719,12 @@ export async function availableModelVariants(model) {
|
|
|
719
719
|
document += `${lines[index]}\n`;
|
|
720
720
|
try {
|
|
721
721
|
const metadata = JSON.parse(document);
|
|
722
|
+
if (metadata.variants === undefined)
|
|
723
|
+
return [];
|
|
722
724
|
if (!metadata.variants ||
|
|
723
725
|
typeof metadata.variants !== "object" ||
|
|
724
726
|
Array.isArray(metadata.variants))
|
|
725
|
-
throw new Error("variants
|
|
727
|
+
throw new Error("invalid variants metadata");
|
|
726
728
|
const variants = Object.keys(metadata.variants);
|
|
727
729
|
if (!variants.every((variant) => VARIANT_PATTERN.test(variant)))
|
|
728
730
|
throw new Error("unsafe variant");
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
3
2
|
import { AgentProfileError, applyAgentProfileChange, availableModels, availableModelVariants, FIXED_AGENT_ROLES, listAgentProfiles, previewAgentProfileChange, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
|
|
4
3
|
import { renderInventory, renderPlan, shellCommand, terminalSafe } from "./cli-output.js";
|
|
5
4
|
import { apply, InstallerError, preview } from "./installer.js";
|
|
6
5
|
import { LifecycleError } from "./lifecycle.js";
|
|
6
|
+
import { promptText, selectOption } from "./terminal-wizard.js";
|
|
7
7
|
function parseOptions(values) {
|
|
8
8
|
const options = { dryRun: false, json: false };
|
|
9
9
|
for (let index = 0; index < values.length; index += 1) {
|
|
@@ -83,56 +83,123 @@ function exactModel(options) {
|
|
|
83
83
|
throw new InstallerError("invalid_input", "--provider is required when --model is not provider/model");
|
|
84
84
|
return validateModel(`${options.provider}/${options.model}`);
|
|
85
85
|
}
|
|
86
|
-
async function
|
|
87
|
-
process.
|
|
88
|
-
const answer = await input.question("> ");
|
|
89
|
-
const index = Number(answer) - 1;
|
|
90
|
-
if (!Number.isSafeInteger(index) || !values[index])
|
|
91
|
-
throw new InstallerError("invalid_input", `Invalid ${label.toLowerCase()} selection`);
|
|
92
|
-
return values[index];
|
|
93
|
-
}
|
|
94
|
-
async function interactiveSelection(options) {
|
|
95
|
-
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
86
|
+
async function interactiveSelection(options, action = "model-set") {
|
|
87
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
96
88
|
throw new InstallerError("terminal_required", "agent configure requires a terminal or explicit --provider and --model");
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
89
|
+
}
|
|
90
|
+
const inventory = await listAgentProfiles(options.scope);
|
|
91
|
+
const configurable = inventory.profiles.filter((item) => item.ownership !== "user-owned");
|
|
92
|
+
let name;
|
|
93
|
+
if (options.name) {
|
|
94
|
+
name = action === "critic-add" && !options.name.startsWith("critic-")
|
|
95
|
+
? validateAgentName(`critic-${options.name}`)
|
|
96
|
+
: validateAgentName(options.name);
|
|
97
|
+
}
|
|
98
|
+
else if (action === "critic-add") {
|
|
99
|
+
const raw = await promptText("Critic name (critic-<suffix>):");
|
|
100
|
+
if (raw === null)
|
|
101
|
+
throw new InstallerError("cancelled", "Wizard cancelled");
|
|
102
|
+
name = raw.startsWith("critic-") ? raw : `critic-${raw}`;
|
|
103
|
+
validateAgentName(name);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const agentNames = configurable.map((item) => item.name);
|
|
107
|
+
if (agentNames.length === 0)
|
|
108
|
+
agentNames.push(...FIXED_AGENT_ROLES);
|
|
109
|
+
const index = await selectOption("Select agent:", agentNames);
|
|
110
|
+
if (index === null)
|
|
111
|
+
throw new InstallerError("cancelled", "Wizard cancelled");
|
|
112
|
+
name = agentNames[index];
|
|
113
|
+
}
|
|
114
|
+
const currentProfile = configurable.find((item) => item.name === name);
|
|
115
|
+
const currentModel = currentProfile?.model;
|
|
116
|
+
const currentVariant = currentProfile?.variant;
|
|
117
|
+
const showTarget = (model, variant) => {
|
|
118
|
+
process.stderr.write(`Target: ${model}${variant ? ` / ${variant}` : ""}\n`);
|
|
119
|
+
};
|
|
120
|
+
if (options.provider && options.model) {
|
|
121
|
+
const model = exactModel(options);
|
|
122
|
+
const variant = validateVariant(options.variant);
|
|
123
|
+
return { name, model, ...(variant ? { variant } : {}) };
|
|
124
|
+
}
|
|
125
|
+
while (true) {
|
|
126
|
+
const actions = [];
|
|
127
|
+
if (currentModel) {
|
|
128
|
+
actions.push(`Keep current (${currentModel}${currentVariant ? ` / ${currentVariant}` : ""})`);
|
|
107
129
|
}
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
actions.push("Change model");
|
|
131
|
+
if (currentVariant)
|
|
132
|
+
actions.push("Clear variant");
|
|
133
|
+
if (!options.name && action === "model-set")
|
|
134
|
+
actions.push("Back");
|
|
135
|
+
actions.push("Cancel");
|
|
136
|
+
const currentLabel = `Agent: ${name}${currentModel ? ` (current: ${currentModel}${currentVariant ? ` / ${currentVariant}` : ""})` : ""}`;
|
|
137
|
+
const actionIndex = await selectOption(currentLabel, actions);
|
|
138
|
+
if (actionIndex === null)
|
|
139
|
+
throw new InstallerError("cancelled", "Wizard cancelled");
|
|
140
|
+
const chosen = actions[actionIndex];
|
|
141
|
+
if (chosen.startsWith("Keep current")) {
|
|
142
|
+
if (!currentModel)
|
|
143
|
+
throw new InstallerError("invalid_state", "No current model to keep");
|
|
144
|
+
showTarget(currentModel, currentVariant);
|
|
145
|
+
return { name, model: currentModel, ...(currentVariant ? { variant: currentVariant } : {}) };
|
|
146
|
+
}
|
|
147
|
+
if (chosen === "Change model") {
|
|
148
|
+
let models;
|
|
149
|
+
try {
|
|
150
|
+
models = await availableModels();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof AgentProfileError && error.code === "catalog_unavailable") {
|
|
154
|
+
process.stderr.write("\nModel catalog is unavailable.\nUse direct CLI with exact --model provider/model and optional --variant.\n\n");
|
|
155
|
+
throw new InstallerError("catalog_unavailable", "Use direct CLI with exact --model provider/model");
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const providers = [...new Set(models.map((m) => m.split("/", 1)[0]))].sort();
|
|
160
|
+
const providerIndex = await selectOption("Select provider:", providers);
|
|
161
|
+
if (providerIndex === null)
|
|
162
|
+
continue;
|
|
163
|
+
const provider = providers[providerIndex];
|
|
164
|
+
const filteredModels = models.filter((m) => m.startsWith(`${provider}/`));
|
|
165
|
+
const modelIndex = await selectOption("Select model:", filteredModels);
|
|
166
|
+
if (modelIndex === null)
|
|
167
|
+
continue;
|
|
168
|
+
const selectedModel = filteredModels[modelIndex];
|
|
169
|
+
let selectedVariant;
|
|
170
|
+
try {
|
|
171
|
+
const variants = await availableModelVariants(selectedModel);
|
|
172
|
+
if (variants.length) {
|
|
173
|
+
const variantOptions = ["(none)", ...variants];
|
|
174
|
+
const variantIndex = await selectOption("Select variant:", variantOptions);
|
|
175
|
+
if (variantIndex === null)
|
|
176
|
+
continue;
|
|
177
|
+
if (variantIndex > 0)
|
|
178
|
+
selectedVariant = variantOptions[variantIndex];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof AgentProfileError && error.code === "catalog_unavailable") {
|
|
183
|
+
process.stderr.write("\nModel variant metadata is unavailable.\nUse direct CLI with exact --model provider/model and optional --variant.\n\n");
|
|
184
|
+
throw new InstallerError("catalog_unavailable", "Use direct CLI with exact --model provider/model");
|
|
185
|
+
}
|
|
110
186
|
throw error;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return { name, model: validateModel(model.includes("/") ? model : `${provider}/${model}`), ...(validateVariant(variant) ? { variant: validateVariant(variant) } : {}) };
|
|
187
|
+
}
|
|
188
|
+
showTarget(selectedModel, selectedVariant);
|
|
189
|
+
return { name, model: selectedModel, ...(selectedVariant ? { variant: selectedVariant } : {}) };
|
|
115
190
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let variants = [];
|
|
122
|
-
try {
|
|
123
|
-
variants = await availableModelVariants(selectedModel);
|
|
191
|
+
if (chosen === "Clear variant") {
|
|
192
|
+
if (!currentModel)
|
|
193
|
+
throw new InstallerError("invalid_state", "No model to clear variant for");
|
|
194
|
+
showTarget(currentModel);
|
|
195
|
+
return { name, model: currentModel };
|
|
124
196
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
197
|
+
if (chosen === "Back") {
|
|
198
|
+
return interactiveSelection({ ...options, name: undefined }, action);
|
|
199
|
+
}
|
|
200
|
+
if (chosen === "Cancel") {
|
|
201
|
+
throw new InstallerError("cancelled", "Wizard cancelled");
|
|
128
202
|
}
|
|
129
|
-
const variant = options.variant === null
|
|
130
|
-
? undefined
|
|
131
|
-
: options.variant ?? (variants.length ? await choose("Variant", ["none", ...variants.filter((value) => value !== "none")], input) : (await input.question("Variant (optional): ")).trim());
|
|
132
|
-
return { name, model: selectedModel, ...(variant && variant !== "none" ? { variant: validateVariant(variant) } : {}) };
|
|
133
|
-
}
|
|
134
|
-
finally {
|
|
135
|
-
input.close();
|
|
136
203
|
}
|
|
137
204
|
}
|
|
138
205
|
async function runProfile(request, options) {
|
|
@@ -210,7 +277,7 @@ async function run(arguments_) {
|
|
|
210
277
|
requireConfirmationMode(options);
|
|
211
278
|
const selected = options.provider && options.model && options.name
|
|
212
279
|
? { name: validateAgentName(options.name), model: exactModel(options), ...(validateVariant(options.variant) ? { variant: validateVariant(options.variant) } : {}) }
|
|
213
|
-
: await interactiveSelection(options);
|
|
280
|
+
: await interactiveSelection(options, "model-set");
|
|
214
281
|
await runProfile({ action: "model-set", ...selected, variant: selected.variant ?? null }, options);
|
|
215
282
|
return;
|
|
216
283
|
}
|
|
@@ -237,9 +304,13 @@ async function run(arguments_) {
|
|
|
237
304
|
: `critic-${options.name ?? ""}`;
|
|
238
305
|
if (operation === "add") {
|
|
239
306
|
const model = exactModel(options);
|
|
240
|
-
if (!model)
|
|
241
|
-
|
|
242
|
-
|
|
307
|
+
if (!model) {
|
|
308
|
+
const selected = await interactiveSelection(options, "critic-add");
|
|
309
|
+
await runProfile({ action: "critic-add", name: selected.name, model: selected.model, variant: selected.variant ?? null }, options);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
await runProfile({ action: "critic-add", name, model, variant: options.variant ?? null }, options);
|
|
313
|
+
}
|
|
243
314
|
}
|
|
244
315
|
else {
|
|
245
316
|
if (options.provider || options.model || options.variant !== undefined)
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ import rtk, { type RtkOptions } from "./plugins/rtk.js";
|
|
|
7
7
|
import zedBell, { type ZedBellOptions } from "./plugins/zed-bell.js";
|
|
8
8
|
import zedClickablePaths, { type ZedClickablePathsOptions } from "./plugins/zed-clickable-paths.js";
|
|
9
9
|
export { COMMAND_REGISTRY, renderCommand } from "./registry.js";
|
|
10
|
-
export { CATEGORIES, resolveRouting, RoutingGate } from "./routing.js";
|
|
10
|
+
export { CATEGORIES, resolveRouting, RoutingGate, ExecutionCardLifecycle, validateExecutionCard } from "./routing.js";
|
|
11
|
+
export type { ExecutionCard, ExecutionCardStatus } from "./routing.js";
|
|
11
12
|
export { AgentProfileError, FIXED_AGENT_ROLES, applyAgentProfileChange, availableModels, availableModelVariants, listAgentProfiles, previewAgentProfileChange, renderAgentProfile, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
|
|
12
13
|
export type { AgentInventory, AgentModelSelection, AgentOwnership, AgentProfileAction, AgentProfileConfig, AgentProfileOperation, AgentProfilePlan, AgentProfileRecord, AgentProfileRequest, AgentProfileResult, AgentProfileScope, AgentState, DeploymentManifest, DeploymentRecord, FixedAgentRole, } from "./agent-profiles.js";
|
|
13
14
|
export { backgroundAttempts, goalLoop, scheduler, autonomyPolicy, rulesInjector, rtk, zedBell, zedClickablePaths };
|
|
@@ -48,6 +49,7 @@ declare const plugin: (input: {
|
|
|
48
49
|
capabilities: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
49
50
|
tools: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
50
51
|
}, import("zod/v4/core").$strip>>;
|
|
52
|
+
execution_card: import("zod").ZodOptional<import("zod").ZodAny>;
|
|
51
53
|
override: import("zod").ZodOptional<import("zod").ZodString>;
|
|
52
54
|
budget: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
53
55
|
cost_class: import("zod").ZodOptional<import("zod").ZodString>;
|
|
@@ -66,6 +68,7 @@ declare const plugin: (input: {
|
|
|
66
68
|
capabilities?: string[] | undefined;
|
|
67
69
|
tools?: string[] | undefined;
|
|
68
70
|
}[];
|
|
71
|
+
execution_card?: any;
|
|
69
72
|
override?: string | undefined;
|
|
70
73
|
budget?: {
|
|
71
74
|
cost_class?: string | undefined;
|
|
@@ -151,6 +154,7 @@ export declare const server: (input: {
|
|
|
151
154
|
capabilities: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
152
155
|
tools: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
153
156
|
}, import("zod/v4/core").$strip>>;
|
|
157
|
+
execution_card: import("zod").ZodOptional<import("zod").ZodAny>;
|
|
154
158
|
override: import("zod").ZodOptional<import("zod").ZodString>;
|
|
155
159
|
budget: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
156
160
|
cost_class: import("zod").ZodOptional<import("zod").ZodString>;
|
|
@@ -169,6 +173,7 @@ export declare const server: (input: {
|
|
|
169
173
|
capabilities?: string[] | undefined;
|
|
170
174
|
tools?: string[] | undefined;
|
|
171
175
|
}[];
|
|
176
|
+
execution_card?: any;
|
|
172
177
|
override?: string | undefined;
|
|
173
178
|
budget?: {
|
|
174
179
|
cost_class?: string | undefined;
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import zedBell from "./plugins/zed-bell.js";
|
|
|
10
10
|
import zedClickablePaths from "./plugins/zed-clickable-paths.js";
|
|
11
11
|
import { applyAgentProfileChange, listAgentProfiles, previewAgentProfileChange, } from "./agent-profiles.js";
|
|
12
12
|
export { COMMAND_REGISTRY, renderCommand } from "./registry.js";
|
|
13
|
-
export { CATEGORIES, resolveRouting, RoutingGate } from "./routing.js";
|
|
13
|
+
export { CATEGORIES, resolveRouting, RoutingGate, ExecutionCardLifecycle, validateExecutionCard } from "./routing.js";
|
|
14
14
|
export { AgentProfileError, FIXED_AGENT_ROLES, applyAgentProfileChange, availableModels, availableModelVariants, listAgentProfiles, previewAgentProfileChange, renderAgentProfile, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
|
|
15
15
|
export { backgroundAttempts, goalLoop, scheduler, autonomyPolicy, rulesInjector, rtk, zedBell, zedClickablePaths };
|
|
16
16
|
const CATALOG = {
|
|
@@ -29,16 +29,17 @@ const plugin = (async (input) => {
|
|
|
29
29
|
task: tool.schema.string(),
|
|
30
30
|
requirements: tool.schema.array(tool.schema.string()).default([]),
|
|
31
31
|
agents: tool.schema.array(tool.schema.object({ agent: tool.schema.string(), available: tool.schema.boolean().optional(), capabilities: tool.schema.array(tool.schema.string()).optional(), tools: tool.schema.array(tool.schema.string()).optional() })),
|
|
32
|
+
execution_card: tool.schema.any().optional(),
|
|
32
33
|
override: tool.schema.string().optional(),
|
|
33
34
|
budget: tool.schema.object({ cost_class: tool.schema.string().optional(), latency_class: tool.schema.string().optional() }).optional(),
|
|
34
35
|
decision: tool.schema.any().optional()
|
|
35
36
|
},
|
|
36
37
|
async execute(args, context) {
|
|
37
|
-
const input = { category: args.category, requirements: args.requirements, agents: args.agents, override: args.override, budget: args.budget };
|
|
38
|
+
const input = { category: args.category, task: args.task, requirements: args.requirements, agents: args.agents, execution_card: args.execution_card, override: args.override, budget: args.budget };
|
|
38
39
|
if (args.action === "preview")
|
|
39
40
|
return JSON.stringify(gate.preview(input));
|
|
40
41
|
const decision = gate.dispatch(input, args.decision);
|
|
41
|
-
gate.grant(context.sessionID, decision);
|
|
42
|
+
gate.grant(context.sessionID, decision, { task: args.task, requirements: args.requirements, card: args.execution_card });
|
|
42
43
|
return JSON.stringify({ decision, status: "routed" });
|
|
43
44
|
}
|
|
44
45
|
});
|
|
@@ -92,7 +93,14 @@ const plugin = (async (input) => {
|
|
|
92
93
|
const agent = typeof args.agent === "string" ? args.agent : typeof args.subagent_type === "string" ? args.subagent_type : undefined;
|
|
93
94
|
if (!agent)
|
|
94
95
|
throw new Error("Native Task requires an explicit agent and an active routing receipt");
|
|
95
|
-
|
|
96
|
+
const hasBinding = "task" in args || "requirements" in args || "execution_card" in args;
|
|
97
|
+
if (!hasBinding)
|
|
98
|
+
gate.consume(input.sessionID, agent);
|
|
99
|
+
else {
|
|
100
|
+
const task = typeof args.task === "string" ? args.task : "";
|
|
101
|
+
const requirements = Array.isArray(args.requirements) ? args.requirements : [];
|
|
102
|
+
gate.consume(input.sessionID, agent, { task, requirements, card: args.execution_card });
|
|
103
|
+
}
|
|
96
104
|
}
|
|
97
105
|
};
|
|
98
106
|
});
|
|
@@ -22,12 +22,23 @@ export async function backgroundAttempts({ client, directory, cwd }, options = {
|
|
|
22
22
|
await appendState(join(root, "receipts.jsonl"), root, { attempt_id: item.attempt_id, event, status: item.status, revision: item.revision, at: new Date().toISOString() });
|
|
23
23
|
};
|
|
24
24
|
const transition = async (item, status, event) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const allowed = {
|
|
26
|
+
queued: ["running", "failed", "cancelled"],
|
|
27
|
+
running: ["waiting", "completed", "failed", "cancelled", "orphaned"],
|
|
28
|
+
waiting: ["running", "completed", "failed", "cancelled", "orphaned"],
|
|
29
|
+
completed: [], failed: [], cancelled: [], orphaned: [],
|
|
30
|
+
};
|
|
31
|
+
if (!allowed[item.status].includes(status))
|
|
32
|
+
throw new Error(`Invalid background-attempt transition: ${item.status} -> ${status}`);
|
|
33
|
+
item.status = status;
|
|
34
|
+
item.revision += 1;
|
|
29
35
|
await save(item, event);
|
|
30
36
|
};
|
|
37
|
+
for (const item of await all()) {
|
|
38
|
+
if (item.project === project && (item.status === "running" || item.status === "waiting")) {
|
|
39
|
+
await transition(item, "orphaned", "recovery.orphaned");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
const pump = async () => {
|
|
32
43
|
const records = await all();
|
|
33
44
|
const running = records.filter((item) => item.status === "running" || item.status === "waiting");
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
type WorkItem = {
|
|
2
|
+
contract_version: "work-item/v1";
|
|
3
|
+
acceptance_criteria: Array<{
|
|
4
|
+
id: string;
|
|
5
|
+
}>;
|
|
6
|
+
};
|
|
1
7
|
type Goal = {
|
|
2
8
|
schema_version: 1;
|
|
3
9
|
goal_id: string;
|
|
@@ -14,6 +20,11 @@ type Goal = {
|
|
|
14
20
|
};
|
|
15
21
|
receipts: Array<Record<string, unknown>>;
|
|
16
22
|
updated_at: string;
|
|
23
|
+
work_item?: WorkItem;
|
|
24
|
+
completion_evidence?: Array<{
|
|
25
|
+
criterion_id: string;
|
|
26
|
+
evidence: string;
|
|
27
|
+
}>;
|
|
17
28
|
};
|
|
18
29
|
type Client = {
|
|
19
30
|
session: {
|
|
@@ -12,6 +12,14 @@ function tokens(messages) {
|
|
|
12
12
|
const value = last?.info?.tokens?.total ?? last?.info?.tokens?.output;
|
|
13
13
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
14
14
|
}
|
|
15
|
+
function validWorkItem(goal) {
|
|
16
|
+
return goal.work_item?.contract_version === "work-item/v1" && Array.isArray(goal.work_item.acceptance_criteria) && goal.work_item.acceptance_criteria.length > 0;
|
|
17
|
+
}
|
|
18
|
+
function hasCompletionEvidence(goal) {
|
|
19
|
+
const criteria = goal.work_item?.acceptance_criteria ?? [];
|
|
20
|
+
const evidence = goal.completion_evidence ?? [];
|
|
21
|
+
return criteria.every((criterion) => evidence.some((item) => item.criterion_id === criterion.id && item.evidence.trim().length > 0));
|
|
22
|
+
}
|
|
15
23
|
export async function goalLoop({ client }, options = {}) {
|
|
16
24
|
if (!options.enabled)
|
|
17
25
|
return {};
|
|
@@ -29,6 +37,8 @@ export async function goalLoop({ client }, options = {}) {
|
|
|
29
37
|
const item = await lookup(session);
|
|
30
38
|
if (!item || item.state.status !== "running")
|
|
31
39
|
return;
|
|
40
|
+
if (!validWorkItem(item.state))
|
|
41
|
+
return settle(session, "blocked", "invalid work item");
|
|
32
42
|
item.state.status = status;
|
|
33
43
|
item.state.revision += 1;
|
|
34
44
|
item.state.updated_at = new Date().toISOString();
|
|
@@ -53,6 +63,8 @@ export async function goalLoop({ client }, options = {}) {
|
|
|
53
63
|
if (item.state.limits.token_budget > 0 && item.state.usage.tokens >= item.state.limits.token_budget)
|
|
54
64
|
return settle(session, "blocked", "token budget reached");
|
|
55
65
|
const verdict = await (options.audit?.(item.state) ?? Promise.resolve("continue"));
|
|
66
|
+
if (verdict === "complete" && !hasCompletionEvidence(item.state))
|
|
67
|
+
return settle(session, "blocked", "completion evidence is incomplete");
|
|
56
68
|
if (verdict === "complete" || verdict === "blocked")
|
|
57
69
|
return settle(session, verdict, "audit verdict");
|
|
58
70
|
await client.session.prompt({ path: { id: session }, body: { parts: [{ type: "text", text: "Continue the current goal within its declared constraints and boundaries." }] } });
|
package/dist/routing.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ export type AvailableAgent = {
|
|
|
8
8
|
};
|
|
9
9
|
export type RoutingInput = {
|
|
10
10
|
category: Category;
|
|
11
|
+
task?: string;
|
|
11
12
|
requirements: string[];
|
|
12
13
|
agents: AvailableAgent[];
|
|
14
|
+
execution_card?: unknown;
|
|
13
15
|
override?: string;
|
|
14
16
|
budget?: {
|
|
15
17
|
cost_class?: string;
|
|
@@ -27,13 +29,68 @@ export type RoutingDecision = {
|
|
|
27
29
|
excluded_reasons: string[];
|
|
28
30
|
}[];
|
|
29
31
|
matrix_revision: string;
|
|
32
|
+
task_digest: string;
|
|
33
|
+
requirements_digest: string;
|
|
34
|
+
execution_card_digest?: string;
|
|
35
|
+
execution_card_revision?: number;
|
|
30
36
|
decision_digest: string;
|
|
31
37
|
};
|
|
38
|
+
export type ControlMarker = {
|
|
39
|
+
path: string;
|
|
40
|
+
expected?: string;
|
|
41
|
+
expected_absent?: boolean;
|
|
42
|
+
};
|
|
43
|
+
export type ExecutionCard = {
|
|
44
|
+
status: "READY";
|
|
45
|
+
card_id: string;
|
|
46
|
+
revision: number;
|
|
47
|
+
objective: string;
|
|
48
|
+
changed_behavior: string[];
|
|
49
|
+
risks: string[];
|
|
50
|
+
write_set: string[];
|
|
51
|
+
control_markers: ControlMarker[];
|
|
52
|
+
decisions: string[];
|
|
53
|
+
steps: Array<{
|
|
54
|
+
path: string;
|
|
55
|
+
operation: string;
|
|
56
|
+
}>;
|
|
57
|
+
acceptance_criteria: string[];
|
|
58
|
+
checks: string[];
|
|
59
|
+
boundaries: {
|
|
60
|
+
forbidden_paths: string[];
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export type ExecutionCardStatus = ExecutionCard["status"] | "RUNNING" | "COMPLETED" | "BLOCKED" | "FAILED" | "REJECTED_PLAN" | "APPROVED" | "CHANGES_REQUIRED" | "CANCELLED";
|
|
32
64
|
export declare function resolveRouting(input: RoutingInput): RoutingDecision;
|
|
65
|
+
export declare function validateExecutionCard(card: unknown): {
|
|
66
|
+
valid: true;
|
|
67
|
+
card: ExecutionCard;
|
|
68
|
+
} | {
|
|
69
|
+
valid: false;
|
|
70
|
+
failedField: string;
|
|
71
|
+
};
|
|
72
|
+
type ReceiptContext = {
|
|
73
|
+
task?: string;
|
|
74
|
+
requirements?: string[];
|
|
75
|
+
card?: unknown;
|
|
76
|
+
};
|
|
77
|
+
export declare class ExecutionCardLifecycle {
|
|
78
|
+
#private;
|
|
79
|
+
constructor(card: unknown);
|
|
80
|
+
get status(): ExecutionCardStatus;
|
|
81
|
+
get card_id(): string;
|
|
82
|
+
get revision(): number;
|
|
83
|
+
transition(status: ExecutionCardStatus, identity: {
|
|
84
|
+
card_id: string;
|
|
85
|
+
revision: number;
|
|
86
|
+
}): ExecutionCardStatus;
|
|
87
|
+
}
|
|
33
88
|
export declare class RoutingGate {
|
|
34
89
|
#private;
|
|
35
90
|
preview(input: RoutingInput): RoutingDecision;
|
|
36
91
|
dispatch(input: RoutingInput, provided: unknown): RoutingDecision;
|
|
37
|
-
grant(sessionID: string, decision: RoutingDecision): void;
|
|
38
|
-
|
|
92
|
+
grant(sessionID: string, decision: RoutingDecision, context?: ReceiptContext): void;
|
|
93
|
+
cancel(sessionID: string): void;
|
|
94
|
+
consume(sessionID: string, agent: string, context?: ReceiptContext): void;
|
|
39
95
|
}
|
|
96
|
+
export {};
|
package/dist/routing.js
CHANGED
|
@@ -58,33 +58,173 @@ export function resolveRouting(input) {
|
|
|
58
58
|
eligible.push(agent);
|
|
59
59
|
}
|
|
60
60
|
const selected = input.override ? eligible.find((agent) => agent.agent === input.override) : eligible[0];
|
|
61
|
+
const card = input.execution_card === undefined ? undefined : validateExecutionCard(input.execution_card);
|
|
62
|
+
if (card && !card.valid)
|
|
63
|
+
throw new Error(`Invalid execution card: ${card.failedField}`);
|
|
61
64
|
const base = selected
|
|
62
|
-
? { schema_version: 1, status: "selected", category: input.category, agent: selected.agent, reason_codes: [input.override ? "explicit_override" : selected.agent === category.profiles[0] ? "primary_available" : "fallback_selected", "capabilities_match"], alternatives, matrix_revision: revision }
|
|
63
|
-
: { schema_version: 1, status: "escalate", category: input.category, reason_codes: [input.override ? "override_unavailable" : "no_eligible_profile"], alternatives, matrix_revision: revision };
|
|
65
|
+
? { schema_version: 1, status: "selected", category: input.category, agent: selected.agent, reason_codes: [input.override ? "explicit_override" : selected.agent === category.profiles[0] ? "primary_available" : "fallback_selected", "capabilities_match"], alternatives, matrix_revision: revision, task_digest: digest(input.task ?? ""), requirements_digest: digest(input.requirements), ...(card?.valid ? { execution_card_digest: digest(card.card), execution_card_revision: card.card.revision } : {}) }
|
|
66
|
+
: { schema_version: 1, status: "escalate", category: input.category, reason_codes: [input.override ? "override_unavailable" : "no_eligible_profile"], alternatives, matrix_revision: revision, task_digest: digest(input.task ?? ""), requirements_digest: digest(input.requirements), ...(card?.valid ? { execution_card_digest: digest(card.card), execution_card_revision: card.card.revision } : {}) };
|
|
64
67
|
return { ...base, decision_digest: digest(base) };
|
|
65
68
|
}
|
|
69
|
+
function isNonEmptyStringArray(value) {
|
|
70
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
|
|
71
|
+
}
|
|
72
|
+
export function validateExecutionCard(card) {
|
|
73
|
+
if (!card || typeof card !== "object" || Array.isArray(card)) {
|
|
74
|
+
return { valid: false, failedField: "root" };
|
|
75
|
+
}
|
|
76
|
+
const c = card;
|
|
77
|
+
const expectedFields = [
|
|
78
|
+
"status", "card_id", "revision", "objective", "changed_behavior", "risks",
|
|
79
|
+
"write_set", "control_markers", "decisions", "steps", "acceptance_criteria",
|
|
80
|
+
"checks", "boundaries",
|
|
81
|
+
];
|
|
82
|
+
if (Object.keys(c).sort().join(",") !== expectedFields.slice().sort().join(","))
|
|
83
|
+
return { valid: false, failedField: "schema" };
|
|
84
|
+
if (c.status !== "READY")
|
|
85
|
+
return { valid: false, failedField: "status" };
|
|
86
|
+
if (typeof c.card_id !== "string" || !c.card_id)
|
|
87
|
+
return { valid: false, failedField: "card_id" };
|
|
88
|
+
if (typeof c.revision !== "number" || !Number.isInteger(c.revision) || c.revision < 1)
|
|
89
|
+
return { valid: false, failedField: "revision" };
|
|
90
|
+
if (typeof c.objective !== "string" || !c.objective)
|
|
91
|
+
return { valid: false, failedField: "objective" };
|
|
92
|
+
if (!isNonEmptyStringArray(c.changed_behavior))
|
|
93
|
+
return { valid: false, failedField: "changed_behavior" };
|
|
94
|
+
if (!isNonEmptyStringArray(c.risks))
|
|
95
|
+
return { valid: false, failedField: "risks" };
|
|
96
|
+
if (!isNonEmptyStringArray(c.write_set))
|
|
97
|
+
return { valid: false, failedField: "write_set" };
|
|
98
|
+
if (new Set(c.write_set).size !== c.write_set.length)
|
|
99
|
+
return { valid: false, failedField: "write_set" };
|
|
100
|
+
if (!Array.isArray(c.control_markers) || c.control_markers.length === 0)
|
|
101
|
+
return { valid: false, failedField: "control_markers" };
|
|
102
|
+
for (const marker of c.control_markers) {
|
|
103
|
+
if (!marker || typeof marker !== "object" || Array.isArray(marker))
|
|
104
|
+
return { valid: false, failedField: "control_markers" };
|
|
105
|
+
const m = marker;
|
|
106
|
+
if (typeof m.path !== "string" || !m.path)
|
|
107
|
+
return { valid: false, failedField: "control_markers" };
|
|
108
|
+
const hasExpected = typeof m.expected === "string" && m.expected.length > 0;
|
|
109
|
+
const hasExpectedAbsent = m.expected_absent === true;
|
|
110
|
+
if (hasExpected === hasExpectedAbsent)
|
|
111
|
+
return { valid: false, failedField: "control_markers" };
|
|
112
|
+
if (Object.keys(m).some((k) => k !== "path" && k !== "expected" && k !== "expected_absent"))
|
|
113
|
+
return { valid: false, failedField: "control_markers" };
|
|
114
|
+
}
|
|
115
|
+
if (!isNonEmptyStringArray(c.decisions))
|
|
116
|
+
return { valid: false, failedField: "decisions" };
|
|
117
|
+
if (!Array.isArray(c.steps) || c.steps.length === 0)
|
|
118
|
+
return { valid: false, failedField: "steps" };
|
|
119
|
+
for (const step of c.steps) {
|
|
120
|
+
if (!step || typeof step !== "object" || Array.isArray(step))
|
|
121
|
+
return { valid: false, failedField: "steps" };
|
|
122
|
+
const s = step;
|
|
123
|
+
if (typeof s.path !== "string" || !s.path)
|
|
124
|
+
return { valid: false, failedField: "steps" };
|
|
125
|
+
if (typeof s.operation !== "string" || !s.operation)
|
|
126
|
+
return { valid: false, failedField: "steps" };
|
|
127
|
+
if (Object.keys(s).some((k) => k !== "path" && k !== "operation"))
|
|
128
|
+
return { valid: false, failedField: "steps" };
|
|
129
|
+
}
|
|
130
|
+
if (!isNonEmptyStringArray(c.acceptance_criteria))
|
|
131
|
+
return { valid: false, failedField: "acceptance_criteria" };
|
|
132
|
+
if (!isNonEmptyStringArray(c.checks))
|
|
133
|
+
return { valid: false, failedField: "checks" };
|
|
134
|
+
if (!c.boundaries || typeof c.boundaries !== "object" || Array.isArray(c.boundaries))
|
|
135
|
+
return { valid: false, failedField: "boundaries" };
|
|
136
|
+
const b = c.boundaries;
|
|
137
|
+
if (!isNonEmptyStringArray(b.forbidden_paths))
|
|
138
|
+
return { valid: false, failedField: "boundaries" };
|
|
139
|
+
if (Object.keys(b).some((k) => k !== "forbidden_paths"))
|
|
140
|
+
return { valid: false, failedField: "boundaries" };
|
|
141
|
+
const writeSet = c.write_set;
|
|
142
|
+
const forbiddenPaths = b.forbidden_paths;
|
|
143
|
+
const stepPaths = c.steps.map((s) => s.path);
|
|
144
|
+
const markerPaths = c.control_markers.map((m) => m.path);
|
|
145
|
+
const referenced = new Set([...stepPaths, ...markerPaths]);
|
|
146
|
+
if (![...referenced].every((p) => writeSet.includes(p)))
|
|
147
|
+
return { valid: false, failedField: "steps" };
|
|
148
|
+
if (writeSet.some((p) => forbiddenPaths.includes(p)))
|
|
149
|
+
return { valid: false, failedField: "boundaries" };
|
|
150
|
+
return { valid: true, card: c };
|
|
151
|
+
}
|
|
152
|
+
export class ExecutionCardLifecycle {
|
|
153
|
+
#status = "READY";
|
|
154
|
+
#cardID;
|
|
155
|
+
#revision;
|
|
156
|
+
constructor(card) {
|
|
157
|
+
const result = validateExecutionCard(card);
|
|
158
|
+
if (!result.valid)
|
|
159
|
+
throw new Error(`Invalid execution card: ${result.failedField}`);
|
|
160
|
+
this.#cardID = result.card.card_id;
|
|
161
|
+
this.#revision = result.card.revision;
|
|
162
|
+
}
|
|
163
|
+
get status() { return this.#status; }
|
|
164
|
+
get card_id() { return this.#cardID; }
|
|
165
|
+
get revision() { return this.#revision; }
|
|
166
|
+
transition(status, identity) {
|
|
167
|
+
if (identity.card_id !== this.#cardID || identity.revision !== this.#revision)
|
|
168
|
+
throw new Error("Execution card identity is stale");
|
|
169
|
+
const allowed = {
|
|
170
|
+
READY: ["RUNNING", "CANCELLED"],
|
|
171
|
+
RUNNING: ["COMPLETED", "BLOCKED", "FAILED", "REJECTED_PLAN", "CANCELLED"],
|
|
172
|
+
COMPLETED: ["APPROVED", "CHANGES_REQUIRED"],
|
|
173
|
+
BLOCKED: [], FAILED: [], REJECTED_PLAN: [], APPROVED: [], CHANGES_REQUIRED: [], CANCELLED: [],
|
|
174
|
+
};
|
|
175
|
+
if (!allowed[this.#status].includes(status))
|
|
176
|
+
throw new Error(`Invalid execution-card transition: ${this.#status} -> ${status}`);
|
|
177
|
+
this.#status = status;
|
|
178
|
+
return this.#status;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
66
181
|
export class RoutingGate {
|
|
67
182
|
#receipts = new Map();
|
|
183
|
+
#ttlMs = 10 * 60 * 1000;
|
|
68
184
|
preview(input) {
|
|
69
185
|
return resolveRouting(input);
|
|
70
186
|
}
|
|
71
187
|
dispatch(input, provided) {
|
|
72
188
|
const decision = resolveRouting(input);
|
|
73
|
-
if (!provided || typeof provided !== "object" || provided
|
|
189
|
+
if (!provided || typeof provided !== "object" || stable(provided) !== stable(decision))
|
|
74
190
|
throw new Error("Routing decision is stale; request a new preview");
|
|
75
191
|
if (decision.status !== "selected" || !decision.agent)
|
|
76
192
|
throw new Error("Routing escalated; no agent was dispatched");
|
|
77
193
|
return decision;
|
|
78
194
|
}
|
|
79
|
-
grant(sessionID, decision) {
|
|
80
|
-
|
|
195
|
+
grant(sessionID, decision, context) {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const taskDigest = context?.task === undefined ? decision.task_digest : digest(context.task);
|
|
198
|
+
const requirementsDigest = context?.requirements === undefined ? decision.requirements_digest : digest(context.requirements);
|
|
199
|
+
const cardDigest = context?.card === undefined ? decision.execution_card_digest : digest(context.card);
|
|
200
|
+
this.#receipts.set(sessionID, {
|
|
201
|
+
decision,
|
|
202
|
+
taskDigest,
|
|
203
|
+
requirementsDigest,
|
|
204
|
+
cardDigest,
|
|
205
|
+
expiresAt: now + this.#ttlMs,
|
|
206
|
+
});
|
|
81
207
|
}
|
|
82
|
-
|
|
208
|
+
cancel(sessionID) { this.#receipts.delete(sessionID); }
|
|
209
|
+
consume(sessionID, agent, context) {
|
|
83
210
|
const receipt = this.#receipts.get(sessionID);
|
|
84
211
|
if (!receipt)
|
|
85
212
|
throw new Error("Native Task requires an active routing receipt; use the route tool");
|
|
86
|
-
if (receipt.agent !== agent)
|
|
213
|
+
if (receipt.decision.agent !== agent)
|
|
87
214
|
throw new Error("Native Task agent does not match the active routing receipt");
|
|
215
|
+
if (Date.now() > receipt.expiresAt)
|
|
216
|
+
throw new Error("Routing receipt has expired; request a new preview");
|
|
217
|
+
if (context) {
|
|
218
|
+
const taskDigest = digest(context.task ?? "");
|
|
219
|
+
if (taskDigest !== receipt.taskDigest)
|
|
220
|
+
throw new Error("Routing receipt task does not match the active routing receipt");
|
|
221
|
+
const requirementsDigest = digest(context.requirements ?? []);
|
|
222
|
+
if (requirementsDigest !== receipt.requirementsDigest)
|
|
223
|
+
throw new Error("Routing receipt requirements do not match the active routing receipt");
|
|
224
|
+
const cardDigest = context.card !== undefined ? digest(context.card) : undefined;
|
|
225
|
+
if (cardDigest !== receipt.cardDigest)
|
|
226
|
+
throw new Error("Routing receipt execution card does not match the active routing receipt");
|
|
227
|
+
}
|
|
88
228
|
this.#receipts.delete(sessionID);
|
|
89
229
|
}
|
|
90
230
|
}
|
package/dist/runtime/state.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export declare function readState<T>(path: string, boundary: string): Promise<T
|
|
|
4
4
|
export declare function listState(boundary: string, suffix?: string): Promise<string[]>;
|
|
5
5
|
export declare function writeState(path: string, boundary: string, value: unknown): Promise<void>;
|
|
6
6
|
export declare function appendState(path: string, boundary: string, value: unknown): Promise<void>;
|
|
7
|
+
export declare function withStateLock<T>(boundary: string, callback: () => Promise<T>): Promise<T>;
|
|
7
8
|
export declare function digest(value: unknown): string;
|
package/dist/runtime/state.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { lstat, mkdir, readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { appendPrivate, writeAtomic } from "../lifecycle.js";
|
|
5
|
+
import { appendPrivate, withLifecycleLock, writeAtomic } from "../lifecycle.js";
|
|
6
6
|
function home(value = process.env.HOME ?? homedir()) {
|
|
7
7
|
if (!isAbsolute(value) || value.split(sep).includes(".."))
|
|
8
8
|
throw new Error("HOME must be an absolute safe path");
|
|
@@ -94,14 +94,21 @@ export async function listState(boundary, suffix = ".json") {
|
|
|
94
94
|
export async function writeState(path, boundary, value) {
|
|
95
95
|
if (!inside(boundary, path))
|
|
96
96
|
throw new Error("state path escapes its boundary");
|
|
97
|
-
await
|
|
98
|
-
|
|
97
|
+
await withLifecycleLock(boundary, async () => {
|
|
98
|
+
await safeDirectory(dirname(path), boundary, true);
|
|
99
|
+
await writeAtomic(path, Buffer.from(`${JSON.stringify(value)}\n`), 0o600);
|
|
100
|
+
});
|
|
99
101
|
}
|
|
100
102
|
export async function appendState(path, boundary, value) {
|
|
101
103
|
if (!inside(boundary, path))
|
|
102
104
|
throw new Error("state path escapes its boundary");
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
+
await withLifecycleLock(boundary, async () => {
|
|
106
|
+
await safeDirectory(dirname(path), boundary, true);
|
|
107
|
+
await appendPrivate(path, value);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export async function withStateLock(boundary, callback) {
|
|
111
|
+
return withLifecycleLock(boundary, callback);
|
|
105
112
|
}
|
|
106
113
|
export function digest(value) {
|
|
107
114
|
return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex");
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function selectOption(label: string, options: readonly string[], stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream, initial?: number): Promise<number | null>;
|
|
2
|
+
export declare function promptText(label: string, stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream): Promise<string | null>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { InstallerError } from "./installer.js";
|
|
2
|
+
function parseKey(buffer) {
|
|
3
|
+
const seq = buffer.toString();
|
|
4
|
+
if (seq === "\r" || seq === "\n")
|
|
5
|
+
return { name: "return", sequence: seq };
|
|
6
|
+
if (seq === "\x03")
|
|
7
|
+
return { name: "ctrl+c", sequence: seq };
|
|
8
|
+
if (seq === "\x04")
|
|
9
|
+
return { name: "ctrl+d", sequence: seq };
|
|
10
|
+
if (seq === "\x1b" || seq === "\x1b\x1b" || seq === "\x1b\x1b\x1b")
|
|
11
|
+
return { name: "escape", sequence: seq };
|
|
12
|
+
if (seq === "\x1b[A" || seq === "\x1b[OA")
|
|
13
|
+
return { name: "up", sequence: seq };
|
|
14
|
+
if (seq === "\x1b[B" || seq === "\x1b[OB")
|
|
15
|
+
return { name: "down", sequence: seq };
|
|
16
|
+
if (seq === "\x1b[C" || seq === "\x1b[OC")
|
|
17
|
+
return { name: "right", sequence: seq };
|
|
18
|
+
if (seq === "\x1b[D" || seq === "\x1b[OD")
|
|
19
|
+
return { name: "left", sequence: seq };
|
|
20
|
+
return { name: "unknown", sequence: seq };
|
|
21
|
+
}
|
|
22
|
+
function clearLines(stderr, count) {
|
|
23
|
+
for (let i = 0; i < count; i++) {
|
|
24
|
+
stderr.write("\x1b[A\x1b[K");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function selectOption(label, options, stdin = process.stdin, stderr = process.stderr, initial = 0) {
|
|
28
|
+
if (!stdin.isTTY || !stderr.isTTY) {
|
|
29
|
+
throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
|
|
30
|
+
}
|
|
31
|
+
let selected = Math.max(0, Math.min(initial, options.length - 1));
|
|
32
|
+
const lineCount = options.length + 1; // label + options
|
|
33
|
+
function render() {
|
|
34
|
+
stderr.write(`${label}\n`);
|
|
35
|
+
for (let i = 0; i < options.length; i++) {
|
|
36
|
+
const cursor = i === selected ? "> " : " ";
|
|
37
|
+
stderr.write(`${cursor}${options[i]}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
render();
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
stdin.setRawMode(true);
|
|
43
|
+
stdin.resume();
|
|
44
|
+
function cleanup() {
|
|
45
|
+
stdin.setRawMode(false);
|
|
46
|
+
stdin.pause();
|
|
47
|
+
stdin.removeListener("data", onData);
|
|
48
|
+
}
|
|
49
|
+
function onData(data) {
|
|
50
|
+
let remaining = data.toString();
|
|
51
|
+
while (remaining) {
|
|
52
|
+
const sequence = remaining.startsWith("\x1b[") ? remaining.slice(0, 3) : remaining[0];
|
|
53
|
+
remaining = remaining.slice(sequence.length);
|
|
54
|
+
const key = parseKey(Buffer.from(sequence));
|
|
55
|
+
if (key.name === "ctrl+c" || key.name === "ctrl+d" || key.name === "escape") {
|
|
56
|
+
cleanup();
|
|
57
|
+
clearLines(stderr, lineCount);
|
|
58
|
+
resolve(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (key.name === "return") {
|
|
62
|
+
cleanup();
|
|
63
|
+
clearLines(stderr, lineCount);
|
|
64
|
+
resolve(selected);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.name === "up") {
|
|
68
|
+
clearLines(stderr, lineCount);
|
|
69
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
70
|
+
render();
|
|
71
|
+
}
|
|
72
|
+
else if (key.name === "down") {
|
|
73
|
+
clearLines(stderr, lineCount);
|
|
74
|
+
selected = (selected + 1) % options.length;
|
|
75
|
+
render();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
stdin.on("data", onData);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export async function promptText(label, stdin = process.stdin, stderr = process.stderr) {
|
|
83
|
+
if (!stdin.isTTY || !stderr.isTTY) {
|
|
84
|
+
throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
|
|
85
|
+
}
|
|
86
|
+
stderr.write(`${label} `);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let buffer = "";
|
|
89
|
+
stdin.setRawMode(true);
|
|
90
|
+
stdin.resume();
|
|
91
|
+
function cleanup() {
|
|
92
|
+
stdin.setRawMode(false);
|
|
93
|
+
stdin.pause();
|
|
94
|
+
stdin.removeListener("data", onData);
|
|
95
|
+
}
|
|
96
|
+
function onData(data) {
|
|
97
|
+
for (const seq of data.toString()) {
|
|
98
|
+
if (seq === "\r" || seq === "\n") {
|
|
99
|
+
cleanup();
|
|
100
|
+
stderr.write("\n");
|
|
101
|
+
resolve(buffer.trim() || null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (seq === "\x03" || seq === "\x04" || seq === "\x1b") {
|
|
105
|
+
cleanup();
|
|
106
|
+
stderr.write("\n");
|
|
107
|
+
resolve(null);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (seq === "\x7f" || seq === "\b") {
|
|
111
|
+
if (buffer.length > 0) {
|
|
112
|
+
buffer = buffer.slice(0, -1);
|
|
113
|
+
stderr.write("\x1b[D \x1b[D");
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (seq >= " ") {
|
|
118
|
+
buffer += seq;
|
|
119
|
+
stderr.write(seq);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
stdin.on("data", onData);
|
|
124
|
+
});
|
|
125
|
+
}
|