@tsconfigdotjson/vibe-os 0.2.0-rc.1 → 0.3.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 +19 -847
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/web/assets/index-82MhMOB4.css +1 -0
- package/dist/web/assets/index-82MhMOB4.css.br +0 -0
- package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
- package/dist/web/assets/index-DCB2nO0u.js +36 -0
- package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
- package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/scripts/notices.ts +3 -1
- package/server/api.ts +449 -20
- package/server/assets.generated.ts +13 -13
- package/server/attach.ts +65 -16
- package/server/browser.ts +1 -65
- package/server/cli.ts +48 -150
- package/server/client.ts +440 -0
- package/server/config.ts +64 -2
- package/server/db.ts +19 -0
- package/server/doctor.ts +176 -123
- package/server/harness.ts +378 -590
- package/server/harnesses/claude.ts +248 -0
- package/server/harnesses/codex.ts +241 -0
- package/server/harnesses/cursor.ts +202 -0
- package/server/harnesses/hermes.ts +259 -0
- package/server/harnesses/util.ts +181 -0
- package/server/headless.ts +358 -0
- package/server/index.ts +47 -14
- package/server/memory.ts +418 -0
- package/server/profiles.ts +27 -15
- package/server/projects.ts +52 -3
- package/server/reaper.ts +5 -3
- package/server/screens.ts +372 -0
- package/server/session.ts +84 -95
- package/server/setup.ts +73 -7
- package/server/windows.ts +51 -1
- package/shared/blanks.ts +113 -0
- package/shared/harness.ts +375 -0
- package/shared/wire.ts +105 -94
- package/dist/web/assets/index-B77Qk8B5.js +0 -35
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +0 -1
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
package/server/setup.ts
CHANGED
|
@@ -33,13 +33,9 @@ import {
|
|
|
33
33
|
parseUfw,
|
|
34
34
|
probeTcp,
|
|
35
35
|
} from "./doctor.ts";
|
|
36
|
-
import {
|
|
37
|
-
CLAUDE_INSTALL,
|
|
38
|
-
CURSOR_INSTALL,
|
|
39
|
-
HERMES_INSTALL,
|
|
40
|
-
UV_INSTALL,
|
|
41
|
-
} from "./harness.ts";
|
|
36
|
+
import { CLAUDE_INSTALL, CURSOR_INSTALL, HERMES_INSTALL } from "./harness.ts";
|
|
42
37
|
import { color, describeError } from "./log.ts";
|
|
38
|
+
import { type OomState, oomState, scopeState } from "./memory.ts";
|
|
43
39
|
import { ENTRY, IS_COMPILED, invocation } from "./runtime.ts";
|
|
44
40
|
|
|
45
41
|
export const TAILSCALE_INSTALL =
|
|
@@ -596,6 +592,74 @@ async function packagesStep(
|
|
|
596
592
|
if (await s.runAll(commands, true)) s.done("installed");
|
|
597
593
|
}
|
|
598
594
|
|
|
595
|
+
/**
|
|
596
|
+
* What it takes to get an OOM daemon running: nothing when one already is,
|
|
597
|
+
* starting earlyoom when it is installed, installing it otherwise. null when
|
|
598
|
+
* it has to be installed and there is no package manager to do it with. Same
|
|
599
|
+
* package name on every manager.
|
|
600
|
+
*/
|
|
601
|
+
export function oomCommands(
|
|
602
|
+
oom: OomState,
|
|
603
|
+
manager: Manager | null,
|
|
604
|
+
): string[][] | null {
|
|
605
|
+
if (oom.oomd || oom.earlyoom === "active") return [];
|
|
606
|
+
const enable = ["systemctl", "enable", "--now", "earlyoom"];
|
|
607
|
+
if (oom.earlyoom === "installed") return [enable];
|
|
608
|
+
if (!manager) return null;
|
|
609
|
+
return [...installCommands(manager, ["earlyoom"]), enable];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* An OOM daemon, so a box out of memory kills one process instead of
|
|
614
|
+
* thrashing until someone notices.
|
|
615
|
+
*/
|
|
616
|
+
async function oomStep(s: Session, manager: Manager | null): Promise<void> {
|
|
617
|
+
if (process.platform !== "linux") return;
|
|
618
|
+
const oom = await oomState();
|
|
619
|
+
const commands = oomCommands(oom, manager);
|
|
620
|
+
if (commands?.length === 0) {
|
|
621
|
+
s.done(`${oom.oomd ? "systemd-oomd" : "earlyoom"} running`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
s.heading("oom daemon");
|
|
625
|
+
s.note(
|
|
626
|
+
oom.swapTotal > 0
|
|
627
|
+
? "with swap and nothing watching, running out of memory stalls the box instead of ending one process"
|
|
628
|
+
: "nothing ends a runaway process before the box stalls",
|
|
629
|
+
);
|
|
630
|
+
if (commands === null) {
|
|
631
|
+
s.warn("no package manager this knows; install earlyoom by hand");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const question =
|
|
635
|
+
oom.earlyoom === "installed"
|
|
636
|
+
? "Start earlyoom, and on every boot?"
|
|
637
|
+
: "Install earlyoom?";
|
|
638
|
+
if (!(await s.confirm(question, true, commands))) return;
|
|
639
|
+
if (await s.runAll(commands, true)) s.done("earlyoom running");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Lingering, without which windows get no memory limits: a scope lives under
|
|
644
|
+
* the user's manager, and logind stops that manager with the last login.
|
|
645
|
+
*/
|
|
646
|
+
async function lingerStep(s: Session, config: Config): Promise<void> {
|
|
647
|
+
if (process.platform !== "linux") return;
|
|
648
|
+
const scope = await scopeState(config.user, null);
|
|
649
|
+
if (!scope.systemd) return;
|
|
650
|
+
if (scope.linger) {
|
|
651
|
+
s.done(`${config.user} lingers, so windows get memory limits`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
s.heading("memory limits");
|
|
655
|
+
s.note(
|
|
656
|
+
"each window runs in its own scope under your user manager, which has to outlive your logins",
|
|
657
|
+
);
|
|
658
|
+
const command = ["loginctl", "enable-linger", config.user];
|
|
659
|
+
if (await s.confirm("Keep your user manager running?", true, [command]))
|
|
660
|
+
if (await s.run(command, true)) s.done("lingering on");
|
|
661
|
+
}
|
|
662
|
+
|
|
599
663
|
async function sshdStep(s: Session, config: Config): Promise<void> {
|
|
600
664
|
if (await probeTcp(config.sshHost, config.sshPort)) {
|
|
601
665
|
s.done(`sshd answering on ${config.sshHost}:${config.sshPort}`);
|
|
@@ -621,7 +685,7 @@ async function harnessStep(s: Session): Promise<void> {
|
|
|
621
685
|
{
|
|
622
686
|
name: "Hermes",
|
|
623
687
|
command: "hermes",
|
|
624
|
-
scripts: [HERMES_INSTALL
|
|
688
|
+
scripts: [HERMES_INSTALL],
|
|
625
689
|
def: s.opts.hermes,
|
|
626
690
|
after: "run hermes setup to pick a provider",
|
|
627
691
|
},
|
|
@@ -1099,6 +1163,8 @@ export async function runSetup(
|
|
|
1099
1163
|
if (!(await sudoStep(s))) return 1;
|
|
1100
1164
|
const manager = await detectManager();
|
|
1101
1165
|
await packagesStep(s, manager);
|
|
1166
|
+
await oomStep(s, manager);
|
|
1167
|
+
await lingerStep(s, config);
|
|
1102
1168
|
await sshdStep(s, config);
|
|
1103
1169
|
await harnessStep(s);
|
|
1104
1170
|
const tailnet = await tailscaleStep(s);
|
package/server/windows.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// between browsers and machines, and so switching workspaces can restore an
|
|
5
5
|
// arrangement rather than rebuild one.
|
|
6
6
|
|
|
7
|
-
import { eq, sql } from "drizzle-orm";
|
|
7
|
+
import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_WINDOW_COLS,
|
|
10
10
|
DEFAULT_WINDOW_ROWS,
|
|
@@ -100,9 +100,13 @@ function toRow(w: typeof windows.$inferSelect) {
|
|
|
100
100
|
promptDone: Boolean(w.promptDone),
|
|
101
101
|
handoffSeen: Boolean(w.handoffSeen),
|
|
102
102
|
handoff: (w.handoff as "ssh" | null) ?? null,
|
|
103
|
+
restore: (w.restore as Restore | null) ?? null,
|
|
103
104
|
};
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/** What happens to a window whose session died under it. See `windows.restore`. */
|
|
108
|
+
export type Restore = "ask" | "resume";
|
|
109
|
+
|
|
106
110
|
/**
|
|
107
111
|
* Places a new window without landing exactly on an existing one.
|
|
108
112
|
*
|
|
@@ -128,6 +132,7 @@ export function createWindow(
|
|
|
128
132
|
db: Db,
|
|
129
133
|
workspaceId: string,
|
|
130
134
|
profileId?: string | null,
|
|
135
|
+
label?: string | null,
|
|
131
136
|
) {
|
|
132
137
|
const existing = listWindows(db, workspaceId);
|
|
133
138
|
|
|
@@ -154,6 +159,7 @@ export function createWindow(
|
|
|
154
159
|
minimized: 0,
|
|
155
160
|
profileId: profileId ?? null,
|
|
156
161
|
promptDone: 0,
|
|
162
|
+
label: label ?? null,
|
|
157
163
|
createdAt: Date.now(),
|
|
158
164
|
})
|
|
159
165
|
.run();
|
|
@@ -205,6 +211,50 @@ export function deleteWindow(db: Db, id: string) {
|
|
|
205
211
|
return row;
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Flags windows whose session died without its harness exiting.
|
|
216
|
+
*
|
|
217
|
+
* Their next session is held until someone says whether to resume the
|
|
218
|
+
* conversation or start over. A window already flagged keeps what it has, so a
|
|
219
|
+
* 'resume' answered before a second restart is not asked again.
|
|
220
|
+
*/
|
|
221
|
+
export function markInterrupted(db: Db, ids: string[]): number {
|
|
222
|
+
if (ids.length === 0) return 0;
|
|
223
|
+
return db
|
|
224
|
+
.update(windows)
|
|
225
|
+
.set({ restore: "ask" })
|
|
226
|
+
.where(and(inArray(windows.id, ids), isNull(windows.restore)))
|
|
227
|
+
.returning({ id: windows.id })
|
|
228
|
+
.all().length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The answer to the offer: resume the conversation, or start a fresh one. */
|
|
232
|
+
export function restoreWindow(db: Db, id: string, resume: boolean) {
|
|
233
|
+
if (!getWindow(db, id)) return undefined;
|
|
234
|
+
db.update(windows)
|
|
235
|
+
.set({ restore: resume ? "resume" : null })
|
|
236
|
+
.where(eq(windows.id, id))
|
|
237
|
+
.run();
|
|
238
|
+
return getWindow(db, id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether the session about to be created should resume, clearing the flag.
|
|
243
|
+
*
|
|
244
|
+
* Called once per login, by whatever builds the session command. An 'ask'
|
|
245
|
+
* nobody answered counts as yes: something logging in to a window it was not
|
|
246
|
+
* offered on (a pop-out, `vibe-os attach`) is reaching for what was there.
|
|
247
|
+
*/
|
|
248
|
+
export function takeResume(db: Db, id: string): boolean {
|
|
249
|
+
const row = db
|
|
250
|
+
.update(windows)
|
|
251
|
+
.set({ restore: null })
|
|
252
|
+
.where(and(eq(windows.id, id), isNotNull(windows.restore)))
|
|
253
|
+
.returning({ id: windows.id })
|
|
254
|
+
.get();
|
|
255
|
+
return row !== undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
208
258
|
/**
|
|
209
259
|
* The session name for a window, with everything else needed to launch it.
|
|
210
260
|
*
|
package/shared/blanks.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill-in-the-blank prompts.
|
|
3
|
+
*
|
|
4
|
+
* A profile's prompt is the same every time, which is most of its value and
|
|
5
|
+
* also its limit: "review the ticket" is only useful if you can say which
|
|
6
|
+
* ticket. A blank is written `{{like this}}` in the prompt, and the band turns
|
|
7
|
+
* each one into a field to type in before the prompt is handed over.
|
|
8
|
+
*
|
|
9
|
+
* The syntax lives in the prompt text rather than in its own column, so this
|
|
10
|
+
* needed no storage of its own — a prompt with no braces in it behaves exactly
|
|
11
|
+
* as it did before.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const BLANK = /\{\{([^{}\n]{1,60})\}\}/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A discriminated union, not one shape with an optional `index`.
|
|
18
|
+
*
|
|
19
|
+
* Only blanks have an index, and the optional field made that invisible to the
|
|
20
|
+
* type system: the band knew a `blank` segment always carried one, and had to
|
|
21
|
+
* say so with a non-null assertion at every use. Narrowing on `type` now proves
|
|
22
|
+
* it instead.
|
|
23
|
+
*/
|
|
24
|
+
export type Segment =
|
|
25
|
+
| { type: "text"; value: string; key: string }
|
|
26
|
+
/** `value` is the label; `index` is its position among the blanks. */
|
|
27
|
+
| { type: "blank"; value: string; index: number; key: string };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Splits a prompt into literal runs and blanks.
|
|
31
|
+
*
|
|
32
|
+
* Two blanks with the same label stay separate fields. They usually mean two
|
|
33
|
+
* different things — `{{file}}` twice in a prompt is far more likely to be two
|
|
34
|
+
* files than the same one written twice — and one field silently driving two
|
|
35
|
+
* places is a worse surprise than typing something twice.
|
|
36
|
+
*/
|
|
37
|
+
export function parsePrompt(prompt: string): Segment[] {
|
|
38
|
+
const segments: Segment[] = [];
|
|
39
|
+
let last = 0;
|
|
40
|
+
let index = 0;
|
|
41
|
+
|
|
42
|
+
// Keyed by offset in the prompt: unique, and stable for as long as the prompt
|
|
43
|
+
// is — which is the band's whole lifetime.
|
|
44
|
+
for (const match of prompt.matchAll(BLANK)) {
|
|
45
|
+
const at = match.index ?? 0;
|
|
46
|
+
if (at > last)
|
|
47
|
+
segments.push({
|
|
48
|
+
type: "text",
|
|
49
|
+
value: prompt.slice(last, at),
|
|
50
|
+
key: `t${last}`,
|
|
51
|
+
});
|
|
52
|
+
segments.push({
|
|
53
|
+
type: "blank",
|
|
54
|
+
value: match[1].trim(),
|
|
55
|
+
index: index++,
|
|
56
|
+
key: `b${at}`,
|
|
57
|
+
});
|
|
58
|
+
last = at + match[0].length;
|
|
59
|
+
}
|
|
60
|
+
if (last < prompt.length)
|
|
61
|
+
segments.push({ type: "text", value: prompt.slice(last), key: `t${last}` });
|
|
62
|
+
return segments;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How many blanks a prompt has. */
|
|
66
|
+
export function countBlanks(prompt: string): number {
|
|
67
|
+
return [...prompt.matchAll(BLANK)].length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Substitutes the filled values back into the prompt.
|
|
72
|
+
*
|
|
73
|
+
* An unfilled blank falls back to its own label, so a prompt handed over early
|
|
74
|
+
* still reads as a sentence rather than collapsing into "review the and
|
|
75
|
+
* report". The band asks before letting that happen, but the text should be
|
|
76
|
+
* sane either way.
|
|
77
|
+
*/
|
|
78
|
+
export function fillPrompt(
|
|
79
|
+
prompt: string,
|
|
80
|
+
values: Record<number, string>,
|
|
81
|
+
): string {
|
|
82
|
+
let index = 0;
|
|
83
|
+
return prompt.replace(BLANK, (_, label: string) => {
|
|
84
|
+
const value = values[index++];
|
|
85
|
+
return value?.trim() ? value.trim() : label.trim();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fills blanks by label, for callers that have names rather than positions.
|
|
91
|
+
*
|
|
92
|
+
* The CLI and the API take `--blank ticket=123`, so every blank with that label
|
|
93
|
+
* gets the value. `missing` lists labels left empty and `unknown` lists labels
|
|
94
|
+
* the prompt does not have, so a caller can refuse instead of sending a prompt
|
|
95
|
+
* with a hole in it.
|
|
96
|
+
*/
|
|
97
|
+
export function fillBlanks(
|
|
98
|
+
prompt: string,
|
|
99
|
+
values: Record<string, string>,
|
|
100
|
+
): { text: string; missing: string[]; unknown: string[] } {
|
|
101
|
+
const labels = parsePrompt(prompt).flatMap((s) =>
|
|
102
|
+
s.type === "blank" ? [s.value] : [],
|
|
103
|
+
);
|
|
104
|
+
const byIndex: Record<number, string> = {};
|
|
105
|
+
labels.forEach((label, i) => {
|
|
106
|
+
if (Object.hasOwn(values, label)) byIndex[i] = values[label];
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
text: fillPrompt(prompt, byIndex),
|
|
110
|
+
missing: [...new Set(labels.filter((l) => !values[l]?.trim()))],
|
|
111
|
+
unknown: Object.keys(values).filter((k) => !labels.includes(k)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { detokenize } from "./args";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a harness is, as data: the command it runs and the flags the editor
|
|
5
|
+
* offers for it.
|
|
6
|
+
*
|
|
7
|
+
* A profile stores an argv list, because that is what actually gets run and it
|
|
8
|
+
* has to stay the single source of truth. The editor's controls do not get
|
|
9
|
+
* their own storage. They read the tokens on the way in and rewrite them on the
|
|
10
|
+
* way out, and every token they do not recognise goes to the advanced field in
|
|
11
|
+
* its original order, so a hand-written flag survives a save.
|
|
12
|
+
*
|
|
13
|
+
* Both sides import this. The server ships each harness's spec to the browser
|
|
14
|
+
* on `GET /api/harnesses`, and the editor renders whatever it is given, so a
|
|
15
|
+
* harness added on the server (or in `~/.vibe-os/harnesses/*.json`) needs no
|
|
16
|
+
* change to the client.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One value a select can take. */
|
|
20
|
+
export interface ChoiceOption {
|
|
21
|
+
value: string;
|
|
22
|
+
/** What the dropdown shows. The value itself when absent. */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** An `<optgroup>` to file it under. */
|
|
25
|
+
group?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A value that is spelled as its own flag rather than as `--flag value`.
|
|
30
|
+
*
|
|
31
|
+
* Claude's "skip every check" is `--dangerously-skip-permissions`, not a
|
|
32
|
+
* `--permission-mode`; Hermes' interface is `--tui` or `--cli` with no flag of
|
|
33
|
+
* its own at all.
|
|
34
|
+
*/
|
|
35
|
+
export interface SpecialOption {
|
|
36
|
+
value: string;
|
|
37
|
+
label: string;
|
|
38
|
+
flag: string;
|
|
39
|
+
aliases?: string[];
|
|
40
|
+
/** Shown under the field while this value is picked. */
|
|
41
|
+
warn?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A flag that takes one value, offered as a dropdown or a typed field. */
|
|
45
|
+
export interface SelectField {
|
|
46
|
+
kind: "select";
|
|
47
|
+
/** Stable id: the key in settings, the discovery report and the DOM. */
|
|
48
|
+
key: string;
|
|
49
|
+
label: string;
|
|
50
|
+
/** `--model` in `--model opus`. Absent when every value is a special. */
|
|
51
|
+
flag?: string;
|
|
52
|
+
/** Other spellings of `flag`, read on the way in and never written. */
|
|
53
|
+
aliases?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Text the value carries in front of it, for a flag shared between fields.
|
|
56
|
+
* Codex sets its thinking level as `-c model_reasoning_effort=high`, and a
|
|
57
|
+
* `-c` with any other key is left alone.
|
|
58
|
+
*/
|
|
59
|
+
prefix?: string;
|
|
60
|
+
/** What to offer when discovery has nothing better. */
|
|
61
|
+
options?: ChoiceOption[];
|
|
62
|
+
specials?: SpecialOption[];
|
|
63
|
+
/** Typed, with the options as suggestions, rather than picked from a list. */
|
|
64
|
+
free?: boolean;
|
|
65
|
+
/** The label for no value. Defaults to "Default — whatever <harness> picks". */
|
|
66
|
+
none?: string;
|
|
67
|
+
/** Placeholder for the typed form. */
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
/**
|
|
70
|
+
* A marker appended to the value, as its own checkbox. Claude's `[1m]` asks
|
|
71
|
+
* for the million-token context window: `--model opus[1m]`.
|
|
72
|
+
*/
|
|
73
|
+
suffix?: { token: string; label: string; hint?: string };
|
|
74
|
+
/** Shown under the field. Backticks become code. */
|
|
75
|
+
hint?: string;
|
|
76
|
+
/** Shown instead of `hint` when there was nothing to offer. */
|
|
77
|
+
emptyHint?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A flag with no value, offered as a switch under Options. */
|
|
81
|
+
export interface ToggleField {
|
|
82
|
+
kind: "toggle";
|
|
83
|
+
flag: string;
|
|
84
|
+
aliases?: string[];
|
|
85
|
+
label: string;
|
|
86
|
+
hint?: string;
|
|
87
|
+
/** Shown under the switches while this one is on. */
|
|
88
|
+
warn?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Claude's MCP picker: `--strict-mcp-config`, then `--mcp-config` with as many
|
|
93
|
+
* paths as follow it.
|
|
94
|
+
*
|
|
95
|
+
* There is no flag that enables servers by name, so the three states are what
|
|
96
|
+
* the two flags can actually say: neither means the harness loads whatever the
|
|
97
|
+
* box has, the strict flag alone means none of it, and adding the config flag
|
|
98
|
+
* gives back exactly the files listed.
|
|
99
|
+
*/
|
|
100
|
+
export interface McpField {
|
|
101
|
+
kind: "mcp";
|
|
102
|
+
key: string;
|
|
103
|
+
label: string;
|
|
104
|
+
strict: string;
|
|
105
|
+
config: string;
|
|
106
|
+
/** Shown when the box has no servers configured. Backticks become code. */
|
|
107
|
+
emptyHint?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type Field = SelectField | ToggleField | McpField;
|
|
111
|
+
|
|
112
|
+
export type McpMode = "all" | "pick" | "none";
|
|
113
|
+
|
|
114
|
+
/** Everything a harness is, minus the code. */
|
|
115
|
+
export interface HarnessSpec {
|
|
116
|
+
/** What profiles store. Letters, digits and dashes. */
|
|
117
|
+
id: string;
|
|
118
|
+
label: string;
|
|
119
|
+
/** The binary, looked up on PATH. */
|
|
120
|
+
command: string;
|
|
121
|
+
/**
|
|
122
|
+
* Where else to look, with `~` for the home directory. A systemd service and
|
|
123
|
+
* an `ssh host 'command'` both lack `~/.local/bin`, which is where most
|
|
124
|
+
* `curl | sh` installers put things.
|
|
125
|
+
*/
|
|
126
|
+
paths?: string[];
|
|
127
|
+
/** Subcommand in front of every profile's flags, like Hermes' `chat`. */
|
|
128
|
+
leading?: string[];
|
|
129
|
+
/**
|
|
130
|
+
* How to pick the last conversation back up after a reboot.
|
|
131
|
+
*
|
|
132
|
+
* `args` go after the profile's flags, or straight after the command when
|
|
133
|
+
* `subcommand` is set: Codex resumes with `codex resume --last`, not a flag.
|
|
134
|
+
* `unless` lists flags that already pick a conversation, which make adding
|
|
135
|
+
* `args` redundant.
|
|
136
|
+
*/
|
|
137
|
+
resume?: { args: string[]; subcommand?: boolean; unless?: string[] };
|
|
138
|
+
/** The line doctor prints when the command is missing. */
|
|
139
|
+
install?: string;
|
|
140
|
+
/** Arguments that print a version. `--version` when absent. */
|
|
141
|
+
version?: string[];
|
|
142
|
+
/**
|
|
143
|
+
* A command that reports whether the box is logged in, and the one that
|
|
144
|
+
* logs it in. A window opened logged out sits at a login prompt.
|
|
145
|
+
*/
|
|
146
|
+
login?: { args: string[]; fix: string };
|
|
147
|
+
/** Flags a new profile starts with. */
|
|
148
|
+
defaults?: string[];
|
|
149
|
+
/** Controls, in the order they render and are written. */
|
|
150
|
+
fields: Field[];
|
|
151
|
+
/** Flags the controls do not own whose value is the next token. */
|
|
152
|
+
valued?: string[];
|
|
153
|
+
/**
|
|
154
|
+
* Flags whose value is optional. Bare they may be a switch (Hermes' `-c` is
|
|
155
|
+
* `--continue`); with a value they name something no control can say, so the
|
|
156
|
+
* pair goes to the advanced field whole.
|
|
157
|
+
*/
|
|
158
|
+
optional?: string[];
|
|
159
|
+
/** Placeholder for the advanced field. */
|
|
160
|
+
extraPlaceholder?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What discovery found on the box, for one harness. */
|
|
164
|
+
export interface HarnessReport {
|
|
165
|
+
id: string;
|
|
166
|
+
/** The command was found. */
|
|
167
|
+
available: boolean;
|
|
168
|
+
version: string | null;
|
|
169
|
+
/**
|
|
170
|
+
* Null is "could not tell" or "this harness has no login". Only a definite
|
|
171
|
+
* false is worth warning about.
|
|
172
|
+
*/
|
|
173
|
+
loggedIn: boolean | null;
|
|
174
|
+
/** Discovered choices by field key. They replace the spec's own options. */
|
|
175
|
+
options: Record<string, ChoiceOption[]>;
|
|
176
|
+
/** What the harness uses for a field left empty, by field key. */
|
|
177
|
+
defaults: Record<string, string>;
|
|
178
|
+
/** Facts about the box worth saying in the editor. Backticks become code. */
|
|
179
|
+
notes: { label: string; text: string; warn?: boolean }[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The editor's view of one profile's argv. */
|
|
183
|
+
export interface FlagSettings {
|
|
184
|
+
/** Select fields by key. Empty means unset. */
|
|
185
|
+
values: Record<string, string>;
|
|
186
|
+
/** Select-field suffixes by key. */
|
|
187
|
+
suffixes: Record<string, boolean>;
|
|
188
|
+
/** Switches by canonical flag. */
|
|
189
|
+
toggles: Record<string, boolean>;
|
|
190
|
+
mcp: McpMode;
|
|
191
|
+
/** Paths passed to the MCP config flag, in the order they will be given. */
|
|
192
|
+
mcpConfigs: string[];
|
|
193
|
+
/** Everything the controls do not own, as the user typed it. */
|
|
194
|
+
extra: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const isValue = (token: string | undefined): token is string =>
|
|
198
|
+
token !== undefined && !token.startsWith("-");
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reads stored argv into the editor's controls.
|
|
202
|
+
*
|
|
203
|
+
* Every spelling of a flag is understood on the way in and one canonical form
|
|
204
|
+
* is written on the way out, so `-m opus` typed into the advanced field comes
|
|
205
|
+
* back as a model selection. A valued flag with nothing after it is malformed
|
|
206
|
+
* and goes to `extra` to be seen and fixed, rather than swallowing whatever
|
|
207
|
+
* followed it.
|
|
208
|
+
*/
|
|
209
|
+
export function parseArgs(spec: HarnessSpec, args: string[]): FlagSettings {
|
|
210
|
+
const specials = new Map<string, { key: string; value: string }>();
|
|
211
|
+
const selects = new Map<string, SelectField[]>();
|
|
212
|
+
const toggleFlags = new Map<string, string>();
|
|
213
|
+
let mcp: McpField | undefined;
|
|
214
|
+
for (const field of spec.fields) {
|
|
215
|
+
if (field.kind === "toggle") {
|
|
216
|
+
for (const f of [field.flag, ...(field.aliases ?? [])])
|
|
217
|
+
toggleFlags.set(f, field.flag);
|
|
218
|
+
} else if (field.kind === "mcp") {
|
|
219
|
+
mcp = field;
|
|
220
|
+
} else {
|
|
221
|
+
for (const s of field.specials ?? [])
|
|
222
|
+
for (const f of [s.flag, ...(s.aliases ?? [])])
|
|
223
|
+
specials.set(f, { key: field.key, value: s.value });
|
|
224
|
+
if (field.flag)
|
|
225
|
+
for (const f of [field.flag, ...(field.aliases ?? [])])
|
|
226
|
+
selects.set(f, [...(selects.get(f) ?? []), field]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const isSpecial = (key: string, value: string) =>
|
|
230
|
+
[...specials.values()].some((s) => s.key === key && s.value === value);
|
|
231
|
+
const valued = new Set(spec.valued ?? []);
|
|
232
|
+
const optional = new Set(spec.optional ?? []);
|
|
233
|
+
|
|
234
|
+
const values: Record<string, string> = {};
|
|
235
|
+
const suffixes: Record<string, boolean> = {};
|
|
236
|
+
const toggles: Record<string, boolean> = {};
|
|
237
|
+
const leftovers: string[] = [];
|
|
238
|
+
const mcpConfigs: string[] = [];
|
|
239
|
+
let strict = false;
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
242
|
+
const token = args[i];
|
|
243
|
+
const next = args[i + 1];
|
|
244
|
+
|
|
245
|
+
if (optional.has(token) && isValue(next)) {
|
|
246
|
+
leftovers.push(token, next);
|
|
247
|
+
i += 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const special = specials.get(token);
|
|
251
|
+
if (special) {
|
|
252
|
+
values[special.key] = special.value;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (mcp && token === mcp.strict) {
|
|
256
|
+
strict = true;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Variadic, unlike every other flag here: it takes as many paths as follow
|
|
260
|
+
// it, so it swallows tokens until the next thing that looks like a flag.
|
|
261
|
+
if (mcp && token === mcp.config) {
|
|
262
|
+
const before = mcpConfigs.length;
|
|
263
|
+
while (isValue(args[i + 1])) {
|
|
264
|
+
mcpConfigs.push(args[i + 1]);
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
if (mcpConfigs.length === before) leftovers.push(token);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const toggle = toggleFlags.get(token);
|
|
271
|
+
if (toggle) {
|
|
272
|
+
toggles[toggle] = true;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const candidates = selects.get(token);
|
|
276
|
+
if (candidates) {
|
|
277
|
+
if (!isValue(next)) {
|
|
278
|
+
leftovers.push(token);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
i += 1;
|
|
282
|
+
const field = candidates.find(
|
|
283
|
+
(f) => !f.prefix || next.startsWith(f.prefix),
|
|
284
|
+
);
|
|
285
|
+
// A shared flag carrying a key no field owns, like Codex's
|
|
286
|
+
// `-c model="o3"`: kept as a pair, exactly as written.
|
|
287
|
+
if (!field) {
|
|
288
|
+
leftovers.push(token, next);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
let value = field.prefix ? next.slice(field.prefix.length) : next;
|
|
292
|
+
if (field.suffix && value.endsWith(field.suffix.token)) {
|
|
293
|
+
suffixes[field.key] = true;
|
|
294
|
+
value = value.slice(0, -field.suffix.token.length);
|
|
295
|
+
}
|
|
296
|
+
// A special beats the flag it stands in for, whichever came first:
|
|
297
|
+
// `--dangerously-skip-permissions` makes `--permission-mode` moot.
|
|
298
|
+
if (!isSpecial(field.key, values[field.key] ?? "\0"))
|
|
299
|
+
values[field.key] = value;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
leftovers.push(token);
|
|
303
|
+
// Keep a value attached to the flag it belongs to.
|
|
304
|
+
if (valued.has(token) && isValue(next)) {
|
|
305
|
+
leftovers.push(next);
|
|
306
|
+
i += 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The config flag without the strict one means "the box's servers and also
|
|
311
|
+
// these", which the three-way control cannot say. Rather than quietly add the
|
|
312
|
+
// strict flag and change what the profile does, that combination goes back
|
|
313
|
+
// to the advanced field exactly as it was written.
|
|
314
|
+
if (mcp && !strict && mcpConfigs.length > 0)
|
|
315
|
+
leftovers.push(mcp.config, ...mcpConfigs.splice(0));
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
values,
|
|
319
|
+
suffixes,
|
|
320
|
+
toggles,
|
|
321
|
+
mcp: strict ? (mcpConfigs.length > 0 ? "pick" : "none") : "all",
|
|
322
|
+
mcpConfigs,
|
|
323
|
+
extra: detokenize(leftovers),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Composes the controls back into a command line for the server to tokenise. */
|
|
328
|
+
export function buildArgs(spec: HarnessSpec, settings: FlagSettings): string {
|
|
329
|
+
const tokens: string[] = [];
|
|
330
|
+
|
|
331
|
+
for (const field of spec.fields) {
|
|
332
|
+
if (field.kind === "toggle") {
|
|
333
|
+
if (settings.toggles[field.flag]) tokens.push(field.flag);
|
|
334
|
+
} else if (field.kind === "mcp") {
|
|
335
|
+
// Picking nothing is the same command line as picking none, so it is
|
|
336
|
+
// written as none rather than left half-stated.
|
|
337
|
+
if (
|
|
338
|
+
settings.mcp === "none" ||
|
|
339
|
+
(settings.mcp === "pick" && settings.mcpConfigs.length === 0)
|
|
340
|
+
) {
|
|
341
|
+
tokens.push(field.strict);
|
|
342
|
+
} else if (settings.mcp === "pick") {
|
|
343
|
+
tokens.push(field.strict, field.config, ...settings.mcpConfigs);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const value = settings.values[field.key];
|
|
347
|
+
if (!value) continue;
|
|
348
|
+
const special = field.specials?.find((s) => s.value === value);
|
|
349
|
+
if (special) {
|
|
350
|
+
tokens.push(special.flag);
|
|
351
|
+
} else if (field.flag) {
|
|
352
|
+
const suffix =
|
|
353
|
+
field.suffix && settings.suffixes[field.key]
|
|
354
|
+
? field.suffix.token
|
|
355
|
+
: "";
|
|
356
|
+
tokens.push(field.flag, `${field.prefix ?? ""}${value}${suffix}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const extra = settings.extra.trim();
|
|
362
|
+
return [detokenize(tokens), extra].filter(Boolean).join(" ");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The settings a new profile of this harness starts from. */
|
|
366
|
+
export const initialSettings = (spec: HarnessSpec): FlagSettings =>
|
|
367
|
+
parseArgs(spec, spec.defaults ?? []);
|
|
368
|
+
|
|
369
|
+
/** Whether a profile of this harness can pick its last conversation back up. */
|
|
370
|
+
export const resumes = (spec: HarnessSpec | undefined): boolean =>
|
|
371
|
+
Boolean(spec?.resume);
|
|
372
|
+
|
|
373
|
+
/** The pseudo-harnesses every box has, which are not adapters. */
|
|
374
|
+
export const SHELL = "shell";
|
|
375
|
+
export const CUSTOM = "custom";
|