sideby 0.16.0 → 0.17.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 (77) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +17 -0
  18. package/dist/src/core/local-build.js +39 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +17 -0
  28. package/dist/src/core/update.js +63 -1
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -0,0 +1,127 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { HookBus } from '../plugins/bus.js';
3
+ import { UserError } from './errors.js';
4
+ import { prepareLaunch } from './launch.js';
5
+ import { readSecretFile } from './secret-file.js';
6
+ /** How long `sideby try` waits for the Host (spec §3.28). The spawner enforces it. */
7
+ export const PROBE_TIMEOUT_MS = 60_000;
8
+ /** Combined stdout and stderr kept after redaction, in bytes. */
9
+ export const PROBE_OUTPUT_MAX_BYTES = 2048;
10
+ /**
11
+ * Runs the Family's probe the way Launch would start the Host: `prepareLaunch`'s environment and
12
+ * arguments (with `probe.args` as the user's arguments), the caller's temp directory as cwd, stdin ignored, and a 60s
13
+ * timeout the spawner enforces. Returns the exit code and the redacted output.
14
+ * Throws a UserError when the Family has no probe; the message says this tool cannot be tried
15
+ * and to start the account with `sideby run` instead.
16
+ */
17
+ export async function runProbe(account, deps) {
18
+ const probe = deps.family.probe;
19
+ if (!probe)
20
+ throw new UserError(`This tool cannot be tried. Start ${account.ref} with \`sideby run ${account.ref}\` and send one short message instead.`, { code: 'no-probe' });
21
+ const prepared = deps.launch ??
22
+ (await prepareLaunch({
23
+ account,
24
+ family: deps.family,
25
+ baseEnv: deps.baseEnv,
26
+ accountArgs: deps.accountArgs ?? [],
27
+ userArgs: probe.args,
28
+ command: 'run',
29
+ bus: deps.bus ?? new HookBus(),
30
+ ...(deps.label ? { label: deps.label } : {}),
31
+ }));
32
+ const secrets = account.secretFile
33
+ ? Object.values(await readSecretFile(account.secretFile, deps.baseEnv))
34
+ : [];
35
+ const spawned = await deps.spawn({
36
+ bin: prepared.bin,
37
+ // Family defaults, config arguments and `launch.before` changes, as `sideby run` passes them.
38
+ args: prepared.args,
39
+ env: prepared.env,
40
+ cwd: deps.cwd,
41
+ timeoutMs: deps.timeoutMs ?? PROBE_TIMEOUT_MS,
42
+ stdin: 'ignore',
43
+ });
44
+ const output = clip(redactSecrets(spawned.stdout + spawned.stderr, secrets), PROBE_OUTPUT_MAX_BYTES);
45
+ return {
46
+ ok: spawned.exitCode === 0 && !spawned.timedOut,
47
+ exitCode: spawned.exitCode,
48
+ output,
49
+ timedOut: spawned.timedOut,
50
+ };
51
+ }
52
+ /** Output kept per stream before redaction, in characters. */
53
+ const SPAWN_KEEP = 65536;
54
+ /**
55
+ * The real spawner for {@link ProbeDeps.spawn}. The Host leads its own process group, so the timeout stops it and
56
+ * any child it started; the result comes at once, even while a child still holds the output pipes open.
57
+ */
58
+ export function spawnProbe(request) {
59
+ return new Promise((resolve) => {
60
+ const child = spawn(request.bin, [...request.args], {
61
+ cwd: request.cwd,
62
+ env: request.env,
63
+ stdio: [request.stdin, 'pipe', 'pipe'],
64
+ detached: true,
65
+ });
66
+ let stdout = '', stderr = '', timedOut = false, done = false;
67
+ const finish = (result) => {
68
+ if (done)
69
+ return;
70
+ done = true;
71
+ clearTimeout(timer);
72
+ resolve(result);
73
+ };
74
+ const timer = setTimeout(() => {
75
+ timedOut = true;
76
+ try {
77
+ if (child.pid)
78
+ process.kill(-child.pid, 'SIGKILL');
79
+ }
80
+ catch {
81
+ child.kill('SIGKILL');
82
+ }
83
+ child.stdout.destroy();
84
+ child.stderr.destroy();
85
+ // It may have exited already while a child of its own kept the pipes open.
86
+ if (child.exitCode !== null || child.signalCode !== null)
87
+ finish({ exitCode: null, stdout, stderr, timedOut });
88
+ }, request.timeoutMs);
89
+ child.stdout.on('data', (d) => {
90
+ if (stdout.length < SPAWN_KEEP)
91
+ stdout += d.toString();
92
+ });
93
+ child.stderr.on('data', (d) => {
94
+ if (stderr.length < SPAWN_KEEP)
95
+ stderr += d.toString();
96
+ });
97
+ child.on('error', () => finish({
98
+ exitCode: 1,
99
+ stdout: '',
100
+ stderr: 'Could not start the Host; check its installation and run `sideby try` again.',
101
+ timedOut: false,
102
+ }));
103
+ child.on('exit', () => {
104
+ if (timedOut)
105
+ finish({ exitCode: null, stdout, stderr, timedOut });
106
+ });
107
+ child.on('close', (exitCode) => finish({ exitCode, stdout, stderr, timedOut }));
108
+ });
109
+ }
110
+ /** Replaces each secret of length 4 or more. Longer values go first so one cannot hide inside another. */
111
+ export function redactSecrets(text, values) {
112
+ const needles = [...new Set(values.filter((v) => v.length >= 4))].sort((a, b) => b.length - a.length);
113
+ let out = text;
114
+ for (const needle of needles)
115
+ out = out.split(needle).join('***');
116
+ return out;
117
+ }
118
+ /** The first `max` bytes, never cutting a UTF-8 code point in half. */
119
+ function clip(text, max) {
120
+ const buf = Buffer.from(text, 'utf8');
121
+ if (buf.length <= max)
122
+ return text;
123
+ let n = max;
124
+ while (n > 0 && (buf[n] & 0xc0) === 0x80)
125
+ n--;
126
+ return buf.subarray(0, n).toString('utf8');
127
+ }
@@ -1,18 +1,23 @@
1
1
  import type { Account, AccountIdentity, QuotaResult } from '../types.ts';
