@shardflux/cli 0.16.0 → 0.18.0-next.4

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 (74) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/README.md +47 -8
  3. package/dist/agent-commands.d.ts +9 -1
  4. package/dist/agent-commands.js +65 -16
  5. package/dist/agent-models.d.ts +61 -0
  6. package/dist/agent-models.js +79 -0
  7. package/dist/agent-state.d.ts +10 -0
  8. package/dist/agent-state.js +13 -0
  9. package/dist/agent-target-commands.d.ts +6 -1
  10. package/dist/agent-target-commands.js +113 -16
  11. package/dist/agent-targets.d.ts +17 -5
  12. package/dist/agent-targets.js +27 -11
  13. package/dist/args.d.ts +5 -1
  14. package/dist/args.js +49 -9
  15. package/dist/cli-proxy-forward.d.ts +45 -0
  16. package/dist/cli-proxy-forward.js +432 -0
  17. package/dist/cli-proxy-hub.d.ts +273 -0
  18. package/dist/cli-proxy-hub.js +749 -0
  19. package/dist/cli-proxy.d.ts +325 -0
  20. package/dist/cli-proxy.js +1016 -0
  21. package/dist/codex-cloud.d.ts +2 -1
  22. package/dist/codex-cloud.js +16 -6
  23. package/dist/command-center-actions.js +16 -1
  24. package/dist/conversation-local.js +2 -2
  25. package/dist/conversation-stash.js +4 -3
  26. package/dist/conversation-terminal.d.ts +5 -0
  27. package/dist/conversation-terminal.js +45 -6
  28. package/dist/conversations.d.ts +18 -2
  29. package/dist/conversations.js +12 -5
  30. package/dist/desktop-linux.d.ts +40 -0
  31. package/dist/desktop-linux.js +186 -0
  32. package/dist/device-approvals.d.ts +12 -0
  33. package/dist/device-approvals.js +75 -0
  34. package/dist/device-helper.d.ts +3 -1
  35. package/dist/device-helper.js +5 -2
  36. package/dist/errors.d.ts +2 -0
  37. package/dist/errors.js +1 -1
  38. package/dist/git-remote-bin.js +0 -0
  39. package/dist/handlers.js +3 -1
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.js +3 -0
  42. package/dist/linux-helper.d.ts +12 -0
  43. package/dist/linux-helper.js +88 -0
  44. package/dist/linux-terminal.d.ts +11 -0
  45. package/dist/linux-terminal.js +42 -0
  46. package/dist/main.js +30 -6
  47. package/dist/menubar.js +44 -4
  48. package/dist/onboarding.d.ts +2 -0
  49. package/dist/onboarding.js +59 -9
  50. package/dist/t3-asar.d.ts +10 -0
  51. package/dist/t3-asar.js +91 -0
  52. package/dist/t3-launch.d.ts +37 -0
  53. package/dist/t3-launch.js +133 -0
  54. package/dist/t3-linux.d.ts +68 -0
  55. package/dist/t3-linux.js +343 -0
  56. package/dist/t3-mode.d.ts +129 -0
  57. package/dist/t3-mode.js +676 -0
  58. package/dist/t3-phone.d.ts +17 -0
  59. package/dist/t3-phone.js +15 -0
  60. package/dist/t3-provider.d.ts +5 -3
  61. package/dist/t3-provider.js +12 -49
  62. package/dist/t3-thread-script.d.ts +18 -0
  63. package/dist/t3-thread-script.js +245 -0
  64. package/dist/terminal-onboarding.d.ts +11 -0
  65. package/dist/terminal-onboarding.js +75 -0
  66. package/dist/thread-on-computer.d.ts +2 -2
  67. package/dist/thread-on-computer.js +13 -7
  68. package/dist/update.d.ts +1 -1
  69. package/dist/update.js +28 -1
  70. package/dist/worktree-twin-engine.d.ts +31 -1
  71. package/dist/worktree-twin-engine.js +253 -40
  72. package/dist/worktree-twin.d.ts +17 -0
  73. package/dist/worktree-twin.js +147 -22
  74. package/package.json +7 -19
