@ours.network/fleet 1.1.0-nightly.9 → 1.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/README.md +231 -46
- package/dist/application/capabilities.js +18 -6
- package/dist/application/fleet-query-service.js +7 -4
- package/dist/application/role-command-service.js +9 -0
- package/dist/application/role-creation-service.d.ts +4 -4
- package/dist/application/role-creation-service.js +8 -1
- package/dist/application/role-repository.d.ts +4 -2
- package/dist/application/role-repository.js +6 -3
- package/dist/application/session-control.d.ts +3 -1
- package/dist/application/session-control.js +4 -2
- package/dist/application/task-room-service.d.ts +92 -39
- package/dist/application/task-room-service.js +562 -289
- package/dist/briefing.js +79 -22
- package/dist/build-info.json +5 -5
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +10 -0
- package/dist/cli.js +129 -47
- package/dist/config.d.ts +38 -3
- package/dist/config.js +193 -28
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +7 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +181 -41
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +40 -4
- package/dist/fleet-command-audit.d.ts +22 -1
- package/dist/fleet-command-audit.js +355 -50
- package/dist/fleet-proxy.d.ts +5 -2
- package/dist/harness/claude-code-session.js +2 -1
- package/dist/harness/codex-app-server-proxy.d.ts +40 -0
- package/dist/harness/codex-app-server-proxy.js +348 -14
- package/dist/harness/codex-session.d.ts +9 -2
- package/dist/harness/codex-session.js +18 -2
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +104 -10
- package/dist/harness/types.d.ts +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/init-guidance.d.ts +1 -1
- package/dist/init-guidance.js +1 -1
- package/dist/init-wizard.d.ts +98 -0
- package/dist/init-wizard.js +631 -0
- package/dist/lifecycle-summary.d.ts +134 -0
- package/dist/lifecycle-summary.js +179 -0
- package/dist/loops/config.d.ts +10 -1
- package/dist/loops/config.js +38 -5
- package/dist/model-recovery.d.ts +1 -1
- package/dist/ops.js +2 -17
- package/dist/owner-channel/channel.d.ts +19 -3
- package/dist/owner-channel/channel.js +301 -120
- package/dist/owner-channel/commands.d.ts +28 -6
- package/dist/owner-channel/commands.js +109 -27
- package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
- package/dist/owner-channel/lifecycle-outbox.js +87 -0
- package/dist/owner-channel/ours-client.d.ts +19 -0
- package/dist/owner-channel/ours-client.js +3 -0
- package/dist/permissions.js +1 -1
- package/dist/preset-migration.d.ts +42 -0
- package/dist/preset-migration.js +450 -0
- package/dist/reports/artifact.d.ts +10 -0
- package/dist/reports/artifact.js +32 -0
- package/dist/reports/index.d.ts +5 -0
- package/dist/reports/index.js +5 -0
- package/dist/reports/render.d.ts +6 -0
- package/dist/reports/render.js +240 -0
- package/dist/reports/service.d.ts +42 -0
- package/dist/reports/service.js +57 -0
- package/dist/reports/tasks.d.ts +52 -0
- package/dist/reports/tasks.js +177 -0
- package/dist/reports/types.d.ts +315 -0
- package/dist/reports/types.js +6 -0
- package/dist/resolved-plan.js +13 -1
- package/dist/rooms-tasks/cli.d.ts +2 -0
- package/dist/rooms-tasks/cli.js +361 -285
- package/dist/rooms-tasks/close.d.ts +9 -1
- package/dist/rooms-tasks/close.js +23 -7
- package/dist/rooms-tasks/config.js +16 -25
- package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
- package/dist/rooms-tasks/cowork-adapter.js +13 -0
- package/dist/rooms-tasks/deletion.d.ts +51 -0
- package/dist/rooms-tasks/deletion.js +217 -0
- package/dist/rooms-tasks/external-worker.d.ts +8 -0
- package/dist/rooms-tasks/external-worker.js +38 -0
- package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
- package/dist/rooms-tasks/launch-snapshot.js +181 -0
- package/dist/rooms-tasks/member-overrides.d.ts +30 -0
- package/dist/rooms-tasks/member-overrides.js +204 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +9 -2
- package/dist/rooms-tasks/provision.js +146 -83
- package/dist/rooms-tasks/room-state.d.ts +1 -0
- package/dist/rooms-tasks/room-state.js +12 -1
- package/dist/rooms-tasks/task-room-name.d.ts +12 -0
- package/dist/rooms-tasks/task-room-name.js +61 -0
- package/dist/rooms-tasks/task-state.d.ts +115 -3
- package/dist/rooms-tasks/task-state.js +361 -11
- package/dist/rooms-tasks/templates.d.ts +4 -1
- package/dist/rooms-tasks/templates.js +34 -3
- package/dist/rooms-tasks/terminal.d.ts +3 -0
- package/dist/rooms-tasks/terminal.js +10 -4
- package/dist/rooms-tasks/types.d.ts +92 -7
- package/dist/rooms-tasks/types.js +14 -1
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +91 -44
- package/dist/session/acp.d.ts +4 -1
- package/dist/session/acp.js +29 -13
- package/dist/session/arbiter.d.ts +2 -1
- package/dist/session/arbiter.js +2 -0
- package/dist/session/codex-app-server-transport.d.ts +53 -0
- package/dist/session/codex-app-server-transport.js +184 -0
- package/dist/session/codex-app-server.d.ts +122 -0
- package/dist/session/codex-app-server.js +1031 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/control.js +17 -1
- package/dist/session/conversation-types.d.ts +4 -4
- package/dist/session/types.d.ts +24 -6
- package/dist/session/types.js +28 -0
- package/dist/spawn.d.ts +9 -0
- package/dist/spawn.js +77 -9
- package/dist/web/fleet-config-service.d.ts +1 -0
- package/dist/web/fleet-config-service.js +25 -7
- package/dist/web/runtime.js +11 -6
- package/dist/web/server.js +28 -1
- package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
- package/dist/web-app/index.html +1 -1
- package/package.json +4 -4
- package/presets/brain-catalog.json +17 -0
- package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
- package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
- package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
- package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +37 -0
- package/presets/fleet/roles/Critic.yaml +23 -1
- package/presets/fleet/roles/Developer.yaml +27 -5
- package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
- package/presets/fleet/room_templates/pair.yaml +9 -7
- package/presets/fleet/room_templates/single.yaml +5 -4
- package/presets/fleet/room_templates/team.yaml +12 -8
- package/presets/manifest.json +2 -1
- package/presets/fleet/agents/Architect.yaml +0 -3
- package/presets/fleet/agents/Secretary.yaml +0 -3
- package/presets/fleet/agents/Tester.yaml +0 -3
- package/presets/fleet/roles/Agent.yaml +0 -6
- package/presets/fleet/roles/Architect.yaml +0 -6
- package/presets/fleet/roles/Secretary.yaml +0 -6
- package/presets/fleet/roles/Tester.yaml +0 -6
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { chmodSync, closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
5
|
+
import { loadConfig, splitRootFor } from './config.js';
|
|
6
|
+
import { packagedPresetRoot } from './preset-bootstrap.js';
|
|
7
|
+
import { listTemplates } from './rooms-tasks/templates.js';
|
|
8
|
+
const WORK_KINDS = ['development', 'review', 'coordination'];
|
|
9
|
+
const ROLE_WORK = {
|
|
10
|
+
Developer: 'development', Critic: 'review',
|
|
11
|
+
LocalCoordinator: 'coordination', Coordinator: 'coordination', FleetCoordinator: 'coordination',
|
|
12
|
+
};
|
|
13
|
+
const REASONING_EFFORT = {
|
|
14
|
+
quick: 'low', balanced: 'medium', thorough: 'high',
|
|
15
|
+
};
|
|
16
|
+
const MODEL_LABELS = {
|
|
17
|
+
'gpt-6-astra': 'GPT-6 Astra',
|
|
18
|
+
'gpt-5.6-sol': 'GPT-5.6 Sol',
|
|
19
|
+
'gpt-5.6-terra': 'GPT-5.6 Terra',
|
|
20
|
+
'gpt-5.6-luna': 'GPT-5.6 Luna',
|
|
21
|
+
'gpt-5.5': 'GPT-5.5',
|
|
22
|
+
'gpt-5.4': 'GPT-5.4',
|
|
23
|
+
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
|
24
|
+
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
|
25
|
+
'claude-fable-5': 'Claude Fable 5',
|
|
26
|
+
'claude-opus-5': 'Claude Opus 5',
|
|
27
|
+
'claude-sonnet-5': 'Claude Sonnet 5',
|
|
28
|
+
};
|
|
29
|
+
export function validateCatalog(value, path = 'supported model catalog') {
|
|
30
|
+
if (value.schema_version !== 1 || !Array.isArray(value.models) || value.models.length === 0)
|
|
31
|
+
throw new Error(`invalid supported model catalog: ${path}`);
|
|
32
|
+
const tuples = new Set();
|
|
33
|
+
for (const model of value.models) {
|
|
34
|
+
if (!['codex', 'claude-code'].includes(model.harness) || model.session !== 'acp'
|
|
35
|
+
|| typeof model.model !== 'string' || !MODEL_LABELS[model.model]
|
|
36
|
+
|| !Array.isArray(model.efforts) || model.efforts.length === 0
|
|
37
|
+
|| new Set(model.efforts).size !== model.efforts.length
|
|
38
|
+
|| model.efforts.some(effort => !['low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(effort))
|
|
39
|
+
|| !['low', 'medium', 'high'].every(effort => model.efforts.includes(effort)))
|
|
40
|
+
throw new Error(`unsupported model state in ${path}`);
|
|
41
|
+
const tuple = `${model.harness}\0${model.session}\0${model.model}`;
|
|
42
|
+
if (tuples.has(tuple))
|
|
43
|
+
throw new Error(`duplicate supported model in ${path}: ${model.model}`);
|
|
44
|
+
tuples.add(tuple);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function catalog() {
|
|
49
|
+
const path = join(packagedPresetRoot(), 'brain-catalog.json');
|
|
50
|
+
return validateCatalog(JSON.parse(readFileSync(path, 'utf8')), path);
|
|
51
|
+
}
|
|
52
|
+
const subscriptionFor = (model) => model.harness === 'codex' ? 'codex' : 'claude';
|
|
53
|
+
export async function askInitQuestions(prompter, configuration) {
|
|
54
|
+
const configPath = resolve(configuration);
|
|
55
|
+
const splitRoot = splitRootFor(configPath);
|
|
56
|
+
const confirmed = await prompter.confirm('Add missing default Fleet configuration while preserving existing files. Continue?', `It will add missing defaults under ${configPath} and ${splitRoot}; existing configuration and templates remain byte-identical. Explicit default adoption is a separate migration. Identities and project files are not changed. Host service setup may also be updated after you finish the questions.`);
|
|
57
|
+
if (!confirmed)
|
|
58
|
+
return undefined;
|
|
59
|
+
const subscriptions = await prompter.multiSelect('Which supported subscriptions do you have?', [{ label: 'Codex', value: 'codex' }, { label: 'Claude', value: 'claude' }]);
|
|
60
|
+
if (!subscriptions)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (subscriptions.length === 0)
|
|
63
|
+
throw new Error('Select at least one subscription.');
|
|
64
|
+
const available = catalog().models.filter(model => subscriptions.includes(subscriptionFor(model)));
|
|
65
|
+
if (!available.length)
|
|
66
|
+
throw new Error('None of the selected subscriptions has a supported model.');
|
|
67
|
+
prompter.note([
|
|
68
|
+
'These are exact supported catalog IDs, not recommendations or entitlement claims.',
|
|
69
|
+
'After setup, run ours-fleet doctor for local Codex availability. Claude entitlement is validated when a role launches.',
|
|
70
|
+
].join(' '));
|
|
71
|
+
const assignmentStrategy = await prompter.select('How should models be assigned?', [
|
|
72
|
+
{ label: 'One model for every job — ordinary setup', value: 'one-model' },
|
|
73
|
+
{ label: 'Different models by job — specialized setup', value: 'per-job' },
|
|
74
|
+
], 0);
|
|
75
|
+
if (!assignmentStrategy)
|
|
76
|
+
return undefined;
|
|
77
|
+
const choices = available.map(model => ({
|
|
78
|
+
label: `${MODEL_LABELS[model.model]} (${subscriptionFor(model) === 'codex' ? 'Codex' : 'Claude'}) — ${model.model}; supported catalog ID, not a recommendation`,
|
|
79
|
+
value: model,
|
|
80
|
+
}));
|
|
81
|
+
const selected = {};
|
|
82
|
+
if (assignmentStrategy === 'one-model') {
|
|
83
|
+
const model = await prompter.select('Which model should handle every job?', choices, 0);
|
|
84
|
+
if (!model)
|
|
85
|
+
return undefined;
|
|
86
|
+
for (const work of WORK_KINDS)
|
|
87
|
+
selected[work] = model;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
for (const work of WORK_KINDS) {
|
|
91
|
+
const model = await prompter.select(`Which model should handle ${work}?`, choices, 0);
|
|
92
|
+
if (!model)
|
|
93
|
+
return undefined;
|
|
94
|
+
selected[work] = model;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const reasoning = await prompter.select('How much reasoning should Fleet use?', [
|
|
98
|
+
{ label: 'Quick — favor speed', value: 'quick' },
|
|
99
|
+
{ label: 'Balanced — everyday work', value: 'balanced' },
|
|
100
|
+
{ label: 'Thorough — harder work', value: 'thorough' },
|
|
101
|
+
], 1);
|
|
102
|
+
if (!reasoning)
|
|
103
|
+
return undefined;
|
|
104
|
+
const answers = {
|
|
105
|
+
subscriptions: [...subscriptions].sort(), assignmentStrategy, models: selected, reasoning,
|
|
106
|
+
};
|
|
107
|
+
const summary = formatSetupSummary(answers, configuration);
|
|
108
|
+
const finalized = await prompter.confirm('Create this default Fleet setup?', `${summary}\n\nNo changes have been made yet.`);
|
|
109
|
+
return finalized ? answers : undefined;
|
|
110
|
+
}
|
|
111
|
+
export function formatSetupSummary(answers, configuration) {
|
|
112
|
+
const configPath = resolve(configuration);
|
|
113
|
+
const splitRoot = splitRootFor(configPath);
|
|
114
|
+
const configExists = existsSync(configPath);
|
|
115
|
+
const rootExists = existsSync(splitRoot);
|
|
116
|
+
const existing = configExists && rootExists ? 'both targets exist'
|
|
117
|
+
: configExists ? 'only the manifest exists'
|
|
118
|
+
: rootExists ? 'only the split configuration directory exists' : 'neither target exists';
|
|
119
|
+
const friendly = (work) => `${MODEL_LABELS[answers.models[work].model]} (${subscriptionFor(answers.models[work]) === 'codex' ? 'Codex' : 'Claude'}, ${answers.models[work].model})`;
|
|
120
|
+
const effort = REASONING_EFFORT[answers.reasoning];
|
|
121
|
+
return [
|
|
122
|
+
'Your default Fleet setup:',
|
|
123
|
+
` Manifest: ${configPath}`,
|
|
124
|
+
` Split configuration: ${splitRoot}`,
|
|
125
|
+
` Existing state: ${existing}`,
|
|
126
|
+
` Model assignment: ${answers.assignmentStrategy === 'one-model' ? 'one model for every job' : 'different models by job'}`,
|
|
127
|
+
` Development: ${friendly('development')}`,
|
|
128
|
+
` Review: ${friendly('review')}`,
|
|
129
|
+
` Coordination: ${friendly('coordination')}`,
|
|
130
|
+
` Reasoning: ${answers.reasoning[0].toUpperCase()}${answers.reasoning.slice(1)} -> ${effort}`,
|
|
131
|
+
' Availability: catalog support is not a recommendation or entitlement claim',
|
|
132
|
+
' Entitlement check: run ours-fleet doctor for Codex; Claude is validated when a role launches',
|
|
133
|
+
' Automatic model fallback: none (no model_chain is generated)',
|
|
134
|
+
' One-agent work: one Developer',
|
|
135
|
+
' Reviewed pair: Developer + independent Critic',
|
|
136
|
+
' Team: LocalCoordinator + Developer + Critic',
|
|
137
|
+
' FleetCoordinator: coordination model',
|
|
138
|
+
'',
|
|
139
|
+
'After the final Yes, Fleet performs host service setup before publishing the complete configuration.',
|
|
140
|
+
'A hard termination after that Yes can leave host integration or private stage/recovery evidence to inspect.',
|
|
141
|
+
].join('\n');
|
|
142
|
+
}
|
|
143
|
+
function preset(relative) {
|
|
144
|
+
return readFileSync(join(packagedPresetRoot(), 'fleet', relative), 'utf8');
|
|
145
|
+
}
|
|
146
|
+
/** Build the exact, deterministic default experience without writing to disk. */
|
|
147
|
+
export function generateSetup(answers) {
|
|
148
|
+
const effort = REASONING_EFFORT[answers.reasoning];
|
|
149
|
+
if (!effort)
|
|
150
|
+
throw new Error(`unsupported reasoning preference: ${String(answers.reasoning)}`);
|
|
151
|
+
if (!['one-model', 'per-job'].includes(answers.assignmentStrategy))
|
|
152
|
+
throw new Error(`unsupported model assignment strategy: ${String(answers.assignmentStrategy)}`);
|
|
153
|
+
if (!Array.isArray(answers.subscriptions) || answers.subscriptions.length === 0
|
|
154
|
+
|| new Set(answers.subscriptions).size !== answers.subscriptions.length
|
|
155
|
+
|| answers.subscriptions.some(subscription => !['codex', 'claude'].includes(subscription)))
|
|
156
|
+
throw new Error('subscriptions must be a non-empty unique selection of Codex and/or Claude');
|
|
157
|
+
const supported = catalog().models;
|
|
158
|
+
const files = new Map();
|
|
159
|
+
files.set('fleet.yaml', [
|
|
160
|
+
'api_version: ours.network/fleet/v2',
|
|
161
|
+
'',
|
|
162
|
+
'defaults:',
|
|
163
|
+
' permissions: { approval: ask, filesystem: workspace, unattended: deny }',
|
|
164
|
+
'',
|
|
165
|
+
].join('\n'));
|
|
166
|
+
for (const work of WORK_KINDS) {
|
|
167
|
+
const proposed = answers.models[work];
|
|
168
|
+
if (!proposed || !answers.subscriptions.includes(subscriptionFor(proposed)))
|
|
169
|
+
throw new Error(`${work} uses a model outside the selected subscriptions`);
|
|
170
|
+
const matches = supported.filter(model => model.harness === proposed.harness
|
|
171
|
+
&& model.session === proposed.session && model.model === proposed.model);
|
|
172
|
+
if (matches.length !== 1)
|
|
173
|
+
throw new Error(`${work} uses an unknown or ambiguous supported model`);
|
|
174
|
+
const model = matches[0];
|
|
175
|
+
if (JSON.stringify(proposed.efforts) !== JSON.stringify(model.efforts))
|
|
176
|
+
throw new Error(`${work} contains tampered model capabilities`);
|
|
177
|
+
if (!model.efforts.includes(effort))
|
|
178
|
+
throw new Error(`${MODEL_LABELS[model.model]} does not support ${answers.reasoning} reasoning`);
|
|
179
|
+
files.set(`brains/${work}.yaml`, [
|
|
180
|
+
`harness: ${model.harness}`,
|
|
181
|
+
`session: ${model.session}`,
|
|
182
|
+
`model: ${model.model}`,
|
|
183
|
+
`effort: ${effort}`,
|
|
184
|
+
'',
|
|
185
|
+
].join('\n'));
|
|
186
|
+
}
|
|
187
|
+
if (answers.assignmentStrategy === 'one-model') {
|
|
188
|
+
const tuples = new Set(WORK_KINDS.map(work => {
|
|
189
|
+
const model = answers.models[work];
|
|
190
|
+
return `${model.harness}\0${model.session}\0${model.model}`;
|
|
191
|
+
}));
|
|
192
|
+
if (tuples.size !== 1)
|
|
193
|
+
throw new Error('one-model assignment requires the same exact model for development, review, and coordination');
|
|
194
|
+
}
|
|
195
|
+
for (const role of ['Coordinator', 'LocalCoordinator', 'Developer', 'Critic']) {
|
|
196
|
+
files.set(`roles/${role}.yaml`, preset(`roles/${role}.yaml`));
|
|
197
|
+
if (role === 'Coordinator')
|
|
198
|
+
continue;
|
|
199
|
+
files.set(`agent_templates/${role}.yaml`, [
|
|
200
|
+
`role: { ref: ${role} }`,
|
|
201
|
+
`brain: { ref: ${ROLE_WORK[role]} }`,
|
|
202
|
+
'coordinator: FleetCoordinator',
|
|
203
|
+
'permissions: { approval: ask, filesystem: workspace, unattended: deny }',
|
|
204
|
+
'monitor: { mode: fleet, interrupt: after_tool }',
|
|
205
|
+
'',
|
|
206
|
+
].join('\n'));
|
|
207
|
+
}
|
|
208
|
+
files.set('agents/FleetCoordinator.yaml', preset('agents/FleetCoordinator.yaml')
|
|
209
|
+
.replace('brain: { ref: claude-default }', 'brain: { ref: coordination }'));
|
|
210
|
+
for (const name of ['single', 'pair', 'team'])
|
|
211
|
+
files.set(`room_templates/${name}.yaml`, preset(`room_templates/${name}.yaml`));
|
|
212
|
+
return { files, answers };
|
|
213
|
+
}
|
|
214
|
+
/** Run the mutation boundary only after the complete questionnaire is accepted. */
|
|
215
|
+
export async function executeInitWizard(prompter, configuration, deps) {
|
|
216
|
+
const answers = await askInitQuestions(prompter, configuration);
|
|
217
|
+
if (!answers)
|
|
218
|
+
return undefined;
|
|
219
|
+
// Resolve every catalog/preset dependency before crossing the first mutation
|
|
220
|
+
// boundary. Production owns this new map; the optional seam forces failures
|
|
221
|
+
// deterministically in tests.
|
|
222
|
+
const setup = (deps.generate ?? generateSetup)(answers);
|
|
223
|
+
(deps.preflight ?? preflightInitPaths)(configuration);
|
|
224
|
+
try {
|
|
225
|
+
await deps.hostSetup();
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
throw new Error([
|
|
229
|
+
'Host setup failed before configuration publication began.',
|
|
230
|
+
'Host integration or Fleet state directories may have changed; the configuration was not published.',
|
|
231
|
+
error.message,
|
|
232
|
+
].join(' '), { cause: error });
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return await deps.publish(configuration, setup);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
throw new Error([
|
|
239
|
+
'Host setup completed, but configuration publication failed.',
|
|
240
|
+
'Host integration or Fleet state directories may have changed; inspect them and any private init stage/recovery evidence.',
|
|
241
|
+
error.message,
|
|
242
|
+
].join(' '), { cause: error });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function canonicalGeneratedSetup(setup) {
|
|
246
|
+
const expected = generateSetup(setup.answers).files;
|
|
247
|
+
const actualPaths = [...setup.files.keys()].sort();
|
|
248
|
+
const expectedPaths = [...expected.keys()].sort();
|
|
249
|
+
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths))
|
|
250
|
+
throw new Error('generated setup has an unexpected, missing, or unsafe file path');
|
|
251
|
+
for (const path of expectedPaths)
|
|
252
|
+
if (setup.files.get(path) !== expected.get(path))
|
|
253
|
+
throw new Error(`generated setup content does not match the accepted answers: ${path}`);
|
|
254
|
+
// Return the newly generated map, never the caller-owned map checked above.
|
|
255
|
+
return { files: expected, answers: setup.answers };
|
|
256
|
+
}
|
|
257
|
+
function preserveExistingSetup(configPath, setup) {
|
|
258
|
+
const files = new Map(setup.files);
|
|
259
|
+
if (existsSync(configPath))
|
|
260
|
+
files.set('fleet.yaml', readFileSync(configPath, 'utf8'));
|
|
261
|
+
const root = splitRootFor(configPath);
|
|
262
|
+
const visit = (directory, prefix = '') => {
|
|
263
|
+
if (!existsSync(directory))
|
|
264
|
+
return;
|
|
265
|
+
for (const entry of readdirSync(directory)) {
|
|
266
|
+
const absolute = join(directory, entry);
|
|
267
|
+
const relative = join(prefix, entry);
|
|
268
|
+
const stat = lstatSync(absolute);
|
|
269
|
+
if (stat.isDirectory())
|
|
270
|
+
visit(absolute, relative);
|
|
271
|
+
else
|
|
272
|
+
files.set(relative, readFileSync(absolute, 'utf8'));
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
visit(root);
|
|
276
|
+
return { files, answers: setup.answers };
|
|
277
|
+
}
|
|
278
|
+
function pathDeps(overrides = {}) {
|
|
279
|
+
return {
|
|
280
|
+
exists: overrides.exists ?? existsSync,
|
|
281
|
+
lstat: overrides.lstat ?? lstatSync,
|
|
282
|
+
stat: overrides.stat ?? statSync,
|
|
283
|
+
uid: overrides.uid ?? (() => process.getuid?.()),
|
|
284
|
+
entries: overrides.entries ?? readdirSync,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function assertOwnerPrivate(path, kind, overrides = {}) {
|
|
288
|
+
const deps = pathDeps(overrides);
|
|
289
|
+
const stat = deps.lstat(path);
|
|
290
|
+
const uid = deps.uid();
|
|
291
|
+
if (stat.isSymbolicLink() || (kind === 'file' ? !stat.isFile() : !stat.isDirectory()))
|
|
292
|
+
throw new Error(`init refuses non-regular ${kind}: ${path}`);
|
|
293
|
+
if (uid !== undefined && stat.uid !== uid)
|
|
294
|
+
throw new Error(`init refuses path owned by uid ${stat.uid}: ${path}`);
|
|
295
|
+
if ((stat.mode & 0o077) !== 0)
|
|
296
|
+
throw new Error(`init requires owner-private mode: ${path}`);
|
|
297
|
+
}
|
|
298
|
+
function assertPrivateTree(path, overrides = {}) {
|
|
299
|
+
const deps = pathDeps(overrides);
|
|
300
|
+
assertOwnerPrivate(path, 'directory', overrides);
|
|
301
|
+
for (const entry of deps.entries(path)) {
|
|
302
|
+
const child = join(path, entry);
|
|
303
|
+
const stat = deps.lstat(child);
|
|
304
|
+
if (stat.isDirectory())
|
|
305
|
+
assertPrivateTree(child, overrides);
|
|
306
|
+
else
|
|
307
|
+
assertOwnerPrivate(child, 'file', overrides);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Read-only safety gate. Run once before host setup and again under the init lock. */
|
|
311
|
+
export function preflightInitPaths(configuration, overrides = {}) {
|
|
312
|
+
const deps = pathDeps(overrides);
|
|
313
|
+
const configPath = resolve(configuration);
|
|
314
|
+
const splitRoot = splitRootFor(configPath);
|
|
315
|
+
const parent = dirname(configPath);
|
|
316
|
+
if (dirname(splitRoot) !== parent)
|
|
317
|
+
throw new Error('manifest and configuration directory must share one parent');
|
|
318
|
+
if (!deps.exists(parent))
|
|
319
|
+
throw new Error(`configuration parent does not exist: ${parent}`);
|
|
320
|
+
const parentStat = deps.lstat(parent);
|
|
321
|
+
const uid = deps.uid();
|
|
322
|
+
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()
|
|
323
|
+
|| (uid !== undefined && parentStat.uid !== uid) || (parentStat.mode & 0o022) !== 0)
|
|
324
|
+
throw new Error(`init requires an owner-controlled, non-group/world-writable parent: ${parent}`);
|
|
325
|
+
const manifestExisted = deps.exists(configPath);
|
|
326
|
+
const rootExisted = deps.exists(splitRoot);
|
|
327
|
+
if (manifestExisted)
|
|
328
|
+
assertOwnerPrivate(configPath, 'file', overrides);
|
|
329
|
+
if (rootExisted)
|
|
330
|
+
assertPrivateTree(splitRoot, overrides);
|
|
331
|
+
for (const target of [configPath, splitRoot]) {
|
|
332
|
+
if (deps.exists(target) && deps.stat(target).dev !== parentStat.dev)
|
|
333
|
+
throw new Error(`init requires same-filesystem publication: ${target}`);
|
|
334
|
+
}
|
|
335
|
+
return { configPath, splitRoot, parent, manifestExisted, rootExisted };
|
|
336
|
+
}
|
|
337
|
+
function fsyncPath(path) {
|
|
338
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
339
|
+
try {
|
|
340
|
+
fsyncSync(fd);
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
closeSync(fd);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function writeStaged(stageManifest, setup) {
|
|
347
|
+
const stageRoot = splitRootFor(stageManifest);
|
|
348
|
+
for (const [relative, contents] of setup.files) {
|
|
349
|
+
const target = relative === 'fleet.yaml' ? stageManifest : join(stageRoot, relative);
|
|
350
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
351
|
+
chmodSync(dirname(target), 0o700);
|
|
352
|
+
replaceFileAtomically(target, contents, 0o600);
|
|
353
|
+
fsyncPath(target);
|
|
354
|
+
}
|
|
355
|
+
for (const kind of ['agents', 'agent_templates', 'roles', 'brains', 'room_templates'])
|
|
356
|
+
fsyncPath(join(stageRoot, kind));
|
|
357
|
+
fsyncPath(stageRoot);
|
|
358
|
+
fsyncPath(dirname(stageManifest));
|
|
359
|
+
}
|
|
360
|
+
function validateStaged(stageManifest) {
|
|
361
|
+
const config = loadConfig(stageManifest, { yamlMode: 'strict' });
|
|
362
|
+
const templates = listTemplates(config.roomTemplates ?? {});
|
|
363
|
+
const names = new Set(templates.map(template => template.name));
|
|
364
|
+
if (!['pair', 'single', 'team'].every(name => names.has(name)))
|
|
365
|
+
throw new Error('generated setup did not resolve the default single, pair, and team experiences');
|
|
366
|
+
for (const template of templates)
|
|
367
|
+
for (const member of template.members)
|
|
368
|
+
if (!config.agentTemplates?.[member.agent_template])
|
|
369
|
+
throw new Error(`generated setup cannot resolve ${template.name} member ${member.agent_template}`);
|
|
370
|
+
if (!config.roles.some(role => role.name === 'FleetCoordinator'))
|
|
371
|
+
throw new Error('generated setup cannot resolve the default coordinator');
|
|
372
|
+
}
|
|
373
|
+
function inode(path) {
|
|
374
|
+
const stat = lstatSync(path);
|
|
375
|
+
return `${stat.dev}:${stat.ino}`;
|
|
376
|
+
}
|
|
377
|
+
/** Replace the complete configuration under one lock, retaining a private recovery copy. */
|
|
378
|
+
export async function publishSetup(configuration, setup, hooks = {}) {
|
|
379
|
+
// The setup object is exported for deterministic tests and orchestration. Treat
|
|
380
|
+
// it as untrusted here: no caller-provided path reaches join()/write before the
|
|
381
|
+
// complete path set and contents are regenerated from the accepted answers.
|
|
382
|
+
let canonicalSetup = canonicalGeneratedSetup(setup);
|
|
383
|
+
const initial = preflightInitPaths(configuration);
|
|
384
|
+
const { configPath, splitRoot, parent } = initial;
|
|
385
|
+
const stem = basename(splitRoot);
|
|
386
|
+
return withFileLock(join(parent, `.${stem}.init.lock`), async () => {
|
|
387
|
+
const locked = preflightInitPaths(configuration);
|
|
388
|
+
const { manifestExisted, rootExisted } = locked;
|
|
389
|
+
canonicalSetup = preserveExistingSetup(configPath, canonicalSetup);
|
|
390
|
+
const nonce = `${new Date().toISOString().replace(/[^0-9]/g, '')}-${process.pid}-${randomUUID()}`;
|
|
391
|
+
const stagePath = join(parent, `.${stem}.init-stage-${nonce}`);
|
|
392
|
+
const recoveryPath = join(parent, `.${stem}.init-recovery-${nonce}`);
|
|
393
|
+
const stageManifest = join(stagePath, 'fleet.yaml');
|
|
394
|
+
const stageRoot = splitRootFor(stageManifest);
|
|
395
|
+
let createdStage = false;
|
|
396
|
+
let createdRecovery = false;
|
|
397
|
+
let publishedRoot;
|
|
398
|
+
let publishedManifest;
|
|
399
|
+
let backedRoot = false;
|
|
400
|
+
let backedManifest = false;
|
|
401
|
+
try {
|
|
402
|
+
hooks.beforeArtifact?.('stage');
|
|
403
|
+
mkdirSync(stagePath, { mode: 0o700 });
|
|
404
|
+
createdStage = true;
|
|
405
|
+
hooks.beforeArtifact?.('recovery');
|
|
406
|
+
mkdirSync(recoveryPath, { mode: 0o700 });
|
|
407
|
+
createdRecovery = true;
|
|
408
|
+
writeStaged(stageManifest, canonicalSetup);
|
|
409
|
+
assertPrivateTree(stagePath);
|
|
410
|
+
validateStaged(stageManifest);
|
|
411
|
+
replaceFileAtomically(join(recoveryPath, 'state.json'), `${JSON.stringify({
|
|
412
|
+
version: 1, configPath, splitRoot,
|
|
413
|
+
manifestExisted, rootExisted,
|
|
414
|
+
}, null, 2)}\n`, 0o600);
|
|
415
|
+
fsyncPath(join(recoveryPath, 'state.json'));
|
|
416
|
+
if (existsSync(splitRoot)) {
|
|
417
|
+
renameSync(splitRoot, join(recoveryPath, 'fleet'));
|
|
418
|
+
backedRoot = true;
|
|
419
|
+
}
|
|
420
|
+
if (existsSync(configPath)) {
|
|
421
|
+
renameSync(configPath, join(recoveryPath, 'fleet.yaml'));
|
|
422
|
+
backedManifest = true;
|
|
423
|
+
}
|
|
424
|
+
fsyncPath(recoveryPath);
|
|
425
|
+
fsyncPath(parent);
|
|
426
|
+
const rootProof = inode(stageRoot);
|
|
427
|
+
hooks.beforePublish?.('root');
|
|
428
|
+
if (existsSync(splitRoot))
|
|
429
|
+
throw new Error(`refusing to overwrite unproven path during publication: ${splitRoot}`);
|
|
430
|
+
if (inode(stageRoot) !== rootProof)
|
|
431
|
+
throw new Error(`staged configuration changed before publication: ${stageRoot}`);
|
|
432
|
+
renameSync(stageRoot, splitRoot);
|
|
433
|
+
publishedRoot = rootProof;
|
|
434
|
+
fsyncPath(parent);
|
|
435
|
+
const manifestProof = inode(stageManifest);
|
|
436
|
+
hooks.beforePublish?.('manifest');
|
|
437
|
+
if (existsSync(configPath))
|
|
438
|
+
throw new Error(`refusing to overwrite unproven path during publication: ${configPath}`);
|
|
439
|
+
if (inode(stageManifest) !== manifestProof)
|
|
440
|
+
throw new Error(`staged manifest changed before publication: ${stageManifest}`);
|
|
441
|
+
renameSync(stageManifest, configPath);
|
|
442
|
+
publishedManifest = manifestProof;
|
|
443
|
+
fsyncPath(parent);
|
|
444
|
+
validateStaged(configPath);
|
|
445
|
+
rmSync(stagePath, { recursive: true });
|
|
446
|
+
fsyncPath(parent);
|
|
447
|
+
return {
|
|
448
|
+
configPath, splitRoot, recoveryPath,
|
|
449
|
+
replacedExisting: manifestExisted || rootExisted,
|
|
450
|
+
manifestExisted, rootExisted,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
const publicationStarted = backedRoot || backedManifest || publishedRoot || publishedManifest;
|
|
455
|
+
if (!publicationStarted) {
|
|
456
|
+
if (createdStage)
|
|
457
|
+
try {
|
|
458
|
+
rmSync(stagePath, { recursive: true });
|
|
459
|
+
}
|
|
460
|
+
catch { /* retain evidence */ }
|
|
461
|
+
if (createdRecovery)
|
|
462
|
+
try {
|
|
463
|
+
rmSync(recoveryPath, { recursive: true });
|
|
464
|
+
}
|
|
465
|
+
catch { /* retain evidence */ }
|
|
466
|
+
fsyncPath(parent);
|
|
467
|
+
throw new Error(`init preparation failed before publication; no configuration was changed: ${error.message}`, { cause: error });
|
|
468
|
+
}
|
|
469
|
+
let rollbackError;
|
|
470
|
+
try {
|
|
471
|
+
if (publishedManifest && existsSync(configPath) && inode(configPath) === publishedManifest)
|
|
472
|
+
rmSync(configPath);
|
|
473
|
+
if (publishedRoot && existsSync(splitRoot) && inode(splitRoot) === publishedRoot)
|
|
474
|
+
rmSync(splitRoot, { recursive: true });
|
|
475
|
+
if (backedRoot) {
|
|
476
|
+
if (existsSync(splitRoot))
|
|
477
|
+
throw new Error(`refusing to overwrite unproven path during rollback: ${splitRoot}`);
|
|
478
|
+
renameSync(join(recoveryPath, 'fleet'), splitRoot);
|
|
479
|
+
}
|
|
480
|
+
if (backedManifest) {
|
|
481
|
+
if (existsSync(configPath))
|
|
482
|
+
throw new Error(`refusing to overwrite unproven path during rollback: ${configPath}`);
|
|
483
|
+
renameSync(join(recoveryPath, 'fleet.yaml'), configPath);
|
|
484
|
+
}
|
|
485
|
+
fsyncPath(parent);
|
|
486
|
+
}
|
|
487
|
+
catch (rollback) {
|
|
488
|
+
rollbackError = rollback;
|
|
489
|
+
}
|
|
490
|
+
if (rollbackError)
|
|
491
|
+
throw new Error(`init publication and rollback failed; recovery evidence retained at ${recoveryPath}; staging retained at ${stagePath}: ${rollbackError.message}`, { cause: error });
|
|
492
|
+
throw new Error(`init publication failed; original configuration restored; recovery evidence retained at ${recoveryPath}; staging retained at ${stagePath}: ${error.message}`, { cause: error });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
export function decodeKey(chunk) {
|
|
497
|
+
const value = chunk.toString();
|
|
498
|
+
if (value === '\u001b[A')
|
|
499
|
+
return 'up';
|
|
500
|
+
if (value === '\u001b[B')
|
|
501
|
+
return 'down';
|
|
502
|
+
if (value === ' ')
|
|
503
|
+
return 'space';
|
|
504
|
+
if (value === '\r' || value === '\n')
|
|
505
|
+
return 'enter';
|
|
506
|
+
if (value === '\u001b' || value === '\u0003' || value === '\u0004')
|
|
507
|
+
return 'escape';
|
|
508
|
+
if (/^y$/i.test(value))
|
|
509
|
+
return 'yes';
|
|
510
|
+
if (/^n$/i.test(value))
|
|
511
|
+
return 'no';
|
|
512
|
+
return 'other';
|
|
513
|
+
}
|
|
514
|
+
export function updateMultiSelect(state, key) {
|
|
515
|
+
if (key === 'up')
|
|
516
|
+
return { ...state, cursor: (state.cursor - 1 + state.selected.length) % state.selected.length };
|
|
517
|
+
if (key === 'down')
|
|
518
|
+
return { ...state, cursor: (state.cursor + 1) % state.selected.length };
|
|
519
|
+
if (key === 'space') {
|
|
520
|
+
const selected = [...state.selected];
|
|
521
|
+
selected[state.cursor] = !selected[state.cursor];
|
|
522
|
+
return { ...state, selected };
|
|
523
|
+
}
|
|
524
|
+
return state;
|
|
525
|
+
}
|
|
526
|
+
export class TerminalPrompter {
|
|
527
|
+
input;
|
|
528
|
+
output;
|
|
529
|
+
notes = [];
|
|
530
|
+
constructor(input, output) {
|
|
531
|
+
this.input = input;
|
|
532
|
+
this.output = output;
|
|
533
|
+
}
|
|
534
|
+
note(message) { this.notes.push(message); this.output.write(`${message}\n`); }
|
|
535
|
+
async confirm(message, detail) {
|
|
536
|
+
this.output.write(`\n${message}\n${detail}\nPress Y to continue or N to cancel. [N]\n`);
|
|
537
|
+
for (;;) {
|
|
538
|
+
const key = await this.key();
|
|
539
|
+
if (key === 'yes')
|
|
540
|
+
return true;
|
|
541
|
+
if (key === 'no' || key === 'enter' || key === 'escape')
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async multiSelect(message, choices) {
|
|
546
|
+
let state = { cursor: 0, selected: choices.map(() => false) };
|
|
547
|
+
let help = '↑/↓ move • Space toggle • Enter continue • Esc cancel';
|
|
548
|
+
for (;;) {
|
|
549
|
+
this.render(message, choices.map((choice, index) => `${state.selected[index] ? '[x]' : '[ ]'} ${choice.label}`), state.cursor, help);
|
|
550
|
+
const key = await this.key();
|
|
551
|
+
if (key === 'escape')
|
|
552
|
+
return undefined;
|
|
553
|
+
if (key === 'enter') {
|
|
554
|
+
if (state.selected.some(Boolean))
|
|
555
|
+
return choices.filter((_, i) => state.selected[i]).map(choice => choice.value);
|
|
556
|
+
help = 'Select at least one choice. • ↑/↓ move • Space toggle • Enter continue • Esc cancel';
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
state = updateMultiSelect(state, key);
|
|
560
|
+
help = '↑/↓ move • Space toggle • Enter continue • Esc cancel';
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async select(message, choices, initial = 0) {
|
|
565
|
+
let cursor = Math.max(0, Math.min(initial, choices.length - 1));
|
|
566
|
+
for (;;) {
|
|
567
|
+
this.render(message, choices.map(choice => choice.label), cursor, 'Current highlight only — ↑/↓ move • Enter records choice • Esc cancel');
|
|
568
|
+
const key = await this.key();
|
|
569
|
+
if (key === 'escape')
|
|
570
|
+
return undefined;
|
|
571
|
+
if (key === 'enter')
|
|
572
|
+
return choices[cursor].value;
|
|
573
|
+
if (key === 'up')
|
|
574
|
+
cursor = (cursor - 1 + choices.length) % choices.length;
|
|
575
|
+
if (key === 'down')
|
|
576
|
+
cursor = (cursor + 1) % choices.length;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
render(message, lines, cursor, help) {
|
|
580
|
+
const notes = this.notes.length ? `${this.notes.join('\n')}\n\n` : '';
|
|
581
|
+
this.output.write(`\u001b[2J\u001b[H${notes}${message}\n\n`);
|
|
582
|
+
lines.forEach((line, index) => this.output.write(`${index === cursor ? '❯' : ' '} ${line}\n`));
|
|
583
|
+
this.output.write(`\n${help}\n`);
|
|
584
|
+
}
|
|
585
|
+
key() {
|
|
586
|
+
return new Promise((resolve, reject) => {
|
|
587
|
+
const raw = this.input.isRaw;
|
|
588
|
+
let settled = false;
|
|
589
|
+
const cleanup = () => {
|
|
590
|
+
this.input.off('data', onData);
|
|
591
|
+
this.input.off('end', onEnd);
|
|
592
|
+
this.input.off('error', onError);
|
|
593
|
+
try {
|
|
594
|
+
this.input.setRawMode(raw);
|
|
595
|
+
}
|
|
596
|
+
catch { /* stream is already closing */ }
|
|
597
|
+
try {
|
|
598
|
+
this.input.pause();
|
|
599
|
+
}
|
|
600
|
+
catch { /* stream is already closing */ }
|
|
601
|
+
};
|
|
602
|
+
const finish = (key) => {
|
|
603
|
+
if (settled)
|
|
604
|
+
return;
|
|
605
|
+
settled = true;
|
|
606
|
+
cleanup();
|
|
607
|
+
resolve(key);
|
|
608
|
+
};
|
|
609
|
+
const onData = (chunk) => finish(decodeKey(chunk));
|
|
610
|
+
const onEnd = () => finish('escape');
|
|
611
|
+
const onError = (error) => {
|
|
612
|
+
if (settled)
|
|
613
|
+
return;
|
|
614
|
+
settled = true;
|
|
615
|
+
cleanup();
|
|
616
|
+
reject(error);
|
|
617
|
+
};
|
|
618
|
+
try {
|
|
619
|
+
this.input.setRawMode(true);
|
|
620
|
+
this.input.once('data', onData);
|
|
621
|
+
this.input.once('end', onEnd);
|
|
622
|
+
this.input.once('error', onError);
|
|
623
|
+
this.input.resume();
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
onError(error);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
export const isInteractiveTerminal = (input, output) => input.isTTY === true && output.isTTY === true && typeof input.setRawMode === 'function';
|