@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,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>;
@@ -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
+ }
@@ -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
- /** A version number alone is insufficient: the installed 0.0.45 desktop lacks the upstream ACP driver. */
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>;
@@ -1,70 +1,33 @@
1
- import { readFileSync, realpathSync, openSync, readSync, closeSync, statSync } from 'node:fs';
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
- const support = new Map();
10
- /** Every shard invocation registers the one installed T3 provider without adding terminal noise. */
11
- export async function registerInstalledT3(env, dir, signal) {
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
- /** A version number alone is insufficient: the installed 0.0.45 desktop lacks the upstream ACP driver. */
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
- const archive = join(app, 'Contents/Resources/app.asar');
24
- let fd;
25
- try {
26
- const stat = statSync(archive), stamp = `${stat.size}:${stat.mtimeMs}`;
27
- if (support.get(archive)?.stamp === stamp)
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(env.T3CODE_HOME || join(env.HOME || homedir(), '.t3'), 'userdata', 'settings.json');
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 done, archived or ended. */
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 done, archived or ended. */
23
- export const activeConversation = (c) => c.state !== 'done' && c.state !== 'archived' && !c.ended_at;
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 {