@shardflux/cli 0.22.0 → 0.23.1

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 (61) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +47 -14
  3. package/dist/acp-journal.js +58 -6
  4. package/dist/acp-login.d.ts +10 -3
  5. package/dist/acp-login.js +31 -4
  6. package/dist/acp-models.d.ts +6 -1
  7. package/dist/acp-models.js +21 -16
  8. package/dist/acp.js +60 -7
  9. package/dist/agent-commands.js +29 -15
  10. package/dist/agent-target-commands.d.ts +20 -1
  11. package/dist/agent-target-commands.js +49 -18
  12. package/dist/agent-targets.js +6 -1
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +14 -14
  15. package/dist/cli-proxy-forward.d.ts +0 -2
  16. package/dist/cli-proxy-forward.js +2 -9
  17. package/dist/cli-proxy-hub.d.ts +22 -30
  18. package/dist/cli-proxy-hub.js +65 -94
  19. package/dist/cli-proxy.d.ts +12 -38
  20. package/dist/cli-proxy.js +51 -168
  21. package/dist/codex-cloud.d.ts +9 -4
  22. package/dist/codex-cloud.js +47 -14
  23. package/dist/codex-linux.d.ts +14 -0
  24. package/dist/codex-linux.js +69 -0
  25. package/dist/command-center-actions.js +13 -4
  26. package/dist/command-center-model.d.ts +2 -0
  27. package/dist/command-center-model.js +6 -1
  28. package/dist/command-center.js +3 -3
  29. package/dist/commands.js +2 -2
  30. package/dist/conversation-commands.js +16 -2
  31. package/dist/conversation-terminal.js +3 -2
  32. package/dist/conversations.d.ts +14 -1
  33. package/dist/conversations.js +61 -7
  34. package/dist/desktop-account.d.ts +16 -0
  35. package/dist/desktop-account.js +29 -0
  36. package/dist/desktop-linux.js +16 -3
  37. package/dist/errors.d.ts +2 -1
  38. package/dist/errors.js +5 -2
  39. package/dist/filesystem-commands.js +25 -3
  40. package/dist/menubar.d.ts +6 -5
  41. package/dist/menubar.js +40 -28
  42. package/dist/move-worktrees.d.ts +13 -7
  43. package/dist/move-worktrees.js +68 -17
  44. package/dist/move.js +20 -25
  45. package/dist/onboarding.js +4 -2
  46. package/dist/prepared-repository.d.ts +2 -0
  47. package/dist/prepared-repository.js +1 -1
  48. package/dist/prepared-repository.py +3 -2
  49. package/dist/project-preparation.d.ts +14 -0
  50. package/dist/project-preparation.js +16 -4
  51. package/dist/project-sync.d.ts +3 -0
  52. package/dist/project-sync.js +4 -1
  53. package/dist/t3-launch.d.ts +2 -1
  54. package/dist/t3-launch.js +11 -2
  55. package/dist/t3-proxy.d.ts +81 -0
  56. package/dist/t3-proxy.js +325 -0
  57. package/dist/terminal-apps.d.ts +11 -0
  58. package/dist/terminal-apps.js +18 -5
  59. package/dist/update.d.ts +2 -0
  60. package/dist/update.js +32 -6
  61. package/package.json +5 -5
