@shardflux/cli 0.24.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +159 -1
- package/README.md +182 -35
- package/dist/account-pool.d.ts +11 -1
- package/dist/account-pool.js +48 -0
- package/dist/acp-journal.js +23 -1
- package/dist/agent-commands.js +4 -3
- package/dist/agent-models.d.ts +31 -1
- package/dist/agent-models.js +27 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +97 -14
- package/dist/board-args.js +26 -4
- package/dist/board-commands.d.ts +9 -5
- package/dist/board-commands.js +43 -16
- package/dist/brain-commands.d.ts +2 -0
- package/dist/brain-commands.js +78 -0
- package/dist/client-settings.d.ts +2 -1
- package/dist/client-settings.js +14 -2
- package/dist/codex-cloud.d.ts +2 -0
- package/dist/codex-cloud.js +14 -11
- package/dist/commands.js +8 -0
- package/dist/conversation-commands.d.ts +5 -1
- package/dist/conversation-commands.js +76 -8
- package/dist/conversation-terminal.js +2 -2
- package/dist/conversations.d.ts +2 -1
- package/dist/desktop-linux.d.ts +12 -1
- package/dist/desktop-linux.js +47 -18
- package/dist/device-approvals.js +24 -2
- package/dist/device-bridge.d.ts +21 -4
- package/dist/device-bridge.js +173 -7
- package/dist/device-folders.d.ts +14 -1
- package/dist/device-folders.js +20 -8
- package/dist/device-helper.d.ts +8 -0
- package/dist/device-helper.js +83 -13
- package/dist/device-mode.d.ts +44 -0
- package/dist/device-mode.js +119 -0
- package/dist/drive-commands.d.ts +2 -2
- package/dist/drive-commands.js +1 -1
- package/dist/errors.js +2 -1
- package/dist/format.js +6 -2
- package/dist/handlers.js +5 -2
- package/dist/land.d.ts +121 -0
- package/dist/land.js +284 -0
- package/dist/mac-app.d.ts +11 -0
- package/dist/mac-app.js +11 -0
- package/dist/main.js +9 -1
- package/dist/menubar.d.ts +18 -13
- package/dist/menubar.js +60 -50
- package/dist/onboarding.d.ts +4 -4
- package/dist/orchestrator-args.d.ts +7 -0
- package/dist/orchestrator-args.js +64 -0
- package/dist/orchestrator-commands.d.ts +43 -0
- package/dist/orchestrator-commands.js +262 -0
- package/dist/person-scope.d.ts +20 -0
- package/dist/person-scope.js +61 -0
- package/dist/project-hub-seed.d.ts +20 -0
- package/dist/project-hub-seed.js +59 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +2 -1
- package/dist/router-commands.d.ts +12 -10
- package/dist/router-commands.js +5 -59
- package/dist/t3-launch.js +2 -2
- package/dist/task-land-hub.py +165 -0
- package/dist/this-computer.d.ts +4 -2
- package/dist/this-computer.js +20 -2
- package/dist/thread-on-computer.js +2 -2
- package/dist/timing.js +3 -1
- package/dist/transcribe-commands.d.ts +7 -0
- package/dist/transcribe-commands.js +84 -0
- package/dist/update.js +4 -2
- package/dist/wait-status.d.ts +1 -1
- package/dist/wait-status.js +2 -0
- package/dist/worktree-twin-engine.d.ts +8 -0
- package/dist/worktree-twin-engine.js +29 -23
- package/dist/worktree-twin.d.ts +6 -0
- package/dist/worktree-twin.js +8 -1
- package/package.json +6 -5
package/dist/desktop-linux.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, execFile } from 'node:child_process';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
3
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync, mkdtempSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { UsageError } from "./errors.js";
|
|
@@ -18,6 +18,41 @@ export function linuxDesktopPackage(arch = process.arch) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
export function graphicalSession(env) { return !!(env.DISPLAY || env.WAYLAND_DISPLAY); }
|
|
21
|
+
/**
|
|
22
|
+
* Whether `pid` runs the app installed at `app`: the GTK tray app (shardflux-desktop) or the one Shardflux app
|
|
23
|
+
* (shardflux-desktop-bin behind its launcher). By /proc/<pid>/exe, because Chromium rewrites an Electron process's
|
|
24
|
+
* cmdline into one space-joined title; a recycled PID runs something else, and a zombie has exited.
|
|
25
|
+
*/
|
|
26
|
+
export function isAppProcess(pid, app, proc = '/proc') {
|
|
27
|
+
try {
|
|
28
|
+
const exe = readlinkSync(`${proc}/${pid}/exe`).replace(/ \(deleted\)$/, '');
|
|
29
|
+
let real = app;
|
|
30
|
+
try {
|
|
31
|
+
real = realpathSync(app);
|
|
32
|
+
}
|
|
33
|
+
catch { /* not installed */ }
|
|
34
|
+
if (![app, real].includes(dirname(exe)) || !/^shardflux-desktop(-bin)?$/.test(basename(exe)))
|
|
35
|
+
return false;
|
|
36
|
+
const stat = readFileSync(`${proc}/${pid}/stat`, 'utf8');
|
|
37
|
+
if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z'))
|
|
38
|
+
return false;
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The libraries ldd could not find, named as the loader names them: the app in the package decides which it needs (the
|
|
48
|
+
* GTK tray app's WebKit and app indicator, the one app's GTK, NSS and ALSA).
|
|
49
|
+
*/
|
|
50
|
+
export function missingLibrariesMessage(ldd) {
|
|
51
|
+
const missing = [...new Set([...ldd.matchAll(/^\s*(\S+)\s+=>\s+not found/gm)].map(m => m[1]))];
|
|
52
|
+
return `Shardflux needs desktop libraries this computer does not have${missing.length ? ` (${missing.join(', ')})` : ''}. Install them with your package manager, then run shard.`;
|
|
53
|
+
}
|
|
54
|
+
/** The Electron executable of a one-app directory (behind its launcher), else the GTK app's executable. */
|
|
55
|
+
const appBinary = (dir) => existsSync(join(dir, 'shardflux-desktop-bin')) ? join(dir, 'shardflux-desktop-bin') : join(dir, 'shardflux-desktop');
|
|
21
56
|
const desktopString = (text) => `"${text.replace(/(["`$\\])/g, '\\$1')}"`;
|
|
22
57
|
// fnm's multishell aliases belong to the launching shell; the desktop and its autostart entry outlive it.
|
|
23
58
|
function stableDesktopRuntime(runtime) {
|
|
@@ -43,15 +78,7 @@ export class LinuxDesktopManager {
|
|
|
43
78
|
this.#env = { ...process.env, ...options.env, HOME: this.home };
|
|
44
79
|
this.#exec = options.exec ?? exec;
|
|
45
80
|
this.#spawn = options.spawn ?? spawn;
|
|
46
|
-
this.#isProcess = options.isProcess ?? ((pid,
|
|
47
|
-
if (readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] !== executable)
|
|
48
|
-
return false;
|
|
49
|
-
process.kill(pid, 0);
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return false;
|
|
54
|
-
} });
|
|
81
|
+
this.#isProcess = options.isProcess ?? ((pid, app) => isAppProcess(pid, app));
|
|
55
82
|
}
|
|
56
83
|
#version(path) {
|
|
57
84
|
try {
|
|
@@ -67,7 +94,7 @@ export class LinuxDesktopManager {
|
|
|
67
94
|
try {
|
|
68
95
|
const pid = Number(readFileSync(join(dir, 'desktop-linux.pid'), 'utf8'));
|
|
69
96
|
// Recycled PIDs must never make us stop an unrelated process.
|
|
70
|
-
if (Number.isInteger(pid) && pid > 0 && this.#isProcess(pid,
|
|
97
|
+
if (Number.isInteger(pid) && pid > 0 && this.#isProcess(pid, this.app))
|
|
71
98
|
pids.push(pid);
|
|
72
99
|
}
|
|
73
100
|
catch { /* no running app */ }
|
|
@@ -84,7 +111,8 @@ export class LinuxDesktopManager {
|
|
|
84
111
|
const entry = join(this.home, '.local/share/applications/dev.shardflux.Desktop.desktop');
|
|
85
112
|
if (!existsSync(entry))
|
|
86
113
|
return Promise.reject(new UsageError('Run shard menubar start first.'));
|
|
87
|
-
|
|
114
|
+
// A login start shows only the tray icon (the one app reads --background; 0.27.0+).
|
|
115
|
+
writeFileSync(file, readFileSync(entry, 'utf8').replace(/^Exec=(.*)$/m, (_m, cmd) => `Exec=${cmd.includes('--background') ? cmd : `${cmd} --background`}`), { mode: 0o644 });
|
|
88
116
|
return Promise.resolve();
|
|
89
117
|
}
|
|
90
118
|
async stop(dir) {
|
|
@@ -109,7 +137,7 @@ export class LinuxDesktopManager {
|
|
|
109
137
|
return { result: 'current', from: before.version, to: version };
|
|
110
138
|
if (before.running) {
|
|
111
139
|
try {
|
|
112
|
-
const { stdout } = await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'idle'], { env: this.#env, timeout: 5000 });
|
|
140
|
+
const { stdout } = await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', 'idle'], { env: { ...this.#env, SHARDFLUX_CONFIG_DIR: dir }, timeout: 5000 });
|
|
113
141
|
if (!JSON.parse(stdout).idle)
|
|
114
142
|
return { result: 'busy', from: before.version, to: version };
|
|
115
143
|
}
|
|
@@ -132,14 +160,15 @@ export class LinuxDesktopManager {
|
|
|
132
160
|
if (existsSync(this.app) && (lstatSync(this.app).isSymbolicLink() || !current.version))
|
|
133
161
|
throw new UsageError(`Refusing to replace an unrelated directory at ${this.app}.`);
|
|
134
162
|
// The loader probes every shared library before any installed copy is stopped or replaced.
|
|
135
|
-
await this.#exec('/usr/bin/ldd', [
|
|
136
|
-
throw new UsageError(
|
|
163
|
+
await this.#exec('/usr/bin/ldd', [appBinary(source)], { timeout: 5_000 }).then(({ stdout }) => { if (stdout.includes('not found'))
|
|
164
|
+
throw new UsageError(missingLibrariesMessage(stdout)); });
|
|
137
165
|
mkdirSync(dirname(this.app), { recursive: true });
|
|
138
166
|
const staging = mkdtempSync(join(dirname(this.app), '.desktop-'));
|
|
139
167
|
const staged = join(staging, 'app'), backup = join(staging, 'previous');
|
|
140
168
|
try {
|
|
141
|
-
cpSync(source, staged, { recursive: true });
|
|
169
|
+
cpSync(source, staged, { recursive: true, verbatimSymlinks: true });
|
|
142
170
|
chmodSync(join(staged, 'shardflux-desktop'), 0o755);
|
|
171
|
+
chmodSync(appBinary(staged), 0o755);
|
|
143
172
|
if (current.running)
|
|
144
173
|
await this.stop(dir);
|
|
145
174
|
if (existsSync(this.app))
|
|
@@ -165,7 +194,7 @@ export class LinuxDesktopManager {
|
|
|
165
194
|
writeFileSync(desktop, `[Desktop Entry]\nType=Application\nName=Shardflux\nComment=Your agents, a shortcut away\nExec=${args.map(desktopString).join(' ')}\nIcon=${join(this.app, 'ui/assets/shardflux-icon.png')}\nTerminal=false\nCategories=Development;\nStartupWMClass=dev.shardflux.Desktop\n`, { mode: 0o644 });
|
|
166
195
|
const autostart = join(this.home, '.config/autostart/shardflux.desktop');
|
|
167
196
|
if (existsSync(autostart))
|
|
168
|
-
|
|
197
|
+
await this.login(true);
|
|
169
198
|
if ((await this.status(dir)).running)
|
|
170
199
|
await this.#exec(join(this.app, 'shardflux-desktop'), ['--control', view], { env, timeout: 5_000 });
|
|
171
200
|
else {
|
package/dist/device-approvals.js
CHANGED
|
@@ -6,6 +6,8 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
6
6
|
import { writePrivateFile } from "./credentials.js";
|
|
7
7
|
import { UsageError } from "./errors.js";
|
|
8
8
|
import { json } from "./format.js";
|
|
9
|
+
import { thisComputer } from "./this-computer.js";
|
|
10
|
+
import { chooseLine, MODE_LABELS, deviceActivity, readDeviceSettings, setDeviceApprovalMode } from "./device-mode.js";
|
|
9
11
|
const paths = (dir, id) => {
|
|
10
12
|
if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
|
|
11
13
|
throw new UsageError('Invalid device request id.');
|
|
@@ -39,12 +41,19 @@ export function answerDeviceApproval(dir, id, choice) {
|
|
|
39
41
|
throw new UsageError('Access outside the conversation folder must be approved once.');
|
|
40
42
|
writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
|
|
41
43
|
}
|
|
44
|
+
/** Config dirs whose helper printed the choose line (once per helper, not per request). */
|
|
45
|
+
const toldChoice = new Set();
|
|
42
46
|
/** Local user-owned files carry approvals; cloud data can never answer its own request. */
|
|
43
47
|
export async function askLocalDeviceApproval(ctx, approval) {
|
|
44
48
|
const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
|
|
45
49
|
rmSync(file.answer, { force: true });
|
|
46
50
|
writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
|
|
47
|
-
|
|
51
|
+
// Not chosen yet (device-mode.ts): one line with the exact commands; choosing one answers this request too.
|
|
52
|
+
if (approval.choose_mode && !toldChoice.has(ctx.store.dir)) {
|
|
53
|
+
toldChoice.add(ctx.store.dir);
|
|
54
|
+
ctx.io.stderr.write(`Device bridge: ${chooseLine()}\n`);
|
|
55
|
+
}
|
|
56
|
+
spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\n${approval.choose_mode ? chooseLine() : 'Review: shard device requests'}`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
|
|
48
57
|
try {
|
|
49
58
|
while (!ctx.signal.aborted) {
|
|
50
59
|
try {
|
|
@@ -62,10 +71,23 @@ export async function askLocalDeviceApproval(ctx, approval) {
|
|
|
62
71
|
rmSync(file.answer, { force: true });
|
|
63
72
|
}
|
|
64
73
|
}
|
|
74
|
+
const activityLine = (a) => `${a.at.slice(0, 16).replace('T', ' ')} ${a.state}${a.exit_code !== undefined ? ` (exit ${a.exit_code})` : ''} ${a.summary}${a.conversation_title ? ` · ${a.conversation_title}` : ''}\n`;
|
|
65
75
|
export const DEVICE_HANDLERS = {
|
|
76
|
+
/** `shard device approvals [auto|read|ask]` (0.27.0+): how this computer answers your agents, and what ran without asking. */
|
|
77
|
+
'device approvals'(ctx, _values, [mode]) {
|
|
78
|
+
const settings = mode ? setDeviceApprovalMode(ctx.store.dir, mode, ctx.store.profile(ctx.apiUrl).session?.user?.id) : readDeviceSettings(ctx.store.dir);
|
|
79
|
+
const recent = deviceActivity(ctx.store.dir, 10);
|
|
80
|
+
if (ctx.json) {
|
|
81
|
+
ctx.io.stdout.write(json({ approvals: settings.approvals ?? null, chosen_at: settings.chosen_at ?? null, recent }));
|
|
82
|
+
return Promise.resolve(0);
|
|
83
|
+
}
|
|
84
|
+
const now = settings.approvals ? `Your agents on ${thisComputer()}: ${MODE_LABELS[settings.approvals]}.\n` : `Your agents on ${thisComputer()}: not chosen yet; each request asks.\n${chooseLine()}\n`;
|
|
85
|
+
ctx.io.stdout.write(now + (recent.length ? `Ran without asking:\n${recent.map(activityLine).join('')}` : ''));
|
|
86
|
+
return Promise.resolve(0);
|
|
87
|
+
},
|
|
66
88
|
'device requests'(ctx) {
|
|
67
89
|
const requests = pendingDeviceApprovals(ctx.store.dir);
|
|
68
|
-
ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}${r.repo_remote ? `\n Repository: ${r.repo_remote}` : ''}${r.brief ? `\n First message: ${r.brief.split('\n')[0].slice(0, 200)}` : ''}\n shard device approve ${r.request_id}${r.scope === 'folder' ? ` [--always: for ${r.folder}]` : ''} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
|
|
90
|
+
ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}${r.repo_remote ? `\n Repository: ${r.repo_remote}` : ''}${r.brief ? `\n First message: ${r.brief.split('\n')[0].slice(0, 200)}` : ''}\n shard device approve ${r.request_id}${r.scope === 'folder' ? ` [--always: for ${r.folder}]` : ''} | shard device deny ${r.request_id}\n`).join('') + (requests.some(r => r.choose_mode) ? `${chooseLine()}\n` : '') : 'No device requests waiting for approval.\n');
|
|
69
91
|
return Promise.resolve(0);
|
|
70
92
|
},
|
|
71
93
|
...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, values, [id]) => {
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -62,8 +62,15 @@ export interface DeviceApproval {
|
|
|
62
62
|
brief?: string;
|
|
63
63
|
/** dir_list: how many levels below the folder are listed. */
|
|
64
64
|
depth?: number;
|
|
65
|
+
/**
|
|
66
|
+
* 0.27.0+: this person has not chosen how this computer answers their agents (device-mode.ts). A surface that knows
|
|
67
|
+
* it asks that first ("Allow everything" / "Ask each time", saved with `shard device approvals auto|ask`): once the
|
|
68
|
+
* mode lets this request run, the helper takes it by itself and this approval goes away; with "Ask each time" the
|
|
69
|
+
* request is then answered as usual. A surface that predates it shows the approval as before.
|
|
70
|
+
*/
|
|
71
|
+
choose_mode?: true;
|
|
65
72
|
}
|
|
66
|
-
type Result = {
|
|
73
|
+
export type Result = {
|
|
67
74
|
state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
|
|
68
75
|
exit_code?: number;
|
|
69
76
|
stdout_b64?: string;
|
|
@@ -72,6 +79,12 @@ type Result = {
|
|
|
72
79
|
dir?: DirListing;
|
|
73
80
|
project?: LinkedProject;
|
|
74
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
|
|
84
|
+
* (422 validation_failed): the link is done, so it is posted again without them (staging 2026-10-17: the answer was
|
|
85
|
+
* refused and the request read as failed).
|
|
86
|
+
*/
|
|
87
|
+
export declare function postResult(post: (answer: Result, again: boolean) => Promise<void>, result: Result): Promise<void>;
|
|
75
88
|
/** Resolve existing ancestors even before the conversation worktree is created. */
|
|
76
89
|
export declare function devicePath(root: string, requested?: string): {
|
|
77
90
|
path: string;
|
|
@@ -105,10 +118,15 @@ export declare function folderApproval(r: DeviceRequest, c: Conversation, target
|
|
|
105
118
|
export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
|
|
106
119
|
export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
|
|
107
120
|
export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
|
|
121
|
+
export type DeviceAsk = (approval: DeviceApproval, signal?: AbortSignal) => Promise<DeviceChoice>;
|
|
108
122
|
export declare class DeviceExecutor {
|
|
109
123
|
#private;
|
|
110
|
-
constructor(ctx: Ctx, ask:
|
|
111
|
-
|
|
124
|
+
constructor(ctx: Ctx, ask: DeviceAsk, deviceId: string);
|
|
125
|
+
/** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
|
|
126
|
+
set deviceId(id: string);
|
|
127
|
+
get deviceId(): string;
|
|
128
|
+
/** `again`: a second answer of the same state with another body (its own idempotency key). */
|
|
129
|
+
result(r: DeviceRequest, result: Result, again?: string): Promise<void>;
|
|
112
130
|
execute(r: DeviceRequest): Promise<void>;
|
|
113
131
|
reap(): Promise<void>;
|
|
114
132
|
close(): Promise<void>;
|
|
@@ -131,4 +149,3 @@ export declare function askConnectedAcp(dir: string, approval: DeviceApproval, s
|
|
|
131
149
|
*/
|
|
132
150
|
export declare function approverSocket(dir: string): string;
|
|
133
151
|
export declare function askDeviceApprover(dir: string, approval: DeviceApproval, signal: AbortSignal): Promise<DeviceChoice | null>;
|
|
134
|
-
export {};
|
package/dist/device-bridge.js
CHANGED
|
@@ -16,6 +16,29 @@ import { withTargetLock } from "./target-lock.js";
|
|
|
16
16
|
import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
|
|
17
17
|
import { yourComputer } from "./this-computer.js";
|
|
18
18
|
import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
|
|
19
|
+
import { ownConversation } from "./agent-config-return.js";
|
|
20
|
+
import { deviceApprovalMode, recordDeviceActivity, runsWithoutAsking } from "./device-mode.js";
|
|
21
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
22
|
+
/**
|
|
23
|
+
* Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
|
|
24
|
+
* (422 validation_failed): the link is done, so it is posted again without them (staging 2026-10-17: the answer was
|
|
25
|
+
* refused and the request read as failed).
|
|
26
|
+
*/
|
|
27
|
+
export async function postResult(post, result) {
|
|
28
|
+
try {
|
|
29
|
+
await post(result, false);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
const project = result.project, status = e.status, code = e.code;
|
|
33
|
+
if (!project?.commit || !(status === 400 || status === 422 || code === 'validation_failed'))
|
|
34
|
+
throw e;
|
|
35
|
+
const older = { ...project };
|
|
36
|
+
delete older.commit;
|
|
37
|
+
delete older.branch;
|
|
38
|
+
// Another body, so its own idempotency key.
|
|
39
|
+
await post({ ...result, project: older }, true);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
|
|
20
43
|
const inside = (root, target) => target === root || target.startsWith(root + sep);
|
|
21
44
|
/**
|
|
@@ -203,9 +226,82 @@ export class DeviceExecutor {
|
|
|
203
226
|
#ask;
|
|
204
227
|
#deviceId;
|
|
205
228
|
#handles = new Map();
|
|
229
|
+
/** Requests that run without asking (device-mode.ts), until their outcome is recorded. */
|
|
230
|
+
#auto = new Map();
|
|
231
|
+
#identity = { at: 0 };
|
|
206
232
|
constructor(ctx, ask, deviceId) { this.#ctx = ctx; this.#ask = ask; this.#deviceId = deviceId; }
|
|
207
|
-
|
|
208
|
-
|
|
233
|
+
/** A new device id for a new sign-in (device-helper.ts): requests for the old one are no longer this device's. */
|
|
234
|
+
set deviceId(id) { this.#deviceId = id; this.#identity = { at: 0 }; }
|
|
235
|
+
get deviceId() { return this.#deviceId; }
|
|
236
|
+
/** `again`: a second answer of the same state with another body (its own idempotency key). */
|
|
237
|
+
async result(r, result, again = '') {
|
|
238
|
+
// How a request that ran without asking ended is kept here first: it ran on this computer whether or not the
|
|
239
|
+
// answer reaches the API (a stopped helper, a network error).
|
|
240
|
+
const auto = this.#auto.get(r.id);
|
|
241
|
+
if (auto && result.state !== 'approved' && result.state !== 'running') {
|
|
242
|
+
this.#auto.delete(r.id);
|
|
243
|
+
recordDeviceActivity(this.#ctx.store.dir, { ...auto, at: new Date().toISOString(), state: result.state, ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}) });
|
|
244
|
+
}
|
|
245
|
+
await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}${again}`, signal: this.#ctx.signal });
|
|
246
|
+
}
|
|
247
|
+
/** This computer's person (the signed-in user, or who created its API key), checked every 10 minutes. */
|
|
248
|
+
async #me() {
|
|
249
|
+
if (Date.now() - this.#identity.at < 10 * 60_000)
|
|
250
|
+
return this.#identity.userId;
|
|
251
|
+
let userId = this.#ctx.store.profile(this.#ctx.apiUrl).session?.user?.id;
|
|
252
|
+
if (!userId) {
|
|
253
|
+
try {
|
|
254
|
+
const me = await conversationClient(this.#ctx).me();
|
|
255
|
+
userId = me.user?.id ?? me.api_key?.created_by_user_id ?? undefined;
|
|
256
|
+
}
|
|
257
|
+
catch { /* unknown: only conversations started here are this person's */ }
|
|
258
|
+
}
|
|
259
|
+
this.#identity = { at: Date.now(), ...(userId ? { userId } : {}) };
|
|
260
|
+
return userId;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Allow once by a grant, by this person's approval mode (device-mode.ts), or by asking. Only this person's own
|
|
264
|
+
* agents run without asking. Until they choose a mode, their first request asks for it (`choose_mode`), and a mode
|
|
265
|
+
* chosen while a request waits (the prompt, Settings, `shard device approvals`) takes the request by itself.
|
|
266
|
+
*/
|
|
267
|
+
async #decide(r, c, approval, granted) {
|
|
268
|
+
if (granted)
|
|
269
|
+
return 'once';
|
|
270
|
+
const dir = this.#ctx.store.dir, me = await this.#me();
|
|
271
|
+
const mine = ownConversation(c, localConversations(this.#ctx), me);
|
|
272
|
+
const mode = deviceApprovalMode(dir, me);
|
|
273
|
+
const auto = (m) => {
|
|
274
|
+
this.#auto.set(r.id, { at: new Date().toISOString(), request_id: r.id, conversation_id: c.id, kind: r.kind, summary: approval.summary, mode: m, state: 'running',
|
|
275
|
+
...(approval.conversation_title ? { conversation_title: approval.conversation_title } : {}), ...(approval.project_name ? { project_name: approval.project_name } : {}),
|
|
276
|
+
...(approval.argv ? { argv: approval.argv } : {}), ...(approval.local_path ? { local_path: approval.local_path } : {}) });
|
|
277
|
+
recordDeviceActivity(dir, this.#auto.get(r.id));
|
|
278
|
+
return 'once';
|
|
279
|
+
};
|
|
280
|
+
if (mine && mode && runsWithoutAsking(mode, r.kind))
|
|
281
|
+
return auto(mode);
|
|
282
|
+
if (!mine)
|
|
283
|
+
return this.#ask(approval);
|
|
284
|
+
const stop = new AbortController();
|
|
285
|
+
let chosen;
|
|
286
|
+
const watch = (async () => {
|
|
287
|
+
while (!stop.signal.aborted) {
|
|
288
|
+
await delay(400, undefined, { signal: stop.signal }).catch(() => undefined);
|
|
289
|
+
const now = deviceApprovalMode(dir, me);
|
|
290
|
+
if (!stop.signal.aborted && now && runsWithoutAsking(now, r.kind)) {
|
|
291
|
+
chosen = now;
|
|
292
|
+
stop.abort();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
try {
|
|
297
|
+
const choice = await this.#ask(mode ? approval : { ...approval, choose_mode: true }, stop.signal).catch((e) => { if (chosen)
|
|
298
|
+
return 'once'; throw e; });
|
|
299
|
+
return chosen ? auto(chosen) : choice;
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
stop.abort();
|
|
303
|
+
await watch;
|
|
304
|
+
}
|
|
209
305
|
}
|
|
210
306
|
async execute(r) {
|
|
211
307
|
const ctx = this.#ctx;
|
|
@@ -218,8 +314,10 @@ export class DeviceExecutor {
|
|
|
218
314
|
const matching = Object.values(records).filter(record => record.project_id === r.project_id);
|
|
219
315
|
const folders = new Set(matching.map(record => record.folder));
|
|
220
316
|
const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
|
|
317
|
+
// No folder of this conversation on this computer: a home conversation's command (an orchestrator thread asking the
|
|
318
|
+
// person's computer) runs in their home folder once they allow it; anything else is answered, never left waiting.
|
|
221
319
|
if (!record || record.project_id !== r.project_id)
|
|
222
|
-
return;
|
|
320
|
+
return this.#unplaced(r);
|
|
223
321
|
let c = await getConversation(ctx, r.conversation_id);
|
|
224
322
|
if (c.project_id !== r.project_id)
|
|
225
323
|
throw new Error('Device request project changed.');
|
|
@@ -227,7 +325,7 @@ export class DeviceExecutor {
|
|
|
227
325
|
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
228
326
|
const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
229
327
|
target.outside ||= planned.outside;
|
|
230
|
-
const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside)
|
|
328
|
+
const choice = await this.#decide(r, c, deviceApproval(r, c, target, record.folder), grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside));
|
|
231
329
|
if (choice === 'deny') {
|
|
232
330
|
await this.result(r, { state: 'denied' });
|
|
233
331
|
return;
|
|
@@ -331,6 +429,74 @@ export class DeviceExecutor {
|
|
|
331
429
|
ws?.cell().close();
|
|
332
430
|
}
|
|
333
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* A request whose conversation has no folder on this device (0.27.0+). A home conversation's exec runs in the home
|
|
434
|
+
* folder, or the ~/ or absolute directory it names, after the person allows this one request (never Always: commands
|
|
435
|
+
* never run under a grant). Everything else is refused with the reason, so its agent hears it instead of waiting forever.
|
|
436
|
+
*/
|
|
437
|
+
async #unplaced(r) {
|
|
438
|
+
const ctx = this.#ctx, home = deviceHome(ctx), say = (state, message) => this.result(r, { state, stderr_b64: Buffer.from(message).toString('base64') });
|
|
439
|
+
let c;
|
|
440
|
+
try {
|
|
441
|
+
c = await getConversation(ctx, r.conversation_id);
|
|
442
|
+
if (c.project_id !== r.project_id)
|
|
443
|
+
throw new Error('Device request project changed.');
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
if (ctx.signal.aborted)
|
|
447
|
+
return;
|
|
448
|
+
await say('denied', e instanceof Error ? e.message : String(e));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (archived(c)) {
|
|
452
|
+
await say('denied', 'The conversation has ended.');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (r.kind !== 'exec' || !r.exec || c.home !== true) {
|
|
456
|
+
await say('denied', `This conversation has no folder on ${yourComputer()}: commands and file moves need its folder here (open the conversation on this computer), or a ~/ path for a file.`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
let target;
|
|
460
|
+
try {
|
|
461
|
+
const requested = r.exec.cwd_relative && r.exec.cwd_relative !== '.' ? r.exec.cwd_relative : '~';
|
|
462
|
+
target = canonicalDevicePath(expandDevicePath(isDevicePath(requested) ? requested : `~/${requested}`, home));
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
await say('denied', e instanceof Error ? e.message : String(e));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const approval = { ...deviceApproval(r, c, { path: target, outside: true }, home),
|
|
469
|
+
summary: `Run on ${yourComputer()}: ${r.exec.argv.join(' ')} · in ${displayPath(target, home)}` };
|
|
470
|
+
let choice;
|
|
471
|
+
try {
|
|
472
|
+
choice = await this.#decide(r, c, approval, false);
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
if (ctx.signal.aborted)
|
|
476
|
+
return;
|
|
477
|
+
await say('denied', e instanceof Error ? e.message : String(e));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (ctx.signal.aborted)
|
|
481
|
+
return;
|
|
482
|
+
if (choice !== 'once') {
|
|
483
|
+
await this.result(r, { state: 'denied' });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const current = await getConversation(ctx, r.conversation_id).catch(() => undefined);
|
|
487
|
+
if (!current || current.project_id !== c.project_id || archived(current)) {
|
|
488
|
+
await say('denied', 'The conversation changed or ended during device approval.');
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await this.result(r, { state: 'approved' });
|
|
492
|
+
await this.result(r, { state: 'running' });
|
|
493
|
+
try {
|
|
494
|
+
await this.result(r, await deviceExec(r.exec.argv, target, r.exec.timeout_seconds, ctx.signal));
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
await say('failed', e instanceof Error ? e.message : String(e));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
334
500
|
/**
|
|
335
501
|
* A folder anywhere on this device (device-folders.ts). Every outcome is answered, so a request is never asked twice:
|
|
336
502
|
* denied before it was approved, failed after.
|
|
@@ -371,7 +537,7 @@ export class DeviceExecutor {
|
|
|
371
537
|
const grants = new DeviceGrants(ctx.store.dir);
|
|
372
538
|
let choice;
|
|
373
539
|
try {
|
|
374
|
-
choice =
|
|
540
|
+
choice = await this.#decide(r, c, folderApproval(r, c, target, folder, home, remote), grants.allowsFolder(ctx.apiUrl, target, r.kind));
|
|
375
541
|
}
|
|
376
542
|
catch (e) {
|
|
377
543
|
if (ctx.signal.aborted)
|
|
@@ -412,7 +578,7 @@ export class DeviceExecutor {
|
|
|
412
578
|
if (r.kind === 'dir_list')
|
|
413
579
|
result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
|
|
414
580
|
else if (r.kind === 'project_link')
|
|
415
|
-
result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id }) };
|
|
581
|
+
result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id, seed: true }) };
|
|
416
582
|
else {
|
|
417
583
|
const f = r.files;
|
|
418
584
|
if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
|
|
@@ -445,7 +611,7 @@ export class DeviceExecutor {
|
|
|
445
611
|
}
|
|
446
612
|
result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : target };
|
|
447
613
|
}
|
|
448
|
-
await this.result(r, result);
|
|
614
|
+
await postResult((answer, again) => this.result(r, answer, again ? ':older-api' : ''), result);
|
|
449
615
|
}
|
|
450
616
|
catch (e) {
|
|
451
617
|
await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
|
package/dist/device-folders.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Ctx, Handler } from './commands.js';
|
|
2
|
+
import type { HubSeed } from './project-hub-seed.js';
|
|
2
3
|
export declare const FOLDER_KINDS: Set<string>;
|
|
3
4
|
/** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
|
|
4
5
|
export declare function isDevicePath(path: string): boolean;
|
|
@@ -25,12 +26,15 @@ export declare function listDeviceDir(root: string, options?: {
|
|
|
25
26
|
max_entries?: number;
|
|
26
27
|
include_hidden?: boolean;
|
|
27
28
|
}, signal?: AbortSignal): Promise<DirListing>;
|
|
29
|
+
/** `commit` and `branch`: where work on it starts, in the project hub (an agent's link, 0.28.0+). */
|
|
28
30
|
export interface LinkedProject {
|
|
29
31
|
project_id: string;
|
|
30
32
|
repo_remote: string;
|
|
31
33
|
folder: string;
|
|
32
34
|
name: string;
|
|
33
35
|
conversation_id?: string;
|
|
36
|
+
commit?: string;
|
|
37
|
+
branch?: string;
|
|
34
38
|
}
|
|
35
39
|
type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
|
|
36
40
|
id: string;
|
|
@@ -42,6 +46,14 @@ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: st
|
|
|
42
46
|
* prompt. The brief goes in on standard input, never in a command line another user can read.
|
|
43
47
|
*/
|
|
44
48
|
export declare const startFromFolder: Starter;
|
|
49
|
+
/**
|
|
50
|
+
* The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
|
|
51
|
+
* (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).
|
|
52
|
+
*/
|
|
53
|
+
export declare function startedConversation(stdout: string): {
|
|
54
|
+
id: string;
|
|
55
|
+
project_id: string;
|
|
56
|
+
};
|
|
45
57
|
/**
|
|
46
58
|
* `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
|
|
47
59
|
* project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
|
|
@@ -52,7 +64,8 @@ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
|
|
|
52
64
|
brief?: string;
|
|
53
65
|
harness?: 'claude' | 'codex';
|
|
54
66
|
projectId?: string;
|
|
55
|
-
|
|
67
|
+
seed?: boolean;
|
|
68
|
+
}, start?: Starter, seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>): Promise<LinkedProject>;
|
|
56
69
|
/** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
|
|
57
70
|
export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
|
|
58
71
|
export {};
|
package/dist/device-folders.js
CHANGED
|
@@ -18,6 +18,7 @@ import { UsageError } from "./errors.js";
|
|
|
18
18
|
import { ensureProjectGit } from "./project-git.js";
|
|
19
19
|
import { normalizeRemote, projectIdentity } from "./project-identity.js";
|
|
20
20
|
import { selectProject } from "./project-preparation.js";
|
|
21
|
+
import { seedProjectHub } from "./project-hub-seed.js";
|
|
21
22
|
export const FOLDER_KINDS = new Set(['dir_list', 'project_link']);
|
|
22
23
|
/** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
|
|
23
24
|
export function isDevicePath(path) { return isAbsolute(path) || path === '~' || path.startsWith('~/'); }
|
|
@@ -148,10 +149,11 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
|
|
|
148
149
|
env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
|
|
149
150
|
});
|
|
150
151
|
const out = [], err = [];
|
|
152
|
+
let outBytes = 0;
|
|
151
153
|
const stop = () => child.kill('SIGTERM');
|
|
152
154
|
const timer = setTimeout(stop, 300_000);
|
|
153
155
|
ctx.signal.addEventListener('abort', stop, { once: true });
|
|
154
|
-
child.stdout.on('data', (b) => { if (
|
|
156
|
+
child.stdout.on('data', (b) => { if ((outBytes += b.length) <= 8 << 20)
|
|
155
157
|
out.push(b); });
|
|
156
158
|
child.stderr.on('data', (b) => { if (err.length < 64)
|
|
157
159
|
err.push(b); });
|
|
@@ -163,11 +165,7 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
|
|
|
163
165
|
try {
|
|
164
166
|
if (code !== 0)
|
|
165
167
|
throw new Error(lastLine(Buffer.concat(err).toString('utf8')) || `The conversation did not start (exit ${code}).`);
|
|
166
|
-
|
|
167
|
-
const id = started.conversation?.id ?? started.workspace?.labels?.shard_conversation;
|
|
168
|
-
if (!id)
|
|
169
|
-
throw new Error('The conversation did not start.');
|
|
170
|
-
resolveStart({ id, project_id: started.conversation?.project_id ?? started.workspace?.project_id ?? '' });
|
|
168
|
+
resolveStart(startedConversation(text));
|
|
171
169
|
}
|
|
172
170
|
catch (error) {
|
|
173
171
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -176,6 +174,17 @@ export const startFromFolder = (ctx, folder, harness, brief) => {
|
|
|
176
174
|
child.stdin.end(brief);
|
|
177
175
|
});
|
|
178
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
|
|
179
|
+
* (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).
|
|
180
|
+
*/
|
|
181
|
+
export function startedConversation(stdout) {
|
|
182
|
+
const started = JSON.parse(stdout);
|
|
183
|
+
const id = started.conversation?.id ?? started.workspace?.labels?.shard_conversation;
|
|
184
|
+
if (!id)
|
|
185
|
+
throw new Error('The conversation did not start.');
|
|
186
|
+
return { id, project_id: started.conversation?.project_id ?? started.workspace?.project_id ?? '' };
|
|
187
|
+
}
|
|
179
188
|
function lastLine(stderr) {
|
|
180
189
|
const text = stderr.trim();
|
|
181
190
|
try {
|
|
@@ -192,7 +201,7 @@ function lastLine(stderr) {
|
|
|
192
201
|
* and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
|
|
193
202
|
* conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
|
|
194
203
|
*/
|
|
195
|
-
export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder) {
|
|
204
|
+
export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder, seedHub = seedProjectHub) {
|
|
196
205
|
const { folder } = projectIdentity(path);
|
|
197
206
|
await ensureProjectGit(ctx, folder);
|
|
198
207
|
const identity = projectIdentity(folder);
|
|
@@ -210,7 +219,10 @@ export async function linkLocalProject(ctx, path, options = {}, start = startFro
|
|
|
210
219
|
if (!projectId)
|
|
211
220
|
throw new UsageError('Choose your default project first: shard projects use <id|slug>.');
|
|
212
221
|
const name = [...(identity.remote ? identity.slug : basename(folder))].slice(0, 200).join('') || identity.slug;
|
|
213
|
-
|
|
222
|
+
// An agent's link: the folder as it is now goes to the project hub, so its work can start in the cloud at once.
|
|
223
|
+
const seed = options.seed ? await seedHub(ctx, folder, projectId) : null;
|
|
224
|
+
return { project_id: projectId, repo_remote: identity.remote ?? `file://${folder}`, folder, name, ...(conversationId ? { conversation_id: conversationId } : {}),
|
|
225
|
+
...(seed ? { commit: seed.commit, ...(seed.branch ? { branch: seed.branch } : {}) } : {}) };
|
|
214
226
|
}
|
|
215
227
|
/** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
|
|
216
228
|
export const PROJECT_LINK_HANDLERS = {
|
package/dist/device-helper.d.ts
CHANGED
|
@@ -9,3 +9,11 @@ export declare function runDeviceHelper(ctx: Ctx, options?: {
|
|
|
9
9
|
* A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
|
|
10
10
|
*/
|
|
11
11
|
export declare function deviceProjects(ctx: Ctx): Promise<string[]>;
|
|
12
|
+
/** 409 `device_id_taken`: this device id is registered in a project by another identity (POST /v1/device-requests/heartbeat). */
|
|
13
|
+
export declare function deviceIdTaken(e: unknown): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A new device id for this computer, replacing `old` (0.27.0+). Local state stays: grants are kept by API and folder
|
|
16
|
+
* or project (device-bridge.ts DeviceGrants), the approval mode by person (device-mode.ts), and the twin's refs by the
|
|
17
|
+
* id that made them. The old id is kept in `device-id-history.json` (the last 20) for support.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renewDeviceId(dir: string, old: string, projectId: string): string;
|