@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.
- package/CHANGELOG.md +67 -2
- package/README.md +47 -8
- package/dist/agent-commands.d.ts +9 -1
- package/dist/agent-commands.js +65 -16
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +6 -1
- package/dist/agent-target-commands.js +113 -16
- package/dist/agent-targets.d.ts +17 -5
- package/dist/agent-targets.js +27 -11
- package/dist/args.d.ts +5 -1
- package/dist/args.js +49 -9
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +2 -1
- package/dist/codex-cloud.js +16 -6
- package/dist/command-center-actions.js +16 -1
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +45 -6
- package/dist/conversations.d.ts +18 -2
- package/dist/conversations.js +12 -5
- package/dist/desktop-linux.d.ts +40 -0
- package/dist/desktop-linux.js +186 -0
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +75 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +5 -2
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +1 -1
- package/dist/git-remote-bin.js +0 -0
- package/dist/handlers.js +3 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +30 -6
- package/dist/menubar.js +44 -4
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +59 -9
- package/dist/t3-asar.d.ts +10 -0
- package/dist/t3-asar.js +91 -0
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-linux.d.ts +68 -0
- package/dist/t3-linux.js +343 -0
- package/dist/t3-mode.d.ts +129 -0
- package/dist/t3-mode.js +676 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-provider.d.ts +5 -3
- package/dist/t3-provider.js +12 -49
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +2 -2
- package/dist/thread-on-computer.js +13 -7
- package/dist/update.d.ts +1 -1
- package/dist/update.js +28 -1
- package/dist/worktree-twin-engine.d.ts +31 -1
- package/dist/worktree-twin-engine.js +253 -40
- package/dist/worktree-twin.d.ts +17 -0
- package/dist/worktree-twin.js +147 -22
- package/package.json +7 -19
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { T3Link } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
export interface T3PhoneLink {
|
|
4
|
+
link: T3Link;
|
|
5
|
+
/** The person's T3 Connect sign-in to approve, once: open `verification_uri`, confirm `user_code`. */
|
|
6
|
+
signIn?: {
|
|
7
|
+
verification_uri: string;
|
|
8
|
+
user_code: string;
|
|
9
|
+
};
|
|
10
|
+
/** No signed-in session here to start the sign-in with (`shard auth login` first). */
|
|
11
|
+
needsLogin?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Links a workspace for the T3 app on the phone (0.17.0+). An API key links with its creator's sign-in; starting that
|
|
15
|
+
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet.
|
|
16
|
+
*/
|
|
17
|
+
export declare function t3PhoneLink(ctx: Ctx, workspaceId: string): Promise<T3PhoneLink>;
|
package/dist/t3-phone.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links a workspace for the T3 app on the phone (0.17.0+). An API key links with its creator's sign-in; starting that
|
|
3
|
+
* sign-in takes the person's own session, so the sign-in comes from `shard`'s session when the key has none yet.
|
|
4
|
+
*/
|
|
5
|
+
export async function t3PhoneLink(ctx, workspaceId) {
|
|
6
|
+
const link = await ctx.cloud().t3.link(workspaceId);
|
|
7
|
+
if (link.status !== 'needs_sign_in')
|
|
8
|
+
return { link };
|
|
9
|
+
if (link.sign_in)
|
|
10
|
+
return { link, signIn: link.sign_in };
|
|
11
|
+
if (ctx.store.profile(ctx.apiUrl).session?.state !== 'active')
|
|
12
|
+
return { link, needsLogin: true };
|
|
13
|
+
const flow = await ctx.account().t3.signIn();
|
|
14
|
+
return { link, signIn: flow };
|
|
15
|
+
}
|
package/dist/t3-provider.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
/** Every shard invocation registers the one installed T3 provider without adding terminal noise. */
|
|
2
|
-
export declare function registerInstalledT3(env: Record<string, string | undefined>, dir: string, signal?: AbortSignal): Promise<void>;
|
|
3
|
-
/**
|
|
1
|
+
/** Every shard invocation registers the one installed T3 provider without adding terminal noise (macOS and Linux). */
|
|
2
|
+
export declare function registerInstalledT3(env: Record<string, string | undefined>, dir: string, signal?: AbortSignal, platform?: NodeJS.Platform): Promise<void>;
|
|
3
|
+
/** Whether a macOS T3 bundle's server has the ACP Registry driver (its Contents/Resources/app.asar; see t3-asar.ts). */
|
|
4
4
|
export declare function t3BundleSupportsAcp(app: string): boolean;
|
|
5
|
+
/** T3's home, as the desktop resolves it on macOS and Linux: T3CODE_HOME (trimmed), else ~/.t3. */
|
|
6
|
+
export declare function t3HomeDir(env: Record<string, string | undefined>): string;
|
|
5
7
|
/** Desktop 0.0.45 watches this server settings file, including symlink targets. No encrypted stores. */
|
|
6
8
|
export declare function configureT3Provider(env: Record<string, string | undefined>, dir: string, remove?: boolean, signal?: AbortSignal): Promise<boolean>;
|
package/dist/t3-provider.js
CHANGED
|
@@ -1,70 +1,33 @@
|
|
|
1
|
-
import { readFileSync, realpathSync
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { writePrivateFile } from "./credentials.js";
|
|
5
5
|
import { withTargetLock } from "./target-lock.js";
|
|
6
6
|
import { installedTargets } from "./agent-targets.js";
|
|
7
|
+
import { t3ArchiveSupportsAcp } from "./t3-asar.js";
|
|
7
8
|
const ID = 'shardflux';
|
|
8
9
|
const provider = { driver: 'acpRegistry', displayName: 'Shardflux', enabled: true, config: { source: 'local', commandPath: 'shard', commandArgs: ['acp'] } };
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (process.platform !== 'darwin')
|
|
13
|
-
return;
|
|
14
|
-
if (!installedTargets(env).targets.includes('t3'))
|
|
10
|
+
/** Every shard invocation registers the one installed T3 provider without adding terminal noise (macOS and Linux). */
|
|
11
|
+
export async function registerInstalledT3(env, dir, signal, platform = process.platform) {
|
|
12
|
+
if (!installedTargets(env, platform).targets.includes('t3'))
|
|
15
13
|
return;
|
|
16
14
|
try {
|
|
17
15
|
await configureT3Provider(env, dir, false, signal);
|
|
18
16
|
}
|
|
19
17
|
catch { /* User-owned malformed or conflicting settings stay intact. */ }
|
|
20
18
|
}
|
|
21
|
-
/**
|
|
19
|
+
/** Whether a macOS T3 bundle's server has the ACP Registry driver (its Contents/Resources/app.asar; see t3-asar.ts). */
|
|
22
20
|
export function t3BundleSupportsAcp(app) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return support.get(archive).capable;
|
|
29
|
-
fd = openSync(archive, 'r');
|
|
30
|
-
const sizes = Buffer.alloc(16);
|
|
31
|
-
if (readSync(fd, sizes, 0, 16, 0) !== 16)
|
|
32
|
-
return false;
|
|
33
|
-
const headerSize = sizes.readUInt32LE(12);
|
|
34
|
-
if (headerSize < 1 || headerSize > 16 << 20)
|
|
35
|
-
return false;
|
|
36
|
-
const header = Buffer.alloc(headerSize);
|
|
37
|
-
if (readSync(fd, header, 0, headerSize, 16) !== headerSize)
|
|
38
|
-
return false;
|
|
39
|
-
let entry = JSON.parse(header.toString());
|
|
40
|
-
for (const part of ['apps', 'server', 'dist', 'bin.mjs']) {
|
|
41
|
-
const child = entry.files?.[part];
|
|
42
|
-
if (!child)
|
|
43
|
-
return false;
|
|
44
|
-
entry = child;
|
|
45
|
-
}
|
|
46
|
-
const size = entry.size, offset = Number(entry.offset) + 8 + sizes.readUInt32LE(4);
|
|
47
|
-
if (!Number.isSafeInteger(size) || !size || size < 1 || size > 64 << 20 || !Number.isSafeInteger(offset) || offset < 16 || offset + size > stat.size)
|
|
48
|
-
return false;
|
|
49
|
-
const bytes = entry.unpacked ? readFileSync(join(`${archive}.unpacked`, 'apps/server/dist/bin.mjs')) : Buffer.alloc(size);
|
|
50
|
-
if (!entry.unpacked && readSync(fd, bytes, 0, size, offset) !== size)
|
|
51
|
-
return false;
|
|
52
|
-
const capable = /ProviderDriverKind\.make\(["']acpRegistry["']\)/.test(bytes.toString());
|
|
53
|
-
support.set(archive, { stamp, capable });
|
|
54
|
-
return capable;
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
finally {
|
|
60
|
-
if (fd !== undefined)
|
|
61
|
-
closeSync(fd);
|
|
62
|
-
}
|
|
21
|
+
return t3ArchiveSupportsAcp(join(app, 'Contents/Resources/app.asar'));
|
|
22
|
+
}
|
|
23
|
+
/** T3's home, as the desktop resolves it on macOS and Linux: T3CODE_HOME (trimmed), else ~/.t3. */
|
|
24
|
+
export function t3HomeDir(env) {
|
|
25
|
+
return env.T3CODE_HOME?.trim() || join(env.HOME || homedir(), '.t3');
|
|
63
26
|
}
|
|
64
27
|
/** Desktop 0.0.45 watches this server settings file, including symlink targets. No encrypted stores. */
|
|
65
28
|
export async function configureT3Provider(env, dir, remove = false, signal) {
|
|
66
29
|
return withTargetLock(dir, () => {
|
|
67
|
-
let file = join(
|
|
30
|
+
let file = join(t3HomeDir(env), 'userdata', 'settings.json');
|
|
68
31
|
let raw = '{}';
|
|
69
32
|
try {
|
|
70
33
|
file = realpathSync(file);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `shard agents new --in t3 --prompt…` runs inside the project VM in SSH mode (T3 Code 0.0.45, whose server runs in
|
|
3
|
+
* the VM). It takes its input on stdin (the prompt and the session it issues never appear in argv) and prints one JSON
|
|
4
|
+
* line. Everything goes through T3's own interfaces, the way T3's CLI does it (apps/server/src/cli/project.ts):
|
|
5
|
+
* 1. the T3 runtime T3 installs (`~/.t3/runtime/versions/<version>/t3`), installing it when the prewarm has not;
|
|
6
|
+
* 2. the running server (`~/.t3/userdata/server-runtime.json`, its pid alive and `/` answering), else
|
|
7
|
+
* `t3 serve --host 127.0.0.1 --port <first free from 3773> --base-dir ~/.t3`, the command T3's SSH launch uses, so
|
|
8
|
+
* the desktop reuses that server when it connects (packages/ssh/src/tunnel.ts);
|
|
9
|
+
* 3. a 5-minute admin session (`t3 auth session issue --json`), used for the calls below and revoked after;
|
|
10
|
+
* 4. the project (`GET /api/orchestration/snapshot`, else `project.create` for the checkout);
|
|
11
|
+
* 5. `thread.create` then `thread.turn.start` (`POST /api/orchestration/dispatch`) over loopback.
|
|
12
|
+
* Every exit prints one JSON line (a failed revoke is logged on stderr, never in place of the result), and the images
|
|
13
|
+
* the CLI wrote for the first message are removed on every path. A server it starts logs to ~/.shardflux/t3-serve.log.
|
|
14
|
+
* The model is the one asked for, else the provider's default in T3's own provider cache (`~/.t3/caches/<id>.json`),
|
|
15
|
+
* else T3's built-in default. An effort is sent as the provider option T3 reads (`effort` for Claude,
|
|
16
|
+
* `reasoningEffort` for Codex) when the model offers it, or when T3 has not described the model yet.
|
|
17
|
+
*/
|
|
18
|
+
export declare const T3_THREAD_SCRIPT: string;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `shard agents new --in t3 --prompt…` runs inside the project VM in SSH mode (T3 Code 0.0.45, whose server runs in
|
|
3
|
+
* the VM). It takes its input on stdin (the prompt and the session it issues never appear in argv) and prints one JSON
|
|
4
|
+
* line. Everything goes through T3's own interfaces, the way T3's CLI does it (apps/server/src/cli/project.ts):
|
|
5
|
+
* 1. the T3 runtime T3 installs (`~/.t3/runtime/versions/<version>/t3`), installing it when the prewarm has not;
|
|
6
|
+
* 2. the running server (`~/.t3/userdata/server-runtime.json`, its pid alive and `/` answering), else
|
|
7
|
+
* `t3 serve --host 127.0.0.1 --port <first free from 3773> --base-dir ~/.t3`, the command T3's SSH launch uses, so
|
|
8
|
+
* the desktop reuses that server when it connects (packages/ssh/src/tunnel.ts);
|
|
9
|
+
* 3. a 5-minute admin session (`t3 auth session issue --json`), used for the calls below and revoked after;
|
|
10
|
+
* 4. the project (`GET /api/orchestration/snapshot`, else `project.create` for the checkout);
|
|
11
|
+
* 5. `thread.create` then `thread.turn.start` (`POST /api/orchestration/dispatch`) over loopback.
|
|
12
|
+
* Every exit prints one JSON line (a failed revoke is logged on stderr, never in place of the result), and the images
|
|
13
|
+
* the CLI wrote for the first message are removed on every path. A server it starts logs to ~/.shardflux/t3-serve.log.
|
|
14
|
+
* The model is the one asked for, else the provider's default in T3's own provider cache (`~/.t3/caches/<id>.json`),
|
|
15
|
+
* else T3's built-in default. An effort is sent as the provider option T3 reads (`effort` for Claude,
|
|
16
|
+
* `reasoningEffort` for Codex) when the model offers it, or when T3 has not described the model yet.
|
|
17
|
+
*/
|
|
18
|
+
export const T3_THREAD_SCRIPT = String.raw `import base64, json, os, socket, subprocess, sys, time, urllib.error, urllib.request, uuid
|
|
19
|
+
cfg = json.load(sys.stdin)
|
|
20
|
+
home = os.path.expanduser('~')
|
|
21
|
+
base = os.path.expanduser(cfg.get('base') or '~/.t3')
|
|
22
|
+
version = cfg['version']
|
|
23
|
+
t3 = cfg.get('t3') or os.path.join(base, 'runtime', 'versions', version, 't3')
|
|
24
|
+
out = {'ok': True, 'server_started': False}
|
|
25
|
+
|
|
26
|
+
def remove_images():
|
|
27
|
+
for image in cfg.get('images') or []:
|
|
28
|
+
try: os.remove(image['path'])
|
|
29
|
+
except OSError: pass
|
|
30
|
+
|
|
31
|
+
def done(ok, error=None, message=None, **extra):
|
|
32
|
+
remove_images()
|
|
33
|
+
out['ok'] = ok
|
|
34
|
+
if error: out['error'] = error
|
|
35
|
+
if message: out['message'] = message[:500]
|
|
36
|
+
out.update(extra)
|
|
37
|
+
sys.stdout.write(json.dumps(out) + '\n')
|
|
38
|
+
sys.exit(0 if ok else 1)
|
|
39
|
+
|
|
40
|
+
def runtime_ready():
|
|
41
|
+
if cfg.get('t3'): return os.access(t3, os.X_OK)
|
|
42
|
+
try:
|
|
43
|
+
with open(os.path.join(os.path.dirname(t3), '.install-complete')) as f: marker = f.read().strip()
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
return os.access(t3, os.X_OK) and marker == version
|
|
47
|
+
|
|
48
|
+
if not runtime_ready():
|
|
49
|
+
# A prewarm that is still downloading holds the install lock; this installer then exits at once and the wait follows it.
|
|
50
|
+
if cfg.get('install_script'):
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(['sh', '-c', cfg['install_script']], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=float(cfg.get('install_timeout', 600)))
|
|
53
|
+
except Exception as e:
|
|
54
|
+
done(False, 'runtime_not_installed', 'The T3 runtime did not install in the workspace: %s' % e)
|
|
55
|
+
deadline = time.time() + float(cfg.get('install_wait', 300))
|
|
56
|
+
while not runtime_ready() and time.time() < deadline: time.sleep(1)
|
|
57
|
+
if not runtime_ready(): done(False, 'runtime_not_installed', 'The T3 runtime did not install in the workspace.')
|
|
58
|
+
|
|
59
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
60
|
+
def get(url, timeout=3):
|
|
61
|
+
with opener.open(url, timeout=timeout) as r: return r.status, r.read()
|
|
62
|
+
def ready(origin):
|
|
63
|
+
try:
|
|
64
|
+
status, _ = get(origin + '/')
|
|
65
|
+
return 200 <= status < 300
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
def alive(pid):
|
|
69
|
+
try:
|
|
70
|
+
os.kill(pid, 0)
|
|
71
|
+
return True
|
|
72
|
+
except OSError:
|
|
73
|
+
return False
|
|
74
|
+
state_dir = os.path.join(base, 'userdata')
|
|
75
|
+
def running_origin():
|
|
76
|
+
try:
|
|
77
|
+
with open(os.path.join(state_dir, 'server-runtime.json')) as f: runtime = json.load(f)
|
|
78
|
+
pid, origin = int(runtime['pid']), str(runtime['origin'])
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
if not (origin.startswith('http://127.0.0.1:') or origin.startswith('http://localhost:')) or not alive(pid): return None
|
|
82
|
+
return origin if ready(origin) else None
|
|
83
|
+
|
|
84
|
+
origin = running_origin()
|
|
85
|
+
if origin is None:
|
|
86
|
+
port = None
|
|
87
|
+
for candidate in range(int(cfg.get('default_port', 3773)), int(cfg.get('default_port', 3773)) + 200):
|
|
88
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
89
|
+
try:
|
|
90
|
+
probe.bind(('127.0.0.1', candidate))
|
|
91
|
+
port = candidate
|
|
92
|
+
break
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
finally:
|
|
96
|
+
probe.close()
|
|
97
|
+
if port is None: done(False, 'server_not_ready', 'No free port for the T3 server.')
|
|
98
|
+
# Shardflux's own log stays out of T3's data directory.
|
|
99
|
+
log_dir = os.path.join(home, '.shardflux')
|
|
100
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
101
|
+
log = open(os.path.join(log_dir, 't3-serve.log'), 'ab')
|
|
102
|
+
env = dict(os.environ, T3CODE_NO_BROWSER='1')
|
|
103
|
+
standin = cfg.get('standin_path')
|
|
104
|
+
if standin and os.access(standin, os.X_OK): env['T3CODE_CLOUDFLARED_PATH'] = standin
|
|
105
|
+
server = subprocess.Popen(['nohup', t3, 'serve', '--host', '127.0.0.1', '--port', str(port), '--base-dir', base],
|
|
106
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=home, start_new_session=True, close_fds=True)
|
|
107
|
+
out['server_started'] = True
|
|
108
|
+
origin = 'http://127.0.0.1:%d' % port
|
|
109
|
+
deadline = time.time() + float(cfg.get('start_wait', 120))
|
|
110
|
+
while not ready(origin):
|
|
111
|
+
if server.poll() is not None or time.time() > deadline: done(False, 'server_not_ready', 'The T3 server in the workspace did not start.')
|
|
112
|
+
time.sleep(0.25)
|
|
113
|
+
out['origin'] = origin
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
_, raw = get(origin + '/.well-known/t3/environment', timeout=10)
|
|
117
|
+
descriptor = json.loads(raw)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
done(False, 'server_not_ready', 'The T3 server did not describe itself: %s' % e)
|
|
120
|
+
out['server_version'] = descriptor.get('serverVersion')
|
|
121
|
+
protocol = descriptor.get('orchestrationProtocolVersion', 1)
|
|
122
|
+
out['protocol'] = protocol
|
|
123
|
+
if protocol != 1:
|
|
124
|
+
done(False, 'version_unsupported', 'T3 %s in the workspace uses orchestration protocol %s.' % (descriptor.get('serverVersion'), protocol))
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
issued = subprocess.run([t3, 'auth', 'session', 'issue', '--base-dir', base, '--ttl', '5m', '--label', 'Shardflux thread start', '--json'],
|
|
128
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=float(cfg.get('issue_timeout', 60)))
|
|
129
|
+
except Exception as e:
|
|
130
|
+
done(False, 'session_not_issued', 'T3 issued no session: %s' % e)
|
|
131
|
+
try:
|
|
132
|
+
session = json.loads(issued.stdout)
|
|
133
|
+
token, session_id = session['token'], session.get('sessionId')
|
|
134
|
+
except Exception:
|
|
135
|
+
done(False, 'session_not_issued', (issued.stderr or 'T3 issued no session').strip())
|
|
136
|
+
|
|
137
|
+
def call(method, path, body=None):
|
|
138
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
139
|
+
request = urllib.request.Request(origin + path, data=data, method=method,
|
|
140
|
+
headers={'authorization': 'Bearer ' + token, 'content-type': 'application/json', 'accept': 'application/json'})
|
|
141
|
+
try:
|
|
142
|
+
with opener.open(request, timeout=60) as r: status, raw = r.status, r.read()
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
status, raw = e.code, e.read()
|
|
145
|
+
try:
|
|
146
|
+
parsed = json.loads(raw) if raw else None
|
|
147
|
+
except Exception:
|
|
148
|
+
parsed = None
|
|
149
|
+
if not 200 <= status < 300:
|
|
150
|
+
tag = parsed.get('_tag') if isinstance(parsed, dict) else None
|
|
151
|
+
reason = parsed.get('reason') if isinstance(parsed, dict) else None
|
|
152
|
+
detail = ' '.join(str(v) for v in (tag, reason) if v)
|
|
153
|
+
done(False, 'dispatch_failed', 'T3 answered %s %s with %s%s.' % (method, path, status, ' ' + detail if detail else ''), status=status, tag=tag, reason=reason)
|
|
154
|
+
return parsed
|
|
155
|
+
|
|
156
|
+
def now():
|
|
157
|
+
return time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime()) + '.%03dZ' % (int(time.time() * 1000) % 1000)
|
|
158
|
+
|
|
159
|
+
def provider_models(instance):
|
|
160
|
+
try:
|
|
161
|
+
with open(os.path.join(base, 'caches', instance + '.json')) as f: provider = json.load(f)
|
|
162
|
+
models = provider.get('models')
|
|
163
|
+
return models if isinstance(models, list) and models else None
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
snapshot = call('GET', '/api/orchestration/snapshot')
|
|
169
|
+
root = os.path.realpath(cfg['project_path']).rstrip('/') or '/'
|
|
170
|
+
project = next((p for p in snapshot.get('projects', []) if p.get('deletedAt') is None and p.get('workspaceRoot') == root), None)
|
|
171
|
+
if project is None:
|
|
172
|
+
project_id = str(uuid.uuid4())
|
|
173
|
+
call('POST', '/api/orchestration/dispatch', {'type': 'project.create', 'commandId': str(uuid.uuid4()), 'projectId': project_id,
|
|
174
|
+
'title': os.path.basename(root) or 'project', 'workspaceRoot': root, 'createdAt': now()})
|
|
175
|
+
out['project_created'] = True
|
|
176
|
+
else:
|
|
177
|
+
project_id = project['id']
|
|
178
|
+
out['project_created'] = False
|
|
179
|
+
out['project_id'] = project_id
|
|
180
|
+
|
|
181
|
+
instance = 'codex' if cfg['app'] == 'codex' else 'claudeAgent'
|
|
182
|
+
option_id = 'reasoningEffort' if cfg['app'] == 'codex' else 'effort'
|
|
183
|
+
models = provider_models(instance)
|
|
184
|
+
# A server started just now describes its providers within seconds of starting.
|
|
185
|
+
wait_until = time.time() + float(cfg.get('models_wait', 10))
|
|
186
|
+
while models is None and out['server_started'] and time.time() < wait_until:
|
|
187
|
+
time.sleep(0.5)
|
|
188
|
+
models = provider_models(instance)
|
|
189
|
+
model = cfg.get('model')
|
|
190
|
+
described = None
|
|
191
|
+
if models:
|
|
192
|
+
if model:
|
|
193
|
+
family = [m for m in models if cfg['app'] == 'claude' and model in ('fable', 'opus', 'sonnet', 'haiku') and str(m.get('slug', '')).startswith('claude-%s-' % model)]
|
|
194
|
+
described = next((m for m in family if not m.get('isLegacy')), None) or (family[0] if family else None)
|
|
195
|
+
described = described or next((m for m in models if m.get('slug') == model or model in (m.get('aliases') or [])), None)
|
|
196
|
+
if described is None:
|
|
197
|
+
done(False, 'model_unavailable', 'T3 in the workspace has no %s model %s.' % (cfg['app'], model), models=[m.get('slug') for m in models if not m.get('isLegacy')])
|
|
198
|
+
model = described['slug']
|
|
199
|
+
else:
|
|
200
|
+
described = next((m for m in models if m.get('isDefault')), None) or next((m for m in models if not m.get('isLegacy')), None)
|
|
201
|
+
model = described['slug'] if described else None
|
|
202
|
+
if not model: model = cfg['default_model']
|
|
203
|
+
effort = cfg.get('effort')
|
|
204
|
+
if effort and described is not None:
|
|
205
|
+
descriptors = ((described.get('capabilities') or {}).get('optionDescriptors') or [])
|
|
206
|
+
option = next((d for d in descriptors if d.get('id') == option_id), None)
|
|
207
|
+
if option is None or effort not in [o.get('id') for o in option.get('options') or []]: effort = None
|
|
208
|
+
selection = {'instanceId': instance, 'model': model}
|
|
209
|
+
if effort: selection['options'] = [{'id': option_id, 'value': effort}]
|
|
210
|
+
out['model'] = model
|
|
211
|
+
out['effort'] = effort or None
|
|
212
|
+
out['instance_id'] = instance
|
|
213
|
+
|
|
214
|
+
# Images the CLI wrote into the VM go inline (T3 0.0.45 UploadChatImageAttachment: dataUrl); T3 stores its own copy.
|
|
215
|
+
attachments = []
|
|
216
|
+
for image in cfg.get('images') or []:
|
|
217
|
+
with open(image['path'], 'rb') as f: data = f.read()
|
|
218
|
+
attachments.append({'type': 'image', 'name': image['name'], 'mimeType': image['mime'], 'sizeBytes': len(data),
|
|
219
|
+
'dataUrl': 'data:%s;base64,%s' % (image['mime'], base64.b64encode(data).decode())})
|
|
220
|
+
out['attachments'] = len(attachments)
|
|
221
|
+
|
|
222
|
+
thread_id = str(uuid.uuid4())
|
|
223
|
+
created_at = now()
|
|
224
|
+
call('POST', '/api/orchestration/dispatch', {'type': 'thread.create', 'commandId': str(uuid.uuid4()), 'threadId': thread_id, 'projectId': project_id,
|
|
225
|
+
'title': cfg['title'], 'modelSelection': selection, 'runtimeMode': cfg.get('runtime_mode', 'full-access'), 'interactionMode': 'default',
|
|
226
|
+
'branch': None, 'worktreePath': None, 'createdAt': created_at})
|
|
227
|
+
out['thread_id'] = thread_id
|
|
228
|
+
started = call('POST', '/api/orchestration/dispatch', {'type': 'thread.turn.start', 'commandId': str(uuid.uuid4()), 'threadId': thread_id,
|
|
229
|
+
'message': {'messageId': str(uuid.uuid4()), 'role': 'user', 'text': cfg['prompt'], 'attachments': attachments},
|
|
230
|
+
'modelSelection': selection, 'titleSeed': cfg['title'], 'runtimeMode': cfg.get('runtime_mode', 'full-access'), 'interactionMode': 'default', 'createdAt': now()})
|
|
231
|
+
out['sequence'] = (started or {}).get('sequence')
|
|
232
|
+
except Exception as e:
|
|
233
|
+
# A connection that drops mid-call still reports what exists (thread_id when thread.create got through).
|
|
234
|
+
done(False, 'dispatch_failed', 'T3 did not answer: %s' % e)
|
|
235
|
+
finally:
|
|
236
|
+
remove_images()
|
|
237
|
+
if session_id:
|
|
238
|
+
try:
|
|
239
|
+
subprocess.run([t3, 'auth', 'session', 'revoke', session_id, '--base-dir', base], stdin=subprocess.DEVNULL, capture_output=True, timeout=float(cfg.get('revoke_timeout', 60)))
|
|
240
|
+
except Exception as e:
|
|
241
|
+
# The session expires by itself in 5 minutes; the thread (if made) is still reported.
|
|
242
|
+
sys.stderr.write('t3 session revoke failed: %s\n' % e)
|
|
243
|
+
out['revoke_failed'] = True
|
|
244
|
+
done(True)
|
|
245
|
+
`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Ctx, Handler } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import { onboardingReport } from './onboarding.js';
|
|
4
|
+
export interface OnboardingDeps {
|
|
5
|
+
handlers?: Record<string, Handler>;
|
|
6
|
+
report?: typeof onboardingReport;
|
|
7
|
+
startHelper?: () => void;
|
|
8
|
+
loginCodex?: (ctx: Ctx) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** Linux's first run orchestrates the same auth, setup and live inspection used by the Mac onboarding. */
|
|
11
|
+
export declare function terminalOnboarding(ctx: Ctx, values: Values, deps?: OnboardingDeps): Promise<number>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { ACCOUNT_HANDLERS } from "./account-commands.js";
|
|
3
|
+
import { AGENT_CREDENTIAL_HANDLERS } from "./agent-credentials.js";
|
|
4
|
+
import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
|
|
5
|
+
import { foundApps, onboardingReport, parseApps } from "./onboarding.js";
|
|
6
|
+
import { requireTermsAcceptance, showLegalNotice } from "./legal.js";
|
|
7
|
+
import { json } from "./format.js";
|
|
8
|
+
import { UsageError } from "./errors.js";
|
|
9
|
+
const handlers = { ...ACCOUNT_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...CODEX_CLOUD_HANDLERS };
|
|
10
|
+
async function loginCodex(ctx) {
|
|
11
|
+
const child = spawn('codex', ['login', ...(!ctx.io.env.DISPLAY && !ctx.io.env.WAYLAND_DISPLAY ? ['--device-auth'] : [])], { env: ctx.io.env, stdio: 'inherit', signal: ctx.signal });
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
child.once('error', () => reject(new UsageError('Install the Codex CLI, then run codex login and shard.')));
|
|
14
|
+
child.once('exit', code => code === 0 ? resolve() : reject(new UsageError('Finish codex login, then run shard again.')));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Linux's first run orchestrates the same auth, setup and live inspection used by the Mac onboarding. */
|
|
18
|
+
export async function terminalOnboarding(ctx, values, deps = {}) {
|
|
19
|
+
const commands = deps.handlers ?? handlers, report = deps.report ?? onboardingReport;
|
|
20
|
+
if (ctx.sessionSource() === null) {
|
|
21
|
+
ctx.io.stdout.write('Sign in to Shardflux\n');
|
|
22
|
+
if (!ctx.input.interactive) {
|
|
23
|
+
showLegalNotice(ctx);
|
|
24
|
+
ctx.io.stdout.write('Run shard in a terminal, or shard auth login --browser followed by shard setup.\n');
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
await requireTermsAcceptance(ctx, values);
|
|
28
|
+
await commands['auth login'](ctx, { browser: true, open: !!(ctx.io.env.DISPLAY || ctx.io.env.WAYLAND_DISPLAY) }, []);
|
|
29
|
+
}
|
|
30
|
+
// Setup creates/reuses the project key. --inspect below is the live source of connection status.
|
|
31
|
+
try {
|
|
32
|
+
ctx.apiKey();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await commands.setup(ctx, { codex: false }, []);
|
|
36
|
+
}
|
|
37
|
+
deps.startHelper?.();
|
|
38
|
+
let apps = parseApps(values.apps) ?? await foundApps(ctx.io.env);
|
|
39
|
+
if (ctx.input.interactive && values.apps === undefined) {
|
|
40
|
+
const selected = (await ctx.input.answer(`Connect Claude, Codex or both? [${apps.join(',') || 'both'}] `))?.trim().toLowerCase();
|
|
41
|
+
apps = parseApps(selected === 'both' ? 'claude,codex' : selected || (apps.length ? apps : 'claude,codex'));
|
|
42
|
+
}
|
|
43
|
+
const inspect = { ...values, apps: apps.join(','), connect: true };
|
|
44
|
+
let state = await report(ctx, inspect);
|
|
45
|
+
for (const connection of state.connections.filter(c => c.picked && c.state === 'action')) {
|
|
46
|
+
const argv = connection.action?.command;
|
|
47
|
+
if (!argv || !ctx.input.interactive)
|
|
48
|
+
continue;
|
|
49
|
+
ctx.io.stdout.write(`${connection.action.title}\n`);
|
|
50
|
+
if (argv[0] === 'codex')
|
|
51
|
+
await (deps.loginCodex ?? loginCodex)(ctx);
|
|
52
|
+
else {
|
|
53
|
+
// These are the existing onboarding action commands, not a second implementation of sign-in.
|
|
54
|
+
const command = argv.slice(1, 4).join(' ');
|
|
55
|
+
if (command === 'agents credentials login')
|
|
56
|
+
await commands[command](ctx, {}, ['claude']);
|
|
57
|
+
else if (command === 'agents codex enable')
|
|
58
|
+
await commands[command](ctx, {}, []);
|
|
59
|
+
else if (argv[1] === 'setup')
|
|
60
|
+
await commands.setup(ctx, { ...inspect, inspect: true, 'retry-codex': true }, []);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
state = await report(ctx, inspect);
|
|
64
|
+
if (ctx.json)
|
|
65
|
+
ctx.io.stdout.write(json({ onboarding: state, next: 'shard ./' }));
|
|
66
|
+
else {
|
|
67
|
+
for (const c of state.connections.filter(c => c.id === 'account' || c.picked))
|
|
68
|
+
ctx.io.stdout.write(`${c.id}: ${c.fact}${c.action?.command && c.state !== 'verified' ? ` Run ${c.action.command.join(' ')}.` : ''}\n`);
|
|
69
|
+
if (state.ready)
|
|
70
|
+
ctx.io.stdout.write('Ready. Run shard ./ in your project.\n');
|
|
71
|
+
else
|
|
72
|
+
ctx.io.stdout.write('Finish the connection above, then run shard again.\n');
|
|
73
|
+
}
|
|
74
|
+
return state.ready ? 0 : 1;
|
|
75
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Ctx, Handler } from './commands.js';
|
|
2
2
|
import type { Values } from './args.js';
|
|
3
3
|
import type { Conversation, Harness } from './conversations.js';
|
|
4
|
-
/** A conversation still bound to its computer: not
|
|
5
|
-
export declare const activeConversation: (c: Pick<Conversation, "state" | "ended_at">) => boolean;
|
|
4
|
+
/** A conversation still bound to its computer: not ended, archived or discarded (a settled `done` turn still is). */
|
|
5
|
+
export declare const activeConversation: (c: Pick<Conversation, "state" | "ended_at" | "route" | "landed">) => boolean;
|
|
6
6
|
export interface ComputerThread {
|
|
7
7
|
id: string;
|
|
8
8
|
key: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { linuxTerminalSession } from "./linux-terminal.js";
|
|
1
2
|
// A new Claude or Codex thread on an existing conversation's computer (0.16.0+): `shard agents new --on <ref>`,
|
|
2
3
|
// `shard ./ --on <ref>` and `shard agents computers`. The thread joins that VM as a worker (its own branch and
|
|
3
4
|
// worktree, sharing the VM's packages, caches, services, ports and files) or, with --review, as a read-only Claude
|
|
@@ -11,16 +12,17 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
11
12
|
import { normalizeRemote, projectIdentity } from "./project-identity.js";
|
|
12
13
|
import { progress } from "./progress.js";
|
|
13
14
|
import { conversationClient } from "./conversation-commands.js";
|
|
14
|
-
import { conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
|
|
15
|
+
import { conversationEnded, conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
|
|
15
16
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
16
17
|
import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
|
|
17
18
|
import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
|
|
18
19
|
import { recordSeedSnapshot } from "./files-mirror.js";
|
|
19
20
|
import { ensureCodexCloud, printCloudThread, submitCloudInitialPrompt } from "./codex-cloud.js";
|
|
21
|
+
import { modelChoice } from "./agent-models.js";
|
|
20
22
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
23
|
const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
22
|
-
/** A conversation still bound to its computer: not
|
|
23
|
-
export const activeConversation = (c) =>
|
|
24
|
+
/** A conversation still bound to its computer: not ended, archived or discarded (a settled `done` turn still is). */
|
|
25
|
+
export const activeConversation = (c) => !conversationEnded(c);
|
|
24
26
|
/** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
|
|
25
27
|
export function projectConversations(ctx, all, folder) {
|
|
26
28
|
const project = projectIdentity(folder), remote = project.remote ?? `file://${project.folder}`, records = localConversations(ctx);
|
|
@@ -135,9 +137,11 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
|
135
137
|
if (chosen !== undefined && chosen !== 'claude' && chosen !== 'codex')
|
|
136
138
|
throw new UsageError('--app must be claude or codex');
|
|
137
139
|
const app = chosen ?? store.app(projectKey);
|
|
140
|
+
const nativeCodex = app === 'codex' && linuxTerminalSession(ctx, values);
|
|
138
141
|
const review = values.review === true;
|
|
139
142
|
if (review && app === 'codex')
|
|
140
143
|
throw new UsageError('--review starts a read-only Claude reviewer. A Codex thread on a computer works on its own branch; omit --review.');
|
|
144
|
+
const choice = modelChoice(values, app);
|
|
141
145
|
const name = str(values.name);
|
|
142
146
|
if (name !== undefined && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(name))
|
|
143
147
|
throw new UsageError('--name must match ^[a-z0-9][a-z0-9-]{0,47}$');
|
|
@@ -151,6 +155,8 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
|
151
155
|
if (!ctx.json && values.detach !== true && (!ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode))
|
|
152
156
|
throw new UsageError('agent terminal requires a TTY; add --json to start it detached');
|
|
153
157
|
}
|
|
158
|
+
else if (nativeCodex)
|
|
159
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
154
160
|
else
|
|
155
161
|
await ensureCodexCloud(ctx);
|
|
156
162
|
progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} on the computer with ${target.title || target.key}…`);
|
|
@@ -159,12 +165,12 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
|
159
165
|
const baseBranch = target.base_branch ?? undefined;
|
|
160
166
|
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
|
161
167
|
const response = await conversationClient(ctx).conversations.create({ projectId: target.project_id, repoRemote: target.repo_remote ?? ws.labels.repo_remote ?? '',
|
|
162
|
-
harness: app, route: app === 'codex' ? 'codex-cloud' : 'terminal', mode: 'join', parentId: target.id, ...(review ? { joinMode: 'reviewer' } : {}),
|
|
163
|
-
base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
|
|
168
|
+
harness: app, route: app === 'codex' && !nativeCodex ? 'codex-cloud' : 'terminal', mode: 'join', parentId: target.id, ...(review ? { joinMode: 'reviewer' } : {}),
|
|
169
|
+
base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}), ...choice }, { idempotencyKey: randomUUID(), signal: ctx.signal });
|
|
164
170
|
let c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
165
171
|
rememberConversation(ctx, c, project.folder, false, baseBranch);
|
|
166
172
|
store.save(projectKey, app);
|
|
167
|
-
if (app === 'codex') {
|
|
173
|
+
if (app === 'codex' && !nativeCodex) {
|
|
168
174
|
try {
|
|
169
175
|
await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
|
|
170
176
|
}
|
|
@@ -177,7 +183,7 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
|
177
183
|
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
178
184
|
if (initialPrompt !== undefined)
|
|
179
185
|
await submitCloudInitialPrompt(ctx, c, initialPrompt, ws.key);
|
|
180
|
-
await printCloudThread(ctx, c, task, initialPrompt !== undefined);
|
|
186
|
+
await printCloudThread(ctx, c, task, initialPrompt !== undefined, choice);
|
|
181
187
|
return 0;
|
|
182
188
|
}
|
|
183
189
|
// The computer's guest prepares the worktree and starts the harness; the terminal attaches to that session.
|
package/dist/update.d.ts
CHANGED
|
@@ -59,5 +59,5 @@ export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?:
|
|
|
59
59
|
export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
|
|
60
60
|
package: string;
|
|
61
61
|
version: string;
|
|
62
|
-
}): void;
|
|
62
|
+
}, env?: NodeJS.ProcessEnv): void;
|
|
63
63
|
export declare const UPDATE_HANDLERS: Record<string, Handler>;
|
package/dist/update.js
CHANGED
|
@@ -264,6 +264,22 @@ export async function update(ctx, values, deps = {}) {
|
|
|
264
264
|
* runs the code from before the update).
|
|
265
265
|
*/
|
|
266
266
|
async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
267
|
+
if (menubar.platform === 'linux') {
|
|
268
|
+
const { LinuxDesktopManager } = await import("./desktop-linux.js");
|
|
269
|
+
const desktop = new LinuxDesktopManager({ home: ctx.io.env.HOME, env: ctx.io.env });
|
|
270
|
+
const before = await desktop.status(ctx.store.dir);
|
|
271
|
+
if (!before.installed || new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
|
|
272
|
+
return { status: 'off' };
|
|
273
|
+
if (runtime && run && env) {
|
|
274
|
+
const result = await run([runtime.node, runtime.script, '--api-url', ctx.apiUrl, 'menubar', 'start', '--json'], { ...env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_NO_UPDATE_CHECK: '1' }, ctx.signal);
|
|
275
|
+
if (result.code !== 0)
|
|
276
|
+
return { status: 'failed' };
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
await desktop.start(ctx.store.dir, ctx.apiUrl);
|
|
280
|
+
const after = await desktop.status(ctx.store.dir);
|
|
281
|
+
return after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
|
|
282
|
+
}
|
|
267
283
|
if (menubar.platform !== 'darwin')
|
|
268
284
|
return { status: 'off' };
|
|
269
285
|
if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
|
|
@@ -286,12 +302,23 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
|
|
|
286
302
|
return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
|
|
287
303
|
}
|
|
288
304
|
/** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
|
|
289
|
-
export function recordInstall(dir, runtime, distribution) {
|
|
305
|
+
export function recordInstall(dir, runtime, distribution, env = {}) {
|
|
290
306
|
let script = runtime.script;
|
|
291
307
|
try {
|
|
292
308
|
script = realpathSync(script);
|
|
293
309
|
}
|
|
294
310
|
catch { /* keep the path */ }
|
|
311
|
+
// A global install (npm, pnpm, bun, Volta) is what the menu bar runs and updates. A run from a source checkout, a
|
|
312
|
+
// project's node_modules or npx never replaces a recorded install that still exists, so a one-off run cannot point
|
|
313
|
+
// the menu bar (and its update button) at it. SHARDFLUX_RECORD_INSTALL=1 records any run (menu bar development).
|
|
314
|
+
if (env.SHARDFLUX_RECORD_INSTALL !== '1' && !['npm', 'pnpm', 'bun', 'volta'].includes(detectInstall(script, distribution.package).manager)) {
|
|
315
|
+
try {
|
|
316
|
+
const recorded = JSON.parse(readFileSync(join(dir, INSTALL_FILE), 'utf8')).script;
|
|
317
|
+
if (typeof recorded === 'string' && recorded !== script && existsSync(recorded))
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
catch { /* nothing recorded yet: this run is the best there is */ }
|
|
321
|
+
}
|
|
295
322
|
// The app this CLI's npm package carries (0.15.2+): the menu bar compares itself with it and swaps itself in.
|
|
296
323
|
let menubarApp = packagedAppPath();
|
|
297
324
|
try {
|