@@ -0,0 +1,81 @@
1
+ import type { TomlTable } from 'smol-toml';
2
+ import type { CellClient, Workspace } from '@shardflux/sdk';
3
+ import type { Ctx } from './commands.js';
4
+ type Env = Record<string, string | undefined>;
5
+ export interface ProxyRoute {
6
+ /** The proxy's base URL as the setup names it. */
7
+ url: string;
8
+ /** It points at this computer. */
9
+ local: boolean;
10
+ /** Its port (the scheme's default when the URL names none). */
11
+ port: number;
12
+ }
13
+ export interface ClaudeProxy extends ProxyRoute {
14
+ /** The `ANTHROPIC_*` variables (and `API_TIMEOUT_MS`) Claude Code runs with. */
15
+ env: Record<string, string>;
16
+ /** The setup brings its own credential (`ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY`). */
17
+ credential: boolean;
18
+ }
19
+ export interface CodexProxy extends ProxyRoute {
20
+ /** The variables the provider names (`env_key`, `env_http_headers`), or `OPENAI_BASE_URL` and `OPENAI_API_KEY`. */
21
+ env: Record<string, string>;
22
+ /** `model_provider` and its `model_providers.<id>` table; null when `OPENAI_BASE_URL` names the proxy. */
23
+ config: TomlTable | null;
24
+ }
25
+ export interface T3ProxySetup {
26
+ claude: ClaudeProxy | null;
27
+ codex: CodexProxy | null;
28
+ }
29
+ export interface ProxyReadOptions {
30
+ env: Env;
31
+ /** A file's text, null when it is missing or unreadable (tests replace it). */
32
+ read?: (path: string) => string | null;
33
+ }
34
+ /** A URL whose host is this computer: 127.0.0.0/8, localhost, ::1 (and 0.0.0.0, which a local listener answers on). */
35
+ export declare function onThisComputer(url: string): boolean;
36
+ /** The proxy each app of T3 Code's own providers uses on this computer (null: it talks to its provider directly). */
37
+ export declare function readT3ProxySetup(opts: ProxyReadOptions): T3ProxySetup;
38
+ export interface VmProxy {
39
+ /** Variables for each app's agent in the VM, with proxy URLs as the VM reaches them. */
40
+ env: {
41
+ claude: Record<string, string>;
42
+ codex: Record<string, string>;
43
+ };
44
+ /** Keys for the VM's ~/.codex/config.toml (null: it stays as it is). */
45
+ codexConfig: TomlTable | null;
46
+ /** The VM forwarder's hub info, when a proxy on this computer is reached through the hosted CLIProxyAPI. */
47
+ hubInfo: string | null;
48
+ /** Claude Code goes through the proxy with the setup's own credential: no Shardflux Claude sign-in is needed. */
49
+ claudeCredential: boolean;
50
+ /** Apps whose proxy only this computer reaches: their VM agents use Shardflux's sign-ins. */
51
+ unreachable: Array<'claude' | 'codex'>;
52
+ /** Of those, the apps whose proxy is a CLIProxyAPI address here: VMs reach it once it is in the account (import). */
53
+ importable: Array<'claude' | 'codex'>;
54
+ }
55
+ /** The proxy URL as a VM reaches it: one on this computer becomes the VM forwarder's address (same path). */
56
+ export declare function vmProxyUrl(url: string): string;
57
+ /**
58
+ * What a conversation VM needs to follow the setup. `hubInfo` is the account's VM hub info (vmHubInfoText), if any;
59
+ * `hubPorts` are the ports a CLIProxyAPI on this computer answers on (8317, and the one the import recorded).
60
+ */
61
+ export declare function vmProxy(setup: T3ProxySetup, hubInfo: string | null, hubPorts?: readonly number[]): VmProxy;
62
+ /** The T3 Code proxy setup as this account's VMs follow it (read again on every call: settings change). */
63
+ export declare function accountVmProxy(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'addSecret'>): VmProxy;
64
+ /**
65
+ * Puts what the VM's agents need in place before they start: the forwarder's hub info (0600), and the Codex provider in
66
+ * ~/.codex/config.toml (its other settings kept). Writes only what differs.
67
+ */
68
+ export declare function prepareVmProxy(cell: Pick<CellClient, 'files'>, plan: VmProxy): Promise<void>;
69
+ /**
70
+ * The VM's own T3 Code (SSH and the T3 app through T3 Connect) runs its Claude Code and Codex providers with the setup's
71
+ * variables, stored as T3 stores them: plain values in its settings.json, credentials in its secret store
72
+ * (`secrets/provider-env-<base64url instance>-<base64url name>.bin`, 0600) with `valueRedacted` in the settings. The
73
+ * instances' other variables and the file's other settings stay. Also places prepareVmProxy's files.
74
+ */
75
+ export declare function prepareVmT3(cell: Pick<CellClient, 'files'>, plan: VmProxy): Promise<void>;
76
+ /**
77
+ * Before T3 Code opens a project VM (`shard agents new|open --in t3`): its T3 Code follows the user's proxy setup. A
78
+ * proxy on this computer without the hosted copy is named with the import; nothing here fails the open.
79
+ */
80
+ export declare function followT3ProxyInVm(ctx: Ctx, ws: Workspace): Promise<void>;
81
+ export {};
@@ -0,0 +1,325 @@
1
+ /**
2
+ * A T3 Code user's proxy setup (CLIProxyAPI, or any Claude- or OpenAI-compatible proxy), read the way T3 Code's own
3
+ * Claude Code and Codex providers run, so a Shardflux conversation in T3 Code sends its model traffic the same way
4
+ * (0.23.0+):
5
+ *
6
+ * - Claude Code: T3's Claude provider (its default instance `claudeAgent`, else another enabled one) runs with its
7
+ * environment variables over T3's own, and Claude Code applies its settings.json `env` over both. `ANTHROPIC_BASE_URL`
8
+ * names the proxy; the `ANTHROPIC_*` variables (base URL, token, models, headers) go to the VM's Claude Code.
9
+ * - Codex: config.toml in the provider's home (`CODEX_HOME`, default ~/.codex): a `model_provider` (the active
10
+ * profile's first) with a `base_url`, else `OPENAI_BASE_URL`. The provider's table and the variables it names
11
+ * (`env_key`, `env_http_headers`) go to the VM's Codex.
12
+ *
13
+ * A CLIProxyAPI on this computer (127.0.0.1, localhost or ::1 at port 8317, or the port the import recorded) is reached
14
+ * from VMs through the account's hosted copy (`shard cli-proxy import`), whose VM forwarder answers on
15
+ * http://127.0.0.1:8317. Another service on this computer stays here (the VM uses Shardflux's sign-ins); any other URL
16
+ * is used as written. T3 Code's default provider instance is the one followed while it is enabled.
17
+ * Sensitive T3 variables come from T3's secret store (`<T3 home>/userdata/secrets/*.bin`); none is ever printed.
18
+ */
19
+ import { readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { parse, stringify } from 'smol-toml';
23
+ import { ShardfluxApiError } from '@shardflux/sdk';
24
+ import { CLI_PROXY_PORT, readHubInfo, vmHubInfoText } from "./cli-proxy-hub.js";
25
+ import { VM_HUB_INFO_PATH } from "./cli-proxy.js";
26
+ import { t3HomeDir } from "./t3-provider.js";
27
+ import { AgentStateStore } from "./agent-state.js";
28
+ const readText = (path) => { try {
29
+ return readFileSync(path, 'utf8');
30
+ }
31
+ catch {
32
+ return null;
33
+ } };
34
+ const object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
35
+ const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
36
+ const json = (t) => { try {
37
+ return object(JSON.parse(t ?? ''));
38
+ }
39
+ catch {
40
+ return {};
41
+ } };
42
+ const httpUrl = (v) => { try {
43
+ return ['http:', 'https:'].includes(new URL(v).protocol);
44
+ }
45
+ catch {
46
+ return false;
47
+ } };
48
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64url');
49
+ /** A URL whose host is this computer: 127.0.0.0/8, localhost, ::1 (and 0.0.0.0, which a local listener answers on). */
50
+ export function onThisComputer(url) {
51
+ let host;
52
+ try {
53
+ host = new URL(url).hostname.toLowerCase();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || host === '0.0.0.0' || /^127\.\d+\.\d+\.\d+$/.test(host);
59
+ }
60
+ const portOf = (url) => { const u = new URL(url); return Number(u.port) || (u.protocol === 'https:' ? 443 : 80); };
61
+ const route = (url) => ({ url, local: onThisComputer(url), port: portOf(url) });
62
+ /**
63
+ * T3's enabled instances of a driver, its default one (the driver's own id) first. The default runs on the legacy
64
+ * `providers.<driver>` settings when it has no entry of its own. Variables marked sensitive are read from T3's secret
65
+ * store, as T3 does before it starts the provider.
66
+ */
67
+ function t3Instances(t3, driver, read) {
68
+ const settings = json(read(join(t3, 'userdata', 'settings.json')));
69
+ const all = object(settings.providerInstances), legacy = object(object(settings.providers)[driver]);
70
+ const ids = Object.keys(all).filter((id) => object(all[id]).driver === driver && object(all[id]).enabled !== false);
71
+ if (!(driver in all) && legacy.enabled !== false)
72
+ ids.push(driver);
73
+ ids.sort((a, b) => Number(b === driver) - Number(a === driver));
74
+ return ids.map((id) => {
75
+ const entry = object(all[id]), env = {};
76
+ for (const v of Array.isArray(entry.environment) ? entry.environment : []) {
77
+ const variable = object(v), name = text(variable.name);
78
+ if (!name)
79
+ continue;
80
+ env[name] = variable.sensitive === true && variable.valueRedacted === true
81
+ ? read(join(t3, 'userdata', 'secrets', `provider-env-${b64(id)}-${b64(name)}.bin`)) ?? ''
82
+ : typeof variable.value === 'string' ? variable.value : '';
83
+ }
84
+ return { id, env, homePath: text(object(entry.config).homePath) ?? (id === driver ? text(legacy.homePath) : null) };
85
+ });
86
+ }
87
+ const expand = (path, home) => (path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path);
88
+ const pick = (env, names) => {
89
+ const out = {};
90
+ for (const n of names) {
91
+ const v = env[n];
92
+ if (typeof v === 'string' && v !== '')
93
+ out[n] = v;
94
+ }
95
+ return out;
96
+ };
97
+ function claudeProxy(instance, base, home, read) {
98
+ const env = { ...base, ...instance?.env };
99
+ const dir = instance?.homePath ? expand(instance.homePath, home) : text(env.CLAUDE_CONFIG_DIR) ? expand(text(env.CLAUDE_CONFIG_DIR), home) : join(home, '.claude');
100
+ // Claude Code applies its settings' env over the environment it was started with.
101
+ for (const [k, v] of Object.entries(object(json(read(join(dir, 'settings.json'))).env)))
102
+ if (typeof v === 'string')
103
+ env[k] = v;
104
+ const url = text(env.ANTHROPIC_BASE_URL);
105
+ if (!url || !httpUrl(url))
106
+ return null;
107
+ const vars = pick(env, Object.keys(env).filter((k) => k.startsWith('ANTHROPIC_') || k === 'API_TIMEOUT_MS'));
108
+ return { ...route(url), env: vars, credential: Boolean(vars.ANTHROPIC_AUTH_TOKEN || vars.ANTHROPIC_API_KEY) };
109
+ }
110
+ function codexProxy(instance, base, home, read) {
111
+ const env = { ...base, ...instance?.env };
112
+ const dir = instance?.homePath ? expand(instance.homePath, home) : text(env.CODEX_HOME) ? expand(text(env.CODEX_HOME), home) : join(home, '.codex');
113
+ let config = {};
114
+ try {
115
+ config = parse(read(join(dir, 'config.toml')) ?? '');
116
+ }
117
+ catch { /* not TOML: Codex refuses it too */ }
118
+ const profile = object(object(config.profiles)[text(config.profile) ?? '']);
119
+ const id = text(profile.model_provider) ?? text(config.model_provider);
120
+ const provider = id ? object(object(config.model_providers)[id]) : {};
121
+ const url = text(provider.base_url);
122
+ if (id && id !== 'openai' && url && httpUrl(url)) {
123
+ const names = [text(provider.env_key), ...Object.values(object(provider.env_http_headers)).map(text)].filter((n) => n !== null);
124
+ return { ...route(url), env: pick(env, names), config: { model_provider: id, model_providers: { [id]: provider } } };
125
+ }
126
+ const openai = text(env.OPENAI_BASE_URL);
127
+ if (openai && httpUrl(openai))
128
+ return { ...route(openai), env: pick(env, ['OPENAI_BASE_URL', 'OPENAI_API_KEY']), config: null };
129
+ return null;
130
+ }
131
+ /** The proxy each app of T3 Code's own providers uses on this computer (null: it talks to its provider directly). */
132
+ export function readT3ProxySetup(opts) {
133
+ const read = opts.read ?? readText, home = opts.env.HOME || homedir(), t3 = t3HomeDir(opts.env);
134
+ const first = (driver, fn) => {
135
+ // T3 Code's default instance while it is enabled; otherwise its other enabled instances, the first with a proxy.
136
+ const list = t3Instances(t3, driver, read);
137
+ for (const i of list[0]?.id === driver ? [list[0]] : list.length ? list : [null]) {
138
+ const found = fn(i);
139
+ if (found)
140
+ return found;
141
+ }
142
+ return null;
143
+ };
144
+ return {
145
+ claude: first('claudeAgent', (i) => claudeProxy(i, opts.env, home, read)),
146
+ codex: first('codex', (i) => codexProxy(i, opts.env, home, read)),
147
+ };
148
+ }
149
+ // ---- the VM side ---------------------------------------------------------------------------------------------------
150
+ const VM_CODEX_CONFIG = '/home/user/.codex/config.toml';
151
+ /** The proxy URL as a VM reaches it: one on this computer becomes the VM forwarder's address (same path). */
152
+ export function vmProxyUrl(url) {
153
+ const u = new URL(url);
154
+ return `http://127.0.0.1:${CLI_PROXY_PORT}${u.pathname === '/' && !url.endsWith('/') ? '' : u.pathname}${u.search}`;
155
+ }
156
+ /**
157
+ * What a conversation VM needs to follow the setup. `hubInfo` is the account's VM hub info (vmHubInfoText), if any;
158
+ * `hubPorts` are the ports a CLIProxyAPI on this computer answers on (8317, and the one the import recorded).
159
+ */
160
+ export function vmProxy(setup, hubInfo, hubPorts = [CLI_PROXY_PORT]) {
161
+ const out = { env: { claude: {}, codex: {} }, codexConfig: null, hubInfo: null, claudeCredential: false, unreachable: [], importable: [] };
162
+ const reach = (app, r) => {
163
+ if (!r.local)
164
+ return r.url;
165
+ if (!hubPorts.includes(r.port)) {
166
+ out.unreachable.push(app);
167
+ return null;
168
+ }
169
+ if (!hubInfo) {
170
+ out.unreachable.push(app);
171
+ out.importable.push(app);
172
+ return null;
173
+ }
174
+ out.hubInfo = hubInfo;
175
+ return vmProxyUrl(r.url);
176
+ };
177
+ if (setup.claude) {
178
+ const url = reach('claude', setup.claude);
179
+ if (url) {
180
+ out.env.claude = { ...setup.claude.env, ANTHROPIC_BASE_URL: url };
181
+ out.claudeCredential = setup.claude.credential;
182
+ }
183
+ }
184
+ if (setup.codex) {
185
+ const url = reach('codex', setup.codex);
186
+ if (url && setup.codex.config) {
187
+ const id = setup.codex.config.model_provider;
188
+ const provider = object(object(setup.codex.config.model_providers)[id]);
189
+ out.codexConfig = { model_provider: id, model_providers: { [id]: { ...provider, base_url: url } } };
190
+ out.env.codex = setup.codex.env;
191
+ }
192
+ else if (url)
193
+ out.env.codex = { ...setup.codex.env, OPENAI_BASE_URL: url };
194
+ }
195
+ return out;
196
+ }
197
+ /** The variables that carry a credential: kept out of every message, and in T3's secret store in a VM. */
198
+ function secretVar(app, name) {
199
+ return app === 'codex' ? name !== 'OPENAI_BASE_URL' : /KEY|TOKEN|SECRET|PASSWORD|HEADER|AUTH|CREDENTIAL/i.test(name);
200
+ }
201
+ /** The T3 Code proxy setup as this account's VMs follow it (read again on every call: settings change). */
202
+ export function accountVmProxy(ctx) {
203
+ const info = readHubInfo(ctx.store.dir, ctx.apiUrl);
204
+ // The port the imported CLIProxyAPI listened on here (tools that used it reach the hosted copy the same way).
205
+ const imported = object(new AgentStateStore(ctx.store.dir).read().cli_proxy_local);
206
+ const ports = [CLI_PROXY_PORT, ...(imported.api_url === ctx.apiUrl && typeof imported.port === 'number' ? [imported.port] : [])];
207
+ const plan = vmProxy(readT3ProxySetup({ env: ctx.io.env }), info ? vmHubInfoText(info) : null, ports);
208
+ for (const app of ['claude', 'codex'])
209
+ for (const [name, value] of Object.entries(plan.env[app]))
210
+ if (secretVar(app, name))
211
+ ctx.addSecret(value);
212
+ if (plan.hubInfo && info?.vm_callback_url)
213
+ ctx.addSecret(info.vm_callback_url);
214
+ return plan;
215
+ }
216
+ const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
217
+ async function remoteText(cell, path) {
218
+ try {
219
+ return await cell.files.readText(path);
220
+ }
221
+ catch (error) {
222
+ if (missing(error))
223
+ return null;
224
+ throw error;
225
+ }
226
+ }
227
+ /**
228
+ * Puts what the VM's agents need in place before they start: the forwarder's hub info (0600), and the Codex provider in
229
+ * ~/.codex/config.toml (its other settings kept). Writes only what differs.
230
+ */
231
+ export async function prepareVmProxy(cell, plan) {
232
+ // The VM's copy stays when it is this hub's with an address at least as new (another computer may have renewed it).
233
+ const hub = async (info) => {
234
+ const current = await remoteText(cell, VM_HUB_INFO_PATH);
235
+ if (current === info)
236
+ return;
237
+ const mine = json(info), theirs = json(current);
238
+ if (current !== null && theirs.workspace_id === mine.workspace_id && Date.parse(String(theirs.callback_created_at)) >= Date.parse(String(mine.callback_created_at)))
239
+ return;
240
+ await cell.files.write(VM_HUB_INFO_PATH, info, { mode: '0600', createParents: true });
241
+ };
242
+ const codex = async (keys) => {
243
+ const current = await remoteText(cell, VM_CODEX_CONFIG);
244
+ let config;
245
+ try {
246
+ config = parse(current ?? '');
247
+ }
248
+ catch {
249
+ return; /* the VM's own config is not TOML: Codex reports it there */
250
+ }
251
+ const id = keys.model_provider;
252
+ const next = { ...config, model_provider: id, model_providers: { ...object(config.model_providers), ...object(keys.model_providers) } };
253
+ // An active profile's own model_provider would win over the top-level one.
254
+ const active = text(config.profile), profiles = object(config.profiles);
255
+ if (active && active in profiles)
256
+ next.profiles = { ...profiles, [active]: { ...object(profiles[active]), model_provider: id } };
257
+ const out = stringify(next);
258
+ if (current === null || stringify(config) !== out)
259
+ await cell.files.write(VM_CODEX_CONFIG, out, { mode: '0600', createParents: true });
260
+ };
261
+ await Promise.all([plan.hubInfo ? hub(plan.hubInfo) : undefined, plan.codexConfig ? codex(plan.codexConfig) : undefined]);
262
+ }
263
+ const VM_T3 = '/home/user/.t3/userdata';
264
+ /**
265
+ * The VM's own T3 Code (SSH and the T3 app through T3 Connect) runs its Claude Code and Codex providers with the setup's
266
+ * variables, stored as T3 stores them: plain values in its settings.json, credentials in its secret store
267
+ * (`secrets/provider-env-<base64url instance>-<base64url name>.bin`, 0600) with `valueRedacted` in the settings. The
268
+ * instances' other variables and the file's other settings stay. Also places prepareVmProxy's files.
269
+ */
270
+ export async function prepareVmT3(cell, plan) {
271
+ await prepareVmProxy(cell, plan);
272
+ const wanted = [['claudeAgent', 'claude', plan.env.claude], ['codex', 'codex', plan.env.codex]];
273
+ if (wanted.every(([, , vars]) => !Object.keys(vars).length))
274
+ return;
275
+ const path = `${VM_T3}/settings.json`, current = await remoteText(cell, path);
276
+ let doc = {};
277
+ try {
278
+ if (current !== null)
279
+ doc = JSON.parse(current);
280
+ }
281
+ catch {
282
+ return; /* T3 reports its own unreadable settings */
283
+ }
284
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
285
+ return;
286
+ const instances = { ...object(doc.providerInstances) }, secrets = [];
287
+ for (const [id, app, vars] of wanted) {
288
+ if (!Object.keys(vars).length)
289
+ continue;
290
+ const entry = object(instances[id]);
291
+ const kept = (Array.isArray(entry.environment) ? entry.environment : []).filter((v) => !(String(object(v).name) in vars));
292
+ const added = Object.entries(vars).map(([name, value]) => {
293
+ if (!secretVar(app, name))
294
+ return { name, value, sensitive: false };
295
+ secrets.push([`${VM_T3}/secrets/provider-env-${b64(id)}-${b64(name)}.bin`, value]);
296
+ return { name, value: '', sensitive: true, valueRedacted: true };
297
+ });
298
+ instances[id] = { ...entry, driver: id, environment: [...kept, ...added] };
299
+ }
300
+ // The secrets first: T3 reloads its settings when the file changes and reads them then.
301
+ await Promise.all(secrets.map(async ([p, v]) => { if ((await remoteText(cell, p)) !== v)
302
+ await cell.files.write(p, v, { mode: '0600', createParents: true }); }));
303
+ const next = `${JSON.stringify({ ...doc, providerInstances: instances }, null, 2)}\n`;
304
+ if (next !== current)
305
+ await cell.files.write(path, next, { mode: '0600', createParents: true });
306
+ }
307
+ /**
308
+ * Before T3 Code opens a project VM (`shard agents new|open --in t3`): its T3 Code follows the user's proxy setup. A
309
+ * proxy on this computer without the hosted copy is named with the import; nothing here fails the open.
310
+ */
311
+ export async function followT3ProxyInVm(ctx, ws) {
312
+ const plan = accountVmProxy(ctx);
313
+ if (plan.importable.length) {
314
+ const apps = plan.importable.map((a) => (a === 'claude' ? 'Claude Code' : 'Codex')).join(' and ');
315
+ ctx.io.stderr.write(`Your T3 Code setup sends ${apps} through CLIProxyAPI on this computer. Cloud VMs use it once it is in your account: shard cli-proxy import\n`);
316
+ }
317
+ if (!plan.hubInfo && !plan.codexConfig && !Object.keys(plan.env.claude).length && !Object.keys(plan.env.codex).length)
318
+ return;
319
+ try {
320
+ await prepareVmT3(ws.cell(), plan);
321
+ }
322
+ catch (error) {
323
+ ctx.io.stderr.write(`T3 Code in ${ws.key} uses your Shardflux sign-ins for now: ${error instanceof Error ? error.message : String(error)}\n`);
324
+ }
325
+ }
@@ -93,6 +93,17 @@ export declare function terminalReport(dir: string, env: Env, terminals: readonl
93
93
  export declare const shellWord: (s: string) => string;
94
94
  /** The command as typed: with this computer's config folder when it is not the default (an app's own, a test's). */
95
95
  export declare function commandLine(argv: readonly string[], env: Env): string;
96
+ /** Set in the command a terminal this CLI opened runs (0.23.0+): it attaches there and never hands off to yet another
97
+ * terminal, even if that terminal does not look like one a person types into. */
98
+ export declare const TERMINAL_HANDOFF = "SHARDFLUX_TERMINAL_ATTACH";
99
+ export declare const handedToTerminal: (env: Env) => boolean;
100
+ /**
101
+ * The command a new terminal runs: without the coding-agent shell variables (AGENT_SHELL_VARIABLES), which a terminal
102
+ * started from an agent's shell inherits on Linux, so the child is a terminal a person types into; and with the
103
+ * one-time hand-off guard. Nimblefox (2026-10-13): from a Codex shell each new terminal looked like an agent shell
104
+ * again and opened another one, stealing focus in a loop.
105
+ */
106
+ export declare function launchLine(argv: readonly string[], env: Env): string;
96
107
  /** What a shell in a new tab types: into the project folder, then the command. */
97
108
  export declare const typedLine: (argv: readonly string[], cwd: string, env: Env) => string;
98
109
  /** A new window's own command (Linux, Alacritty): it keeps the window open on an error so the person can read it. */
@@ -15,7 +15,7 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync
15
15
  import { homedir } from 'node:os';
16
16
  import { basename, dirname, join } from 'node:path';
17
17
  import { AgentStateStore } from "./agent-state.js";
18
- import { codingAgentShell } from "./agent-setup.js";
18
+ import { AGENT_SHELL_VARIABLES, codingAgentShell } from "./agent-setup.js";
19
19
  import { applicationDirs } from "./agent-targets.js";
20
20
  import { UsageError } from "./errors.js";
21
21
  import { linuxTerminalCommand } from "./linux-terminal.js";
@@ -258,11 +258,24 @@ export const shellWord = (s) => s && BARE_WORD.test(s) ? s : `'${s.replaceAll("'
258
258
  export function commandLine(argv, env) {
259
259
  return `${env.SHARDFLUX_CONFIG_DIR ? `env SHARDFLUX_CONFIG_DIR=${shellWord(env.SHARDFLUX_CONFIG_DIR)} ` : ''}${argv.map(shellWord).join(' ')}`;
260
260
  }
261
+ /** Set in the command a terminal this CLI opened runs (0.23.0+): it attaches there and never hands off to yet another
262
+ * terminal, even if that terminal does not look like one a person types into. */
263
+ export const TERMINAL_HANDOFF = 'SHARDFLUX_TERMINAL_ATTACH';
264
+ export const handedToTerminal = (env) => env[TERMINAL_HANDOFF] === '1';
265
+ /**
266
+ * The command a new terminal runs: without the coding-agent shell variables (AGENT_SHELL_VARIABLES), which a terminal
267
+ * started from an agent's shell inherits on Linux, so the child is a terminal a person types into; and with the
268
+ * one-time hand-off guard. Nimblefox (2026-10-13): from a Codex shell each new terminal looked like an agent shell
269
+ * again and opened another one, stealing focus in a loop.
270
+ */
271
+ export function launchLine(argv, env) {
272
+ return `env ${AGENT_SHELL_VARIABLES.map(name => `-u ${name} `).join('')}${TERMINAL_HANDOFF}=1 ${commandLine(argv, env).replace(/^env /, '')}`;
273
+ }
261
274
  /** What a shell in a new tab types: into the project folder, then the command. */
262
- export const typedLine = (argv, cwd, env) => `cd ${shellWord(cwd)} && ${commandLine(argv, env)}`;
275
+ export const typedLine = (argv, cwd, env) => `cd ${shellWord(cwd)} && ${launchLine(argv, env)}`;
263
276
  /** A new window's own command (Linux, Alacritty): it keeps the window open on an error so the person can read it. */
264
277
  export function windowCommand(argv, cwd, env) {
265
- return ['sh', '-c', `cd ${shellWord(cwd)} 2>/dev/null; ${commandLine(argv, env)}; status=$?; if [ "$status" -ne 0 ]; then printf '\\nPress Enter to close.'; read answer; fi; exit "$status"`];
278
+ return ['sh', '-c', `cd ${shellWord(cwd)} 2>/dev/null; ${launchLine(argv, env)}; status=$?; if [ "$status" -ne 0 ]; then printf '\\nPress Enter to close.'; read answer; fi; exit "$status"`];
266
279
  }
267
280
  /** An AppleScript string literal. */
268
281
  export const appleString = (s) => `"${s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r')}"`;
@@ -304,7 +317,7 @@ export function warpTabConfig(argv, cwd, env) {
304
317
  'id = "conversation"',
305
318
  'type = "terminal"',
306
319
  `directory = ${JSON.stringify(cwd)}`,
307
- `commands = [${JSON.stringify(commandLine(argv, env))}]`,
320
+ `commands = [${JSON.stringify(launchLine(argv, env))}]`,
308
321
  'is_focused = true',
309
322
  '',
310
323
  ].join('\n');
@@ -366,7 +379,7 @@ async function openWith(t, mode, l) {
366
379
  const { argv, cwd, env, platform } = l;
367
380
  const line = typedLine(argv, cwd, env);
368
381
  const name = t.name;
369
- const command = commandLine(argv, env);
382
+ const command = launchLine(argv, env);
370
383
  const mac = platform === 'darwin';
371
384
  const activate = async () => { if (mac && t.path)
372
385
  await terminalNative.run('/usr/bin/open', ['-a', t.path], { env, timeoutMs: 5000 }); };
package/dist/update.d.ts CHANGED
@@ -55,6 +55,8 @@ export interface UpdateDeps {
55
55
  /** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
56
56
  export declare function onlyAppToSwap(pending: Component[], packagedApp: string | null): boolean;
57
57
  export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
58
+ /** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
59
+ export declare function stableNodePath(node: string, env: NodeJS.ProcessEnv): string;
58
60
  /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
59
61
  export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
60
62
  package: string;
package/dist/update.js CHANGED
@@ -9,9 +9,9 @@
9
9
  * macOS) and shows the outcome from last-update.json, also when the update replaced the app that started it.
10
10
  */
11
11
  import { spawn } from 'node:child_process';
12
- import { closeSync, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
12
+ import { accessSync, closeSync, constants, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
- import { basename, dirname, join, sep } from 'node:path';
14
+ import { basename, dirname, isAbsolute, join, sep } from 'node:path';
15
15
  import { clientVersionStatus, SDK_VERSION } from '@shardflux/sdk';
16
16
  import { CLI_VERSION } from "./args.js";
17
17
  import { AgentStateStore } from "./agent-state.js";
@@ -108,7 +108,7 @@ export async function components(ctx, install, catalog, menubar = menubarManager
108
108
  local.push(['@shardflux/cli', CLI_VERSION]);
109
109
  local.push(['@shardflux/sdk', SDK_VERSION]);
110
110
  if (menubar.platform === 'darwin') {
111
- const status = await menubar.status().catch(() => null);
111
+ const status = await menubar.status(ctx.store.dir).catch(() => null);
112
112
  if (status?.installed && status.version)
113
113
  local.push([MENUBAR, status.version]);
114
114
  }
@@ -292,7 +292,7 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
292
292
  return { status: 'off' };
293
293
  if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
294
294
  return { status: 'off' };
295
- const before = await menubar.status().catch(() => null);
295
+ const before = await menubar.status(ctx.store.dir).catch(() => null);
296
296
  if (!before?.installed)
297
297
  return { status: 'off' };
298
298
  if (runtime && run && env) {
@@ -306,9 +306,35 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
306
306
  return { status: 'current' };
307
307
  await menubar.start(ctx.store.dir, ctx.apiUrl);
308
308
  }
309
- const after = await menubar.status().catch(() => null);
309
+ const after = await menubar.status(ctx.store.dir).catch(() => null);
310
310
  return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
311
311
  }
312
+ /** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
313
+ export function stableNodePath(node, env) {
314
+ let real;
315
+ try {
316
+ real = realpathSync(node);
317
+ }
318
+ catch {
319
+ return node;
320
+ }
321
+ const candidates = (env.PATH ?? '').split(':').filter(isAbsolute).map(dir => join(dir, 'node'));
322
+ // Homebrew's opt link also works when launchd's PATH has no Homebrew entry (including versioned formulae).
323
+ const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(node);
324
+ if (brew)
325
+ candidates.push(join(brew[1], 'opt', brew[2], 'bin/node'), join(brew[1], 'bin/node'));
326
+ for (const candidate of candidates) {
327
+ try {
328
+ if (candidate.includes('/Cellar/'))
329
+ continue;
330
+ accessSync(candidate, constants.X_OK);
331
+ if (realpathSync(candidate) === real)
332
+ return candidate;
333
+ }
334
+ catch { /* missing alias: try the next one */ }
335
+ }
336
+ return node;
337
+ }
312
338
  /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
313
339
  export function recordInstall(dir, runtime, distribution, env = {}) {
314
340
  let script = runtime.script;
@@ -336,7 +362,7 @@ export function recordInstall(dir, runtime, distribution, env = {}) {
336
362
  catch {
337
363
  menubarApp = null;
338
364
  }
339
- const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
365
+ const doc = { version: 1, node: stableNodePath(runtime.node, env), script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
340
366
  const text = `${JSON.stringify(doc, null, 2)}\n`;
341
367
  try {
342
368
  if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,7 @@
47
47
  "smol-toml": "1.9.0",
48
48
  "snappyjs": "0.7.0",
49
49
  "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.26.0"
50
+ "@shardflux/sdk": "^0.27.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@eslint/js": "10.0.1",
@@ -58,9 +58,9 @@
58
58
  "typescript-eslint": "8.70.1"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@shardflux/desktop-linux-x64": "0.1.8",
62
- "@shardflux/desktop-linux-arm64": "0.1.8",
63
- "@shardflux/menubar-darwin": "^0.8.3"
61
+ "@shardflux/desktop-linux-x64": "0.1.11",
62
+ "@shardflux/desktop-linux-arm64": "0.1.11",
63
+ "@shardflux/menubar-darwin": "^0.8.5"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "node scripts/build.mjs",