@shardflux/cli 0.16.0 → 0.18.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -2
- package/README.md +47 -8
- package/dist/agent-commands.d.ts +9 -1
- package/dist/agent-commands.js +65 -16
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +6 -1
- package/dist/agent-target-commands.js +113 -16
- package/dist/agent-targets.d.ts +17 -5
- package/dist/agent-targets.js +27 -11
- package/dist/args.d.ts +5 -1
- package/dist/args.js +49 -9
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +2 -1
- package/dist/codex-cloud.js +16 -6
- package/dist/command-center-actions.js +16 -1
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +45 -6
- package/dist/conversations.d.ts +18 -2
- package/dist/conversations.js +12 -5
- package/dist/desktop-linux.d.ts +40 -0
- package/dist/desktop-linux.js +186 -0
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +75 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +5 -2
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +1 -1
- package/dist/git-remote-bin.js +0 -0
- package/dist/handlers.js +3 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +30 -6
- package/dist/menubar.js +44 -4
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +59 -9
- package/dist/t3-asar.d.ts +10 -0
- package/dist/t3-asar.js +91 -0
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-linux.d.ts +68 -0
- package/dist/t3-linux.js +343 -0
- package/dist/t3-mode.d.ts +129 -0
- package/dist/t3-mode.js +676 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-provider.d.ts +5 -3
- package/dist/t3-provider.js +12 -49
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +2 -2
- package/dist/thread-on-computer.js +13 -7
- package/dist/update.d.ts +1 -1
- package/dist/update.js +28 -1
- package/dist/worktree-twin-engine.d.ts +31 -1
- package/dist/worktree-twin-engine.js +253 -40
- package/dist/worktree-twin.d.ts +17 -0
- package/dist/worktree-twin.js +147 -22
- package/package.json +7 -19
package/dist/t3-mode.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T3 Code mode (0.17.0+): new Shardflux threads start in T3 Code itself. T3 has no link that creates or focuses a
|
|
3
|
+
* thread, so the thread is made through T3's own server API, with a short-lived admin session that T3's own CLI
|
|
4
|
+
* issues and revokes, and T3 is brought to the front.
|
|
5
|
+
*
|
|
6
|
+
* - **ACP mode** (a T3 build with ACP providers, 0.0.46+): T3's server runs on this computer (macOS or Linux) and
|
|
7
|
+
* Shardflux is one of its providers (`shard acp`, registered by every `shard` run). The thread is launched on the
|
|
8
|
+
* running desktop server (`server-runtime.json`) over its WebSocket RPC (`orchestration.launchThread`) with the
|
|
9
|
+
* Shardflux provider, which then gives the thread its own cloud VM. The session is issued by the T3 server the app
|
|
10
|
+
* bundles (`ELECTRON_RUN_AS_NODE=1 <app>/Contents/MacOS/<exe> <app.asar>/apps/server/dist/bin.mjs auth session
|
|
11
|
+
* issue`; on Linux the app's `t3code` with its `resources/app.asar`, or T3's own `~/.t3/bin/t3` for an AppImage).
|
|
12
|
+
* - **SSH mode** (stable 0.0.45): T3's server runs in the project VM. `T3_THREAD_SCRIPT` runs there and dispatches
|
|
13
|
+
* `thread.create` and `thread.turn.start` over loopback (POST /api/orchestration/dispatch).
|
|
14
|
+
*
|
|
15
|
+
* T3's server settings (settings.json) are written only by configureT3Provider, as every `shard` run does to register
|
|
16
|
+
* the Shardflux provider (t3-provider.ts). Everything else (sessions, projects, threads) changes only through T3's own
|
|
17
|
+
* API and CLI.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
22
|
+
import { writePrivateFile } from "./credentials.js";
|
|
23
|
+
import { basename, join } from 'node:path';
|
|
24
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
25
|
+
import { CodedError, UsageError, describeError } from "./errors.js";
|
|
26
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
27
|
+
import { applicationDirs, desktopAppPath, t3Apps } from "./agent-targets.js";
|
|
28
|
+
import { configureT3Provider, t3BundleSupportsAcp, t3HomeDir } from "./t3-provider.js";
|
|
29
|
+
import { linuxLastUsed, linuxServerCli, linuxSupportsAcp, openLinuxT3 } from "./t3-linux.js";
|
|
30
|
+
import { acpSignedIn } from "./acp-login.js";
|
|
31
|
+
import { AcpModelCache } from "./acp-models.js";
|
|
32
|
+
import { nativeModelOptions } from "./acp-catalog.js";
|
|
33
|
+
import { objectValue } from "./acp-rpc.js";
|
|
34
|
+
import { T3_THREAD_SCRIPT } from "./t3-thread-script.js";
|
|
35
|
+
import { t3InstallScript } from "./t3-runtime.js";
|
|
36
|
+
import { uploadInitialImages } from "./initial-prompt.js";
|
|
37
|
+
export const T3_BUNDLE_ID = 'com.t3tools.t3code';
|
|
38
|
+
/** Where each app's new conversations can open: Claude in a terminal, Claude Desktop, claude.ai or T3; Codex in the Codex app or T3. */
|
|
39
|
+
export const OPEN_IN_TARGETS = { claude: ['terminal', 'claude-desktop', 'web', 't3'], codex: ['codex-app', 't3'] };
|
|
40
|
+
export const openInTargets = (app, platform = process.platform) => platform === 'linux' && app === 'codex' ? ['terminal', 't3'] : OPEN_IN_TARGETS[app];
|
|
41
|
+
/** T3 0.0.45's built-in defaults (DEFAULT_MODEL_BY_PROVIDER), used only when T3 in the VM has not described its models yet. */
|
|
42
|
+
const T3_DEFAULT_MODEL = { claude: 'claude-fable-5-1', codex: 'gpt-6-astra' };
|
|
43
|
+
const T3_START_WAIT_MS = 30_000;
|
|
44
|
+
const CONVERSATION_WAIT_MS = 30_000;
|
|
45
|
+
const SIGN_IN_FIX = 'Sign in with shard auth login (or the Shardflux provider’s Sign in in T3 Code), then run this again.';
|
|
46
|
+
const SPOTLIGHT_DATE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4}$/;
|
|
47
|
+
const isoDate = (raw) => {
|
|
48
|
+
const parsed = SPOTLIGHT_DATE.test(raw) ? new Date(raw.replace(' ', 'T').replace(' ', '')) : null;
|
|
49
|
+
return parsed && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Native lookups that tests replace: Spotlight's last-used dates of app bundles, one `mdls` for all of them (macOS), or
|
|
53
|
+
* when each program last started, from its access time (Linux, t3-linux.ts).
|
|
54
|
+
*/
|
|
55
|
+
export const t3Native = {
|
|
56
|
+
lastUsedMany(paths, env, platform = process.platform) {
|
|
57
|
+
if (!paths.length)
|
|
58
|
+
return {};
|
|
59
|
+
if (platform !== 'darwin')
|
|
60
|
+
return Object.fromEntries(paths.map(path => [path, linuxLastUsed(path)]));
|
|
61
|
+
const r = spawnSync('mdls', ['-name', 'kMDItemLastUsedDate', '-raw', ...paths], { env, encoding: 'utf8', input: '', timeout: 3000 });
|
|
62
|
+
// -raw separates the values with NUL; a bundle Spotlight has no date for prints "(null)".
|
|
63
|
+
const values = r.status === 0 ? r.stdout.split('\0').map(v => v.trim()) : [];
|
|
64
|
+
return Object.fromEntries(paths.map((path, i) => [path, values.length === paths.length ? isoDate(values[i]) : null]));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
/** Spotlight takes about 40 ms per call, so the dates are kept for a few minutes (app-usage.json in the config dir). */
|
|
68
|
+
export const LAST_USED_TTL_MS = 5 * 60_000;
|
|
69
|
+
export function lastUsedDates(ctx, paths, now = Date.now()) {
|
|
70
|
+
const file = join(ctx.store.dir, 'app-usage.json');
|
|
71
|
+
let cache = {};
|
|
72
|
+
try {
|
|
73
|
+
cache = JSON.parse(readFileSync(file, 'utf8'));
|
|
74
|
+
}
|
|
75
|
+
catch { /* first lookup */ }
|
|
76
|
+
const fresh = typeof cache.checked_at === 'number' && now - cache.checked_at >= 0 && now - cache.checked_at < LAST_USED_TTL_MS;
|
|
77
|
+
if (fresh && cache.dates && paths.every(p => p in cache.dates))
|
|
78
|
+
return Object.fromEntries(paths.map(p => [p, cache.dates[p] ?? null]));
|
|
79
|
+
const dates = t3Native.lastUsedMany(paths, ctx.io.env);
|
|
80
|
+
try {
|
|
81
|
+
writePrivateFile(ctx.store.dir, file, JSON.stringify({ checked_at: now, dates }) + '\n');
|
|
82
|
+
}
|
|
83
|
+
catch { /* a cache only */ }
|
|
84
|
+
return dates;
|
|
85
|
+
}
|
|
86
|
+
/** Whether an installed T3 runs the Shardflux provider: its server has the ACP Registry driver (t3-asar.ts, t3-linux.ts). */
|
|
87
|
+
const supportsAcp = (app) => app.linux ? linuxSupportsAcp({ ...app, linux: app.linux }) : t3BundleSupportsAcp(app.path);
|
|
88
|
+
/** The installed T3 Code, preferring a build with ACP providers when several channels are installed. */
|
|
89
|
+
export function t3Install(env, platform = process.platform, dirs = applicationDirs(env, platform)) {
|
|
90
|
+
const apps = t3Apps(dirs, platform, env);
|
|
91
|
+
const acp = apps.find(supportsAcp);
|
|
92
|
+
const app = acp ?? apps[0];
|
|
93
|
+
return app ? { path: app.path, version: app.version, mode: acp ? 'acp' : 'ssh', ...(app.linux ? { linux: app.linux } : {}) } : undefined;
|
|
94
|
+
}
|
|
95
|
+
export { t3HomeDir };
|
|
96
|
+
/** The Shardflux provider instance in T3's server settings (read only; every `shard` run writes it, see t3-provider.ts). */
|
|
97
|
+
export function t3ProviderRegistered(env) {
|
|
98
|
+
try {
|
|
99
|
+
const doc = JSON.parse(readFileSync(join(t3HomeDir(env), 'userdata', 'settings.json'), 'utf8'));
|
|
100
|
+
const instance = doc.providerInstances?.shardflux;
|
|
101
|
+
return !!instance && instance.driver === 'acpRegistry' && instance.enabled !== false && JSON.stringify(instance.config?.commandArgs) === '["acp"]';
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The project a workspace key belongs to, matched against a project slug (two repositories with one name: `<slug>-<6 hex>`). */
|
|
108
|
+
function sameProject(key, slug) {
|
|
109
|
+
const project = key.split('/')[0] ?? '';
|
|
110
|
+
return project === slug || (project.startsWith(`${slug.slice(0, 89)}-`) && /^[0-9a-f]{6}$/.test(project.slice(Math.min(slug.length, 89) + 1)));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* T3 is connected when a new thread can start in it now: in ACP mode the Shardflux provider is in T3's settings and
|
|
114
|
+
* this computer is signed in; in SSH mode this project has a T3 SSH environment set up from this computer (signed in).
|
|
115
|
+
* `last_used_at` is looked up only when asked for (`agents targets`).
|
|
116
|
+
*/
|
|
117
|
+
export function t3Status(ctx, opts = {}) {
|
|
118
|
+
const install = opts.install === undefined ? t3Install(ctx.io.env) : opts.install ?? undefined;
|
|
119
|
+
if (!install)
|
|
120
|
+
return { installed: false, mode: null, connected: false, version: null, last_used_at: null };
|
|
121
|
+
const connected = acpSignedIn(ctx) && (install.mode === 'acp'
|
|
122
|
+
? t3ProviderRegistered(ctx.io.env)
|
|
123
|
+
: !!opts.project && new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).some(e => sameProject(e.workspace, opts.project)));
|
|
124
|
+
return { installed: true, mode: install.mode, connected, version: install.version, last_used_at: opts.lastUsed ? lastUsedDates(ctx, usagePaths(ctx, install).paths)[install.path] ?? null : null };
|
|
125
|
+
}
|
|
126
|
+
/** T3 and the other installed desktop apps whose last use decides the default: one Spotlight lookup covers them all. */
|
|
127
|
+
function usagePaths(ctx, install) {
|
|
128
|
+
const dirs = applicationDirs(ctx.io.env);
|
|
129
|
+
const others = [desktopAppPath(dirs, 'Claude.app', 'com.anthropic.claudefordesktop'), desktopAppPath(dirs, 'Codex.app', 'com.openai.codex') ?? desktopAppPath(dirs, 'ChatGPT.app', 'com.openai.codex')]
|
|
130
|
+
.filter((p) => !!p);
|
|
131
|
+
return { paths: [install.path, ...others], others };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Where each app's new conversations open for `project`: the choice saved with `agents targets --open-in` while it is
|
|
135
|
+
* available, else T3 when it is connected and the most recently used of the installed desktop apps, else a terminal
|
|
136
|
+
* (Claude) or the Codex app. Lookups run only when an answer needs them: none for a saved terminal, web or Codex app
|
|
137
|
+
* choice, and Spotlight only when T3 is installed and connected (cached, see lastUsedDates).
|
|
138
|
+
*/
|
|
139
|
+
export function openInFor(ctx, project, known = {}) {
|
|
140
|
+
const saved = objectValue(new AgentStateStore(ctx.store.dir).read().open_in);
|
|
141
|
+
const dirs = applicationDirs(ctx.io.env);
|
|
142
|
+
let install, status, first;
|
|
143
|
+
const t3 = () => install === undefined ? (install = t3Install(ctx.io.env) ?? null) : install;
|
|
144
|
+
const claudeDesktop = () => known.clients ? known.clients.includes('claude-desktop') : desktopAppPath(dirs, 'Claude.app', 'com.anthropic.claudefordesktop') !== undefined;
|
|
145
|
+
const valid = (app) => {
|
|
146
|
+
const value = saved[app];
|
|
147
|
+
if (typeof value !== 'string' || !openInTargets(app).includes(value))
|
|
148
|
+
return undefined;
|
|
149
|
+
return value === 't3' ? (t3() ? value : undefined) : value === 'claude-desktop' ? (claudeDesktop() ? value : undefined) : value;
|
|
150
|
+
};
|
|
151
|
+
const t3First = () => first ??= (() => {
|
|
152
|
+
const app = t3();
|
|
153
|
+
if (!app)
|
|
154
|
+
return false;
|
|
155
|
+
status ??= t3Status(ctx, { project, install: app });
|
|
156
|
+
if (!status.connected)
|
|
157
|
+
return false;
|
|
158
|
+
const { paths, others } = usagePaths(ctx, app);
|
|
159
|
+
const dates = lastUsedDates(ctx, paths);
|
|
160
|
+
const mine = dates[app.path];
|
|
161
|
+
return !!mine && others.every(p => !dates[p] || dates[p] <= mine);
|
|
162
|
+
})();
|
|
163
|
+
const choose = (app) => {
|
|
164
|
+
const value = valid(app);
|
|
165
|
+
return value ? { target: value, saved: true } : { target: t3First() ? 't3' : app === 'claude' || process.platform === 'linux' ? 'terminal' : 'codex-app', saved: false };
|
|
166
|
+
};
|
|
167
|
+
return { claude: choose('claude'), codex: choose('codex') };
|
|
168
|
+
}
|
|
169
|
+
export function openInChoices(ctx, project, known = {}) {
|
|
170
|
+
const choices = openInFor(ctx, project, known);
|
|
171
|
+
return { claude: choices.claude.target, codex: choices.codex.target };
|
|
172
|
+
}
|
|
173
|
+
const available = (target, status, clients) => target === 't3' ? status.installed : target === 'claude-desktop' ? clients.includes('claude-desktop') : true;
|
|
174
|
+
/** `--open-in claude=t3,codex=t3`: validated against what is installed, then saved in agents.json. */
|
|
175
|
+
export function saveOpenIn(ctx, spec, status, clients) {
|
|
176
|
+
const choices = {};
|
|
177
|
+
for (const part of spec.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
178
|
+
const [app, target, extra] = part.split('=').map(s => s.trim());
|
|
179
|
+
if (app !== 'claude' && app !== 'codex' || !target || extra !== undefined)
|
|
180
|
+
throw new UsageError('--open-in takes app=target pairs, e.g. claude=t3,codex=t3.');
|
|
181
|
+
if (!openInTargets(app).includes(target))
|
|
182
|
+
throw new UsageError(`--open-in: ${app} opens in ${openInTargets(app).join(', ')}.`);
|
|
183
|
+
if (target === 't3' && !status.installed)
|
|
184
|
+
throw t3NotInstalled();
|
|
185
|
+
if (!available(target, status, clients))
|
|
186
|
+
throw new UsageError(`--open-in: ${target} is not installed on this computer.`);
|
|
187
|
+
choices[app] = target;
|
|
188
|
+
}
|
|
189
|
+
if (!Object.keys(choices).length)
|
|
190
|
+
throw new UsageError('--open-in takes app=target pairs, e.g. claude=t3,codex=t3.');
|
|
191
|
+
new AgentStateStore(ctx.store.dir).update(doc => { doc.open_in = { ...objectValue(doc.open_in), ...choices }; });
|
|
192
|
+
}
|
|
193
|
+
/** A T3 thread was started, then a later step failed: the error names the thread so a client opens it, never starts another. */
|
|
194
|
+
export function t3OpenFailed(error, ref) {
|
|
195
|
+
return new CodedError('t3_open_failed', `Started the thread in T3 Code, but T3 Code did not open: ${describeError(error).message}`, 'Open T3 Code; the thread is in its list.', { t3_thread_id: ref.t3_thread_id, conversation_id: ref.conversation_id ?? null, workspace_key: ref.workspace_key ?? null });
|
|
196
|
+
}
|
|
197
|
+
function t3Started(error, ref) {
|
|
198
|
+
const info = describeError(error);
|
|
199
|
+
return new CodedError(error instanceof CodedError && error.code.startsWith('t3_') ? error.code : 't3_server_unreachable', `Started the thread in T3 Code, then: ${info.message}`, 'Open T3 Code; the thread is in its list.', { t3_thread_id: ref.t3_thread_id, conversation_id: ref.conversation_id ?? null, workspace_key: null });
|
|
200
|
+
}
|
|
201
|
+
export const t3NotInstalled = () => new CodedError('t3_not_installed', 'T3 Code is not installed on this computer.', 'Install T3 Code from https://t3.codes, open it once, then run this again.');
|
|
202
|
+
const t3NotConnected = (message, fix) => new CodedError('t3_not_connected', message, fix);
|
|
203
|
+
const t3Unreachable = (message, fix = 'Open T3 Code, then run this again.') => new CodedError('t3_server_unreachable', message, fix);
|
|
204
|
+
const t3Unsupported = (message) => new CodedError('t3_version_unsupported', message, 'Update T3 Code (Settings → About), then run this again.');
|
|
205
|
+
/** A T3 server newer than this shard speaks: shard is what needs the update. */
|
|
206
|
+
const t3TooNew = (message) => new CodedError('t3_version_unsupported', message, 'Update shard with shard update, then run this again.');
|
|
207
|
+
/** The first line of the prompt, as T3 titles a thread before it names it. */
|
|
208
|
+
export function t3ThreadTitle(prompt) {
|
|
209
|
+
const line = prompt.split('\n').map(l => l.trim()).find(Boolean) ?? 'New thread';
|
|
210
|
+
return line.length > 80 ? `${line.slice(0, 79).trimEnd()}…` : line;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Opens T3 Code (or brings it to the front): the install `shard` chose. On macOS `open -a <path>` (every T3 channel
|
|
214
|
+
* shares one bundle id, so `open -b` could start another installed channel); on Linux its desktop entry with
|
|
215
|
+
* `gtk-launch`, else its program (t3-linux.ts).
|
|
216
|
+
*/
|
|
217
|
+
export async function openT3(env, install) {
|
|
218
|
+
if (!install)
|
|
219
|
+
throw t3NotInstalled();
|
|
220
|
+
if (install.linux)
|
|
221
|
+
return openLinuxT3(env, { path: install.path, version: install.version, linux: install.linux });
|
|
222
|
+
const child = spawn('open', ['-a', install.path], { env, stdio: 'ignore' });
|
|
223
|
+
await new Promise((resolve, reject) => {
|
|
224
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('T3 Code did not open in time')); }, 10_000);
|
|
225
|
+
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
226
|
+
child.once('exit', code => { clearTimeout(timer); if (code === 0)
|
|
227
|
+
resolve();
|
|
228
|
+
else
|
|
229
|
+
reject(new Error(`could not open T3 Code (open exited ${code})`)); });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// ── SSH mode: the T3 server in the project VM ──────────────────────────────────────────────────────────────────────
|
|
233
|
+
/** Starts the thread in the VM's T3 server (T3_THREAD_SCRIPT); the prompt travels on stdin, never in argv. */
|
|
234
|
+
export async function startSshThread(ctx, ws, version, projectPath, request) {
|
|
235
|
+
if (!version)
|
|
236
|
+
throw t3Unsupported('The installed T3 Code reports no release version, so the matching T3 server cannot run in the workspace.');
|
|
237
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
238
|
+
// Images go into the VM as files first (exec stdin is bounded); the script attaches them inline and removes them.
|
|
239
|
+
const images = request.images ?? [];
|
|
240
|
+
const unreachable = (error) => error instanceof CodedError ? error
|
|
241
|
+
: t3Unreachable(`T3 in ${ws.key} could not be reached: ${describeError(error).message}`, 'Run shard agents new --in t3 again.');
|
|
242
|
+
try {
|
|
243
|
+
await uploadInitialImages(cell, images, ctx.signal);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
throw unreachable(error);
|
|
247
|
+
}
|
|
248
|
+
const input = {
|
|
249
|
+
version, install_script: t3InstallScript(version), project_path: projectPath, app: request.app, model: request.model ?? null, effort: request.effort ?? null,
|
|
250
|
+
title: t3ThreadTitle(request.prompt), prompt: request.prompt, default_model: T3_DEFAULT_MODEL[request.app], standin_path: '/home/user/.local/bin/sf-t3-tunnel',
|
|
251
|
+
images: images.map((image, i) => ({ path: image.path, name: `image-${i + 1}.${image.type === 'jpeg' ? 'jpg' : image.type}`, mime: `image/${image.type}` })),
|
|
252
|
+
};
|
|
253
|
+
let run;
|
|
254
|
+
try {
|
|
255
|
+
run = await cell.exec.run(['python3', '-c', T3_THREAD_SCRIPT], { user: 'user', stdin: JSON.stringify(input), timeoutMs: 360_000, maxOutputBytes: 1 << 16, signal: ctx.signal });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw unreachable(error);
|
|
259
|
+
}
|
|
260
|
+
let result;
|
|
261
|
+
try {
|
|
262
|
+
result = JSON.parse(run.stdout.trim().split('\n').at(-1) ?? '');
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
throw t3Unreachable(`T3 in ${ws.key} gave no answer${run.stderr.trim() ? `: ${run.stderr.trim().slice(0, 300)}` : ''}.`, `Run shard agents new --in t3 again.`);
|
|
266
|
+
}
|
|
267
|
+
if (result.ok !== true || typeof result.thread_id !== 'string')
|
|
268
|
+
throw sshThreadError(ws.key, result);
|
|
269
|
+
const effort = typeof result.effort === 'string' ? result.effort : null;
|
|
270
|
+
return { mode: 'ssh', t3_thread_id: result.thread_id, conversation_id: null, workspace: { workspace_key: ws.key, id: ws.id },
|
|
271
|
+
model: typeof result.model === 'string' ? result.model : null, effort, ...(request.effort && !effort ? { effort_reason: 'not_offered' } : {}) };
|
|
272
|
+
}
|
|
273
|
+
function sshThreadError(key, result) {
|
|
274
|
+
const message = typeof result.message === 'string' ? result.message : `T3 in ${key} did not start the thread.`;
|
|
275
|
+
// thread.create succeeded before a later step failed: the thread is in T3 and is named, so it is opened, not duplicated.
|
|
276
|
+
const thread = typeof result.thread_id === 'string' ? { t3_thread_id: result.thread_id } : {};
|
|
277
|
+
switch (result.error) {
|
|
278
|
+
case 'version_unsupported': return t3TooNew(`T3 ${typeof result.server_version === 'string' ? result.server_version : ''} in ${key} is newer than this shard supports (orchestration protocol ${String(result.protocol)}).`.replace('T3 in', 'T3 in'));
|
|
279
|
+
case 'model_unavailable': return new CodedError('t3_model_unavailable', message, 'Choose one of the listed models with --model.', { models: Array.isArray(result.models) ? result.models : [] });
|
|
280
|
+
case 'dispatch_failed': return new CodedError('t3_thread_failed', message, 'Run the command again; the project checkout in the workspace must exist.', { status: result.status ?? null, tag: result.tag ?? null, reason: result.reason ?? null, ...thread });
|
|
281
|
+
default: return new CodedError('t3_server_unreachable', message, `Run shard agents new --in t3 again; T3 in ${key} starts its server on the next try.`, thread);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function describe(origin, signal) {
|
|
285
|
+
try {
|
|
286
|
+
const r = await fetch(`${origin}/.well-known/t3/environment`, { signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) });
|
|
287
|
+
return r.ok ? await r.json() : undefined;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (signal.aborted)
|
|
291
|
+
throw error;
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async function runningServer(base, signal) {
|
|
296
|
+
let runtime;
|
|
297
|
+
try {
|
|
298
|
+
runtime = JSON.parse(readFileSync(join(base, 'userdata', 'server-runtime.json'), 'utf8'));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const origin = typeof runtime.origin === 'string' ? runtime.origin : '';
|
|
304
|
+
if (!/^http:\/\/(127\.0\.0\.1|localhost):\d{1,5}$/.test(origin) || !Number.isSafeInteger(runtime.pid))
|
|
305
|
+
return undefined;
|
|
306
|
+
try {
|
|
307
|
+
process.kill(runtime.pid, 0);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error.code !== 'EPERM')
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const descriptor = await describe(origin, signal);
|
|
314
|
+
return descriptor ? { origin, descriptor, base } : undefined;
|
|
315
|
+
}
|
|
316
|
+
/** The running desktop server; T3 is opened (which starts it) when none answers. */
|
|
317
|
+
export async function localT3Server(ctx, install, waitMs = T3_START_WAIT_MS) {
|
|
318
|
+
const base = t3HomeDir(ctx.io.env);
|
|
319
|
+
const found = await runningServer(base, ctx.signal);
|
|
320
|
+
if (found)
|
|
321
|
+
return found;
|
|
322
|
+
await openT3(ctx.io.env, install);
|
|
323
|
+
const deadline = Date.now() + waitMs;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
await delay(500, undefined, { signal: ctx.signal });
|
|
326
|
+
const started = await runningServer(base, ctx.signal);
|
|
327
|
+
if (started)
|
|
328
|
+
return started;
|
|
329
|
+
}
|
|
330
|
+
throw t3Unreachable(`T3 Code’s server did not answer within ${Math.round(waitMs / 1000)} s.`);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The server CLI the app bundles: on macOS the bundle's executable with `<app.asar>/apps/server/dist/bin.mjs`; on Linux
|
|
334
|
+
* the app's own executable the same way, or for an AppImage T3's own launcher (linuxServerCli).
|
|
335
|
+
*/
|
|
336
|
+
function serverCli(install, env) {
|
|
337
|
+
if (install.linux) {
|
|
338
|
+
const cli = linuxServerCli({ path: install.path, version: install.version, linux: install.linux }, t3HomeDir(env));
|
|
339
|
+
if (!cli)
|
|
340
|
+
throw t3Unreachable(`T3 Code has not written its command line launcher (${join(t3HomeDir(env), 'bin', 't3')}) yet.`, 'Open T3 Code once, then run this again.');
|
|
341
|
+
return cli;
|
|
342
|
+
}
|
|
343
|
+
const r = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleExecutable', join(install.path, 'Contents', 'Info.plist')], { encoding: 'utf8', input: '', timeout: 2000 });
|
|
344
|
+
const exe = r.status === 0 ? r.stdout.trim() : '';
|
|
345
|
+
if (!exe || exe.includes('/'))
|
|
346
|
+
throw t3Unsupported('The installed T3 Code bundle names no executable.');
|
|
347
|
+
return { command: join(install.path, 'Contents', 'MacOS', exe), args: [join(install.path, 'Contents', 'Resources', 'app.asar', 'apps', 'server', 'dist', 'bin.mjs')] };
|
|
348
|
+
}
|
|
349
|
+
/** Runs the T3 server CLI the app bundles (Electron as Node), the same code the desktop runs as its backend. */
|
|
350
|
+
function t3Cli(ctx, install, args) {
|
|
351
|
+
const cli = serverCli(install, ctx.io.env);
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
const child = spawn(cli.command, [...cli.args, ...args], { env: { ...ctx.io.env, ELECTRON_RUN_AS_NODE: '1' }, stdio: ['ignore', 'pipe', 'pipe'], signal: ctx.signal });
|
|
354
|
+
let stdout = '', stderr = '';
|
|
355
|
+
child.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
356
|
+
child.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
357
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
|
|
358
|
+
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
359
|
+
child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async function issueSession(ctx, install, base) {
|
|
363
|
+
const r = await t3Cli(ctx, install, ['auth', 'session', 'issue', '--base-dir', base, '--ttl', '5m', '--label', 'Shardflux thread start', '--json']);
|
|
364
|
+
let session = {};
|
|
365
|
+
try {
|
|
366
|
+
session = JSON.parse(r.stdout);
|
|
367
|
+
}
|
|
368
|
+
catch { /* reported below */ }
|
|
369
|
+
if (r.code !== 0 || typeof session.token !== 'string' || typeof session.sessionId !== 'string')
|
|
370
|
+
throw t3Unreachable(`T3 Code did not issue a session${r.stderr.trim() ? `: ${r.stderr.trim().split('\n').at(-1)}` : ''}.`);
|
|
371
|
+
ctx.addSecret(session.token);
|
|
372
|
+
return { token: session.token, sessionId: session.sessionId };
|
|
373
|
+
}
|
|
374
|
+
async function revokeSession(ctx, install, base, session) {
|
|
375
|
+
await t3Cli({ ...ctx, signal: new AbortController().signal }, install, ['auth', 'session', 'revoke', session.sessionId, '--base-dir', base]).catch(() => undefined);
|
|
376
|
+
}
|
|
377
|
+
class T3HttpError extends Error {
|
|
378
|
+
status;
|
|
379
|
+
tag;
|
|
380
|
+
reason;
|
|
381
|
+
constructor(status, tag, reason, path) {
|
|
382
|
+
super(`T3 Code answered ${path} with ${status}${tag ? ` ${tag}` : ''}${reason ? ` (${reason})` : ''}.`);
|
|
383
|
+
this.status = status;
|
|
384
|
+
this.tag = tag;
|
|
385
|
+
this.reason = reason;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function t3Http(server, session, method, path, signal, body) {
|
|
389
|
+
const r = await fetch(server.origin + path, {
|
|
390
|
+
method, signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
|
|
391
|
+
headers: { authorization: `Bearer ${session.token}`, accept: 'application/json', 'x-t3-orchestration-protocol': '2', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
392
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
393
|
+
});
|
|
394
|
+
const text = await r.text();
|
|
395
|
+
let parsed;
|
|
396
|
+
try {
|
|
397
|
+
parsed = text ? JSON.parse(text) : {};
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
parsed = {};
|
|
401
|
+
}
|
|
402
|
+
const doc = objectValue(parsed);
|
|
403
|
+
if (!r.ok)
|
|
404
|
+
throw new T3HttpError(r.status, typeof doc._tag === 'string' ? doc._tag : undefined, typeof doc.reason === 'string' ? doc.reason : undefined, path);
|
|
405
|
+
return doc;
|
|
406
|
+
}
|
|
407
|
+
/** One Effect RPC request over T3's WebSocket (JSON frames: Request → Exit), authorized by a ticket from the session. */
|
|
408
|
+
export async function t3Rpc(server, session, tag, payload, signal, timeoutMs = 30_000) {
|
|
409
|
+
const ticket = await t3Http(server, session, 'POST', '/api/auth/websocket-ticket', signal);
|
|
410
|
+
if (typeof ticket.ticket !== 'string')
|
|
411
|
+
throw t3Unreachable('T3 Code issued no WebSocket ticket.');
|
|
412
|
+
const url = new URL('/ws', server.origin.replace(/^http/, 'ws'));
|
|
413
|
+
url.searchParams.set('wsTicket', ticket.ticket);
|
|
414
|
+
url.searchParams.set('orchestrationProtocol', '2');
|
|
415
|
+
const ws = new WebSocket(url);
|
|
416
|
+
try {
|
|
417
|
+
return await new Promise((resolve, reject) => {
|
|
418
|
+
const fail = (error) => { clearTimeout(timer); signal.removeEventListener('abort', aborted); reject(error); };
|
|
419
|
+
const aborted = () => fail(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
420
|
+
const timer = setTimeout(() => fail(t3Unreachable(`T3 Code did not answer ${tag} within ${Math.round(timeoutMs / 1000)} s.`)), timeoutMs);
|
|
421
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
422
|
+
ws.onopen = () => ws.send(JSON.stringify({ _tag: 'Request', id: '1', tag, payload, headers: [] }));
|
|
423
|
+
ws.onerror = () => fail(t3Unreachable(`T3 Code refused the WebSocket connection for ${tag}.`));
|
|
424
|
+
ws.onclose = event => fail(t3Unreachable(`T3 Code closed the connection before answering ${tag} (${event.code}).`));
|
|
425
|
+
ws.onmessage = event => {
|
|
426
|
+
let data;
|
|
427
|
+
try {
|
|
428
|
+
data = JSON.parse(String(event.data));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const raw of Array.isArray(data) ? data : [data]) {
|
|
434
|
+
const message = objectValue(raw);
|
|
435
|
+
if (message._tag === 'Ping')
|
|
436
|
+
ws.send(JSON.stringify({ _tag: 'Pong' }));
|
|
437
|
+
else if (message._tag === 'Exit' && String(message.requestId) === '1') {
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
signal.removeEventListener('abort', aborted);
|
|
440
|
+
const exit = objectValue(message.exit);
|
|
441
|
+
if (exit._tag === 'Success')
|
|
442
|
+
resolve(exit.value);
|
|
443
|
+
else
|
|
444
|
+
reject(new T3RpcFailure(tag, exit.cause));
|
|
445
|
+
}
|
|
446
|
+
else if (message._tag === 'Defect' || message._tag === 'ClientProtocolError')
|
|
447
|
+
fail(new T3RpcFailure(tag, [message]));
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
ws.onclose = null;
|
|
454
|
+
ws.close();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
class T3RpcFailure extends Error {
|
|
458
|
+
causes;
|
|
459
|
+
constructor(tag, cause) {
|
|
460
|
+
const causes = (Array.isArray(cause) ? cause : [cause]).map(objectValue);
|
|
461
|
+
const error = objectValue(causes[0]?.error ?? causes[0]?.defect);
|
|
462
|
+
super(`T3 Code refused ${tag}: ${typeof error.message === 'string' ? error.message : (typeof error._tag === 'string' ? error._tag : 'failed')}.`);
|
|
463
|
+
this.causes = causes;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function ensureLocalProject(server, session, folder, signal) {
|
|
467
|
+
const root = realpathSync(folder);
|
|
468
|
+
const snapshot = await t3Http(server, session, 'GET', '/api/projects', signal);
|
|
469
|
+
const projects = Array.isArray(snapshot.projects) ? snapshot.projects.map(objectValue) : [];
|
|
470
|
+
const existing = projects.find(p => p.deletedAt === null && p.workspaceRoot === root && typeof p.id === 'string');
|
|
471
|
+
if (existing)
|
|
472
|
+
return existing.id;
|
|
473
|
+
const created = await t3Http(server, session, 'POST', '/api/projects/mutate', signal, { type: 'project.create', commandId: randomUUID(), projectId: randomUUID(), title: basename(root) || 'project', workspaceRoot: root });
|
|
474
|
+
if (typeof created.id !== 'string')
|
|
475
|
+
throw t3Unreachable('T3 Code did not return the project it created.');
|
|
476
|
+
return created.id;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The Shardflux provider's model: `shardflux:<app>:<id>`, `default` unless asked. With the conversation catalog this
|
|
480
|
+
* computer cached for the project (acp-models.ts), an unknown model is refused before anything starts, and an effort is
|
|
481
|
+
* sent only as a config option the session offers (T3 applies modelSelection.options by ACP config option id).
|
|
482
|
+
*/
|
|
483
|
+
function acpSelection(ctx, folder, request) {
|
|
484
|
+
const raw = request.model ?? 'default';
|
|
485
|
+
const prefixed = /^shardflux:(claude|codex):(.+)$/.exec(raw);
|
|
486
|
+
if (prefixed && prefixed[1] !== request.app)
|
|
487
|
+
throw new UsageError(`--model ${raw} is a ${prefixed[1]} model; use --app ${prefixed[1]}.`);
|
|
488
|
+
const id = prefixed ? prefixed[2] : raw;
|
|
489
|
+
const native = new AcpModelCache(ctx.store.dir).lookup(ctx, folder)?.catalog[request.app];
|
|
490
|
+
if (native && id !== 'default') {
|
|
491
|
+
let ids = [];
|
|
492
|
+
try {
|
|
493
|
+
ids = nativeModelOptions(native).map(o => String(o.value));
|
|
494
|
+
}
|
|
495
|
+
catch { /* an unreadable catalog checks nothing */ }
|
|
496
|
+
if (ids.length && !ids.includes(id))
|
|
497
|
+
throw new CodedError('t3_model_unavailable', `Shardflux has no ${request.app} model ${id} for this project.`, `Choose one of: ${ids.join(', ')}.`, { models: ids });
|
|
498
|
+
}
|
|
499
|
+
const model = `shardflux:${request.app}:${id}`;
|
|
500
|
+
let effort = null, option;
|
|
501
|
+
if (request.effort && native) {
|
|
502
|
+
const configs = Array.isArray(native.configOptions) ? native.configOptions.map(objectValue) : [];
|
|
503
|
+
option = configs.find(c => c.type === 'select' && (c.category === 'thought_level' || ['effort', 'reasoning_effort', 'reasoningEffort', 'thought_level'].includes(String(c.id))));
|
|
504
|
+
const values = Array.isArray(option?.options) ? option.options.flatMap(o => { const v = objectValue(o); return Array.isArray(v.options) ? v.options.map(x => objectValue(x).value) : [v.value]; }) : [];
|
|
505
|
+
if (option && values.includes(request.effort))
|
|
506
|
+
effort = request.effort;
|
|
507
|
+
}
|
|
508
|
+
// The levels a session offers are known from this project's first Shardflux conversation on this computer.
|
|
509
|
+
const effortReason = request.effort && !effort ? (native ? 'not_offered' : 'models_not_known') : undefined;
|
|
510
|
+
return { selection: { instanceId: 'shardflux', model, ...(effort && option ? { options: [{ id: String(option.id), value: effort }] } : {}) }, model, effort, ...(effortReason ? { effortReason } : {}) };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Images for the first message (T3 0.0.46+): `attachments.createUploadUrl` over the RPC, then the bytes to the signed
|
|
514
|
+
* upload URL (POST, exact Content-Length); the launch then claims them by id.
|
|
515
|
+
*/
|
|
516
|
+
async function uploadLocalImages(server, session, images, signal) {
|
|
517
|
+
const attachments = [];
|
|
518
|
+
for (const [i, image] of images.entries()) {
|
|
519
|
+
const meta = { type: 'image', name: `image-${i + 1}.${image.type === 'jpeg' ? 'jpg' : image.type}`, mimeType: `image/${image.type}`, sizeBytes: image.data.byteLength };
|
|
520
|
+
const issued = objectValue(await t3Rpc(server, session, 'attachments.createUploadUrl', meta, signal));
|
|
521
|
+
if (typeof issued.attachmentId !== 'string' || typeof issued.relativeUrl !== 'string' || !issued.relativeUrl.startsWith('/'))
|
|
522
|
+
throw new CodedError('t3_thread_failed', 'T3 Code issued no upload URL for the image.', 'Run the command again.');
|
|
523
|
+
const r = await fetch(server.origin + issued.relativeUrl, { method: 'POST', body: image.data, headers: { 'content-type': meta.mimeType, 'content-length': String(image.data.byteLength) }, signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]) });
|
|
524
|
+
if (!r.ok) {
|
|
525
|
+
const detail = (await r.text().catch(() => '')).trim().slice(0, 200);
|
|
526
|
+
throw new CodedError('t3_thread_failed', `T3 Code refused the image upload (${r.status}${detail ? `: ${detail}` : ''}).`, 'Attach a PNG, JPEG, GIF or WebP image of at most 10 MiB.');
|
|
527
|
+
}
|
|
528
|
+
attachments.push({ type: 'image', id: issued.attachmentId, name: meta.name, mimeType: meta.mimeType, sizeBytes: meta.sizeBytes });
|
|
529
|
+
}
|
|
530
|
+
return attachments;
|
|
531
|
+
}
|
|
532
|
+
/** The VM of a conversation the Shardflux provider recorded on this computer (its ACP session record in agents.json). */
|
|
533
|
+
async function conversationWorkspaceRef(ctx, conversationId) {
|
|
534
|
+
const state = new AgentStateStore(ctx.store.dir).read();
|
|
535
|
+
const record = objectValue(objectValue(objectValue(state.conversations)[conversationId]).conversation);
|
|
536
|
+
const session = Object.values(objectValue(state.acp_sessions)).map(objectValue).find(r => r.conversationId === conversationId);
|
|
537
|
+
const id = typeof record.workspace_id === 'string' ? record.workspace_id : typeof session?.workspaceId === 'string' ? session.workspaceId : null;
|
|
538
|
+
if (!id)
|
|
539
|
+
return null;
|
|
540
|
+
try {
|
|
541
|
+
return { workspace_key: (await ctx.cloud().workspaces.get(id)).key, id };
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The conversation of the Shardflux ACP session a T3 thread opened. T3 names that session in the thread's attempts
|
|
549
|
+
* (`nativeThreadId`) once the first turn starts; until then the one session the provider opened for this folder since
|
|
550
|
+
* the launch is it (agents.json, shared with the `shard acp` T3 runs). Null when neither settles within the wait.
|
|
551
|
+
*/
|
|
552
|
+
async function threadConversation(ctx, server, session, threadId, folder, known, waitMs) {
|
|
553
|
+
const deadline = Date.now() + waitMs;
|
|
554
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
555
|
+
let native;
|
|
556
|
+
while (!ctx.signal.aborted) {
|
|
557
|
+
if (!native) {
|
|
558
|
+
try {
|
|
559
|
+
const detail = await t3Http(server, session, 'GET', `/api/orchestration/threads/${encodeURIComponent(threadId)}`, ctx.signal);
|
|
560
|
+
const attempts = Array.isArray(objectValue(detail.projection).attempts) ? objectValue(detail.projection).attempts : [];
|
|
561
|
+
native = attempts.map(a => objectValue(a).nativeThreadId).find((id) => typeof id === 'string' && id.startsWith('sf-') && !known.has(id));
|
|
562
|
+
}
|
|
563
|
+
catch { /* the thread is still being set up */ }
|
|
564
|
+
}
|
|
565
|
+
const sessions = objectValue(store.read().acp_sessions);
|
|
566
|
+
if (native) {
|
|
567
|
+
const record = objectValue(sessions[native]);
|
|
568
|
+
if (typeof record.conversationId === 'string')
|
|
569
|
+
return record.conversationId;
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
const fresh = Object.entries(sessions).filter(([id, r]) => !known.has(id) && objectValue(r).folder === folder).map(([, r]) => objectValue(r));
|
|
573
|
+
if (fresh.length === 1 && typeof fresh[0].conversationId === 'string')
|
|
574
|
+
return fresh[0].conversationId;
|
|
575
|
+
}
|
|
576
|
+
if (Date.now() >= deadline)
|
|
577
|
+
return null;
|
|
578
|
+
await delay(500, undefined, { signal: ctx.signal });
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
/** Starts a T3 thread on the Shardflux provider (ACP mode) and brings T3 to the front. */
|
|
583
|
+
export async function startAcpThread(ctx, install, folder, request, waitMs = CONVERSATION_WAIT_MS) {
|
|
584
|
+
if (!acpSignedIn(ctx))
|
|
585
|
+
throw t3NotConnected('This computer is not signed in to Shardflux, so T3 Code’s Shardflux provider cannot start a VM.', SIGN_IN_FIX);
|
|
586
|
+
if (!t3ProviderRegistered(ctx.io.env)) {
|
|
587
|
+
try {
|
|
588
|
+
await configureT3Provider(ctx.io.env, ctx.store.dir, false, ctx.signal);
|
|
589
|
+
}
|
|
590
|
+
catch { /* reported below */ }
|
|
591
|
+
if (!t3ProviderRegistered(ctx.io.env))
|
|
592
|
+
throw t3NotConnected('T3 Code has no Shardflux provider.', 'In T3 Code, Settings → Providers → Add → Shardflux (or remove the other provider named shardflux), then run this again.');
|
|
593
|
+
}
|
|
594
|
+
const server = await localT3Server(ctx, install);
|
|
595
|
+
const protocol = server.descriptor.orchestrationProtocolVersion ?? 1;
|
|
596
|
+
const named = `T3 Code ${server.descriptor.serverVersion ?? install.version ?? ''}`.trimEnd();
|
|
597
|
+
if (protocol < 2)
|
|
598
|
+
throw t3Unsupported(`${named} speaks orchestration protocol ${protocol}; Shardflux threads need protocol 2.`);
|
|
599
|
+
if (protocol > 2)
|
|
600
|
+
throw t3TooNew(`${named} is newer than this shard supports (orchestration protocol ${protocol}).`);
|
|
601
|
+
const { selection, model, effort, effortReason } = acpSelection(ctx, folder, request);
|
|
602
|
+
const session = await issueSession(ctx, install, server.base);
|
|
603
|
+
try {
|
|
604
|
+
const projectId = await ensureLocalProject(server, session, folder, ctx.signal);
|
|
605
|
+
const known = new Set(Object.keys(objectValue(new AgentStateStore(ctx.store.dir).read().acp_sessions)));
|
|
606
|
+
const attachments = await uploadLocalImages(server, session, request.images ?? [], ctx.signal);
|
|
607
|
+
const threadId = randomUUID();
|
|
608
|
+
let launched;
|
|
609
|
+
try {
|
|
610
|
+
launched = await t3Rpc(server, session, 'orchestration.launchThread', {
|
|
611
|
+
commandId: randomUUID(), threadId, projectId, title: t3ThreadTitle(request.prompt), modelSelection: selection, runtimeMode: 'full-access', interactionMode: 'default',
|
|
612
|
+
workspaceStrategy: { type: 'root' }, initialMessage: { messageId: randomUUID(), text: request.prompt, attachments },
|
|
613
|
+
}, ctx.signal);
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error instanceof T3RpcFailure && /No orchestration provider adapter is registered for shardflux/.test(JSON.stringify(error.causes))) {
|
|
617
|
+
throw t3NotConnected('T3 Code has not loaded its Shardflux provider yet.', 'Open T3 Code’s Settings → Providers, check Shardflux is on, then run this again.');
|
|
618
|
+
}
|
|
619
|
+
if (error instanceof T3RpcFailure)
|
|
620
|
+
throw new CodedError('t3_thread_failed', error.message, 'Run the command again.');
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
const id = objectValue(launched).threadId;
|
|
624
|
+
const t3ThreadId = typeof id === 'string' ? id : threadId;
|
|
625
|
+
// The thread exists from here on: T3 comes to the front first, and a failure after this names the thread.
|
|
626
|
+
const opened = await openT3(ctx.io.env, install).then(() => undefined, (error) => error);
|
|
627
|
+
let conversation = null;
|
|
628
|
+
try {
|
|
629
|
+
conversation = await threadConversation(ctx, server, session, t3ThreadId, realpathSync(folder), known, waitMs);
|
|
630
|
+
if (conversation)
|
|
631
|
+
new AgentStateStore(ctx.store.dir).update(doc => { doc.t3_threads = { ...objectValue(doc.t3_threads), [conversation]: t3ThreadId }; });
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
throw t3Started(error, { t3_thread_id: t3ThreadId, conversation_id: conversation });
|
|
635
|
+
}
|
|
636
|
+
const workspace = conversation ? await conversationWorkspaceRef(ctx, conversation) : null;
|
|
637
|
+
if (opened)
|
|
638
|
+
throw t3OpenFailed(opened, { t3_thread_id: t3ThreadId, conversation_id: conversation, workspace_key: workspace?.workspace_key ?? null });
|
|
639
|
+
return { mode: 'acp', t3_thread_id: t3ThreadId, conversation_id: conversation, workspace, model, effort, ...(effortReason ? { effort_reason: effortReason } : {}) };
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
await revokeSession(ctx, install, server.base, session);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const stamp = (thread) => typeof thread.updatedAt === 'string' ? thread.updatedAt : typeof thread.createdAt === 'string' ? thread.createdAt : '';
|
|
646
|
+
/** The T3 thread of a Shardflux conversation started in T3 (ACP mode): the one remembered, else a search of recent Shardflux threads. */
|
|
647
|
+
export async function findAcpThread(ctx, install, conversationId) {
|
|
648
|
+
const state = new AgentStateStore(ctx.store.dir).read();
|
|
649
|
+
const remembered = objectValue(state.t3_threads)[conversationId];
|
|
650
|
+
if (typeof remembered === 'string')
|
|
651
|
+
return remembered;
|
|
652
|
+
const sessions = new Set(Object.entries(objectValue(state.acp_sessions)).filter(([, r]) => objectValue(r).conversationId === conversationId).map(([id]) => id));
|
|
653
|
+
if (!sessions.size)
|
|
654
|
+
return null;
|
|
655
|
+
const server = await localT3Server(ctx, install);
|
|
656
|
+
if ((server.descriptor.orchestrationProtocolVersion ?? 1) !== 2)
|
|
657
|
+
return null;
|
|
658
|
+
const session = await issueSession(ctx, install, server.base);
|
|
659
|
+
try {
|
|
660
|
+
const shell = await t3Http(server, session, 'GET', '/api/orchestration/shell', ctx.signal);
|
|
661
|
+
const threads = (Array.isArray(shell.threads) ? shell.threads.map(objectValue) : []).filter(t => t.providerInstanceId === 'shardflux' && typeof t.id === 'string')
|
|
662
|
+
.sort((a, b) => stamp(b).localeCompare(stamp(a))).slice(0, 25);
|
|
663
|
+
for (const thread of threads) {
|
|
664
|
+
const detail = await t3Http(server, session, 'GET', `/api/orchestration/threads/${encodeURIComponent(thread.id)}`, ctx.signal).catch(() => ({}));
|
|
665
|
+
const attempts = objectValue(objectValue(detail).projection).attempts;
|
|
666
|
+
if (Array.isArray(attempts) && attempts.some(a => sessions.has(String(objectValue(a).nativeThreadId)))) {
|
|
667
|
+
new AgentStateStore(ctx.store.dir).update(doc => { doc.t3_threads = { ...objectValue(doc.t3_threads), [conversationId]: thread.id }; });
|
|
668
|
+
return thread.id;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
await revokeSession(ctx, install, server.base, session);
|
|
675
|
+
}
|
|
676
|
+
}
|