@@ -0,0 +1,75 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readdirSync, readFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { writePrivateFile } from "./credentials.js";
7
+ import { UsageError } from "./errors.js";
8
+ import { json } from "./format.js";
9
+ const paths = (dir, id) => {
10
+ if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
11
+ throw new UsageError('Invalid device request id.');
12
+ return { pending: join(dir, 'device-approvals', `${id}.json`), answer: join(dir, 'device-answers', `${id}.json`) };
13
+ };
14
+ export function pendingDeviceApprovals(dir) {
15
+ let names;
16
+ try {
17
+ names = readdirSync(join(dir, 'device-approvals'));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ return names.filter(n => n.endsWith('.json')).flatMap(n => {
23
+ try {
24
+ const p = JSON.parse(readFileSync(paths(dir, n.slice(0, -5)).pending, 'utf8'));
25
+ process.kill(p.pid, 0);
26
+ return [p];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ });
32
+ }
33
+ export function answerDeviceApproval(dir, id, choice) {
34
+ const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
35
+ if (!pending)
36
+ throw new UsageError('That request is no longer waiting for approval. Run shard device requests.');
37
+ if (choice === 'always' && pending.outside)
38
+ throw new UsageError('Access outside the conversation folder must be approved once.');
39
+ writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
40
+ }
41
+ /** Local user-owned files carry approvals; cloud data can never answer its own request. */
42
+ export async function askLocalDeviceApproval(ctx, approval) {
43
+ const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
44
+ rmSync(file.answer, { force: true });
45
+ writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
46
+ spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\nReview: shard device requests`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
47
+ try {
48
+ while (!ctx.signal.aborted) {
49
+ try {
50
+ const answer = JSON.parse(readFileSync(file.answer, 'utf8'));
51
+ if (answer.nonce === nonce && ['once', 'always', 'deny'].includes(answer.choice))
52
+ return answer.choice;
53
+ }
54
+ catch { /* wait for the local CLI */ }
55
+ await delay(200, undefined, { signal: ctx.signal }).catch(() => undefined);
56
+ }
57
+ return 'deny';
58
+ }
59
+ finally {
60
+ rmSync(file.pending, { force: true });
61
+ rmSync(file.answer, { force: true });
62
+ }
63
+ }
64
+ export const DEVICE_HANDLERS = {
65
+ 'device requests'(ctx) {
66
+ const requests = pendingDeviceApprovals(ctx.store.dir);
67
+ ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}\n shard device approve ${r.request_id} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
68
+ return Promise.resolve(0);
69
+ },
70
+ ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, values, [id]) => {
71
+ answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? values.always === true ? 'always' : 'once' : 'deny');
72
+ ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action === 'approve' && values.always ? 'always' : action }) : `Device request ${id}: ${action === 'approve' ? values.always ? 'allowed for project' : 'approved once' : 'denied'}.\n`);
73
+ return Promise.resolve(0);
74
+ }])),
75
+ };
@@ -1,3 +1,5 @@
1
1
  import type { Ctx } from './commands.js';
2
2
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
3
- export declare function runDeviceHelper(ctx: Ctx): Promise<number>;
3
+ export declare function runDeviceHelper(ctx: Ctx, options?: {
4
+ service?: boolean;
5
+ }): Promise<number>;
@@ -12,13 +12,14 @@ import { writePrivateFile } from "./credentials.js";
12
12
  import { conversationClient } from "./conversation-commands.js";
13
13
  import { Readable } from 'node:stream';
14
14
  import { runWorktreeTwin } from "./worktree-twin-engine.js";
15
+ import { askLocalDeviceApproval } from "./device-approvals.js";
15
16
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
16
- export async function runDeviceHelper(ctx) {
17
+ export async function runDeviceHelper(ctx, options = {}) {
17
18
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
18
19
  ctx = { ...ctx, signal };
19
20
  const pending = new Map();
20
21
  const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
21
- const input = (async () => {
22
+ const input = options.service ? Promise.resolve() : (async () => {
22
23
  let tail = '';
23
24
  for await (const bytes of inputSource) {
24
25
  tail += Buffer.from(bytes).toString();
@@ -41,6 +42,8 @@ export async function runDeviceHelper(ctx) {
41
42
  const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
42
43
  if (acp)
43
44
  return acp;
45
+ if (options.service)
46
+ return askLocalDeviceApproval(ctx, approval);
44
47
  return new Promise(resolve => {
45
48
  const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
46
49
  signal.addEventListener('abort', denied, { once: true });
package/dist/errors.d.ts CHANGED
@@ -114,6 +114,8 @@ export declare class ExecSessionRunningError extends HintedError {
114
114
  export interface ErrorInfo {
115
115
  code: string;
116
116
  message: string;
117
+ /** A refusal `shard` detects itself (CodedError, 0.17.0+): the next step, as plain text (the `hint:` line). */
118
+ fix?: string;
117
119
  status?: number;
118
120
  request_id?: string;
119
121
  retryable?: boolean;
package/dist/errors.js CHANGED
@@ -241,7 +241,7 @@ export function describeError(err) {
241
241
  if (err instanceof GitError)
242
242
  return { code: 'git_error', message: err.message, retryable: false };
243
243
  if (err instanceof CodedError)
244
- return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
244
+ return { code: err.code, message: err.message, retryable: false, ...(err.hint ? { fix: err.hint } : {}), ...(err.details ? { details: err.details } : {}) };
245
245
  if (err instanceof CheckoutTimeoutError) {
246
246
  return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
247
247
  }
File without changes
package/dist/handlers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEVICE_HANDLERS } from "./device-approvals.js";
1
2
  import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
2
3
  import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
3
4
  /**
@@ -18,4 +19,5 @@ import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversa
18
19
  import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
19
20
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
20
21
  import { THREAD_HANDLERS } from "./thread-on-computer.js";
21
- export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
22
+ import { CLI_PROXY_HANDLERS } from "./cli-proxy.js";
23
+ export const HANDLERS = { ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/index.d.ts CHANGED
@@ -24,4 +24,8 @@ export type { ErrorInfo } from './errors.js';
24
24
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
25
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
26
26
  export { readAgentCredentials, syncAgentCredentials } from './agent-credentials.js';
27
+ export { detectLocalCliProxy, importCliProxy } from './cli-proxy.js';
28
+ export type { ImportOptions, ImportResult, LocalCliProxy, LocalLogin } from './cli-proxy.js';
29
+ export { ensureCliProxyHub, readHubInfo } from './cli-proxy-hub.js';
30
+ export type { EnsureOptions, EnsureResult, HubInfo } from './cli-proxy-hub.js';
27
31
  export type { CredentialSources } from './agent-credentials.js';
package/dist/index.js CHANGED
@@ -16,3 +16,6 @@ export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, Execut
16
16
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
17
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
18
18
  export { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
19
+ // Hosted CLIProxyAPI (onboarding offers the import when detectLocalCliProxy() finds logins).
20
+ export { detectLocalCliProxy, importCliProxy } from "./cli-proxy.js";
21
+ export { ensureCliProxyHub, readHubInfo } from "./cli-proxy-hub.js";
@@ -0,0 +1,12 @@
1
+ import type { Ctx } from './commands.js';
2
+ export declare const HELPER_UNIT = "shardflux-helper.service";
3
+ export declare const helperUnitPath: (home: string) => string;
4
+ /** No credentials in the unit: the helper reads the same private store as shard. */
5
+ export declare function helperUnit(ctx: Pick<Ctx, 'runtime' | 'store' | 'apiUrl' | 'io'>): string;
6
+ export type Systemctl = (args: string[]) => boolean;
7
+ export declare function installLinuxHelper(ctx: Ctx, deps?: {
8
+ platform?: NodeJS.Platform;
9
+ run?: Systemctl;
10
+ }): boolean;
11
+ /** The caller owns fallback lifetime; never leave an unmanaged background process behind. */
12
+ export declare function startLinuxHelper(ctx: Ctx): () => Promise<void>;
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, realpathSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { CLI_VERSION } from "./args.js";
6
+ import { writePrivateFile } from "./credentials.js";
7
+ import { runDeviceHelper } from "./device-helper.js";
8
+ export const HELPER_UNIT = 'shardflux-helper.service';
9
+ export const helperUnitPath = (home) => join(home, '.config/systemd/user', HELPER_UNIT);
10
+ const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('\n', '\\n').replaceAll('\r', '\\r') + '"';
11
+ /** No credentials in the unit: the helper reads the same private store as shard. */
12
+ export function helperUnit(ctx) {
13
+ if (!ctx.runtime)
14
+ throw new Error('An installed shard is required.');
15
+ let script = ctx.runtime.script;
16
+ try {
17
+ script = realpathSync(script);
18
+ }
19
+ catch { /* preserve the supplied installed path */ }
20
+ const env = { HOME: ctx.io.env.HOME, PATH: ctx.io.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', SHARDFLUX_CONFIG_DIR: ctx.store.dir,
21
+ SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1',
22
+ ...Object.fromEntries(['DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap(k => ctx.io.env[k] ? [[k, ctx.io.env[k]]] : [])) };
23
+ return `# Shardflux CLI ${CLI_VERSION}\n[Unit]\nDescription=Shardflux background helper\nAfter=network-online.target\n\n[Service]\nType=simple\nExecStart=${[ctx.runtime.node, script, 'device-helper', '--service'].map(s => quote(s.replaceAll('$', '$$'))).join(' ')}\n${Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`).join('\n')}\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=10\nUMask=0077\n\n[Install]\nWantedBy=default.target\n`;
24
+ }
25
+ export function installLinuxHelper(ctx, deps = {}) {
26
+ if ((deps.platform ?? process.platform) !== 'linux' || !ctx.runtime || !ctx.io.env.HOME)
27
+ return false;
28
+ const run = deps.run ?? (args => spawnSync('systemctl', ['--user', ...args], { env: ctx.io.env, stdio: 'ignore', timeout: 5000 }).status === 0);
29
+ // Probe before writing: containers without a user manager use the command-lifetime helper.
30
+ if (!run(['show-environment']))
31
+ return false;
32
+ const file = helperUnitPath(ctx.io.env.HOME), text = helperUnit(ctx);
33
+ const receipt = join(ctx.store.dir, 'linux-helper-started'), digest = createHash('sha256').update(text).digest('hex');
34
+ let before = '', started = '';
35
+ try {
36
+ before = readFileSync(file, 'utf8');
37
+ }
38
+ catch { /* first sign-in */ }
39
+ try {
40
+ started = readFileSync(receipt, 'utf8');
41
+ }
42
+ catch { /* no successful start yet */ }
43
+ if (before !== text) {
44
+ writePrivateFile(dirname(file), file, text);
45
+ if (!run(['daemon-reload']) || !run(['enable', '--now', HELPER_UNIT]))
46
+ return false;
47
+ if (before && !run(['restart', HELPER_UNIT]))
48
+ return false;
49
+ }
50
+ else if (!run(['is-active', '--quiet', HELPER_UNIT])) {
51
+ if (!run(['enable', '--now', HELPER_UNIT]))
52
+ return false;
53
+ }
54
+ else if (started !== digest && !run(['restart', HELPER_UNIT]))
55
+ return false;
56
+ // A failed restart must be retried by the next command even though the new unit was already written.
57
+ if (started !== digest)
58
+ writePrivateFile(ctx.store.dir, receipt, digest);
59
+ return true;
60
+ }
61
+ /** The caller owns fallback lifetime; never leave an unmanaged background process behind. */
62
+ export function startLinuxHelper(ctx) {
63
+ const noop = () => Promise.resolve();
64
+ if (process.platform !== 'linux' || !ctx.runtime)
65
+ return noop;
66
+ try {
67
+ ctx.apiKey();
68
+ }
69
+ catch {
70
+ return noop;
71
+ }
72
+ try {
73
+ if (installLinuxHelper(ctx))
74
+ return noop;
75
+ }
76
+ catch { /* use the command-lifetime helper */ }
77
+ const notice = join(ctx.store.dir, 'linux-helper-notice');
78
+ try {
79
+ readFileSync(notice);
80
+ }
81
+ catch {
82
+ ctx.io.stderr.write('Shardflux helper runs while shard commands are open on this computer.\n');
83
+ writePrivateFile(ctx.store.dir, notice, '1\n');
84
+ }
85
+ const abort = new AbortController();
86
+ const work = runDeviceHelper({ ...ctx, signal: AbortSignal.any([ctx.signal, abort.signal]) }, { service: true }).catch(() => undefined);
87
+ return async () => { abort.abort(); await work; };
88
+ }
@@ -0,0 +1,11 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
4
+ export declare function linuxTerminalSession(ctx: Pick<Ctx, 'io' | 'json'>, values: Values, platform?: NodeJS.Platform): boolean;
5
+ export declare const shellWord: (s: string) => string;
6
+ export declare function linuxTerminalCommand(argv: string[], env: Record<string, string | undefined>): {
7
+ command: string;
8
+ args: string[];
9
+ } | null;
10
+ /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
11
+ export declare function openLinuxTerminal(ctx: Ctx, ref: string, app?: string): Promise<number>;
@@ -0,0 +1,42 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
5
+ export function linuxTerminalSession(ctx, values, platform = process.platform) {
6
+ return platform === 'linux' && (values.in === 'terminal' || (values.in === undefined && !!ctx.io.stdin.isTTY && !ctx.json));
7
+ }
8
+ export const shellWord = (s) => `'${s.replaceAll("'", "'\\''")}'`;
9
+ export function linuxTerminalCommand(argv, env) {
10
+ const executable = (name) => name.includes('/') ? [name] : (env.PATH ?? '').split(':').filter(Boolean).map(p => join(p, name));
11
+ const exists = (name) => executable(name).some(p => { try {
12
+ accessSync(p, constants.X_OK);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ } });
18
+ // The shell keeps the window open on an attach error so the user can read and retry it.
19
+ const attach = ['sh', '-c', `${argv.map(shellWord).join(' ')}; status=$?; if [ "$status" -ne 0 ]; then printf '\nPress Enter to close.'; read answer; fi; exit "$status"`];
20
+ for (const command of [...new Set([env.TERMINAL?.trim(), 'x-terminal-emulator', 'gnome-terminal'].filter((s) => !!s))]) {
21
+ if (exists(command))
22
+ return { command, args: [command === 'gnome-terminal' ? '--' : '-e', ...attach] };
23
+ }
24
+ return null;
25
+ }
26
+ /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
27
+ export async function openLinuxTerminal(ctx, ref, app) {
28
+ const words = ['agents', 'open', ref, '--in', 'terminal', ...(app ? ['--app', app] : [])];
29
+ const argv = ctx.runtime ? [ctx.runtime.node, ctx.runtime.script, '--api-url', ctx.apiUrl, ...words] : ['shard', ...words];
30
+ const choice = linuxTerminalCommand(argv, ctx.io.env);
31
+ if (choice) {
32
+ const child = spawn(choice.command, choice.args, { env: ctx.io.env, cwd: process.cwd(), detached: true, stdio: 'ignore' });
33
+ try {
34
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
35
+ child.unref();
36
+ return 0;
37
+ }
38
+ catch { /* print a runnable command if the terminal could not start */ }
39
+ }
40
+ ctx.io.stdout.write(`${['shard', '--api-url', ctx.apiUrl, ...words].map(shellWord).join(' ')}\n`);
41
+ return 0;
42
+ }
package/dist/main.js CHANGED
@@ -13,12 +13,16 @@
13
13
  import { homedir, hostname } from 'node:os';
14
14
  import { statSync } from 'node:fs';
15
15
  import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
16
- import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
16
+ import { CLI_VERSION, CENTER_COMMAND, ONBOARDING_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
17
17
  import { commandCenter } from "./command-center.js";
18
- import { launchConversationProject } from "./conversation-terminal.js";
18
+ import { launchProjectCommand } from "./conversation-terminal.js";
19
19
  import { runAcp } from "./acp.js";
20
20
  import { registerInstalledT3 } from "./t3-provider.js";
21
+ import { refreshForwarderAgent } from "./cli-proxy.js";
21
22
  import { runAcpLogin } from "./acp-login.js";
23
+ import { terminalOnboarding } from "./terminal-onboarding.js";
24
+ import { tryLinuxDesktop } from "./desktop-linux.js";
25
+ import { startLinuxHelper } from "./linux-helper.js";
22
26
  import { runDeviceHelper } from "./device-helper.js";
23
27
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
24
28
  import { CredentialStore, configDir } from "./credentials.js";
@@ -660,7 +664,7 @@ export async function run(argv, io, opts = {}) {
660
664
  refreshMenubarInBackground({ dir: store.dir, home: io.env.HOME || homedir(), runtime: opts.runtime, env: { ...process.env, ...io.env } });
661
665
  }
662
666
  // The update check runs next to the command (not for `version`, which reports it itself).
663
- if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh') {
667
+ if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward') {
664
668
  try {
665
669
  updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
666
670
  updates.start();
@@ -673,11 +677,28 @@ export async function run(argv, io, opts = {}) {
673
677
  catch (err) {
674
678
  return report(err);
675
679
  }
676
- const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
680
+ let stopHelper;
681
+ const startHelper = () => {
682
+ if (stopHelper)
683
+ return;
684
+ try {
685
+ ctx.apiKey();
686
+ }
687
+ catch {
688
+ return;
689
+ }
690
+ stopHelper = startLinuxHelper(ctx);
691
+ };
692
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? (ctx) => runDeviceHelper(ctx, { service: parsed.values.service === true }) : command === 'menubar refresh' ? runMenubarRefresh : parsed.command === PROJECT_COMMAND ? launchProjectCommand : parsed.command === ONBOARDING_COMMAND ? async (ctx, values) => await tryLinuxDesktop(ctx) ? 0 : terminalOnboarding(ctx, values, { startHelper }) : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
677
693
  if (!handler)
678
694
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
679
- if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh')
695
+ // Long-running helpers other programs start (an editor, launchd, the menu bar) skip the per-command housekeeping.
696
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward') {
697
+ // An updated shard restarts this computer's CLIProxyAPI forwarder on itself (a no-op unless the version changed).
698
+ await refreshForwarderAgent(io.env, ctx.store.dir).catch(() => false);
680
699
  await housekeeping(ctx);
700
+ startHelper();
701
+ }
681
702
  const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
682
703
  // On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
683
704
  const interrupted = new Promise((resolve) => {
@@ -712,6 +733,9 @@ export async function run(argv, io, opts = {}) {
712
733
  if (notice)
713
734
  io.stderr.write(notice);
714
735
  }
736
+ if (!['acp', 'device-helper'].includes(parsed.command.path[0]) && command !== 'menubar refresh' && command !== 'cli-proxy forward')
737
+ startHelper();
738
+ await stopHelper?.();
715
739
  return code;
716
740
  }
717
741
  /**
@@ -738,7 +762,7 @@ export async function main(opts = {}) {
738
762
  const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
739
763
  // The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
740
764
  if (process.platform === 'darwin' && runtime.script)
741
- recordInstall(configDir(process.env), runtime, opts.distribution ?? CLI_DISTRIBUTION);
765
+ recordInstall(configDir(process.env), runtime, opts.distribution ?? CLI_DISTRIBUTION, process.env);
742
766
  const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal, runtime, ...(opts.distribution ? { distribution: opts.distribution } : {}) });
743
767
  await Promise.all([flush(process.stdout), flush(process.stderr)]);
744
768
  process.exit(code);
package/dist/menubar.js CHANGED
@@ -8,6 +8,7 @@ import { compareVersions } from '@shardflux/sdk';
8
8
  import { AgentStateStore } from "./agent-state.js";
9
9
  import { UsageError } from "./errors.js";
10
10
  import { json } from "./format.js";
11
+ import { LinuxDesktopManager, linuxDesktopPackage } from "./desktop-linux.js";
11
12
  const execute = promisify(execFile);
12
13
  /**
13
14
  * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
@@ -336,11 +337,19 @@ export function packagedAppPath() {
336
337
  */
337
338
  export function refreshMenubarInBackground(o) {
338
339
  try {
339
- if ((o.platform ?? process.platform) !== 'darwin')
340
+ const platform = o.platform ?? process.platform;
341
+ if (!['darwin', 'linux'].includes(platform))
340
342
  return false;
341
- const installed = plistVersion(join(o.home, 'Applications', 'Shardflux.app'));
342
- const source = (o.packageApp ?? packagedAppPath)();
343
- const packaged = source ? plistVersion(source) : null;
343
+ const manifest = (dir) => { try {
344
+ const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
345
+ return m.id === 'dev.shardflux.Desktop' ? m.version : null;
346
+ }
347
+ catch {
348
+ return null;
349
+ } };
350
+ const installed = platform === 'linux' ? manifest(join(o.home, '.local/share/shardflux/desktop')) : plistVersion(join(o.home, 'Applications', 'Shardflux.app'));
351
+ const source = (o.packageApp ?? (platform === 'linux' ? linuxDesktopPackage : packagedAppPath))();
352
+ const packaged = source ? platform === 'linux' ? manifest(source) : plistVersion(source) : null;
344
353
  if (!installed || !packaged || compareAppVersions(installed, packaged) >= 0)
345
354
  return false;
346
355
  if (new AgentStateStore(o.dir).read().menubar_stopped === true)
@@ -404,6 +413,19 @@ function refreshLock(dir) {
404
413
  }
405
414
  /** `shard menubar refresh` (not listed in help, so not in HANDLERS): what refreshMenubarInBackground starts. */
406
415
  export const runMenubarRefresh = async (ctx) => {
416
+ if (process.platform === 'linux') {
417
+ const manager = new LinuxDesktopManager({ home: ctx.io.env.HOME, env: ctx.io.env });
418
+ const stopped = new AgentStateStore(ctx.store.dir).read().menubar_stopped === true;
419
+ const unlock = refreshLock(ctx.store.dir);
420
+ try {
421
+ const result = stopped ? { result: 'off' } : !unlock ? { result: 'locked' } : await manager.refresh(ctx.store.dir, ctx.apiUrl);
422
+ ctx.io.stdout.write(ctx.json ? json(result) : `Desktop: ${result.result}\n`);
423
+ }
424
+ finally {
425
+ unlock?.();
426
+ }
427
+ return 0;
428
+ }
407
429
  const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
408
430
  let outcome;
409
431
  if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
@@ -425,6 +447,24 @@ export const runMenubarRefresh = async (ctx) => {
425
447
  };
426
448
  export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].map((action) => {
427
449
  const handler = async (ctx, values) => {
450
+ if (process.platform === 'linux') {
451
+ const { LinuxDesktopManager } = await import("./desktop-linux.js");
452
+ const manager = new LinuxDesktopManager({ home: ctx.io.env.HOME, env: ctx.io.env });
453
+ let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop(ctx.store.dir) : await manager.status(ctx.store.dir);
454
+ if (values.login !== undefined) {
455
+ if (values.login !== 'on' && values.login !== 'off')
456
+ throw new UsageError('--login must be on or off.');
457
+ await manager.login(values.login === 'on');
458
+ status = await manager.status(ctx.store.dir);
459
+ }
460
+ if (action !== 'status')
461
+ new AgentStateStore(ctx.store.dir).update(doc => { if (action === 'stop')
462
+ doc.menubar_stopped = true;
463
+ else
464
+ delete doc.menubar_stopped; });
465
+ ctx.io.stdout.write(ctx.json ? json({ ...status, packaged_version: manager.packagedVersion() }) : `Shardflux: ${status.running ? 'running' : 'stopped'} · login ${status.login}\n`);
466
+ return 0;
467
+ }
428
468
  if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
429
469
  throw new UsageError('--login must be on or off.');
430
470
  const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
@@ -26,10 +26,12 @@ type CredentialRow = {
26
26
  checked_at: string;
27
27
  };
28
28
  };
29
+ /** `url`: a page the person opens (the T3 Connect sign-in), instead of a command. */
29
30
  type Action = {
30
31
  title: string;
31
32
  command?: string[];
32
33
  retry?: boolean;
34
+ url?: string;
33
35
  };
34
36
  export interface Connection {
35
37
  id: 'account' | AgentApp | 't3';
@@ -1,12 +1,17 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { delimiter, join, resolve, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { ShardfluxApiError } from '@shardflux/sdk';
4
5
  import { resolveWorkspace } from "./commands.js";
6
+ import { AgentStateStore } from "./agent-state.js";
7
+ import { describeError } from "./errors.js";
8
+ import { t3PhoneLink } from "./t3-phone.js";
5
9
  import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
6
10
  import { getConversation } from "./conversations.js";
7
11
  import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
8
- import { applicationDirs, t3Apps } from "./agent-targets.js";
9
- import { configureT3Provider, t3BundleSupportsAcp } from "./t3-provider.js";
12
+ import { applicationDirs } from "./agent-targets.js";
13
+ import { configureT3Provider } from "./t3-provider.js";
14
+ import { t3Install } from "./t3-mode.js";
10
15
  /**
11
16
  * The first-run demo's task: it shows the agent's own computer at work. The user copies it and pastes it into the
12
17
  * launcher themselves; Daylight's own CLAUDE.md and AGENTS.md say how to run it and test it in the desktop browser.
@@ -63,11 +68,12 @@ export function parseApps(value) {
63
68
  * over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
64
69
  */
65
70
  export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.io.env), platform = process.platform) {
66
- const app = t3Apps(appDirs, platform)[0];
71
+ // The same install every T3 command uses (macOS and Linux): a build with ACP providers first.
72
+ const app = t3Install(ctx.io.env, platform, appDirs);
67
73
  if (!app)
68
74
  return null;
69
75
  const base = { id: 't3', optional: true, found: true, app_path: app.path };
70
- if (t3BundleSupportsAcp(app.path)) {
76
+ if (app.mode === 'acp') {
71
77
  try {
72
78
  await configureT3Provider(ctx.io.env, ctx.store.dir, false, ctx.signal);
73
79
  // The provider works once there is an API key; without an account its own Sign in (shard acp --login) makes one.
@@ -79,15 +85,59 @@ export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.
79
85
  return { ...base, mode: 'provider', state: 'action', fact: error instanceof Error ? error.message : 'T3 Code’s settings could not be updated.', action: { title: 'Check again' } };
80
86
  }
81
87
  }
82
- // Stable T3: an SSH environment per project; the newest one this API set up is the one to add.
88
+ // Stable T3: an SSH environment per project; the newest one this API set up is the one to add. They are recorded in
89
+ // agents.json (targets.json keeps T3 out of its manifest); a manifest entry from an older CLI still counts.
90
+ const recorded = new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).at(-1);
83
91
  const manifest = await jsonFile(join(ctx.store.dir, 'targets.json'));
84
92
  const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
85
- const host = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').map(e => e.host).at(-1);
86
- if (host)
87
- return { ...base, mode: 'ssh', state: 'verified', fact: `In T3 Code: Add environment → SSH → ${host}` };
93
+ const legacy = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').at(-1);
94
+ const host = recorded?.host ?? legacy?.host;
95
+ const workspaceKey = recorded?.workspace ?? (typeof legacy?.workspace === 'string' ? legacy.workspace : undefined);
96
+ if (host) {
97
+ const desktop = `In T3 Code: Add environment → SSH → ${host}`;
98
+ const phone = await t3Phone(ctx, workspaceKey);
99
+ if (!phone)
100
+ return { ...base, mode: 'ssh', state: 'verified', fact: desktop };
101
+ return { ...base, mode: 'ssh', ...phone, fact: phone.state === 'verified' ? `${desktop}. On your phone: ${phone.fact}` : phone.fact };
102
+ }
88
103
  // An SSH environment is per project (shardflux-<project>-main): it is set up once a project is picked.
89
104
  return { ...base, mode: 'ssh', state: 'info', fact: 'Available after you pick a project.', next: shard(['agents', 'new', '--in', 't3', '--path', '<project>']) };
90
105
  }
106
+ /**
107
+ * The T3 app on the phone (T3 Connect, 0.17.0+): the workspace is linked with the person's one T3 Connect sign-in. The
108
+ * sign-in is a page to open once; afterwards every project appears in the T3 app by itself. Undefined when the API has
109
+ * no T3 Connect: the desktop route is the whole card then.
110
+ */
111
+ async function t3Phone(ctx, key) {
112
+ if (!key)
113
+ return undefined;
114
+ try {
115
+ const ws = await resolveWorkspace(ctx, key);
116
+ const cloud = ctx.cloud();
117
+ const known = (await cloud.t3.status()).links.find(l => l.workspace_id === ws.id);
118
+ // A workspace connected to T3 before T3 Connect, or one whose link failed, is linked now: it is the person's choice.
119
+ const { link, signIn, needsLogin } = !known || known.status === 'error' || known.status === 'needs_sign_in'
120
+ ? await t3PhoneLink(ctx, ws.id) : { link: known, signIn: undefined, needsLogin: false };
121
+ if (link.status === 'linked')
122
+ return { state: 'verified', fact: `${link.label} in the T3 app.` };
123
+ if (signIn) {
124
+ return { state: 'action', fact: `To use it from the T3 app on your phone, confirm ${signIn.user_code} once.`,
125
+ action: { title: 'Connect phone', url: signIn.verification_uri } };
126
+ }
127
+ if (needsLogin)
128
+ return { state: 'info', fact: 'Sign in to Shardflux on this computer to use it from the T3 app.' };
129
+ return { state: 'pending', fact: `Adding ${link.label} to the T3 app on your phone…` };
130
+ }
131
+ catch (error) {
132
+ // No T3 Connect on this API, or nothing to ask it: the desktop route stands on its own.
133
+ if (!(error instanceof ShardfluxApiError) || describeError(error).code === 'not_found')
134
+ return undefined;
135
+ // Another member's T3 Connect sign-in links this project: it is in their T3 app, not something to fix here.
136
+ if (error.details?.reason === 't3_linked_by_other_member')
137
+ return { state: 'info', fact: 'In the T3 app of the member who linked it.' };
138
+ return { state: 'unknown', fact: 'The T3 app link could not be checked just now.', action: { title: 'Retry', retry: true } };
139
+ }
140
+ }
91
141
  const shard = (words) => ['shard', ...words];
92
142
  /** Claude: the account login is ready and Claude itself accepts it right now. */
93
143
  export function claudeConnection(row) {
@@ -105,7 +155,7 @@ export function codexConnection(local, synced, connector, health) {
105
155
  const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
106
156
  if (!synced) {
107
157
  return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
108
- : { state: 'action', fact: 'Sign in to Codex on this Mac first.', action: { title: 'Sign in to Codex', command: ['codex', 'login'] } };
158
+ : { state: 'action', fact: 'Sign in to Codex on this computer first.', action: { title: 'Sign in to Codex', command: ['codex', 'login'] } };
109
159
  }
110
160
  const retry = { title: 'Retry', retry: true, command: shard(['setup', '--inspect', '--apps', 'codex', '--connect', '--retry-codex']) };
111
161
  switch (health) {
@@ -0,0 +1,10 @@
1
+ /** The server CLI inside the archive: what Electron runs as Node to issue T3 sessions. */
2
+ export declare const T3_SERVER_ENTRY = "apps/server/dist/bin.mjs";
3
+ /** The app's release version (package.json `version`, e.g. `0.0.46-nightly.20261010.2908`), or null. */
4
+ export declare function asarVersion(archive: string): string | null;
5
+ /**
6
+ * Whether the server registers the ACP Registry driver. A version number alone is insufficient (0.0.45 lacks it while
7
+ * upstream main had it). Builds put the driver in `bin.mjs` itself, or (nightlies since October 2026) in a chunk the
8
+ * thin `bin.mjs` entry imports (`binCli-<hash>.mjs`): the entry and the chunks it names are searched.
9
+ */
10
+ export declare function t3ArchiveSupportsAcp(archive: string): boolean;