@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
|
@@ -5,17 +5,23 @@ import { ensureCloudClaude } from "./claude-cloud-login.js";
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { Readable, Duplex } from 'node:stream';
|
|
7
7
|
import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
|
|
8
|
+
import { conversationWorkspace, getConversation } from "./conversations.js";
|
|
8
9
|
import { out, resolveWorkspace } from "./commands.js";
|
|
9
|
-
import {
|
|
10
|
+
import { MODEL_CHOICES } from "./agent-models.js";
|
|
11
|
+
import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
|
|
10
12
|
import { join, relative } from 'node:path';
|
|
11
13
|
import { projectIdentity } from "./project-identity.js";
|
|
12
14
|
import { projectWorkspacePath } from "./agent-repository.js";
|
|
13
|
-
import { UsageError } from "./errors.js";
|
|
15
|
+
import { UsageError, describeError } from "./errors.js";
|
|
16
|
+
import { t3PhoneLink } from "./t3-phone.js";
|
|
14
17
|
import { prepareDesktopRouter } from "./conversation-router.js";
|
|
15
18
|
import { openUrl } from "./browser.js";
|
|
16
19
|
import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
|
|
17
20
|
import { prewarmT3 } from "./t3-runtime.js";
|
|
18
21
|
import { AgentStateStore } from "./agent-state.js";
|
|
22
|
+
import { workspaceRepositoryPath } from "./agent-repository.js";
|
|
23
|
+
import { openInChoices, openT3, saveOpenIn, startSshThread, t3Install, t3NotInstalled, t3OpenFailed, t3Status } from "./t3-mode.js";
|
|
24
|
+
import { openT3Conversation, t3Fields, withT3Context } from "./t3-launch.js";
|
|
19
25
|
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
20
26
|
const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
|
|
21
27
|
/** How long opening a Claude conversation this computer just started waits for Claude to publish its Remote Control URL. */
|
|
@@ -72,6 +78,12 @@ export async function prepareSsh(ws, ctx, targets, host) {
|
|
|
72
78
|
return cell;
|
|
73
79
|
}
|
|
74
80
|
async function openApp(target, env) {
|
|
81
|
+
if (target === 't3') {
|
|
82
|
+
const install = t3Install(env);
|
|
83
|
+
if (!install)
|
|
84
|
+
throw t3NotInstalled();
|
|
85
|
+
return openT3(env, install.path);
|
|
86
|
+
}
|
|
75
87
|
const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
|
|
76
88
|
await new Promise((resolve, reject) => {
|
|
77
89
|
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
|
|
@@ -137,12 +149,18 @@ export function proxyStream(io, ended, outputGate) {
|
|
|
137
149
|
* this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
|
|
138
150
|
* launch probes the guest for up to 30 s; history browsing stays passive.
|
|
139
151
|
*/
|
|
140
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The Claude Remote Control URL of a session in `ws`. With several threads on one computer (0.16.0+) a conversation's
|
|
154
|
+
* own session is chosen by its harness session id; a thread that has not reported one yet is unambiguous only alone.
|
|
155
|
+
*/
|
|
156
|
+
export async function remoteControlUrl(ctx, ws, conversation) {
|
|
141
157
|
let cursor;
|
|
142
158
|
let url;
|
|
159
|
+
const session = conversation?.harness_session_id ?? undefined;
|
|
160
|
+
const mine = (id) => session === undefined || id === session;
|
|
143
161
|
do {
|
|
144
162
|
const page = await ctx.cloud().agents.list({ limit: 200, ...(cursor ? { cursor } : {}) });
|
|
145
|
-
url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
|
|
163
|
+
url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.id.slice(a.workspace_id.length + 1)))?.remote_control_url;
|
|
146
164
|
cursor = page.nextCursor ?? undefined;
|
|
147
165
|
} while (!url && cursor && !ctx.signal.aborted);
|
|
148
166
|
// A new Claude process can accept its first prompt before Remote Control registers.
|
|
@@ -158,7 +176,7 @@ export async function remoteControlUrl(ctx, ws) {
|
|
|
158
176
|
});
|
|
159
177
|
if (result.exitCode !== 0)
|
|
160
178
|
break;
|
|
161
|
-
url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
|
|
179
|
+
url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.session_id))?.remote_control_url;
|
|
162
180
|
if (!url && Date.now() < deadline)
|
|
163
181
|
await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
|
|
164
182
|
}
|
|
@@ -184,9 +202,9 @@ export const CLAUDE_DESKTOP_BUNDLE = 'com.anthropic.claudefordesktop';
|
|
|
184
202
|
* Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
|
|
185
203
|
* opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
|
|
186
204
|
*/
|
|
187
|
-
export async function openClaudeDesktopSession(ctx, ws) {
|
|
205
|
+
export async function openClaudeDesktopSession(ctx, ws, conversation) {
|
|
188
206
|
await ensureCloudClaude(ctx, ws);
|
|
189
|
-
const session = await remoteControlUrl(ctx, ws);
|
|
207
|
+
const session = await remoteControlUrl(ctx, ws, conversation);
|
|
190
208
|
const url = claudeDesktopLink(session);
|
|
191
209
|
if (!ctx.json)
|
|
192
210
|
await openLink(url, ctx.io.env);
|
|
@@ -206,23 +224,41 @@ async function openLink(url, env) {
|
|
|
206
224
|
}
|
|
207
225
|
const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
208
226
|
const detected = installedTargets(ctx.io.env);
|
|
227
|
+
if (target === 't3' && !detected.targets.includes('t3'))
|
|
228
|
+
throw t3NotInstalled();
|
|
209
229
|
if (target !== 'web' && !detected.targets.includes(target))
|
|
210
230
|
throw new UsageError(`${target} is not installed on this machine`);
|
|
211
|
-
|
|
231
|
+
// A conversation started in T3 (its Shardflux provider) opens its own thread there (0.17.0+).
|
|
232
|
+
if (target === 't3' && /^conv_[a-z0-9]{26}$/.test(ref))
|
|
233
|
+
return openT3Conversation(ctx, ref, values);
|
|
234
|
+
// A conversation id opens that conversation's own session on its computer (claude.ai, the desktop apps).
|
|
235
|
+
const conversation = /^conv_[a-z0-9]{26}$/.test(ref) ? await getConversation(ctx, ref) : undefined;
|
|
236
|
+
let ws = conversation ? await conversationWorkspace(ctx, conversation) : await resolveWorkspace(ctx, keyOf(ref));
|
|
212
237
|
if (values.reachable !== undefined)
|
|
213
238
|
ws = await ctx.cloud().workspaces.open({ key: ws.key, ...agentReachability(values), wait: false });
|
|
214
|
-
|
|
239
|
+
// T3 lists both agents and opens without Claude (it shows Claude as signed out); the Claude apps need it.
|
|
240
|
+
if (target === 't3')
|
|
241
|
+
await ensureCloudClaude(ctx, ws).catch((error) => {
|
|
242
|
+
if (!(error instanceof UsageError))
|
|
243
|
+
throw error;
|
|
244
|
+
ctx.io.stderr.write(`Claude Code in T3: ${error.message}\n`);
|
|
245
|
+
});
|
|
246
|
+
else if (target !== 'codex-app')
|
|
215
247
|
await ensureCloudClaude(ctx, ws);
|
|
216
248
|
if (target === 'web') {
|
|
217
|
-
const url = await remoteControlUrl(ctx, ws);
|
|
249
|
+
const url = await remoteControlUrl(ctx, ws, conversation);
|
|
218
250
|
if (!ctx.json && !openUrl(url, ctx.io.env))
|
|
219
251
|
throw new Error(`could not open ${url}`);
|
|
220
|
-
out(ctx, `${url}\n`, { target, workspace: ws.data, url });
|
|
252
|
+
out(ctx, `${url}\n`, { target, workspace: ws.data, url, ...(conversation ? { conversation_id: conversation.id } : {}) });
|
|
221
253
|
return 0;
|
|
222
254
|
}
|
|
223
|
-
return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
|
|
255
|
+
return openDesktopWorkspace(ctx, ws, target, detected.versions.t3, false, undefined, undefined, conversation?.id);
|
|
224
256
|
};
|
|
225
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Opens a workspace as a desktop app's SSH environment. For T3 (0.17.0+) a first message becomes a thread on the T3
|
|
259
|
+
* server in the VM (SSH mode), and T3 is brought to the front with --json too (`opened: true`).
|
|
260
|
+
*/
|
|
261
|
+
export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false, thread, install, conversation) {
|
|
226
262
|
if (target !== 't3')
|
|
227
263
|
ws = await prepareDesktopRouter(ctx, ws);
|
|
228
264
|
await prepareAgentMachine(ctx, ws);
|
|
@@ -235,13 +271,58 @@ export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed
|
|
|
235
271
|
: target === 'claude-desktop'
|
|
236
272
|
? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
|
|
237
273
|
: `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
|
|
238
|
-
|
|
274
|
+
let started;
|
|
275
|
+
if (target === 't3' && thread) {
|
|
276
|
+
try {
|
|
277
|
+
started = await startSshThread(ctx, ws, t3Version, workspaceRepositoryPath(ws), thread);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
throw withT3Context(error, { workspace_key: ws.key, conversation_id: conversation ?? (ws.labels.shard_conversation || null) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (target === 't3') {
|
|
284
|
+
// The thread exists once startSshThread returns: a failure to show T3 names it rather than inviting a retry.
|
|
285
|
+
try {
|
|
286
|
+
await openT3(ctx.io.env, (install ?? t3Install(ctx.io.env))?.path ?? '');
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
throw started ? t3OpenFailed(error, { t3_thread_id: started.t3_thread_id, conversation_id: conversation ?? (ws.labels.shard_conversation || null), workspace_key: ws.key }) : error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else if (!ctx.json)
|
|
239
293
|
await openApp(target, ctx.io.env);
|
|
240
294
|
new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
|
|
241
|
-
|
|
295
|
+
if (target === 't3')
|
|
296
|
+
new AgentStateStore(ctx.store.dir).saveT3Environment({ workspace: ws.key, host: entry.host, api_url: ctx.apiUrl });
|
|
297
|
+
const phone = target === 't3' ? await linkT3Phone(ctx, ws) : undefined;
|
|
298
|
+
const threadLine = started ? `Started a thread in T3 Code on ${agentMachineName(ws.key)}${started.model ? ` (${started.model}${started.effort ? `, ${started.effort}` : ''})` : ''}.\n` : '';
|
|
299
|
+
out(ctx, `${threadLine}${instructions}\n${phone?.line ?? ''}`, { target, workspace: ws.data, host: entry.host, instructions, ...(phone ? { t3_link: phone.link } : {}),
|
|
300
|
+
...(target === 't3' ? t3Fields(ws, entry.project, install ?? t3Install(ctx.io.env), started, conversation) : {}),
|
|
242
301
|
app_bundle_id: { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target] });
|
|
243
302
|
return 0;
|
|
244
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* The T3 app on the phone reaches this workspace through the account's T3 Connect sign-in (0.17.0+). The sign-in is
|
|
306
|
+
* asked for once per person; linking continues by itself once it is approved.
|
|
307
|
+
*/
|
|
308
|
+
async function linkT3Phone(ctx, ws) {
|
|
309
|
+
try {
|
|
310
|
+
const { link, signIn, needsLogin } = await t3PhoneLink(ctx, ws.id);
|
|
311
|
+
const line = signIn
|
|
312
|
+
? `Phone: to use ${link.label} from the T3 app, open ${signIn.verification_uri} and confirm ${signIn.user_code} (once).\n`
|
|
313
|
+
: needsLogin ? `Phone: to use ${link.label} from the T3 app, sign in with shard auth login, then run this again.\n`
|
|
314
|
+
: `Phone: ${link.label} appears in the T3 app's environments.\n`;
|
|
315
|
+
// --json carries the sign-in shard started with its session too, so the menu bar can show it.
|
|
316
|
+
return { line, link: signIn ? { ...link, sign_in: { ...link.sign_in, ...signIn } } : needsLogin ? { ...link, needs_login: true } : link };
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// An API without T3 Connect leaves the desktop route as it is.
|
|
320
|
+
if (describeError(error).code === 'not_found')
|
|
321
|
+
return undefined;
|
|
322
|
+
ctx.io.stderr.write(`Phone link: ${describeError(error).message}\n`);
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
245
326
|
export async function queueT3Prewarm(ctx, ws, version) {
|
|
246
327
|
try {
|
|
247
328
|
await prewarmT3(ctx, ws, version);
|
|
@@ -251,7 +332,6 @@ export async function queueT3Prewarm(ctx, ws, version) {
|
|
|
251
332
|
}
|
|
252
333
|
}
|
|
253
334
|
const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
|
|
254
|
-
const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
|
|
255
335
|
/** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
|
|
256
336
|
function configuredTargets(targets, e, t3 = false) {
|
|
257
337
|
return [
|
|
@@ -264,30 +344,56 @@ function configuredTargets(targets, e, t3 = false) {
|
|
|
264
344
|
function targetFiles(targets, entries) {
|
|
265
345
|
return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
|
|
266
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* The project `agents targets` reports T3 for: the agent argument's, else `--path`'s (resolved as `agents new --path`
|
|
349
|
+
* resolves it: the repository remote, else the folder), else the current folder's when it is one.
|
|
350
|
+
*/
|
|
351
|
+
function targetsProject(ref, path) {
|
|
352
|
+
if (ref)
|
|
353
|
+
return keyOf(ref).split('/')[0];
|
|
354
|
+
if (path !== undefined)
|
|
355
|
+
return projectIdentity(path).slug;
|
|
356
|
+
try {
|
|
357
|
+
return projectIdentity('./').slug;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
267
363
|
export const TARGET_HANDLERS = {
|
|
268
364
|
async 'agents targets'(ctx, values, [ref]) {
|
|
269
|
-
const
|
|
365
|
+
const found = installedTargets(ctx.io.env);
|
|
366
|
+
// models (0.17.0+): the model and effort choices a new thread can start with; clients never hardcode them.
|
|
367
|
+
// T3 Code mode (0.17.0+): T3's state for this project (the agent's, else this folder's), and where `shard ./` opens
|
|
368
|
+
// each app's new conversations (--open-in sets it).
|
|
369
|
+
const project = targetsProject(ref, typeof values.path === 'string' ? values.path : undefined);
|
|
370
|
+
const t3 = t3Status(ctx, { project, lastUsed: true });
|
|
371
|
+
if (typeof values['open-in'] === 'string')
|
|
372
|
+
saveOpenIn(ctx, values['open-in'], t3, found.clients);
|
|
373
|
+
const detected = { ...found, t3, open_in: openInChoices(ctx, project, { clients: found.clients }) };
|
|
270
374
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
271
375
|
const key = ref ? keyOf(ref) : undefined;
|
|
272
376
|
const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
|
|
273
377
|
const entries = ref || values.remove === true
|
|
274
378
|
? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
|
|
275
379
|
: [];
|
|
276
|
-
const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n
|
|
380
|
+
const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n` +
|
|
381
|
+
(t3.installed ? `T3 Code ${t3.version ?? ''}: ${t3.mode === 'acp' ? 'Shardflux provider' : 'SSH environments'}, ${t3.connected ? 'connected' : 'not connected'}\n`.replace(' :', ':') : '') +
|
|
382
|
+
`New conversations open in: Claude → ${detected.open_in.claude}, Codex → ${detected.open_in.codex}\n`;
|
|
277
383
|
if (values.remove === true) {
|
|
278
384
|
const removed = before.filter(e => !entries.some(n => n.host === e.host));
|
|
279
|
-
out(ctx, removed.length ? `Removed ${removed.map(e => `${e.workspace} (${e.host})`).join(', ')} from ${targetFiles(targets, removed).map(f => tilde(targets, f)).join(', ')}\n` : 'No managed targets to remove.\n', { ...detected, entries, removed });
|
|
385
|
+
out(ctx, removed.length ? `Removed ${removed.map(e => `${e.workspace} (${e.host})`).join(', ')} from ${targetFiles(targets, removed).map(f => tilde(targets, f)).join(', ')}\n` : 'No managed targets to remove.\n', { ...detected, entries, removed, models: MODEL_CHOICES });
|
|
280
386
|
return Promise.resolve(0);
|
|
281
387
|
}
|
|
282
388
|
const entry = key ? entries.find(e => e.workspace === key) : undefined;
|
|
283
389
|
if (!entry) {
|
|
284
|
-
out(ctx, detection, { ...detected, entries });
|
|
390
|
+
out(ctx, detection, { ...detected, entries, models: MODEL_CHOICES });
|
|
285
391
|
return Promise.resolve(0);
|
|
286
392
|
}
|
|
287
|
-
const
|
|
288
|
-
const configured = configuredTargets(targets, entry,
|
|
393
|
+
const t3Installed = detected.targets.includes('t3');
|
|
394
|
+
const configured = configuredTargets(targets, entry, t3Installed);
|
|
289
395
|
out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
|
|
290
|
-
['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !
|
|
396
|
+
['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !t3Installed : !entry.targets.includes(t)).map(t => ` ${{ 'claude-desktop': 'Claude Desktop', 'codex-app': 'Codex app', t3: 'T3 Code' }[t].padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })), models: MODEL_CHOICES });
|
|
291
397
|
return Promise.resolve(0);
|
|
292
398
|
},
|
|
293
399
|
'agents open claude-desktop': openTarget('claude-desktop'),
|
package/dist/agent-targets.d.ts
CHANGED
|
@@ -7,6 +7,9 @@ export interface TargetEntry {
|
|
|
7
7
|
agent: string;
|
|
8
8
|
targets: DesktopTarget[];
|
|
9
9
|
}
|
|
10
|
+
/** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
|
|
11
|
+
* "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
|
|
12
|
+
export declare const targetLabel: (e: Pick<TargetEntry, "agent" | "project">) => string;
|
|
10
13
|
export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
|
|
11
14
|
/** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
|
|
12
15
|
export declare function managedBlock(text: string, body: string): string;
|
|
@@ -17,6 +20,8 @@ export declare function t3Apps(dirs: string[], platform?: NodeJS.Platform): Arra
|
|
|
17
20
|
}>;
|
|
18
21
|
/** The app folders scanned for desktop apps: SHARDFLUX_APPLICATIONS_DIRS, else /Applications and ~/Applications. */
|
|
19
22
|
export declare function applicationDirs(env: Record<string, string | undefined>): string[];
|
|
23
|
+
/** The first `name` app bundle with this bundle id in the app folders (macOS only). */
|
|
24
|
+
export declare function desktopAppPath(dirs: string[], name: string, bundle: string, platform?: NodeJS.Platform): string | undefined;
|
|
20
25
|
export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
|
|
21
26
|
targets: string[];
|
|
22
27
|
clients: string[];
|
package/dist/agent-targets.js
CHANGED
|
@@ -15,6 +15,9 @@ const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%')
|
|
|
15
15
|
const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
|
|
16
16
|
// Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
|
|
17
17
|
const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
|
|
18
|
+
/** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
|
|
19
|
+
* "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
|
|
20
|
+
export const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `${e.agent} · ${e.project}`;
|
|
18
21
|
export function targetIdentity(key) {
|
|
19
22
|
const parts = key.split('/');
|
|
20
23
|
if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
|
|
@@ -71,16 +74,22 @@ export function applicationDirs(env) {
|
|
|
71
74
|
const home = env.HOME || homedir();
|
|
72
75
|
return env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')];
|
|
73
76
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
/** The first `name` app bundle with this bundle id in the app folders (macOS only). */
|
|
78
|
+
export function desktopAppPath(dirs, name, bundle, platform = process.platform) {
|
|
79
|
+
if (platform !== 'darwin')
|
|
80
|
+
return undefined;
|
|
81
|
+
return dirs.map(dir => join(dir, name)).find(path => {
|
|
78
82
|
const info = join(path, 'Contents', 'Info.plist');
|
|
79
83
|
if (!existsSync(info))
|
|
80
84
|
return false;
|
|
81
85
|
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
82
86
|
return result.status === 0 && result.stdout.trim() === bundle;
|
|
83
87
|
});
|
|
88
|
+
}
|
|
89
|
+
export function installedTargets(env, platform = process.platform, appDirs) {
|
|
90
|
+
// SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
|
|
91
|
+
const dirs = appDirs ?? applicationDirs(env);
|
|
92
|
+
const hasApp = (name, bundle) => desktopAppPath(dirs, name, bundle, platform) !== undefined;
|
|
84
93
|
const versions = {};
|
|
85
94
|
const t3Bundles = t3Apps(dirs, platform);
|
|
86
95
|
const t3 = t3Bundles.length > 0;
|
|
@@ -280,7 +289,7 @@ export class AgentTargets {
|
|
|
280
289
|
if (!(prop in doc) && !manifest.created[file].includes(prop))
|
|
281
290
|
manifest.created[file].push(prop);
|
|
282
291
|
doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
|
|
283
|
-
const label = e
|
|
292
|
+
const label = targetLabel(e);
|
|
284
293
|
return target === 'claude-desktop' ? { id: e.host, name: label, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label }] };
|
|
285
294
|
})];
|
|
286
295
|
if (!doc[prop].length && manifest.created[file].includes(prop))
|
package/dist/agent-terminal.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ export interface TerminalConnection {
|
|
|
8
8
|
}
|
|
9
9
|
/** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
|
|
10
10
|
export declare function sleepingAttach(error: unknown): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
|
|
13
|
+
* workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
|
|
14
|
+
* 2 s by consecutive failures, which reset once an attach delivers.
|
|
15
|
+
*/
|
|
16
|
+
export declare function reattachDelayMs(error: unknown, failures: number, random?: () => number): number;
|
|
17
|
+
/** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
|
|
18
|
+
export declare function admissionNotice(error: unknown): string | null;
|
|
11
19
|
export declare function retryableAttach(error: unknown): boolean;
|
|
12
20
|
/** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
|
|
13
21
|
export declare class PtyOutputCursor {
|
package/dist/agent-terminal.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
-
import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
|
|
2
|
+
import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix, isAdmissionRefusal } from '@shardflux/sdk';
|
|
3
3
|
import { UsageError } from "./errors.js";
|
|
4
4
|
/** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
|
|
5
5
|
export function sleepingAttach(error) {
|
|
6
6
|
return error instanceof ShardfluxApiError && error.code === 'workspace_not_running';
|
|
7
7
|
}
|
|
8
8
|
const ASLEEP_NOTICE = '\r\n\x1b[2m· asleep. Type to wake it.\x1b[0m\r\n';
|
|
9
|
+
/**
|
|
10
|
+
* The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
|
|
11
|
+
* workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
|
|
12
|
+
* 2 s by consecutive failures, which reset once an attach delivers.
|
|
13
|
+
*/
|
|
14
|
+
export function reattachDelayMs(error, failures, random = Math.random) {
|
|
15
|
+
const hinted = error instanceof ShardfluxApiError ? error.retryAfterSeconds : undefined;
|
|
16
|
+
if (hinted !== undefined && hinted > 0)
|
|
17
|
+
return Math.floor(hinted * 1000 * (1 + 0.2 * random()));
|
|
18
|
+
return Math.min(2_000, 100 * 2 ** Math.min(failures, 5));
|
|
19
|
+
}
|
|
20
|
+
/** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
|
|
21
|
+
export function admissionNotice(error) {
|
|
22
|
+
if (!isAdmissionRefusal(error))
|
|
23
|
+
return null;
|
|
24
|
+
const position = typeof error.details?.queue_position === 'number' ? ` (position ${error.details.queue_position})` : '';
|
|
25
|
+
return error.reason === 'host_capacity' ? `waiting for room on the workspace's host${position}` : 'waiting for a free working slot: every workspace the plan lets work at once is busy';
|
|
26
|
+
}
|
|
9
27
|
export function retryableAttach(error) {
|
|
10
28
|
if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
|
|
11
29
|
return false;
|
|
@@ -164,6 +182,10 @@ export async function attachTerminal(options) {
|
|
|
164
182
|
controller.abort();
|
|
165
183
|
} });
|
|
166
184
|
let reconnects = 0;
|
|
185
|
+
// Consecutive failed attaches: the backoff's exponent, back to 0 once an attach delivers a message.
|
|
186
|
+
let failures = 0;
|
|
187
|
+
let lastError;
|
|
188
|
+
let notice = null;
|
|
167
189
|
let asleep = false;
|
|
168
190
|
try {
|
|
169
191
|
while (!signal.aborted) {
|
|
@@ -193,6 +215,9 @@ export async function attachTerminal(options) {
|
|
|
193
215
|
for await (const message of connection.messages()) {
|
|
194
216
|
if (signal.aborted)
|
|
195
217
|
break;
|
|
218
|
+
// The attach delivers: a later drop starts the backoff over, and the next wait for room is said again.
|
|
219
|
+
failures = 0;
|
|
220
|
+
notice = null;
|
|
196
221
|
const bytes = cursor.consume(message);
|
|
197
222
|
if (bytes?.length)
|
|
198
223
|
io.stdout.write(bytes);
|
|
@@ -212,6 +237,7 @@ export async function attachTerminal(options) {
|
|
|
212
237
|
asleep = true;
|
|
213
238
|
continue;
|
|
214
239
|
}
|
|
240
|
+
lastError = error;
|
|
215
241
|
}
|
|
216
242
|
finally {
|
|
217
243
|
const previous = connection;
|
|
@@ -220,7 +246,14 @@ export async function attachTerminal(options) {
|
|
|
220
246
|
await previous?.close();
|
|
221
247
|
}
|
|
222
248
|
reconnects += 1;
|
|
223
|
-
|
|
249
|
+
failures += 1;
|
|
250
|
+
// Waiting for room on the workspace's host (or a working slot): said once, and again when the position changes.
|
|
251
|
+
const waiting = admissionNotice(lastError);
|
|
252
|
+
if (waiting && waiting !== notice)
|
|
253
|
+
io.stderr.write(`\r\n\x1b[2m· ${waiting}\x1b[0m\r\n`);
|
|
254
|
+
notice = waiting;
|
|
255
|
+
await options.sleep(reattachDelayMs(lastError, failures), signal);
|
|
256
|
+
lastError = undefined;
|
|
224
257
|
}
|
|
225
258
|
if (inputFailure)
|
|
226
259
|
throw inputFailure;
|
package/dist/args.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.17.0";
|
|
2
2
|
export interface OptionSpec {
|
|
3
3
|
type: 'string' | 'boolean';
|
|
4
4
|
short?: string;
|
|
@@ -27,9 +27,12 @@ export interface CommandSpec {
|
|
|
27
27
|
positionals: PositionalSpec[];
|
|
28
28
|
options: Record<string, OptionSpec>;
|
|
29
29
|
examples?: string[];
|
|
30
|
+
/** Runs, but is left out of the top and group help (a program other tools start, such as a forwarder). */
|
|
31
|
+
hidden?: boolean;
|
|
30
32
|
}
|
|
31
33
|
export declare const GLOBAL_OPTIONS: Record<string, OptionSpec>;
|
|
32
34
|
export declare const PROJECT_COMMAND: CommandSpec;
|
|
35
|
+
export declare const ONBOARDING_COMMAND: CommandSpec;
|
|
33
36
|
export declare const CENTER_COMMAND: CommandSpec;
|
|
34
37
|
export declare const COMMANDS: CommandSpec[];
|
|
35
38
|
export declare function normalizeWords(words: string[]): string[];
|
|
@@ -74,6 +77,7 @@ export type Parsed = {
|
|
|
74
77
|
*/
|
|
75
78
|
export declare function parseCommandLine(argv: readonly string[], options?: {
|
|
76
79
|
tty?: boolean;
|
|
80
|
+
platform?: NodeJS.Platform;
|
|
77
81
|
isProjectPath?: (path: string) => boolean;
|
|
78
82
|
}): Parsed;
|
|
79
83
|
export declare function usageLine(c: CommandSpec): string;
|