@shardflux/cli 0.17.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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  ## 0.17.0 (not yet published)
4
4
 
5
- - Linux (Ubuntu 24.04, x64 and arm64): bare `shard` signs in and connects Claude/Codex in the terminal. The
6
- systemd user helper keeps the live twin, files and conversation mirrors running; `shard device requests`,
7
- `shard device approve <id>` and `shard device deny <id>` handle local approvals. Desktop launches open a terminal;
8
- macOS keeps its extra menu bar app.
5
+ - Linux (Ubuntu 24.04, x64 and arm64): bare `shard` opens the Shardflux app in a graphical session. The optional CPU package installs without npm install scripts, adds Shardflux to the app grid, and starts at login. Headless sessions keep terminal onboarding. The systemd user helper keeps the live twin, files and conversation mirrors running; `shard device requests`, `shard device approve <id>` and `shard device deny <id>` handle local approvals.
6
+ - The Ubuntu desktop app includes the custom tray panel, launcher capture and project/model menus, all Settings panes, local device and credential approval panels, notifications with actions, file downloads and packaged-app updates. `shard device approve --always` remembers matching in-project requests; outside-folder requests remain one-time approvals.
7
+ - T3 Code on Linux: `shard` finds T3 Code installed from its .deb or AppImage (and AppImageLauncher, Gear Lever,
8
+ flatpak and snap entries), registers the Shardflux provider in it, shows it in onboarding, and opens it from its
9
+ desktop entry. `shard agents new --in t3`, `shard agents open t3` and `shard agents targets --open-in` work as on
10
+ macOS: a T3 build with ACP providers starts each thread on its Shardflux provider in a cloud VM of its own, and T3
11
+ Code 0.0.45 opens the project VM as an SSH environment. `shard agents targets --json` reports it under `t3`
12
+ (`last_used_at`: when T3 last started). The Shardflux provider is also recognized in the latest T3 nightlies on
13
+ macOS.
9
14
 
10
15
  - A Codex project opens in T3 Code without a Claude sign-in (T3 shows Claude as signed out, and says how to sign in), and `--json` names the T3 Connect sign-in `shard` started for the phone (`t3_link.sign_in`, or `t3_link.needs_login` when this computer has no session).
11
16
  - A conversation settles when its turn ends (`done` or `unresolved` right away) and its VM takes the next message.
package/README.md CHANGED
@@ -87,9 +87,10 @@ time `shard` runs, with launch at login enabled; `shard menubar stop` turns it o
87
87
 
88
88
  T3 Code uses one **Shardflux** provider; choose a Claude Code or Codex model to start that agent in the cloud.
89
89
  Model choices come from your cloud agent's session and use the same coding tools and settings as its terminal.