2
+ /** Families whose Accounts can be a Same Login (ADR-0016, ADR-0028). */
3
+ export declare const SAME_LOGIN_FAMILIES: ReadonlySet<string>;
2
4
  /**
3
- * Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
4
- * belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
5
+ * Families whose Identity alone does not tell one Quota apart, so two Accounts also need equal Login Keys. Codex: one
6
+ * email can belong to several ChatGPT workspaces, each with its own Quota (ADR-0028).
5
7
  */
6
- export declare const SAME_LOGIN_FAMILIES: ReadonlySet<string>;
8
+ export declare const LOGIN_KEY_FAMILIES: ReadonlySet<string>;
7
9
  export interface LoginMember {
8
10
  ref: string;
9
11
  family: string;
10
12
  kind: Account['kind'];
11
13
  identity?: AccountIdentity;
14
+ /** The opaque Login Key (ADR-0028), only for comparing here; never put into an output. */
15
+ loginKey?: string;
12
16
  }
13
17
  /**
14
18
  * Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
15
- * same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
19
+ * same organization (both have one and it is equal, or neither has one). Without an email nothing groups. In a Family
20
+ * of `LOGIN_KEY_FAMILIES` both must also have a Login Key, and the keys must be equal.
16
21
  */
17
22
  export declare function isSameLogin(a: LoginMember, b: LoginMember): boolean;
18
23
  /** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
@@ -1,12 +1,15 @@
1
+ /** Families whose Accounts can be a Same Login (ADR-0016, ADR-0028). */
2
+ export const SAME_LOGIN_FAMILIES = new Set(['claude', 'codex']);
1
3
  /**
2
- * Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
3
- * belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
4
+ * Families whose Identity alone does not tell one Quota apart, so two Accounts also need equal Login Keys. Codex: one
5
+ * email can belong to several ChatGPT workspaces, each with its own Quota (ADR-0028).
4
6
  */
