@shardflux/cli 0.15.2 → 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.
- package/CHANGELOG.md +144 -14
- package/README.md +92 -7
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +15 -1
- package/dist/agent-commands.js +84 -23
- 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 +13 -3
- package/dist/agent-target-commands.js +129 -23
- package/dist/agent-targets.d.ts +5 -0
- package/dist/agent-targets.js +14 -5
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +5 -1
- package/dist/args.js +74 -11
- 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 +3 -0
- package/dist/codex-cloud.js +17 -5
- package/dist/command-center-actions.js +25 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- 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 +54 -6
- package/dist/conversations.d.ts +31 -3
- package/dist/conversations.js +53 -10
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +73 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +14 -4
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -2
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +5 -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 +38 -8
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-mode.d.ts +121 -0
- package/dist/t3-mode.js +654 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- 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 +35 -0
- package/dist/thread-on-computer.js +206 -0
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +125 -0
- package/dist/worktree-twin-engine.js +615 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +313 -0
- package/dist/worktree-twin.js +843 -0
- 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>;
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Ctx, Handler } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { Conversation, Harness } from './conversations.js';
|
|
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
|
+
export interface ComputerThread {
|
|
7
|
+
id: string;
|
|
8
|
+
key: string;
|
|
9
|
+
title: string;
|
|
10
|
+
harness: Harness;
|
|
11
|
+
state: Conversation['state'];
|
|
12
|
+
route: Conversation['route'];
|
|
13
|
+
role: string;
|
|
14
|
+
join_mode: string | null;
|
|
15
|
+
branch: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Computer {
|
|
18
|
+
workspace_id: string;
|
|
19
|
+
workspace_key: string;
|
|
20
|
+
state: 'running' | 'suspended';
|
|
21
|
+
last_activity_at: string | null;
|
|
22
|
+
conversations: ComputerThread[];
|
|
23
|
+
}
|
|
24
|
+
/** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
|
|
25
|
+
export declare function projectConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>, all: Conversation[], folder: string): Conversation[];
|
|
26
|
+
/** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
|
|
27
|
+
export declare function listComputers(ctx: Ctx, folder: string): Promise<Computer[]>;
|
|
28
|
+
/**
|
|
29
|
+
* The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
|
|
30
|
+
* the VM's own conversation, else its oldest live thread).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveOn(ctx: Ctx, ref: string, folder: string): Promise<Conversation>;
|
|
33
|
+
/** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
|
|
34
|
+
export declare function startThreadOn(ctx: Ctx, values: Values, path: string, explicitApp?: string): Promise<number>;
|
|
35
|
+
export declare const THREAD_HANDLERS: Record<string, Handler>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { linuxTerminalSession } from "./linux-terminal.js";
|
|
2
|
+
// A new Claude or Codex thread on an existing conversation's computer (0.16.0+): `shard agents new --on <ref>`,
|
|
3
|
+
// `shard ./ --on <ref>` and `shard agents computers`. The thread joins that VM as a worker (its own branch and
|
|
4
|
+
// worktree, sharing the VM's packages, caches, services, ports and files) or, with --review, as a read-only Claude
|
|
5
|
+
// reviewer. Codex threads are genuine Codex Cloud threads. docs/progress/thread-on-existing-vm.md.
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
8
|
+
import { out, str } from "./commands.js";
|
|
9
|
+
import { UsageError } from "./errors.js";
|
|
10
|
+
import { table } from "./format.js";
|
|
11
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
12
|
+
import { normalizeRemote, projectIdentity } from "./project-identity.js";
|
|
13
|
+
import { progress } from "./progress.js";
|
|
14
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
15
|
+
import { conversationEnded, conversationName, conversationWorkspace, getConversation, listConversations, localConversations, rememberConversation, validateConversation, waitThreadReady } from "./conversations.js";
|
|
16
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
17
|
+
import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
|
|
18
|
+
import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
|
|
19
|
+
import { recordSeedSnapshot } from "./files-mirror.js";
|
|
20
|
+
import { ensureCodexCloud, printCloudThread, submitCloudInitialPrompt } from "./codex-cloud.js";
|
|
21
|
+
import { modelChoice } from "./agent-models.js";
|
|
22
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
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);
|
|
26
|
+
/** The project's conversations: its repository's, or (for a folder without a remote) the ones started from it. */
|
|
27
|
+
export function projectConversations(ctx, all, folder) {
|
|
28
|
+
const project = projectIdentity(folder), remote = project.remote ?? `file://${project.folder}`, records = localConversations(ctx);
|
|
29
|
+
const same = (r) => { try {
|
|
30
|
+
return normalizeRemote(r, project.folder) === remote;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
} };
|
|
35
|
+
return all.filter(c => c.repo_remote ? same(c.repo_remote) : records[c.id]?.folder === project.folder);
|
|
36
|
+
}
|
|
37
|
+
/** The VM's own conversation first (its label), then the oldest thread on it. */
|
|
38
|
+
function host(bound, label) {
|
|
39
|
+
return bound.find(c => c.id === label) ?? [...bound].sort((a, b) => a.created_at.localeCompare(b.created_at))[0];
|
|
40
|
+
}
|
|
41
|
+
const thread = (c) => ({ id: c.id, key: c.key, title: c.title, harness: c.harness, state: c.state, route: c.route,
|
|
42
|
+
role: c.role ?? 'main', join_mode: c.join_mode ?? null, branch: c.branch });
|
|
43
|
+
/** Computers a new thread can start on: each live VM (running or parked, never deleted) of the project's active conversations. */
|
|
44
|
+
export async function listComputers(ctx, folder) {
|
|
45
|
+
const client = conversationClient(ctx);
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
for (const c of projectConversations(ctx, await listConversations(ctx), folder).filter(activeConversation))
|
|
48
|
+
groups.set(c.workspace_id, [...groups.get(c.workspace_id) ?? [], c]);
|
|
49
|
+
const computers = await Promise.all([...groups].map(async ([id, bound]) => {
|
|
50
|
+
let ws;
|
|
51
|
+
try {
|
|
52
|
+
ws = await client.workspaces.get(id);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof ShardfluxApiError && error.status === 404)
|
|
56
|
+
return null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const observed = String(ws.data.observed_state ?? '');
|
|
60
|
+
if (ws.data.deleted_at || ['failed', 'deleted', 'deleting'].includes(observed))
|
|
61
|
+
return null;
|
|
62
|
+
const own = host(bound, ws.labels.shard_conversation);
|
|
63
|
+
const ordered = [own, ...bound.filter(c => c !== own).sort((a, b) => a.created_at.localeCompare(b.created_at))];
|
|
64
|
+
const last = bound.map(c => c.last_activity_at ?? c.created_at).sort().at(-1) ?? null;
|
|
65
|
+
return { workspace_id: ws.id, workspace_key: ws.key, state: observed === 'suspended' ? 'suspended' : 'running', last_activity_at: last, conversations: ordered.map(thread) };
|
|
66
|
+
}));
|
|
67
|
+
return computers.filter((c) => c !== null).sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The conversation whose computer `--on` names: a conversation id, key or name, or the VM's workspace key or id (then
|
|
71
|
+
* the VM's own conversation, else its oldest live thread).
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveOn(ctx, ref, folder) {
|
|
74
|
+
if (!ref.trim())
|
|
75
|
+
throw new UsageError('--on needs a conversation or computer; list them with shard agents computers.');
|
|
76
|
+
let target;
|
|
77
|
+
if (/^conv_[a-z0-9]{26}$/.test(ref))
|
|
78
|
+
target = await getConversation(ctx, ref);
|
|
79
|
+
else {
|
|
80
|
+
const all = await listConversations(ctx);
|
|
81
|
+
const keyed = all.filter(c => c.key === ref);
|
|
82
|
+
if (keyed.length === 1)
|
|
83
|
+
target = keyed[0];
|
|
84
|
+
else if (UUID.test(ref) || ref.includes('/')) {
|
|
85
|
+
const client = conversationClient(ctx);
|
|
86
|
+
let ws = null;
|
|
87
|
+
try {
|
|
88
|
+
ws = UUID.test(ref) ? await client.workspaces.get(ref) : await client.workspaces.findByKey(ref, { includeDeleted: false, signal: ctx.signal });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (ws)
|
|
95
|
+
target = host(all.filter(c => c.workspace_id === ws.id && activeConversation(c)), ws.labels.shard_conversation);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const named = projectConversations(ctx, all, folder).filter(c => conversationName(c) === ref);
|
|
99
|
+
const live = named.filter(activeConversation);
|
|
100
|
+
if ((live.length || named.length) > 1)
|
|
101
|
+
throw new UsageError(`Several conversations are named ${ref}; use its key or id (shard agents computers lists them).`);
|
|
102
|
+
target = live[0] ?? named[0];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!target)
|
|
106
|
+
throw new UsageError(`No conversation or computer "${ref}" in this project. List them with shard agents computers.`);
|
|
107
|
+
if (!activeConversation(target))
|
|
108
|
+
throw new UsageError(`${target.title || target.key} has ended, so its computer is not available. Start the thread on a new computer, or continue that conversation with shard agents open ${target.id}.`);
|
|
109
|
+
return target;
|
|
110
|
+
}
|
|
111
|
+
/** `git rev-parse HEAD` of the target's worktree in its VM: the new thread starts from its committed work. */
|
|
112
|
+
async function targetHead(ws, target, signal) {
|
|
113
|
+
const head = await ws.cell().exec.run(['git', '-C', target.worktree_path, 'rev-parse', 'HEAD'], { timeoutMs: 30_000, maxOutputBytes: 4096, signal });
|
|
114
|
+
const sha = head.stdout.trim();
|
|
115
|
+
return head.exitCode === 0 && SHA.test(sha) ? sha : target.base_commit;
|
|
116
|
+
}
|
|
117
|
+
/** Records a thread that could never be admitted as discarded, so it never holds the shared computer. */
|
|
118
|
+
async function abandon(ctx, c) {
|
|
119
|
+
try {
|
|
120
|
+
await conversationClient(ctx).conversations.land(c.id, 'discard', { idempotencyKey: `abandon-${c.id}`, signal: ctx.signal });
|
|
121
|
+
}
|
|
122
|
+
catch { /* the API refuses it again on the next try */ }
|
|
123
|
+
}
|
|
124
|
+
/** Starts the thread on `--on`'s computer and opens it like a new conversation of that app. */
|
|
125
|
+
export async function startThreadOn(ctx, values, path, explicitApp) {
|
|
126
|
+
const ref = str(values.on) ?? '';
|
|
127
|
+
for (const flag of ['like', 'in', 'resume', 'new'])
|
|
128
|
+
if (values[flag] !== undefined && !(flag === 'in' && ['terminal', 'codex-app'].includes(String(values.in))))
|
|
129
|
+
throw new UsageError(`--${flag} cannot be combined with --on.`);
|
|
130
|
+
if (values.computer === true)
|
|
131
|
+
throw new UsageError('--computer is a setting of a new computer; a thread with --on uses its computer as it is.');
|
|
132
|
+
const text = await readInitialPrompt(ctx, values);
|
|
133
|
+
const images = await readInitialImages(ctx, values, text);
|
|
134
|
+
const initialPrompt = text === undefined ? undefined : promptWithImages(text, images);
|
|
135
|
+
const project = projectIdentity(path), projectKey = project.remote ?? project.folder, store = new AgentStateStore(ctx.store.dir);
|
|
136
|
+
const chosen = explicitApp ?? str(values.app);
|
|
137
|
+
if (chosen !== undefined && chosen !== 'claude' && chosen !== 'codex')
|
|
138
|
+
throw new UsageError('--app must be claude or codex');
|
|
139
|
+
const app = chosen ?? store.app(projectKey);
|
|
140
|
+
const nativeCodex = app === 'codex' && linuxTerminalSession(ctx, values);
|
|
141
|
+
const review = values.review === true;
|
|
142
|
+
if (review && app === 'codex')
|
|
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);
|
|
145
|
+
const name = str(values.name);
|
|
146
|
+
if (name !== undefined && !/^[a-z0-9][a-z0-9-]{0,47}$/.test(name))
|
|
147
|
+
throw new UsageError('--name must match ^[a-z0-9][a-z0-9-]{0,47}$');
|
|
148
|
+
const target = await resolveOn(ctx, ref, path);
|
|
149
|
+
let signedIn = true;
|
|
150
|
+
if (app === 'claude') {
|
|
151
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
152
|
+
signedIn = Boolean(await cloudClaudeLogin(ctx));
|
|
153
|
+
if (!signedIn)
|
|
154
|
+
requireClaudeTerminal(ctx);
|
|
155
|
+
if (!ctx.json && values.detach !== true && (!ctx.io.stdin.isTTY || !ctx.io.stdin.setRawMode))
|
|
156
|
+
throw new UsageError('agent terminal requires a TTY; add --json to start it detached');
|
|
157
|
+
}
|
|
158
|
+
else if (nativeCodex)
|
|
159
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
160
|
+
else
|
|
161
|
+
await ensureCodexCloud(ctx);
|
|
162
|
+
progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} on the computer with ${target.title || target.key}…`);
|
|
163
|
+
const ws = await conversationWorkspace(ctx, target);
|
|
164
|
+
const commit = await targetHead(ws, target, ctx.signal);
|
|
165
|
+
const baseBranch = target.base_branch ?? undefined;
|
|
166
|
+
await uploadInitialImages(ws.cell(), images, ctx.signal);
|
|
167
|
+
const response = await conversationClient(ctx).conversations.create({ projectId: target.project_id, repoRemote: target.repo_remote ?? ws.labels.repo_remote ?? '',
|
|
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 });
|
|
170
|
+
let c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
171
|
+
rememberConversation(ctx, c, project.folder, false, baseBranch);
|
|
172
|
+
store.save(projectKey, app);
|
|
173
|
+
if (app === 'codex' && !nativeCodex) {
|
|
174
|
+
try {
|
|
175
|
+
await ctx.cloud().codex.admit(c.id, { idempotencyKey: `codex-${c.id}`, signal: ctx.signal });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
await abandon(ctx, c);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
const task = await ctx.cloud().codex.waitTask(c.id, { timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
182
|
+
// The worktree exists once admission prepared it: record where the thread started for the files shelf.
|
|
183
|
+
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
184
|
+
if (initialPrompt !== undefined)
|
|
185
|
+
await submitCloudInitialPrompt(ctx, c, initialPrompt, ws.key);
|
|
186
|
+
await printCloudThread(ctx, c, task, initialPrompt !== undefined, choice);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
// The computer's guest prepares the worktree and starts the harness; the terminal attaches to that session.
|
|
190
|
+
c = await waitThreadReady(ctx, c);
|
|
191
|
+
if (!review)
|
|
192
|
+
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
193
|
+
if (!signedIn)
|
|
194
|
+
await loginClaudeInWorkspace(ctx, ws);
|
|
195
|
+
const { conversationTerminal } = await import("./conversation-terminal.js");
|
|
196
|
+
return conversationTerminal(ctx, ws, c, projectKey, { ...values, ...(initialPrompt === undefined ? {} : { initialPrompt }) });
|
|
197
|
+
}
|
|
198
|
+
export const THREAD_HANDLERS = {
|
|
199
|
+
async 'agents computers'(ctx, values) {
|
|
200
|
+
const computers = await listComputers(ctx, str(values.path) ?? './');
|
|
201
|
+
const label = (t) => `${t.title || t.key} (${t.harness}, ${t.state.replace('_', ' ')})`;
|
|
202
|
+
out(ctx, computers.length ? table(['COMPUTER', 'STATE', 'THREADS'], computers.map(c => [c.workspace_key, c.state, c.conversations.map(label).join('; ')]))
|
|
203
|
+
: 'No computers to start a thread on. Start a conversation with shard ./ first.\n', { computers });
|
|
204
|
+
return 0;
|
|
205
|
+
},
|
|
206
|
+
};
|
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>;
|