@shardflux/cli 0.22.0 → 0.23.1
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 +102 -0
- package/README.md +47 -14
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +10 -3
- package/dist/acp-login.js +31 -4
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +21 -16
- package/dist/acp.js +60 -7
- package/dist/agent-commands.js +29 -15
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +49 -18
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -14
- package/dist/cli-proxy-forward.d.ts +0 -2
- package/dist/cli-proxy-forward.js +2 -9
- package/dist/cli-proxy-hub.d.ts +22 -30
- package/dist/cli-proxy-hub.js +65 -94
- package/dist/cli-proxy.d.ts +12 -38
- package/dist/cli-proxy.js +51 -168
- package/dist/codex-cloud.d.ts +9 -4
- package/dist/codex-cloud.js +47 -14
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +13 -4
- package/dist/command-center-model.d.ts +2 -0
- package/dist/command-center-model.js +6 -1
- package/dist/command-center.js +3 -3
- package/dist/commands.js +2 -2
- package/dist/conversation-commands.js +16 -2
- package/dist/conversation-terminal.js +3 -2
- package/dist/conversations.d.ts +14 -1
- package/dist/conversations.js +61 -7
- package/dist/desktop-account.d.ts +16 -0
- package/dist/desktop-account.js +29 -0
- package/dist/desktop-linux.js +16 -3
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +5 -2
- package/dist/filesystem-commands.js +25 -3
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +40 -28
- package/dist/move-worktrees.d.ts +13 -7
- package/dist/move-worktrees.js +68 -17
- package/dist/move.js +20 -25
- package/dist/onboarding.js +4 -2
- package/dist/prepared-repository.d.ts +2 -0
- package/dist/prepared-repository.js +1 -1
- package/dist/prepared-repository.py +3 -2
- package/dist/project-preparation.d.ts +14 -0
- package/dist/project-preparation.js +16 -4
- package/dist/project-sync.d.ts +3 -0
- package/dist/project-sync.js +4 -1
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +11 -2
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +11 -0
- package/dist/terminal-apps.js +18 -5
- package/dist/update.d.ts +2 -0
- package/dist/update.js +32 -6
- package/package.json +5 -5
package/dist/menubar.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { desktopAccount } from "./desktop-account.js";
|
|
1
2
|
import { execFile, spawn } from 'node:child_process';
|
|
2
3
|
import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
@@ -16,7 +17,7 @@ const execute = promisify(execFile);
|
|
|
16
17
|
* whether or not a built Shardflux.app is in the package.
|
|
17
18
|
*/
|
|
18
19
|
export const nativeTools = {
|
|
19
|
-
run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
|
|
20
|
+
run: async (tool, args, signal, env) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal, ...(env ? { env: { ...process.env, ...env } } : {}) })).stdout,
|
|
20
21
|
};
|
|
21
22
|
const BUNDLE_ID = 'dev.shardflux.menubar';
|
|
22
23
|
export function compareBundleVersions(left, right) {
|
|
@@ -123,7 +124,7 @@ export class MenubarManager {
|
|
|
123
124
|
constructor(options = {}) {
|
|
124
125
|
this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
|
|
125
126
|
this.#platform = options.platform ?? process.platform;
|
|
126
|
-
this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
|
|
127
|
+
this.#run = options.run ?? ((tool, args, env) => nativeTools.run(tool, args, options.signal, env));
|
|
127
128
|
this.#packageApp = options.packageApp ?? (() => {
|
|
128
129
|
const app = packagedAppPath();
|
|
129
130
|
if (!app)
|
|
@@ -160,43 +161,51 @@ export class MenubarManager {
|
|
|
160
161
|
return null;
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The app's --control for `configDir`: its pids are the app running for that config directory, wherever it runs from
|
|
166
|
+
* (menu bar 0.8.4+, one app per config directory), and any app running from the installed bundle.
|
|
167
|
+
*/
|
|
168
|
+
async #control(configDir, action, ...args) {
|
|
164
169
|
makeExecutable(this.app);
|
|
165
|
-
|
|
170
|
+
const env = configDir ? { SHARDFLUX_CONFIG_DIR: configDir } : undefined;
|
|
171
|
+
return parseNativeStatus(await this.#run(join(this.app, 'Contents', 'MacOS', 'ShardfluxMenuBar'), ['--control', action, ...args], env));
|
|
166
172
|
}
|
|
167
|
-
async status() {
|
|
173
|
+
async status(configDir) {
|
|
168
174
|
this.#requireMac();
|
|
169
175
|
if (!existsSync(this.app))
|
|
170
176
|
return { installed: false, version: null, running: false, pids: [], login: 'off' };
|
|
171
177
|
const version = await this.#version(this.app);
|
|
172
|
-
return { installed: true, version, ...await this.#control('status') };
|
|
178
|
+
return { installed: true, version, ...await this.#control(configDir, 'status') };
|
|
173
179
|
}
|
|
174
180
|
async #stripQuarantine() {
|
|
175
181
|
const attributes = await this.#run('/usr/bin/xattr', ['-lr', this.app]);
|
|
176
182
|
if (attributes.includes('com.apple.quarantine'))
|
|
177
183
|
await this.#run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', this.app]);
|
|
178
184
|
}
|
|
179
|
-
async stop() {
|
|
180
|
-
const current = await this.status();
|
|
185
|
+
async stop(configDir) {
|
|
186
|
+
const current = await this.status(configDir);
|
|
181
187
|
if (!current.installed || !current.running)
|
|
182
188
|
return current;
|
|
183
|
-
await this.#control('stop');
|
|
189
|
+
await this.#control(configDir, 'stop');
|
|
184
190
|
for (let i = 0; i < 20; i++) {
|
|
185
|
-
const status = await this.status();
|
|
191
|
+
const status = await this.status(configDir);
|
|
186
192
|
if (!status.running)
|
|
187
193
|
return status;
|
|
188
194
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
189
195
|
}
|
|
190
196
|
throw new UsageError('Shardflux is still stopping. Run shard menubar status before retrying.');
|
|
191
197
|
}
|
|
192
|
-
async login(value) {
|
|
193
|
-
if (!(await this.status()).installed)
|
|
198
|
+
async login(value, configDir) {
|
|
199
|
+
if (!(await this.status(configDir)).installed)
|
|
194
200
|
throw new UsageError('Run shard menubar start before changing launch at login.');
|
|
195
|
-
await this.#control('login', value);
|
|
196
|
-
return this.status();
|
|
201
|
+
await this.#control(configDir, 'login', value);
|
|
202
|
+
return this.status(configDir);
|
|
197
203
|
}
|
|
198
|
-
/**
|
|
199
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Replaces the installed app with `source` (verified first), stopping it while it is swapped: the app running from
|
|
206
|
+
* the bundle and the one running for `configDir` from elsewhere, so no older app stays behind.
|
|
207
|
+
*/
|
|
208
|
+
async #install(source, current, configDir) {
|
|
200
209
|
// Verify the source before stopping or replacing any existing app.
|
|
201
210
|
await this.#run('/usr/bin/codesign', ['--verify', '--deep', '--strict', source]);
|
|
202
211
|
mkdirSync(dirname(this.app), { recursive: true });
|
|
@@ -206,7 +215,7 @@ export class MenubarManager {
|
|
|
206
215
|
try {
|
|
207
216
|
cpSync(source, staged, { recursive: true, dereference: false });
|
|
208
217
|
if (current.running)
|
|
209
|
-
await this.stop();
|
|
218
|
+
await this.stop(configDir);
|
|
210
219
|
if (current.installed)
|
|
211
220
|
renameSync(this.app, backup);
|
|
212
221
|
makeExecutable(staged);
|
|
@@ -235,17 +244,19 @@ export class MenubarManager {
|
|
|
235
244
|
async start(configDir, apiUrl) {
|
|
236
245
|
this.#requireMac();
|
|
237
246
|
const source = this.#packageApp(), sourceVersion = await this.#version(source);
|
|
238
|
-
const current = await this.status();
|
|
247
|
+
const current = await this.status(configDir);
|
|
239
248
|
if (!current.installed || compareAppVersions(current.version, sourceVersion) < 0)
|
|
240
|
-
await this.#install(source, current);
|
|
249
|
+
await this.#install(source, current, configDir);
|
|
241
250
|
await this.#stripQuarantine();
|
|
242
|
-
if (!(await this.status()).running) {
|
|
251
|
+
if (!(await this.status(configDir)).running) {
|
|
243
252
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
244
253
|
const log = join(configDir, 'menubar.log');
|
|
245
|
-
|
|
254
|
+
// A start that races another one for this config directory hands over to it without opening the launcher.
|
|
255
|
+
await this.#run('/usr/bin/open', ['-g', this.app, '--env', `SHARDFLUX_CONFIG_DIR=${configDir}`, '--env', `SHARDFLUX_API_URL=${apiUrl}`,
|
|
256
|
+
'--env', 'SHARDFLUX_MENUBAR_LAUNCH=background', '--stdout', log, '--stderr', log]);
|
|
246
257
|
}
|
|
247
258
|
for (let i = 0; i < 20; i++) {
|
|
248
|
-
const status = await this.status();
|
|
259
|
+
const status = await this.status(configDir);
|
|
249
260
|
if (status.running)
|
|
250
261
|
return status;
|
|
251
262
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -260,7 +271,7 @@ export class MenubarManager {
|
|
|
260
271
|
*/
|
|
261
272
|
async refresh(configDir, apiUrl) {
|
|
262
273
|
this.#requireMac();
|
|
263
|
-
const current = await this.status();
|
|
274
|
+
const current = await this.status(configDir);
|
|
264
275
|
if (!current.installed || !current.version)
|
|
265
276
|
return { result: 'not_installed', from: null, to: null };
|
|
266
277
|
const to = await this.packagedVersion(), from = current.version;
|
|
@@ -274,7 +285,7 @@ export class MenubarManager {
|
|
|
274
285
|
await this.start(configDir, apiUrl);
|
|
275
286
|
return { result: 'restarted', from, to };
|
|
276
287
|
}
|
|
277
|
-
await this.#install(this.#packageApp(), current);
|
|
288
|
+
await this.#install(this.#packageApp(), current, configDir);
|
|
278
289
|
await this.#stripQuarantine();
|
|
279
290
|
return { result: 'installed', from, to };
|
|
280
291
|
}
|
|
@@ -301,7 +312,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
|
|
|
301
312
|
if (store.read().menubar_stopped === true)
|
|
302
313
|
return undefined;
|
|
303
314
|
try {
|
|
304
|
-
const status = await manager.status();
|
|
315
|
+
const status = await manager.status(dir);
|
|
305
316
|
// A running app is brought up to the one this CLI carries (after `npm i -g shardflux`) by the background refresh
|
|
306
317
|
// every command starts (refreshMenubarInBackground), at a moment when it shows no window.
|
|
307
318
|
if (status.running)
|
|
@@ -310,7 +321,7 @@ export async function ensureMenubar(dir, apiUrl, manager = new MenubarManager(),
|
|
|
310
321
|
if (!started.running)
|
|
311
322
|
return undefined;
|
|
312
323
|
if (started.login !== 'on')
|
|
313
|
-
await manager.login('on').catch(() => undefined);
|
|
324
|
+
await manager.login('on', dir).catch(() => undefined);
|
|
314
325
|
return 'Menu bar started · shard menubar stop to turn it off';
|
|
315
326
|
}
|
|
316
327
|
catch {
|
|
@@ -468,7 +479,7 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
|
|
|
468
479
|
if (values.login !== undefined && !['on', 'off'].includes(String(values.login)))
|
|
469
480
|
throw new UsageError('--login must be on or off.');
|
|
470
481
|
const manager = new MenubarManager({ home: ctx.io.env.HOME || homedir(), signal: ctx.signal });
|
|
471
|
-
let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop() : await manager.status();
|
|
482
|
+
let status = action === 'start' ? await manager.start(ctx.store.dir, ctx.apiUrl) : action === 'stop' ? await manager.stop(ctx.store.dir) : await manager.status(ctx.store.dir);
|
|
472
483
|
// An explicit stop keeps `shard` from starting the menu bar again; an explicit start re-enables it.
|
|
473
484
|
if (action !== 'status')
|
|
474
485
|
new AgentStateStore(ctx.store.dir).update(doc => { if (action === 'stop')
|
|
@@ -476,9 +487,10 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
|
|
|
476
487
|
else
|
|
477
488
|
delete doc.menubar_stopped; });
|
|
478
489
|
if (values.login === 'on' || values.login === 'off')
|
|
479
|
-
status = await manager.login(values.login);
|
|
490
|
+
status = await manager.login(values.login, ctx.store.dir);
|
|
480
491
|
ctx.io.stdout.write(ctx.json ? json(status) : `Menu bar: ${status.running ? 'running' : 'stopped'}${status.installed ? ` · app ${status.version}` : ' · not installed'} · login ${status.login}\n`);
|
|
481
492
|
return 0;
|
|
482
493
|
};
|
|
483
494
|
return [`menubar ${action}`, handler];
|
|
484
495
|
}));
|
|
496
|
+
MENUBAR_HANDLERS['menubar account'] = async (ctx) => { ctx.io.stdout.write(json(await desktopAccount(ctx))); return 0; };
|
package/dist/move-worktrees.d.ts
CHANGED
|
@@ -27,26 +27,32 @@ export interface WorktreeMigration {
|
|
|
27
27
|
bytes: number;
|
|
28
28
|
files: number;
|
|
29
29
|
referenced: string[];
|
|
30
|
+
/** Registrations Git reports as prunable (their folder or its .git is gone): nothing to copy, never a refusal. */
|
|
31
|
+
stale: string[];
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
|
|
35
|
+
* this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
|
|
36
|
+
*/
|
|
37
|
+
export declare function referencedWorktrees(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<string[]>;
|
|
31
38
|
/** Git is the source of membership; sibling folders and symlink targets never expand the upload scope. */
|
|
32
39
|
export declare function inspectWorktreeMigration(folder: string, transcript: Buffer, signal?: AbortSignal): Promise<WorktreeMigration | null>;
|
|
33
40
|
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
34
41
|
export declare function captureWorktreeMigration(plan: WorktreeMigration, signal?: AbortSignal): Promise<void>;
|
|
35
42
|
export declare function migrationScope(plan: WorktreeMigration): {
|
|
43
|
+
local_refs: string[];
|
|
44
|
+
bytes: number;
|
|
45
|
+
files: number;
|
|
46
|
+
scope: string;
|
|
47
|
+
size_basis: string;
|
|
48
|
+
stale_registrations?: string[] | undefined;
|
|
36
49
|
worktrees: {
|
|
37
50
|
path: string;
|
|
38
51
|
branch: string;
|
|
39
52
|
target: string;
|
|
40
53
|
}[];
|
|
41
54
|
referenced: string[];
|
|
42
|
-
local_refs: string[];
|
|
43
|
-
bytes: number;
|
|
44
|
-
files: number;
|
|
45
|
-
scope: string;
|
|
46
|
-
size_basis: string;
|
|
47
55
|
};
|
|
48
|
-
/** User refs a normal move leaves behind. Shardflux's own refs/shard/* (twin snapshots, land and agent refs) are rebuilt, never user work. */
|
|
49
|
-
export declare function omittedLocalRefs(plan: WorktreeMigration): string[];
|
|
50
56
|
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
51
57
|
export declare function rewriteWorktreeTranscript(bytes: Buffer, plan: WorktreeMigration, root: string): Buffer;
|
|
52
58
|
export interface MigrationResult {
|
package/dist/move-worktrees.js
CHANGED
|
@@ -9,6 +9,60 @@ import { projectGit } from "./project-sync-manifest.js";
|
|
|
9
9
|
import { UsageError } from "./errors.js";
|
|
10
10
|
import { rewriteSessionPaths } from "./local-sessions.js";
|
|
11
11
|
const inside = (root, path) => path === root || path.startsWith(`${root}/`);
|
|
12
|
+
/** `git worktree list --porcelain -z`: each registration's path and whether Git calls it prunable. */
|
|
13
|
+
async function registeredWorktrees(folder, signal) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const field of (await git(folder, ['worktree', 'list', '--porcelain', '-z'], signal)).split('\0')) {
|
|
16
|
+
if (field.startsWith('worktree '))
|
|
17
|
+
out.push({ path: field.slice(9), prunable: false });
|
|
18
|
+
else if ((field === 'prunable' || field.startsWith('prunable ')) && out.length)
|
|
19
|
+
out.at(-1).prunable = true;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether the transcript names `path` itself (or a file below it), not just a longer registered worktree path that
|
|
25
|
+
* starts with it (the main checkout is a prefix of every worktree nested inside it). JSON-escaped spellings count.
|
|
26
|
+
*/
|
|
27
|
+
function names(text, path, registered) {
|
|
28
|
+
const longer = registered.filter(p => p.length > path.length && p.startsWith(path));
|
|
29
|
+
for (const spelled of new Set([path, JSON.stringify(path).slice(1, -1)])) {
|
|
30
|
+
for (let at = text.indexOf(spelled); at >= 0; at = text.indexOf(spelled, at + 1)) {
|
|
31
|
+
const next = text[at + spelled.length];
|
|
32
|
+
if (next !== undefined && /[\w.-]/.test(next))
|
|
33
|
+
continue; // a sibling folder: /repo-old for /repo
|
|
34
|
+
if (longer.some(p => text.startsWith(p, at) || text.startsWith(JSON.stringify(p).slice(1, -1), at)))
|
|
35
|
+
continue;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Other registered worktrees of this repository that the session's transcript works in. A normal move leaves them on
|
|
43
|
+
* this computer; the CLI says so. Best effort: an empty list when Git cannot answer.
|
|
44
|
+
*/
|
|
45
|
+
export async function referencedWorktrees(folder, transcript, signal) {
|
|
46
|
+
try {
|
|
47
|
+
const selected = await realpath(folder), text = transcript.toString('utf8');
|
|
48
|
+
const live = (await registeredWorktrees(folder, signal)).filter(w => !w.prunable).map(w => w.path);
|
|
49
|
+
const all = [...new Set([...live, selected])];
|
|
50
|
+
const others = [];
|
|
51
|
+
for (const path of live) {
|
|
52
|
+
const real = await realpath(path).catch(() => '');
|
|
53
|
+
if (!real || real === selected)
|
|
54
|
+
continue;
|
|
55
|
+
if (names(text, path, all) || (real !== path && names(text, real, all)))
|
|
56
|
+
others.push(real);
|
|
57
|
+
}
|
|
58
|
+
return others;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw e;
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
12
66
|
const git = (folder, args, signal) => projectGit(folder, args, signal);
|
|
13
67
|
const hash = async (file, signal) => {
|
|
14
68
|
const sha = createHash('sha256');
|
|
@@ -47,15 +101,17 @@ export async function inspectWorktreeMigration(folder, transcript, signal) {
|
|
|
47
101
|
if (await readFile(alternates, 'utf8').then(s => !!s.trim(), (e) => { if (e.code !== 'ENOENT')
|
|
48
102
|
throw e; return false; }))
|
|
49
103
|
throw new UsageError('This repository borrows an external Git object store. Make it self-contained before preserving all worktrees.');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
// A registration whose folder (or its .git) is gone has nothing to copy: Git itself calls it prunable. It is
|
|
105
|
+
// reported, never a reason to refuse; its branch is a local ref and moves with the others.
|
|
106
|
+
const registered = [], sources = [], stale = [];
|
|
107
|
+
for (const w of await registeredWorktrees(folder, signal)) {
|
|
108
|
+
const real = w.prunable ? '' : await realpath(w.path).catch(() => '');
|
|
109
|
+
if (!real) {
|
|
110
|
+
stale.push(w.path);
|
|
111
|
+
continue;
|
|
58
112
|
}
|
|
113
|
+
registered.push(w.path);
|
|
114
|
+
sources.push(real);
|
|
59
115
|
}
|
|
60
116
|
if (!sources.includes(folder) || !sources.includes(main))
|
|
61
117
|
throw new UsageError('Git did not list the selected and main worktrees.');
|
|
@@ -73,9 +129,9 @@ export async function inspectWorktreeMigration(folder, transcript, signal) {
|
|
|
73
129
|
status: await git(source, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], signal), files: [],
|
|
74
130
|
exclude: sources.filter(p => p !== source && inside(source, p)).map(p => relative(source, p)) });
|
|
75
131
|
}
|
|
76
|
-
const text = transcript.toString('utf8');
|
|
77
|
-
const referenced = trees.filter(t => t.source !== folder && t.spellings.some(p => text
|
|
78
|
-
return { version: 1, selected: folder, commonDir, main, trees, referenced,
|
|
132
|
+
const text = transcript.toString('utf8'), all = trees.flatMap(t => t.spellings);
|
|
133
|
+
const referenced = trees.filter(t => t.source !== folder && t.spellings.some(p => names(text, p, all))).map(t => t.source);
|
|
134
|
+
return { version: 1, selected: folder, commonDir, main, trees, referenced, stale,
|
|
79
135
|
refs: await git(folder, ['for-each-ref', '--format=%(objectname) %(refname)'], signal), bytes: 0, files: 0 };
|
|
80
136
|
}
|
|
81
137
|
/** Size/hash pass only after preserve-all is selected (ignored caches may be large). */
|
|
@@ -111,16 +167,11 @@ export async function captureWorktreeMigration(plan, signal) {
|
|
|
111
167
|
}
|
|
112
168
|
export function migrationScope(plan) {
|
|
113
169
|
return { worktrees: plan.trees.map(t => ({ path: t.source, branch: t.branch || '(detached)', target: t.target })), referenced: plan.referenced,
|
|
170
|
+
...(plan.stale.length ? { stale_registrations: plan.stale } : {}),
|
|
114
171
|
local_refs: plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1)),
|
|
115
172
|
bytes: plan.bytes, files: plan.files, scope: 'All files, including ignored files and Git metadata, inside these registered worktrees. Symlink targets and unrelated sibling folders are not uploaded.',
|
|
116
173
|
size_basis: 'File bytes before tar headers; archive bytes and free-space requirement are larger. Ignored dependency/build caches are included.' };
|
|
117
174
|
}
|
|
118
|
-
/** User refs a normal move leaves behind. Shardflux's own refs/shard/* (twin snapshots, land and agent refs) are rebuilt, never user work. */
|
|
119
|
-
export function omittedLocalRefs(plan) {
|
|
120
|
-
const selected = plan.trees.find(t => t.source === plan.selected);
|
|
121
|
-
return plan.refs.trim().split('\n').filter(Boolean).map(line => line.slice(line.indexOf(' ') + 1))
|
|
122
|
-
.filter(ref => ref !== selected.branch && !ref.startsWith('refs/remotes/') && !ref.startsWith('refs/shard/'));
|
|
123
|
-
}
|
|
124
175
|
/** Longest paths first, before replacing a parent. Message text and signed thinking keep the existing rewrite contract. */
|
|
125
176
|
export function rewriteWorktreeTranscript(bytes, plan, root) {
|
|
126
177
|
const mappings = plan.trees.flatMap(tree => tree.spellings.map(from => ({ from, to: tree.target === '.' ? root : `${root}/${tree.target}` })));
|
package/dist/move.js
CHANGED
|
@@ -33,7 +33,7 @@ import { openUrl } from "./browser.js";
|
|
|
33
33
|
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
34
34
|
import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
|
|
35
35
|
import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
|
|
36
|
-
import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope,
|
|
36
|
+
import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, preserveWorktreeMigration, referencedWorktrees, rewriteWorktreeTranscript } from "./move-worktrees.js";
|
|
37
37
|
const VM_HOME = '/home/user';
|
|
38
38
|
const SPACE = '/shardflux/project';
|
|
39
39
|
/** A transcript above this moves with the repository's history bound. */
|
|
@@ -196,13 +196,6 @@ async function alreadyMoved(ctx, m) {
|
|
|
196
196
|
throw e;
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
function requireExplicitWorktreeScope(plan, values, sessionId) {
|
|
200
|
-
if (!plan || values['preserve-all-worktrees'] === true || values['this-worktree-only'] === true)
|
|
201
|
-
return;
|
|
202
|
-
const omitted = omittedLocalRefs(plan);
|
|
203
|
-
if (plan.trees.length > 1 || omitted.length)
|
|
204
|
-
throw new UsageError(`This repository has ${plan.trees.length} registered worktrees and ${omitted.length} other local refs${plan.referenced.length ? `; the session references ${plan.referenced.length} other worktrees` : ''}. Normal migration omits those refs and other worktree files. Inspect paths and bytes with shard move --session ${sessionId} --plan, then choose --preserve-all-worktrees or explicitly --this-worktree-only.`);
|
|
205
|
-
}
|
|
206
199
|
export async function moveSession(ctx, values) {
|
|
207
200
|
const env = { ...process.env, ...ctx.io.env };
|
|
208
201
|
const here = projectRoot(typeof values.path === 'string' ? values.path : process.cwd());
|
|
@@ -216,28 +209,27 @@ export async function moveSession(ctx, values) {
|
|
|
216
209
|
return 0;
|
|
217
210
|
}
|
|
218
211
|
}
|
|
212
|
+
// A normal move continues the session from this checkout, uncommitted edits included; the repository's other
|
|
213
|
+
// worktrees and local branches stay on this computer, untouched (as `this-worktree-only` says explicitly).
|
|
214
|
+
// --preserve-all-worktrees copies them too.
|
|
215
|
+
const preserveAll = values['preserve-all-worktrees'] === true;
|
|
216
|
+
if (preserveAll && values['this-worktree-only'] === true)
|
|
217
|
+
throw new UsageError('Choose --preserve-all-worktrees or --this-worktree-only.');
|
|
219
218
|
if (values.plan !== true) {
|
|
220
|
-
// A local MCP caller is still in its turn
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const current = completeRecords(readTranscript(s.file));
|
|
224
|
-
confirmedCwds(s, current, folder);
|
|
225
|
-
requireExplicitWorktreeScope(await inspectWorktreeMigration(folder, current, ctx.signal), values, s.session_id);
|
|
226
|
-
}
|
|
219
|
+
// A local MCP caller is still in its turn: an unrelated folder is refused while it can still answer.
|
|
220
|
+
if (!preserveAll)
|
|
221
|
+
confirmedCwds(s, completeRecords(readTranscript(s.file)), folder);
|
|
227
222
|
await waitForTurnEnd(ctx, s, env);
|
|
228
223
|
}
|
|
229
224
|
const bytes = completeRecords(readTranscript(s.file));
|
|
230
225
|
if (bytes.length > TRANSCRIPT_LIMIT)
|
|
231
226
|
throw new UsageError('This session’s transcript is above the 256 MiB move bound.');
|
|
232
|
-
|
|
233
|
-
throw new UsageError('Choose --preserve-all-worktrees or --this-worktree-only.');
|
|
234
|
-
const plan = values['this-worktree-only'] === true && values.plan !== true ? null : await inspectWorktreeMigration(folder, bytes, ctx.signal);
|
|
227
|
+
const plan = preserveAll || values.plan === true ? await inspectWorktreeMigration(folder, bytes, ctx.signal) : null;
|
|
235
228
|
// Refuse before syncing credentials, creating a VM or snapshotting an unrelated
|
|
236
229
|
// folder. An explicit all-worktree scope also accepts record-confirmed linked owners.
|
|
237
|
-
confirmedCwds(s, bytes, folder,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
throw new UsageError('Preserve-all-worktrees requires a Git repository.');
|
|
230
|
+
confirmedCwds(s, bytes, folder, plan ?? undefined);
|
|
231
|
+
let elsewhere = [];
|
|
232
|
+
if (plan) {
|
|
241
233
|
await captureWorktreeMigration(plan, ctx.signal);
|
|
242
234
|
const scope = migrationScope(plan);
|
|
243
235
|
if (values.plan === true) {
|
|
@@ -246,8 +238,11 @@ export async function moveSession(ctx, values) {
|
|
|
246
238
|
}
|
|
247
239
|
ctx.io.stderr.write(`Preserving ${plan.trees.length} registered worktrees, ${plan.files} files, ${plan.bytes} file bytes (archives add headers). Includes ignored files and Git metadata:\n${plan.trees.map(t => ` ${t.source}`).join('\n')}\nSymlink targets and unrelated sibling folders are not uploaded.\n`);
|
|
248
240
|
}
|
|
249
|
-
else
|
|
250
|
-
|
|
241
|
+
else if (values['this-worktree-only'] !== true) {
|
|
242
|
+
elsewhere = await referencedWorktrees(folder, bytes, ctx.signal);
|
|
243
|
+
if (elsewhere.length && !ctx.json)
|
|
244
|
+
ctx.io.stderr.write(`This session also worked in ${elsewhere.length === 1 ? 'another worktree' : `${elsewhere.length} other worktrees`} of this repository (${elsewhere.join(', ')}); ${elsewhere.length === 1 ? 'it stays' : 'they stay'} on this computer. shard move --preserve-all-worktrees copies every worktree.\n`);
|
|
245
|
+
}
|
|
251
246
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
252
247
|
if (!ctx.json)
|
|
253
248
|
ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
|
|
@@ -298,7 +293,7 @@ export async function moveSession(ctx, values) {
|
|
|
298
293
|
if (!attach) {
|
|
299
294
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
300
295
|
await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
|
|
301
|
-
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}) });
|
|
296
|
+
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {}) });
|
|
302
297
|
return 0;
|
|
303
298
|
}
|
|
304
299
|
const where = openInChoices(ctx, project.slug)[s.harness];
|
package/dist/onboarding.js
CHANGED
|
@@ -301,8 +301,10 @@ export async function onboardingReport(ctx, values, platform = process.platform)
|
|
|
301
301
|
const claude = { id: 'claude', picked: apps.includes('claude'), found: found.includes('claude'),
|
|
302
302
|
...(!connected ? signIn : credentialError !== undefined ? failure(credentialError) : claudeConnection(credentials.find(row => row.app === 'claude'))) };
|
|
303
303
|
const codex = { id: 'codex', picked: apps.includes('codex'), found: found.includes('codex'),
|
|
304
|
-
...(!connected ? signIn :
|
|
305
|
-
:
|
|
304
|
+
...(!connected ? signIn : codexError instanceof ShardfluxApiError && codexError.reason === 'codex_chatgpt_login_required'
|
|
305
|
+
? { state: 'action', fact: codexError.message, action: { title: 'Sign in with ChatGPT', command: ['codex', 'login'] } }
|
|
306
|
+
: (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
|
|
307
|
+
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
|
|
306
308
|
const t3 = await t3Connection(ctx, connected || ctx.sessionSource() !== null, undefined, undefined, values['t3-connect'] === true, values);
|
|
307
309
|
const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
|
|
308
310
|
// The cloud machine (0.20.2+), the same row `shard machine setup --status` reports: whether what is installed carries
|
|
@@ -172,6 +172,8 @@ export interface PreparedApplyOptions {
|
|
|
172
172
|
};
|
|
173
173
|
/** The VM's workspace: a checkout another VM's conversation made (and a template image brought) is taken over. */
|
|
174
174
|
workspace?: string;
|
|
175
|
+
/** Media already sent land before the reply, not after it (a client that compares the worktree's files at once). */
|
|
176
|
+
landNow?: boolean;
|
|
175
177
|
}
|
|
176
178
|
/** Where and how a revision is checked out: the VM half's apply request. */
|
|
177
179
|
export interface PreparedApplyTarget {
|
|
@@ -785,7 +785,7 @@ const applyRequest = ({ root, branch, options = {} }) => ({ root, branch, ...opt
|
|
|
785
785
|
...(options.background === false ? { background: false } : {}), ...(options.releasePin ? { release_pin: options.releasePin } : {}),
|
|
786
786
|
...(options.after ? { after: options.after } : {}),
|
|
787
787
|
...(options.setupIntent ? { setup_intent: { conversation: options.setupIntent.conversation, deps_hash: options.setupIntent.depsHash } } : {}),
|
|
788
|
-
...(options.workspace ? { workspace: options.workspace } : {}) });
|
|
788
|
+
...(options.workspace ? { workspace: options.workspace } : {}), ...(options.landNow ? { land_now: true } : {}) });
|
|
789
789
|
/**
|
|
790
790
|
* Checks out the revision as `branch` at `root`, in one exec. Media not in the cache yet stay pending
|
|
791
791
|
* (landDeferredFiles). A root holding the same revision is left as it is; one holding another is refused, unless it is
|
|
@@ -998,8 +998,9 @@ def apply_locked(cache, req, revision, root, branch, m=None, extras_tar=None, pr
|
|
|
998
998
|
except BaseException:
|
|
999
999
|
discard(work, root)
|
|
1000
1000
|
raise
|
|
1001
|
-
# Media are files Git ignores: the agent can start before they are written. They land right after the reply
|
|
1002
|
-
|
|
1001
|
+
# Media are files Git ignores: the agent can start before they are written. They land right after the reply, or
|
|
1002
|
+
# before it for a client that compares the worktree's files at once (an editor's file sync).
|
|
1003
|
+
land_now(cache, root, [], sync=not req.get("background", True) or req.get("land_now") is True)
|
|
1003
1004
|
if req.get("after"):
|
|
1004
1005
|
AFTER.append(lambda: run_after(root, req["after"]))
|
|
1005
1006
|
if req.get("background", True):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Ctx, Handler } from './commands.js';
|
|
2
2
|
import type { PreparedTransfer } from './prepared-repository.js';
|
|
3
|
+
import { CodedError } from './errors.js';
|
|
3
4
|
export interface SelectedProject {
|
|
4
5
|
api_url: string;
|
|
5
6
|
folder: string;
|
|
@@ -24,6 +25,19 @@ export interface SelectedProject {
|
|
|
24
25
|
/** Preparation VMs this revision's attempts deleted: the next open is a new one, never the deleted one again. */
|
|
25
26
|
attempt?: number;
|
|
26
27
|
}
|
|
28
|
+
/** The guest's setup state (`sf-agent template --json`): its steps, each failed one with the guest's error. */
|
|
29
|
+
export interface SetupStatus {
|
|
30
|
+
state?: string;
|
|
31
|
+
tasks?: {
|
|
32
|
+
name: string;
|
|
33
|
+
state: string;
|
|
34
|
+
error?: {
|
|
35
|
+
code?: string;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
export declare function setupFailure(status: SetupStatus): CodedError;
|
|
27
41
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
28
42
|
export declare const PREPARE_AGAIN_BYTES: number;
|
|
29
43
|
/** Quiet this long after the last edit before preparing again. */
|
|
@@ -23,10 +23,22 @@ import { projectGit } from "./project-sync-manifest.js";
|
|
|
23
23
|
import { inspectWorktree, pauseReason, twinSeed, worktreeId } from "./worktree-twin.js";
|
|
24
24
|
import { applyPreparedSnapshot, capturePreparedSnapshot, preparedCachePath, preparedGitConfig, pruneLocalPrepared, prunePreparedCache, releaseLocalSnapshot, transferPreparedSnapshot } from "./prepared-repository.js";
|
|
25
25
|
import { withTargetLock } from "./target-lock.js";
|
|
26
|
-
import { UsageError } from "./errors.js";
|
|
26
|
+
import { CodedError, UsageError } from "./errors.js";
|
|
27
27
|
import { dependencyHash } from "./project-template-setup.js";
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A setup that ran and failed: the same files fail again, so it waits for a change instead of a timer. Reported as
|
|
30
|
+
* `project_setup_failed` with the failed step and its own message (0.23.0+; before, `internal_error`).
|
|
31
|
+
*/
|
|
32
|
+
class SetupFailed extends CodedError {
|
|
33
|
+
}
|
|
34
|
+
export function setupFailure(status) {
|
|
35
|
+
const step = status.tasks?.find(t => t.state === 'failed');
|
|
36
|
+
const message = step ? `Project setup failed at ${step.name}: ${step.error?.message ?? 'the step failed'}` : 'Project setup failed.';
|
|
37
|
+
// A step setup could not work out (a missing lockfile) or a command that failed; the repository's own command for the
|
|
38
|
+
// step is the other way forward.
|
|
39
|
+
const first = step?.error?.code === 'setup_detection_unresolved' ? 'commit what the message names' : 'fix the cause in the repository and commit';
|
|
40
|
+
const fix = `${first}${step ? `, or set run for ${step.name} under steps in .shardflux/setup.yaml` : ''}; the project is prepared again after the change`;
|
|
41
|
+
return new SetupFailed('project_setup_failed', message, fix, step ? { step: step.name, ...(step.error?.code ? { error_code: step.error.code } : {}) } : undefined);
|
|
30
42
|
}
|
|
31
43
|
/** New content past this, or a dependency change, makes a quiet project worth preparing again. */
|
|
32
44
|
export const PREPARE_AGAIN_BYTES = 8 << 20;
|
|
@@ -147,7 +159,7 @@ export async function prepareSelectedProject(ctx, selected) {
|
|
|
147
159
|
throw new Error(`Project setup status failed: ${status.stderr.trim()}`);
|
|
148
160
|
const state = JSON.parse(status.stdout);
|
|
149
161
|
if (state.state === 'failed')
|
|
150
|
-
throw
|
|
162
|
+
throw setupFailure(state);
|
|
151
163
|
if (Date.now() > deadline)
|
|
152
164
|
throw new Error('The project template is still being saved; the next preparation picks it up.');
|
|
153
165
|
await delay(5000, undefined, { signal: ctx.signal });
|
package/dist/project-sync.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare function projectReconciler(options: {
|
|
|
38
38
|
signal?: AbortSignal;
|
|
39
39
|
vmBaseline?: boolean;
|
|
40
40
|
cache?: LocalHashCache;
|
|
41
|
+
inFlight?: () => Iterable<string>;
|
|
41
42
|
}): (() => Promise<void>) & {
|
|
42
43
|
settled(): boolean;
|
|
43
44
|
};
|
|
@@ -51,6 +52,8 @@ export interface ProjectSyncOptions {
|
|
|
51
52
|
vmBaseline?: boolean;
|
|
52
53
|
/** This computer's file hashes, filled ahead of the first reconcile (localManifest with the same map, 0.21.1+). */
|
|
53
54
|
cache?: LocalHashCache;
|
|
55
|
+
/** Paths another transfer is still bringing to the VM (a prepared start's large media, 0.22.0+): left alone until then. */
|
|
56
|
+
inFlight?: () => Iterable<string>;
|
|
54
57
|
}
|
|
55
58
|
/** Editors reconcile between turns without a watcher keeping an idle VM active. */
|
|
56
59
|
export declare function projectSyncBoundary(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: ProjectSyncOptions): Promise<ProjectSyncBoundary>;
|
package/dist/project-sync.js
CHANGED
|
@@ -36,6 +36,9 @@ export function projectReconciler(options) {
|
|
|
36
36
|
report(`Project sync skipped ${side} ${skip.path}: ${skip.reason}`);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
// Files another transfer is still bringing to the VM: this pass leaves them alone (a later one sees them landed).
|
|
40
|
+
for (const path of options.inFlight?.() ?? [])
|
|
41
|
+
blocked.add(path);
|
|
39
42
|
// Until a pass completes, a VM that has run nothing since this checkout seeded it holds the baseline of every file
|
|
40
43
|
// both sides have: an edit made here before the first pass is this computer's change, not a conflict (0.21.1+).
|
|
41
44
|
let common = state.common;
|
|
@@ -144,7 +147,7 @@ export async function projectSyncBoundary(ctx, ws, project, options = {}) {
|
|
|
144
147
|
};
|
|
145
148
|
const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
|
|
146
149
|
const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal,
|
|
147
|
-
vmBaseline: options.vmBaseline === true && !stored, ...(options.cache ? { cache: options.cache } : {}) });
|
|
150
|
+
vmBaseline: options.vmBaseline === true && !stored, ...(options.cache ? { cache: options.cache } : {}), ...(options.inFlight ? { inFlight: options.inFlight } : {}) });
|
|
148
151
|
return { root, conflicts: () => state.conflicts.slice(), reconcile, settled: () => reconcile.settled() };
|
|
149
152
|
}
|
|
150
153
|
export async function startProjectSync(ctx, ws, project, options = {}) {
|
package/dist/t3-launch.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare function launchT3(ctx: Ctx, values: Values, path: string, explici
|
|
|
32
32
|
export declare function t3Fields(ws: Workspace, project: string, install: T3Install | undefined, started: T3ThreadResult | undefined, conversation?: string | null): Record<string, unknown>;
|
|
33
33
|
/**
|
|
34
34
|
* `agents open t3 <conversation>` for a conversation that runs on T3's Shardflux provider (ACP mode): T3 is brought to
|
|
35
|
-
* the front with that thread's id. A
|
|
35
|
+
* the front with that thread's id. A Codex Cloud conversation opens its Codex chat; a conversation of another route
|
|
36
|
+
* opens its VM as T3's SSH environment.
|
|
36
37
|
*/
|
|
37
38
|
export declare function openT3Conversation(ctx: Ctx, conversationId: string, values?: Values): Promise<number>;
|
package/dist/t3-launch.js
CHANGED
|
@@ -99,13 +99,22 @@ export function t3Fields(ws, project, install, started, conversation) {
|
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
101
|
* `agents open t3 <conversation>` for a conversation that runs on T3's Shardflux provider (ACP mode): T3 is brought to
|
|
102
|
-
* the front with that thread's id. A
|
|
102
|
+
* the front with that thread's id. A Codex Cloud conversation opens its Codex chat; a conversation of another route
|
|
103
|
+
* opens its VM as T3's SSH environment.
|
|
103
104
|
*/
|
|
104
105
|
export async function openT3Conversation(ctx, conversationId, values = {}) {
|
|
106
|
+
const conversation = await getConversation(ctx, conversationId);
|
|
107
|
+
// A Codex Cloud conversation's chat is its Codex thread, which T3 Code cannot show: its existing history opens (as
|
|
108
|
+
// `agents open` does), without waking the VM, linking it to T3 or starting another conversation (0.23.0+).
|
|
109
|
+
if (conversation.route === 'codex-cloud') {
|
|
110
|
+
if (!ctx.json)
|
|
111
|
+
ctx.io.stderr.write('Codex Cloud runs this conversation, so its chat is in Codex, not T3 Code.\n');
|
|
112
|
+
const { openCodexConversation } = await import("./codex-cloud.js");
|
|
113
|
+
return openCodexConversation(ctx, conversation);
|
|
114
|
+
}
|
|
105
115
|
const install = t3Install(ctx.io.env);
|
|
106
116
|
if (!install)
|
|
107
117
|
throw t3NotInstalled();
|
|
108
|
-
const conversation = await getConversation(ctx, conversationId);
|
|
109
118
|
if (install.mode === 'acp' && conversation.route === 'acp') {
|
|
110
119
|
let threadId;
|
|
111
120
|
try {
|