5
- export const SAME_LOGIN_FAMILIES = new Set(['claude']);
7
+ export const LOGIN_KEY_FAMILIES = new Set(['codex']);
6
8
  const norm = (s) => s?.trim().toLowerCase();
7
9
  /**
8
10
  * Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
9
- * same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
11
+ * same organization (both have one and it is equal, or neither has one). Without an email nothing groups. In a Family
12
+ * of `LOGIN_KEY_FAMILIES` both must also have a Login Key, and the keys must be equal.
10
13
  */
11
14
  export function isSameLogin(a, b) {
12
15
  if (a.ref === b.ref || a.family !== b.family || !SAME_LOGIN_FAMILIES.has(a.family))
@@ -16,6 +19,8 @@ export function isSameLogin(a, b) {
16
19
  const ea = norm(a.identity?.email);
17
20
  if (!ea || ea !== norm(b.identity?.email))
18
21
  return false;
22
+ if (LOGIN_KEY_FAMILIES.has(a.family) && (!a.loginKey || a.loginKey !== b.loginKey))
23
+ return false;
19
24
  return norm(a.identity?.org) === norm(b.identity?.org);
20
25
  }
21
26
  /** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
@@ -1,3 +1,4 @@
1
+ import type { FamilyDef } from '../types.ts';
1
2
  import { UserError } from './errors.ts';
2
3
  export declare class SecretFileError extends UserError {
3
4
  }
@@ -28,3 +29,28 @@ export declare function inspectSecretFile(file: string): Promise<SecretFileState
28
29
  /** Reads a Secret File after inspectSecretFile accepts it. */
29
30
  export declare function readSecretFile(file: string, env?: Readonly<Record<string, string | undefined>>): Promise<Record<string, string>>;
30
31
  export declare function secretFileTemplate(vars: readonly string[], familyTitle: string): string;
32
+ /** Variable names `sideby key` accepts, such as `ANTHROPIC_AUTH_TOKEN`. */
33
+ export declare const SECRET_VAR_NAME: RegExp;
34
+ export interface SetSecretResult {
35
+ /** Path whose bytes were written. When `file` is a symlink, this is its real target. */
36
+ path: string;
37
+ /** True when `file` was a symlink. The link itself is unchanged. */
38
+ viaLink: boolean;
39
+ name: string;
40
+ /** `replaced` keeps the assignment's line; `appended` adds one at the end. */
41
+ action: 'replaced' | 'appended';
42
+ }
43
+ /**
44
+ * The Secret File variable `sideby key` writes when the user does not pass `--var`.
45
+ * Uses `family.secretVar` when the Family sets one, otherwise the first `apiVars` entry
46
+ * that ends in `_KEY` or `_TOKEN`. Throws when the Family has neither, and says to pass `--var`.
47
+ */
48
+ export declare function secretVarFor(family: Pick<FamilyDef, 'title' | 'apiVars' | 'secretVar'>): string;
49
+ /**
50
+ * Writes one assignment into a Secret File. The line that assigns `name` is replaced and stays
51
+ * where it was; every other line is kept byte for byte. When `name` is absent, a line is appended.
52
+ * The write is atomic and the file ends at mode 600. A symlink is left in place and the bytes go
53
+ * to its real target, which `path` reports. `name` must match {@link SECRET_VAR_NAME}. A value
54
+ * that contains a newline is rejected. The value is never included in an error or in the result.
55
+ */
56
+ export declare function setSecret(file: string, name: string, value: string): Promise<SetSecretResult>;
@@ -1,5 +1,6 @@
1
- import { lstat, readFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
2
  import { UserError } from './errors.js';
3
+ import { lstatOrNull, realpathOrNull, writeFileAtomic } from './fs-safe.js';
3
4
  export class SecretFileError extends UserError {
4
5
  }
5
6
  const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
@@ -82,13 +83,9 @@ export function parseSecretFile(text, file, env = {}) {
82
83
  * with mode 600. Only the file's type and mode are inspected, never its contents.
83
84
  */
84
85
  export async function inspectSecretFile(file) {
85
- let st;
86
- try {
87
- st = await lstat(file);
88
- }
89
- catch {
86
+ const st = await lstatOrNull(file);
87
+ if (!st)
90
88
  return { kind: 'missing' };
91
- }
92
89
  if (!st.isFile())
93
90
  return { kind: 'not-file' };
94
91
  const mode = st.mode & 0o777;
@@ -114,3 +111,125 @@ export function secretFileTemplate(vars, familyTitle) {
114
111
  ];
115
112
  return lines.join('\n');
116
113
  }
114
+ /** Variable names `sideby key` accepts, such as `ANTHROPIC_AUTH_TOKEN`. */
115
+ export const SECRET_VAR_NAME = /^[A-Z][A-Z0-9_]*$/;
116
+ const KEY_VAR = /(?:_KEY|_TOKEN)$/;
117
+ const ASSIGN = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
118
+ /**
119
+ * The Secret File variable `sideby key` writes when the user does not pass `--var`.
120
+ * Uses `family.secretVar` when the Family sets one, otherwise the first `apiVars` entry
121
+ * that ends in `_KEY` or `_TOKEN`. Throws when the Family has neither, and says to pass `--var`.
122
+ */
123
+ export function secretVarFor(family) {
124
+ const name = family.secretVar ?? family.apiVars.find((v) => SECRET_VAR_NAME.test(v) && KEY_VAR.test(v));
125
+ if (!name || !SECRET_VAR_NAME.test(name))
126
+ throw new SecretFileError(`${family.title} has no default key variable. Run \`sideby key <account> --var <NAME>\` with the variable that tool's documentation names.`);
127
+ return name;
128
+ }
129
+ /**
130
+ * Writes one assignment into a Secret File. The line that assigns `name` is replaced and stays
131
+ * where it was; every other line is kept byte for byte. When `name` is absent, a line is appended.
132
+ * The write is atomic and the file ends at mode 600. A symlink is left in place and the bytes go
133
+ * to its real target, which `path` reports. `name` must match {@link SECRET_VAR_NAME}. A value
134
+ * that contains a newline is rejected. The value is never included in an error or in the result.
135
+ */
136
+ export async function setSecret(file, name, value) {
137
+ if (!SECRET_VAR_NAME.test(name))
138
+ throw new SecretFileError(`variable name must match [A-Z][A-Z0-9_]* (for example ANTHROPIC_AUTH_TOKEN). Run \`sideby key <account> --var <NAME>\`.`);
139
+ if (value.includes('\n') || value.includes('\r'))
140
+ throw new SecretFileError(`${name} must be a single line. Run \`sideby key <account>\` and enter one line, or fill the key in the Panel.`);
141
+ const formatted = `${name}=${formatSecretValue(value)}`;
142
+ const located = await locateSecretFile(file);
143
+ let previous = '';
144
+ if (located.existed) {
145
+ try {
146
+ previous = await readFile(located.path, 'utf8');
147
+ }
148
+ catch (err) {
149
+ throw writeFailed(located.path, err);
150
+ }
151
+ }
152
+ const next = assignLine(previous, name, formatted);
153
+ try {
154
+ await writeFileAtomic(located.path, next.text, 0o600);
155
+ }
156
+ catch (err) {
157
+ throw writeFailed(located.path, err);
158
+ }
159
+ return { path: located.path, viaLink: located.viaLink, name, action: next.action };
160
+ }
161
+ /** Where the bytes go. A symlink is not followed by the writer; the caller passes the real target. */
162
+ async function locateSecretFile(file) {
163
+ const st = await lstatOrNull(file);
164
+ if (!st)
165
+ return { path: file, viaLink: false, existed: false };
166
+ if (st.isSymbolicLink()) {
167
+ const real = await realpathOrNull(file);
168
+ if (!real)
169
+ throw new SecretFileError(`${file} is a broken link. Point it at a proxy.env file, or replace the link with a file, then run \`sideby key\` again.`);
170
+ const target = await lstatOrNull(real);
171
+ if (!target?.isFile())
172
+ throw new SecretFileError(`${file} points at ${real}, which is not a file. Point the link at a proxy.env file, then run \`sideby key\` again.`);
173
+ return { path: real, viaLink: true, existed: true };
174
+ }
175
+ if (st.isDirectory())
176
+ throw new SecretFileError(`${file} is a directory. \`sideby key\` writes the proxy.env file; move that directory aside, then run \`sideby key\` again.`);
177
+ if (!st.isFile())
178
+ throw new SecretFileError(`${file} is not a regular file. Replace it with a proxy.env file, then run \`sideby key\` again.`);
179
+ return { path: file, viaLink: false, existed: true };
180
+ }
181
+ function assignLine(text, name, formatted) {
182
+ const lines = splitKeepEnds(text);
183
+ let replaced = false;
184
+ const rebuilt = lines
185
+ .map((line) => {
186
+ if (assignmentName(line.body) !== name)
187
+ return line.body + line.eol;
188
+ replaced = true;
189
+ return formatted + line.eol;
190
+ })
191
+ .join('');
192
+ if (replaced)
193
+ return { text: rebuilt, action: 'replaced' };
194
+ const sep = text.length > 0 && !text.endsWith('\n') ? '\n' : '';
195
+ return { text: `${text}${sep}${formatted}\n`, action: 'appended' };
196
+ }
197
+ function assignmentName(body) {
198
+ const trimmed = body.trim();
199
+ if (!trimmed || trimmed.startsWith('#'))
200
+ return null;
201
+ return ASSIGN.exec(body)?.[1] ?? null;
202
+ }
203
+ /** A value `parseSecretFile` reads back unchanged. Single quotes stay literal; nothing is executed. */
204
+ function formatSecretValue(value) {
205
+ if (/^[A-Za-z0-9_./:@%+-]*$/.test(value))
206
+ return value;
207
+ if (!value.includes("'"))
208
+ return `'${value}'`;
209
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$');
210
+ return `"${escaped}"`;
211
+ }
212
+ function splitKeepEnds(text) {
213
+ if (text === '')
214
+ return [];
215
+ const out = [];
216
+ const re = /\r\n|\n/g;
217
+ let last = 0;
218
+ for (let m = re.exec(text); m; m = re.exec(text)) {
219
+ out.push({ body: text.slice(last, m.index), eol: m[0] });
220
+ last = m.index + m[0].length;
221
+ }
222
+ if (last < text.length)
223
+ out.push({ body: text.slice(last), eol: '' });
224
+ return out;
225
+ }
226
+ function writeFailed(file, err) {
227
+ if (err instanceof SecretFileError)
228
+ return err;
229
+ const code = err.code;
230
+ if (code === 'ENOENT')
231
+ return new SecretFileError(`${file} cannot be written because a parent directory is missing. Create the account with \`sideby new <family> <name> --api\`, then run \`sideby key\` again.`);
232
+ if (code === 'EACCES' || code === 'EPERM')
233
+ return new SecretFileError(`${file} cannot be written (permission denied). Make the account directory yours, then run \`sideby key\` again.`);
234
+ return new SecretFileError(`${file} could not be written. Check that the account directory is a real folder you can write to, then run \`sideby key\` again.`);
235
+ }
@@ -0,0 +1,93 @@
1
+ import { access, stat } from 'node:fs/promises';
2
+ import type { Env } from '../types.ts';
3
+ import type { DesktopDeps } from './desktop-app.ts';
4
+ export type TerminalId = 'terminal' | 'iterm2' | 'ghostty' | 'orca' | 'wezterm' | 'kitty' | 'alacritty' | 'custom';
5
+ export interface TerminalDef {
6
+ id: TerminalId;
7
+ name: string;
8
+ bundleId: string | null;
9
+ app: string | null;
10
+ termPrograms: readonly string[];
11
+ marker?: string;
12
+ binary?: string;
13
+ }
14
+ /** Supported macOS terminals; custom templates have no app bundle or environment marker. */
15
+ export declare const TERMINALS: readonly TerminalDef[];
16
+ export interface InstalledTerminal {
17
+ id: TerminalId;
18
+ appPath?: string;
19
+ executable?: string;
20
+ }
21
+ export interface TerminalDeps {
22
+ home: string;
23
+ env: Env;
24
+ platform?: NodeJS.Platform;
25
+ appDirs?: readonly string[];
26
+ fs?: {
27
+ stat: typeof stat;
28
+ access: typeof access;
29
+ };
30
+ }
31
+ /** Finds bundles and command line launchers without opening apps or reading user files. */
32
+ export declare function installedTerminals(deps: TerminalDeps): Promise<InstalledTerminal[]>;
33
+ /** Chooses an installed preference, then the launching environment, then macOS Terminal. */
34
+ export declare function guessTerminal(opts: {
35
+ preferred?: string;
36
+ env: Env;
37
+ installed: readonly InstalledTerminal[];
38
+ }): {
39
+ id: TerminalId;
40
+ source: 'config' | 'env' | 'default';
41
+ };
42
+ /** What the terminal runs: this Node and this sideby's CLI entry, so neither PATH nor the login shell matters. */
43
+ export interface AskLauncher {
44
+ node: string;
45
+ entry: string;
46
+ }
47
+ /** This process's Node and the real path of the CLI entry next to this module (`main.ts` from source, `main.js` from dist). */
48
+ export declare function askLauncher(): AskLauncher;
49
+ /** Why the terminal cannot run this launcher, or null when it can. */
50
+ export declare function launcherProblem(launcher: AskLauncher, stateDir?: string): {
51
+ ok: false;
52
+ reason: 'npx-cache' | 'unsafe-path';
53
+ detail: string;
54
+ } | null;
55
+ /**
56
+ * Builds the only shell command accepted by the AI Task terminal launcher: the quoted Node and CLI entry, or
57
+ * `sideby` by name when `launcher` is null (a command for the user to paste where sideby is on PATH). It carries the
58
+ * state folder the task was written to, so a terminal whose environment differs from the Panel's still finds it.
59
+ */
60
+ export declare function askRunCommand(taskId: string, stateDir: string, launcher?: AskLauncher | null): string;
61
+ export interface TerminalLaunchOptions {
62
+ cwd: string;
63
+ command: string;
64
+ custom?: string;
65
+ appPath?: string;
66
+ executable?: string;
67
+ }
68
+ export interface TerminalCommand {
69
+ file: string;
70
+ args: string[];
71
+ }
72
+ /**
73
+ * Splits a custom terminal template into argv, throwing unless it names a program first and has both `{cmd}` and
74
+ * `{cwd}` in its arguments.
75
+ */
76
+ export declare function validateCustomTemplate(template: string): [string, ...string[]];
77
+ /** Builds argv only. The caller supplies discovered bundle/binary paths when available. */
78
+ export declare function launchArgs(id: TerminalId, opts: TerminalLaunchOptions): TerminalCommand;
79
+ export type TerminalOpenResult = {
80
+ ok: true;
81
+ } | {
82
+ ok: false;
83
+ reason: 'unsupported-platform';
84
+ } | {
85
+ ok: false;
86
+ reason: 'not-installed' | 'automation-denied' | 'failed' | 'npx-cache' | 'unsafe-path';
87
+ detail: string;
88
+ };
89
+ /** Opens through the injected Desktop exec boundary, returning safe, actionable failures only. */
90
+ export declare function openTerminal(id: TerminalId, opts: TerminalLaunchOptions & {
91
+ platform?: NodeJS.Platform;
92
+ installed: readonly InstalledTerminal[];
93
+ }, exec: DesktopDeps['exec']): Promise<TerminalOpenResult>;