@tsconfigdotjson/vibe-os 0.2.0 → 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.
Files changed (45) hide show
  1. package/README.md +19 -847
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/web/assets/index-82MhMOB4.css +1 -0
  4. package/dist/web/assets/index-82MhMOB4.css.br +0 -0
  5. package/dist/web/assets/index-82MhMOB4.css.gz +0 -0
  6. package/dist/web/assets/index-DCB2nO0u.js +36 -0
  7. package/dist/web/assets/index-DCB2nO0u.js.br +0 -0
  8. package/dist/web/assets/index-DCB2nO0u.js.gz +0 -0
  9. package/dist/web/index.html +2 -2
  10. package/package.json +2 -1
  11. package/scripts/notices.ts +3 -1
  12. package/server/api.ts +449 -20
  13. package/server/assets.generated.ts +13 -13
  14. package/server/attach.ts +65 -16
  15. package/server/browser.ts +1 -65
  16. package/server/cli.ts +48 -150
  17. package/server/client.ts +440 -0
  18. package/server/config.ts +64 -2
  19. package/server/db.ts +19 -0
  20. package/server/doctor.ts +176 -123
  21. package/server/harness.ts +378 -590
  22. package/server/harnesses/claude.ts +248 -0
  23. package/server/harnesses/codex.ts +241 -0
  24. package/server/harnesses/cursor.ts +202 -0
  25. package/server/harnesses/hermes.ts +259 -0
  26. package/server/harnesses/util.ts +181 -0
  27. package/server/headless.ts +358 -0
  28. package/server/index.ts +47 -14
  29. package/server/memory.ts +418 -0
  30. package/server/profiles.ts +27 -15
  31. package/server/projects.ts +52 -3
  32. package/server/reaper.ts +5 -3
  33. package/server/screens.ts +372 -0
  34. package/server/session.ts +84 -95
  35. package/server/setup.ts +73 -7
  36. package/server/windows.ts +51 -1
  37. package/shared/blanks.ts +113 -0
  38. package/shared/harness.ts +375 -0
  39. package/shared/wire.ts +105 -94
  40. package/dist/web/assets/index-B77Qk8B5.js +0 -35
  41. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  42. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  43. package/dist/web/assets/index-CH5Em-Mo.css +0 -1
  44. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  45. 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, UV_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
  *
@@ -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";