@shardflux/cli 0.16.0 → 0.17.0

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 (64) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +43 -5
  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 +117 -16
  11. package/dist/agent-targets.d.ts +2 -0
  12. package/dist/agent-targets.js +10 -4
  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/device-approvals.d.ts +12 -0
  31. package/dist/device-approvals.js +73 -0
  32. package/dist/device-helper.d.ts +3 -1
  33. package/dist/device-helper.js +5 -2
  34. package/dist/errors.d.ts +2 -0
  35. package/dist/errors.js +1 -1
  36. package/dist/handlers.js +3 -1
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.js +3 -0
  39. package/dist/linux-helper.d.ts +12 -0
  40. package/dist/linux-helper.js +88 -0
  41. package/dist/linux-terminal.d.ts +11 -0
  42. package/dist/linux-terminal.js +42 -0
  43. package/dist/main.js +29 -6
  44. package/dist/onboarding.d.ts +2 -0
  45. package/dist/onboarding.js +53 -5
  46. package/dist/t3-launch.d.ts +37 -0
  47. package/dist/t3-launch.js +133 -0
  48. package/dist/t3-mode.d.ts +121 -0
  49. package/dist/t3-mode.js +654 -0
  50. package/dist/t3-phone.d.ts +17 -0
  51. package/dist/t3-phone.js +15 -0
  52. package/dist/t3-thread-script.d.ts +18 -0
  53. package/dist/t3-thread-script.js +245 -0
  54. package/dist/terminal-onboarding.d.ts +11 -0
  55. package/dist/terminal-onboarding.js +75 -0
  56. package/dist/thread-on-computer.d.ts +2 -2
  57. package/dist/thread-on-computer.js +13 -7
  58. package/dist/update.d.ts +1 -1
  59. package/dist/update.js +12 -1
  60. package/dist/worktree-twin-engine.d.ts +31 -1
  61. package/dist/worktree-twin-engine.js +253 -40
  62. package/dist/worktree-twin.d.ts +17 -0
  63. package/dist/worktree-twin.js +147 -22
  64. package/package.json +3 -3
@@ -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
+ }
@@ -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
@@ -286,12 +286,23 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
286
286
  return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
287
287
  }
288
288
  /** 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) {
289
+ export function recordInstall(dir, runtime, distribution, env = {}) {
290
290
  let script = runtime.script;
291
291
  try {
292
292
  script = realpathSync(script);
293
293
  }
294
294
  catch { /* keep the path */ }
295
+ // A global install (npm, pnpm, bun, Volta) is what the menu bar runs and updates. A run from a source checkout, a
296
+ // project's node_modules or npx never replaces a recorded install that still exists, so a one-off run cannot point
297
+ // the menu bar (and its update button) at it. SHARDFLUX_RECORD_INSTALL=1 records any run (menu bar development).
298
+ if (env.SHARDFLUX_RECORD_INSTALL !== '1' && !['npm', 'pnpm', 'bun', 'volta'].includes(detectInstall(script, distribution.package).manager)) {
299
+ try {
300
+ const recorded = JSON.parse(readFileSync(join(dir, INSTALL_FILE), 'utf8')).script;
301
+ if (typeof recorded === 'string' && recorded !== script && existsSync(recorded))
302
+ return;
303
+ }
304
+ catch { /* nothing recorded yet: this run is the best there is */ }
305
+ }
295
306
  // The app this CLI's npm package carries (0.15.2+): the menu bar compares itself with it and swaps itself in.
296
307
  let menubarApp = packagedAppPath();
297
308
  try {
@@ -56,19 +56,46 @@ export interface EngineDeps {
56
56
  hub?: string;
57
57
  vmTmp?: string;
58
58
  quietMs?: number;
59
+ /**
60
+ * Long-polls the conversation's next finished turn after `after` (RFC 3339): the turn's conversation and report time,
61
+ * null when the wait timed out. Throws TurnWaitUnsupported when the API has no turn waits.
62
+ */
63
+ waitTurn?(c: TwinConversation, after: string, signal: AbortSignal): Promise<{
64
+ conversation: TwinConversation;
65
+ reported_at: string;
66
+ } | null | 'ended'>;
59
67
  }
60
68
  /** A turn ended with committed work only; the agent is not asking anything. */
61
69
  export declare function finished(c: TwinConversation, agents: TwinAgent[]): TwinAgent | 'state' | null;
62
70
  export declare function eligible(c: TwinConversation, ledger: TwinLedger, now: number): boolean;
63
71
  export declare function conflictMessage(into: string, files: string[]): string;
72
+ type Ready = {
73
+ c: TwinConversation;
74
+ record: LocalConversation;
75
+ mode: Exclude<LandingMode, 'off'>;
76
+ agent: TwinAgent | null;
77
+ at: number;
78
+ };
79
+ /** Refused by an API without turn waits (POST /wait with turn_after, 422): the twin polls instead. */
80
+ export declare class TurnWaitUnsupported extends Error {
81
+ }
64
82
  export declare class TwinEngine {
65
83
  #private;
66
84
  constructor(deps: EngineDeps);
67
- /** One pass over every project with a local conversation; returns the suggested wait before the next. */
85
+ /** Resolves when a pushed turn is waiting to land (the loop's sleep ends at once). */
86
+ pushed(): Promise<void>;
87
+ /** Stops every turn wait. */
88
+ stop(): void;
89
+ /**
90
+ * One pass; returns the suggested wait before the next. Turns pushed by the waits land first, without a listing;
91
+ * otherwise (and at least every 15 s, or every second when the API has no turn waits) every project is listed.
92
+ */
68
93
  tick(signal?: AbortSignal): Promise<number>;
69
94
  /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
70
95
  resetBackoff(): void;
71
96
  landOne(c: TwinConversation, record: LocalConversation, mode: Exclude<LandingMode, 'off'>, agent: TwinAgent | null, signal?: AbortSignal): Promise<number | undefined>;
97
+ /** Several finished conversations for one branch: merged in their VMs at once, the files written once. */
98
+ landBatch(items: Ready[], signal?: AbortSignal): Promise<number | undefined>;
72
99
  }
73
100
  export interface LoopDeps {
74
101
  tick(signal: AbortSignal): Promise<number>;
@@ -81,6 +108,8 @@ export interface LoopDeps {
81
108
  network(): string;
82
109
  /** Whether the API host answers now (a TCP connect, no request). */
83
110
  reachable(): Promise<boolean>;
111
+ /** Resolves when a pushed turn waits to land: the loop's sleep ends at once. */
112
+ kicked?(): Promise<void>;
84
113
  log(line: string): void;
85
114
  }
86
115
  /**
@@ -93,3 +122,4 @@ export declare function runTwinLoop(deps: LoopDeps, signal: AbortSignal): Promis
93
122
  /** The device helper's twin. Nothing here asks the user anything. */
94
123
  export declare function runWorktreeTwin(ctx: Ctx, device: string, signal: AbortSignal): Promise<void>;
95
124
  export declare const TWIN_HANDLERS: Record<string, Handler>;
125
+ export {};