90
- The provider is registered automatically when T3 Code is installed. Choose **Sign in** on its card in T3's provider
91
- settings: T3 runs `shard acp --login` in its own terminal, which creates or signs in to your account (with this Mac's
92
- Codex login, or your email) and connects Claude Code and Codex, skipping every step that is already done (0.15.0+).
90
+ The provider is registered automatically when T3 Code is installed, on macOS and on Linux from its .deb or AppImage
91
+ (0.17.0+). Choose **Sign in** on its card in T3's provider settings: T3 runs `shard acp --login` in its own terminal,
92
+ which creates or signs in to your account (with this computer's Codex login, or your email) and connects Claude Code
93
+ and Codex, skipping every step that is already done (0.15.0+).
93
94
  With T3 Code 0.0.45, `shard agents new --in t3` adds the project as an SSH environment instead.
94
95
 
95
96
  Start another thread on a computer that is already set up (0.16.0+): it shares that conversation's installed
@@ -78,12 +78,8 @@ export async function prepareSsh(ws, ctx, targets, host) {
78
78
  return cell;
79
79
  }
80
80
  async function openApp(target, env) {
81
- if (target === 't3') {
82
- const install = t3Install(env);
83
- if (!install)
84
- throw t3NotInstalled();
85
- return openT3(env, install.path);
86
- }
81
+ if (target === 't3')
82
+ return openT3(env, t3Install(env));
87
83
  const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
88
84
  await new Promise((resolve, reject) => {
89
85
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
@@ -283,7 +279,7 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
283
279
  if (target === 't3') {
284
280
  // The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
285
281
  try {
286
- await openT3(ctx.io.env, (install ?? t3Install(ctx.io.env))?.path ?? '');
282
+ await openT3(ctx.io.env, install ?? t3Install(ctx.io.env));
287
283
  }
288
284
  catch (error) {
289
285
  throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
@@ -1,3 +1,4 @@
1
+ import type { T3LinuxApp } from './t3-linux.js';
1
2
  export type DesktopTarget = 'claude-desktop' | 'codex-app' | 't3';
2
3
  export interface TargetEntry {
3
4
  host: string;
@@ -13,13 +14,22 @@ export declare const targetLabel: (e: Pick<TargetEntry, "agent" | "project">) =>
13
14
  export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
14
15
  /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
15
16
  export declare function managedBlock(text: string, body: string): string;
16
- /** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
17
- export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Array<{
17
+ /**
18
+ * An installed T3 Code: on macOS `path` is the .app bundle; on Linux it is the program its desktop entry runs (the
19
+ * executable, or the AppImage), and `linux` says how it was installed and how it starts (t3-linux.ts).
20
+ */
21
+ export interface T3App {
18
22
  path: string;
19
23
  version: string | null;
20
- }>;
21
- /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
22
- export declare function applicationDirs(env: Record<string, string | undefined>): string[];
24
+ linux?: T3LinuxApp;
25
+ }
26
+ /** The installed T3 Code apps (any channel), with their path and version, in the given app folders (applicationDirs). */
27
+ export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform, env?: Record<string, string | undefined>): T3App[];
28
+ /**
29
+ * The folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS (colon-separated) when set; else /Applications and
30
+ * ~/Applications on macOS, and on Linux the XDG application folders and the usual AppImage folders (linuxApplicationDirs).
31
+ */
32
+ export declare function applicationDirs(env: Record<string, string | undefined>, platform?: NodeJS.Platform): string[];
23
33
  /** The first `name` app bundle with this bundle id in the app folders (macOS only). */
24
34
  export declare function desktopAppPath(dirs: string[], name: string, bundle: string, platform?: NodeJS.Platform): string | undefined;
25
35
  export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
@@ -7,6 +7,7 @@ import { writePrivateFile } from "./credentials.js";
7
7
  import { UsageError } from "./errors.js";
8
8
  import { projectWorkspacePath } from "./agent-repository.js";
9
9
  import { withTargetLock } from "./target-lock.js";
10
+ import { linuxApplicationDirs, linuxT3Apps } from "./t3-linux.js";
10
11
  const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
11
12
  const BEGIN = '# BEGIN Shardflux managed targets';
12
13
  const END = '# END Shardflux managed targets';
@@ -36,8 +37,10 @@ export function managedBlock(text, body) {
36
37
  const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
37
38
  return text.slice(0, start) + block + text.slice(stop);
38
39
  }
39
- /** The installed T3 Code apps (any channel), with their path and version, in the given app folders. */
40
- export function t3Apps(dirs, platform = process.platform) {
40
+ /** The installed T3 Code apps (any channel), with their path and version, in the given app folders (applicationDirs). */
41
+ export function t3Apps(dirs, platform = process.platform, env = process.env) {
42
+ if (platform === 'linux')
43
+ return linuxT3Apps(dirs, env);
41
44
  const found = [];
42
45
  if (platform !== 'darwin')
43
46
  return found;
@@ -69,10 +72,15 @@ export function t3Apps(dirs, platform = process.platform) {
69
72
  }
70
73
  return found;
71
74
  }
72
- /** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
73
- export function applicationDirs(env) {
75
+ /**
76
+ * The folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS (colon-separated) when set; else /Applications and
77
+ * ~/Applications on macOS, and on Linux the XDG application folders and the usual AppImage folders (linuxApplicationDirs).
78
+ */
79
+ export function applicationDirs(env, platform = process.platform) {
74
80
  const home = env.HOME || homedir();
75
- return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
81
+ if (env.SHARDFLUX_APPLICATIONS_DIRS !== undefined)
82
+ return env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean);
83
+ return platform === 'linux' ? linuxApplicationDirs(env) : ['/Applications', join(home, 'Applications')];
76
84
  }
77
85
  /** The first `name` app bundle with this bundle id in the app folders (macOS only). */
78
86
  export function desktopAppPath(dirs, name, bundle, platform = process.platform) {
@@ -88,10 +96,10 @@ export function desktopAppPath(dirs, name, bundle, platform = process.platform)
88
96
  }
89
97
  export function installedTargets(env, platform = process.platform, appDirs) {
90
98
  // SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
91
- const dirs = appDirs ?? applicationDirs(env);
99
+ const dirs = appDirs ?? applicationDirs(env, platform);
92
100
  const hasApp = (name, bundle) => desktopAppPath(dirs, name, bundle, platform) !== undefined;
93
101
  const versions = {};
94
- const t3Bundles = t3Apps(dirs, platform);
102
+ const t3Bundles = t3Apps(dirs, platform, env);
95
103
  const t3 = t3Bundles.length > 0;
96
104
  for (const app of t3Bundles)
97
105
  if (app.version)
@@ -106,7 +114,9 @@ export function installedTargets(env, platform = process.platform, appDirs) {
106
114
  const clients = [hasApp('Claude.app', 'com.anthropic.claudefordesktop') && 'claude-desktop', (hasApp('Codex.app', 'com.openai.codex') || hasApp('ChatGPT.app', 'com.openai.codex')) && 'codex-app', executable('claude') && 'claude', executable('codex') && 'codex'].filter((v) => !!v);
107
115
  if (t3)
108
116
  clients.push('t3');
109
- return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : []), 'web'], clients, versions };
117
+ // Linux has T3 Code (0.17.0+); Claude Desktop and the Codex app are macOS apps.
118
+ const desktop = platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : platform === 'linux' && t3 ? ['t3'] : [];
119
+ return { targets: [...desktop, 'web'], clients, versions };
110
120
  }
111
121
  /** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
112
122
  export class AgentTargets {
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.17.0';
16
+ export const CLI_VERSION = '0.18.0-next.4';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -1229,7 +1229,7 @@ export const ONBOARDING_COMMAND = { path: ['<onboarding>'], summary: 'Set up Sha
1229
1229
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1230
1230
  const AGENT_COMMANDS = [
1231
1231
  { path: ['device', 'requests'], summary: 'Review local device requests waiting for approval', positionals: [], options: {} },
1232
- ...['approve', 'deny'].map(action => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: {} })),
1232
+ ...['approve', 'deny'].map((action) => ({ path: ['device', action], summary: `${action} a local device request`, positionals: [{ name: 'id', required: true }], options: action === 'approve' ? { always: { type: 'boolean', description: 'Allow matching requests for this project; never for outside-folder access.' } } : {} })),
1233
1233
  { path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
1234
1234
  { path: ['agents', 'targets'], summary: 'Detect apps or configure managed desktop connections', positionals: [{ name: 'agent' }], options: {
1235
1235
  remove: { type: 'boolean', description: 'Remove this agent’s managed entries, or all entries for this API URL.' },
@@ -0,0 +1,40 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import type { Ctx } from './commands.js';
3
+ declare const exec: typeof execFile.__promisify__;
4
+ export interface DesktopStatus {
5
+ installed: boolean;
6
+ version: string | null;
7
+ running: boolean;
8
+ pids: number[];
9
+ login: 'on' | 'off';
10
+ }
11
+ export declare function linuxDesktopPackage(arch?: NodeJS.Architecture): string | null;
12
+ export declare function graphicalSession(env: NodeJS.ProcessEnv): boolean;
13
+ export declare class LinuxDesktopManager {
14
+ #private;
15
+ readonly home: string;
16
+ readonly app: string;
17
+ constructor(options?: {
18
+ home?: string;
19
+ source?: () => string | null;
20
+ env?: NodeJS.ProcessEnv;
21
+ exec?: typeof exec;
22
+ spawn?: typeof spawn;
23
+ isProcess?: (pid: number, executable: string) => boolean;
24
+ });
25
+ status(dir: string): Promise<DesktopStatus>;
26
+ packagedVersion(): string | null;
27
+ login(on: boolean): Promise<void>;
28
+ stop(dir: string): Promise<DesktopStatus>;
29
+ refresh(dir: string, api: string): Promise<{
30
+ result: string;
31
+ from?: string | null;
32
+ to?: string | null;
33
+ }>;
34
+ start(dir: string, api: string, view?: string, runtime?: {
35
+ node: string;
36
+ script: string;
37
+ }): Promise<DesktopStatus>;
38
+ }
39
+ export declare function tryLinuxDesktop(ctx: Ctx): Promise<boolean>;
40
+ export {};
@@ -0,0 +1,186 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { promisify } from 'node:util';
7
+ import { UsageError } from "./errors.js";
8
+ import { compareVersions } from '@shardflux/sdk';
9
+ const exec = promisify(execFile);
10
+ export function linuxDesktopPackage(arch = process.arch) {
11
+ if (!['x64', 'arm64'].includes(arch))
12
+ return null;
13
+ try {
14
+ return join(dirname(createRequire(import.meta.url).resolve(`@shardflux/desktop-linux-${arch}/package.json`)), 'app');
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function graphicalSession(env) { return !!(env.DISPLAY || env.WAYLAND_DISPLAY); }
21
+ const desktopString = (text) => `"${text.replace(/(["`$\\])/g, '\\$1')}"`;
22
+ export class LinuxDesktopManager {
23
+ home;
24
+ app;
25
+ #source;
26
+ #env;
27
+ #exec;
28
+ #spawn;
29
+ #isProcess;
30
+ constructor(options = {}) {
31
+ this.home = options.home ?? homedir();
32
+ this.app = join(this.home, '.local', 'share', 'shardflux', 'desktop');
33
+ this.#source = options.source ?? linuxDesktopPackage;
34
+ this.#env = { ...process.env, ...options.env, HOME: this.home };
35
+ this.#exec = options.exec ?? exec;
36
+ this.#spawn = options.spawn ?? spawn;
37
+ this.#isProcess = options.isProcess ?? ((pid, executable) => { try {
38
+ if (readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] !== executable)
39
+ return false;
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ } });
46
+ }
47
+ #version(path) {
48
+ try {
49
+ const manifest = JSON.parse(readFileSync(join(path, 'manifest.json'), 'utf8'));
50
+ return manifest.id === 'dev.shardflux.Desktop' && typeof manifest.version === 'string' ? manifest.version : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ async status(dir) {
57
+ const version = this.#version(this.app), pids = [];
58
+ try {
59
+ const pid = Number(readFileSync(join(dir, 'desktop-linux.pid'), 'utf8'));
60
+ // Recycled PIDs must never make us stop an unrelated process.
61
+ if (Number.isInteger(pid) && pid > 0 && this.#isProcess(pid, join(this.app, 'shardflux-desktop')))
62
+ pids.push(pid);
63
+ }
64
+ catch { /* no running app */ }
65
+ return { installed: version !== null, version, running: pids.length > 0, pids, login: existsSync(join(this.home, '.config/autostart/shardflux.desktop')) ? 'on' : 'off' };
66
+ }
67
+ packagedVersion() { const path = this.#source(); return path ? this.#version(path) : null; }
68
+ async login(on) {
69
+ const file = join(this.home, '.config/autostart/shardflux.desktop');
70
+ if (!on) {
71
+ rmSync(file, { force: true });
72
+ return;
73
+ }
74
+ mkdirSync(dirname(file), { recursive: true });
75
+ const entry = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
76
+ if (!existsSync(entry))
77
+ throw new UsageError('Run shard menubar start first.');
78
+ cpSync(entry, file);
79
+ }
80
+ async stop(dir) {
81
+ const status = await this.status(dir);
82
+ if (status.running) {
83
+ await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'stop'], { env: { ...this.#env, SHARDFLUX_CONFIG_DIR: dir }, timeout: 5_000 });
84
+ for (let i = 0; i < 30; i++) {
85
+ await new Promise(r => setTimeout(r, 100));
86
+ if (!(await this.status(dir)).running)
87
+ break;
88
+ }
89
+ if ((await this.status(dir)).running)
90
+ throw new UsageError('Shardflux is still stopping. Try shard menubar status.');
91
+ }
92
+ return this.status(dir);
93
+ }
94
+ async refresh(dir, api) {
95
+ const before = await this.status(dir), version = this.packagedVersion();
96
+ if (!before.installed || !version)
97
+ return { result: 'off' };
98
+ if ((compareVersions(before.version, version) ?? 0) >= 0)
99
+ return { result: 'current', from: before.version, to: version };
100
+ if (before.running) {
101
+ try {
102
+ const { stdout } = await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'idle'], { env: this.#env, timeout: 5000 });
103
+ if (!JSON.parse(stdout).idle)
104
+ return { result: 'busy', from: before.version, to: version };
105
+ }
106
+ catch {
107
+ return { result: 'busy', from: before.version, to: version };
108
+ }
109
+ }
110
+ const after = await this.start(dir, api);
111
+ return { result: 'updated', from: before.version, to: after.version };
112
+ }
113
+ async start(dir, api, view = 'onboarding', runtime = { node: process.execPath, script: process.argv[1] }) {
114
+ const source = this.#source(), version = source && this.#version(source);
115
+ if (!source || !version)
116
+ throw new UsageError('Install the window with npm i -g shardflux --include=optional, then run shard.');
117
+ if (!graphicalSession(this.#env))
118
+ throw new UsageError('Open shard from your desktop session to show the window.');
119
+ const current = await this.status(dir);
120
+ if (!current.version || (compareVersions(current.version, version) ?? -1) < 0) {
121
+ if (existsSync(this.app) && (lstatSync(this.app).isSymbolicLink() || !current.version))
122
+ throw new UsageError(`Refusing to replace an unrelated directory at ${this.app}.`);
123
+ // The loader probes every shared library before any installed copy is stopped or replaced.
124
+ await this.#exec('/usr/bin/ldd', [join(source, 'shardflux-desktop')], { timeout: 5_000 }).then(({ stdout }) => { if (stdout.includes('not found'))
125
+ throw new UsageError('The Shardflux window needs libwebkit2gtk-4.1-0 and libayatana-appindicator3-1. Install those desktop libraries, then run shard.'); });
126
+ mkdirSync(dirname(this.app), { recursive: true });
127
+ const staging = mkdtempSync(join(dirname(this.app), '.desktop-'));
128
+ const staged = join(staging, 'app'), backup = join(staging, 'previous');
129
+ try {
130
+ cpSync(source, staged, { recursive: true });
131
+ chmodSync(join(staged, 'shardflux-desktop'), 0o755);
132
+ if (current.running)
133
+ await this.stop(dir);
134
+ if (existsSync(this.app))
135
+ renameSync(this.app, backup);
136
+ try {
137
+ renameSync(staged, this.app);
138
+ }
139
+ catch (error) {
140
+ if (existsSync(backup))
141
+ renameSync(backup, this.app);
142
+ throw error;
143
+ }
144
+ }
145
+ finally {
146
+ rmSync(staging, { recursive: true, force: true });
147
+ }
148
+ }
149
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
150
+ const env = { ...this.#env, SHARDFLUX_CONFIG_DIR: dir, SHARDFLUX_API_URL: api, SHARDFLUX_NODE: runtime.node, SHARDFLUX_CLI: runtime.script };
151
+ const desktop = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
152
+ mkdirSync(dirname(desktop), { recursive: true });
153
+ const args = ['/usr/bin/env', `SHARDFLUX_CONFIG_DIR=${dir}`, `SHARDFLUX_API_URL=${api}`, `SHARDFLUX_NODE=${runtime.node}`, `SHARDFLUX_CLI=${runtime.script}`, join(this.app, 'shardflux-desktop')];
154
+ writeFileSync(desktop, `[Desktop Entry]\nType=Application\nName=Shardflux\nComment=Your agents, a shortcut away\nExec=${args.map(desktopString).join(' ')}\nIcon=${join(this.app, 'ui/assets/shardflux-icon.png')}\nTerminal=false\nCategories=Development;\nStartupWMClass=dev.shardflux.Desktop\n`, { mode: 0o644 });
155
+ if ((await this.status(dir)).running)
156
+ await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', view], { env, timeout: 5_000 });
157
+ else {
158
+ const child = this.#spawn(join(this.app, 'shardflux-desktop'), [view], { env, detached: true, stdio: 'ignore' });
159
+ await new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', resolve); });
160
+ child.unref();
161
+ for (let i = 0; i < 40; i++) {
162
+ if ((await this.status(dir)).running)
163
+ break;
164
+ await new Promise(r => setTimeout(r, 100));
165
+ }
166
+ }
167
+ const result = await this.status(dir);
168
+ if (!result.running)
169
+ throw new UsageError('The Shardflux window did not open. Run shard menubar start from your desktop session.');
170
+ return result;
171
+ }
172
+ }
173
+ export async function tryLinuxDesktop(ctx) {
174
+ if (process.platform !== 'linux' || ctx.json || ctx.io.env.SHARDFLUX_DESKTOP_CHILD === '1' || !graphicalSession(ctx.io.env))
175
+ return false;
176
+ try {
177
+ const manager = new LinuxDesktopManager({ home: ctx.io.env.HOME, env: ctx.io.env });
178
+ await manager.start(ctx.store.dir, ctx.apiUrl);
179
+ await manager.login(true);
180
+ return true;
181
+ }
182
+ catch (err) {
183
+ ctx.io.stderr.write(`${err instanceof Error ? err.message : 'The Shardflux window could not open.'}\n`);
184
+ return false;
185
+ }
186
+ }
@@ -34,6 +34,8 @@ export function answerDeviceApproval(dir, id, choice) {
34
34
  const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
35
35
  if (!pending)
36
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.');
37
39
  writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
38
40
  }
39
41
  /** Local user-owned files carry approvals; cloud data can never answer its own request. */
@@ -65,9 +67,9 @@ export const DEVICE_HANDLERS = {
65
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');
66
68
  return Promise.resolve(0);
67
69
  },
68
- ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, _values, [id]) => {
69
- answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? 'once' : 'deny');
70
- ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action }) : `Device request ${id}: ${action === 'approve' ? 'approved once' : 'denied'}.\n`);
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`);
71
73
  return Promise.resolve(0);
72
74
  }])),
73
75
  };
File without changes
package/dist/main.js CHANGED
@@ -21,6 +21,7 @@ import { registerInstalledT3 } from "./t3-provider.js";
21
21
  import { refreshForwarderAgent } from "./cli-proxy.js";
22
22
  import { runAcpLogin } from "./acp-login.js";
23
23
  import { terminalOnboarding } from "./terminal-onboarding.js";
24
+ import { tryLinuxDesktop } from "./desktop-linux.js";
24
25
  import { startLinuxHelper } from "./linux-helper.js";
25
26
  import { runDeviceHelper } from "./device-helper.js";
26
27
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
@@ -688,7 +689,7 @@ export async function run(argv, io, opts = {}) {
688
689
  }
689
690
  stopHelper = startLinuxHelper(ctx);
690
691
  };
691
- 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 ? (ctx, values) => terminalOnboarding(ctx, values, { startHelper }) : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
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(' ')];
692
693
  if (!handler)
693
694
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
694
695
  // Long-running helpers other programs start (an editor, launchd, the menu bar) skip the per-command housekeeping.
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 });
@@ -9,8 +9,9 @@ import { t3PhoneLink } from "./t3-phone.js";
9
9
  import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
10
10
  import { getConversation } from "./conversations.js";
11
11
  import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
12
- import { applicationDirs, t3Apps } from "./agent-targets.js";
13
- 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";
14
15
  /**
15
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
16
17
  * launcher themselves; Daylight's own CLAUDE.md and AGENTS.md say how to run it and test it in the desktop browser.
@@ -67,11 +68,12 @@ export function parseApps(value) {
67
68
  * over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
68
69
  */
69
70
  export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.io.env), platform = process.platform) {
70
- 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);
71
73
  if (!app)
72
74
  return null;
73
75
  const base = { id: 't3', optional: true, found: true, app_path: app.path };
74
- if (t3BundleSupportsAcp(app.path)) {
76
+ if (app.mode === 'acp') {
75
77
  try {
76
78
  await configureT3Provider(ctx.io.env, ctx.store.dir, false, ctx.signal);
77
79
  // The provider works once there is an API key; without an account its own Sign in (shard acp --login) makes one.
@@ -123,7 +125,7 @@ async function t3Phone(ctx, key) {
123
125
  action: { title: 'Connect phone', url: signIn.verification_uri } };
124
126
  }
125
127
  if (needsLogin)
126
- return { state: 'info', fact: 'Sign in to Shardflux on this Mac to use it from the T3 app.' };
128
+ return { state: 'info', fact: 'Sign in to Shardflux on this computer to use it from the T3 app.' };
127
129
  return { state: 'pending', fact: `Adding ${link.label} to the T3 app on your phone…` };
128
130
  }
129
131
  catch (error) {
@@ -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;