@shardflux/cli 0.23.1 → 0.24.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +249 -4
  3. package/dist/account-args.js +8 -3
  4. package/dist/account-commands.js +15 -2
  5. package/dist/account-pool.js +5 -1
  6. package/dist/acp-journal.d.ts +2 -2
  7. package/dist/acp-journal.js +14 -4
  8. package/dist/acp-permission-elsewhere.d.ts +38 -0
  9. package/dist/acp-permission-elsewhere.js +85 -0
  10. package/dist/acp-rpc.d.ts +2 -1
  11. package/dist/acp-rpc.js +11 -2
  12. package/dist/acp.js +125 -30
  13. package/dist/agent-credentials.d.ts +3 -0
  14. package/dist/agent-credentials.js +7 -0
  15. package/dist/args.d.ts +1 -1
  16. package/dist/args.js +154 -8
  17. package/dist/board-args.d.ts +6 -0
  18. package/dist/board-args.js +155 -0
  19. package/dist/board-commands.d.ts +62 -0
  20. package/dist/board-commands.js +395 -0
  21. package/dist/codex-cloud.d.ts +30 -3
  22. package/dist/codex-cloud.js +86 -12
  23. package/dist/conversation-commands.d.ts +30 -0
  24. package/dist/conversation-commands.js +312 -9
  25. package/dist/conversation-transcript.d.ts +44 -0
  26. package/dist/conversation-transcript.js +206 -0
  27. package/dist/conversations.d.ts +5 -2
  28. package/dist/conversations.js +20 -10
  29. package/dist/default-volume-commands.d.ts +3 -0
  30. package/dist/default-volume-commands.js +35 -0
  31. package/dist/device-approvals.js +4 -3
  32. package/dist/device-bridge.d.ts +42 -1
  33. package/dist/device-bridge.js +241 -14
  34. package/dist/device-folders.d.ts +58 -0
  35. package/dist/device-folders.js +228 -0
  36. package/dist/device-helper.d.ts +6 -0
  37. package/dist/device-helper.js +87 -14
  38. package/dist/drive-commands.d.ts +25 -0
  39. package/dist/drive-commands.js +201 -0
  40. package/dist/filesystem-commands.js +2 -1
  41. package/dist/handlers.js +6 -1
  42. package/dist/local-sessions.d.ts +7 -1
  43. package/dist/local-sessions.js +38 -0
  44. package/dist/main.js +3 -0
  45. package/dist/move-files.d.ts +39 -0
  46. package/dist/move-files.js +167 -0
  47. package/dist/move-interrupt.d.ts +18 -0
  48. package/dist/move-interrupt.js +193 -0
  49. package/dist/move.js +28 -17
  50. package/dist/onboarding.d.ts +6 -2
  51. package/dist/onboarding.js +24 -6
  52. package/dist/project-git.d.ts +7 -1
  53. package/dist/project-git.js +16 -3
  54. package/dist/project-preparation.d.ts +7 -0
  55. package/dist/project-preparation.js +14 -0
  56. package/dist/project-template-setup.js +32 -11
  57. package/dist/router-commands.d.ts +22 -0
  58. package/dist/router-commands.js +179 -0
  59. package/dist/worktree-twin-engine.js +16 -6
  60. package/package.json +7 -5
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Folders on this device that a conversation's agent asks about (0.25.0+): `dir_list` lists one (names, kinds, sizes
3
+ * and Git remotes; never file contents), `project_link` makes one a project of yours, seeded from here, and
4
+ * `files_pull`/`files_push` with an absolute or `~/` local path move a file to or from anywhere on this device.
5
+ *
6
+ * Nothing runs before this device approves it. The choices are Allow once and Always for this folder. Always covers
7
+ * that kind of request for that folder and everything inside it, resolved through symlinks, from any of your
8
+ * conversations on this API: a path outside every such folder asks again, and `exec` never uses a folder grant.
9
+ */
10
+ import { execFile, spawn } from 'node:child_process';
11
+ import { existsSync, realpathSync } from 'node:fs';
12
+ import { lstat, readdir } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+ import { out, str } from "./commands.js";
16
+ import { readInitialPrompt } from "./initial-prompt.js";
17
+ import { UsageError } from "./errors.js";
18
+ import { ensureProjectGit } from "./project-git.js";
19
+ import { normalizeRemote, projectIdentity } from "./project-identity.js";
20
+ import { selectProject } from "./project-preparation.js";
21
+ export const FOLDER_KINDS = new Set(['dir_list', 'project_link']);
22
+ /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
23
+ export function isDevicePath(path) { return isAbsolute(path) || path === '~' || path.startsWith('~/'); }
24
+ export function deviceHome(ctx) { return ctx.io.env.HOME || homedir(); }
25
+ export function expandDevicePath(path, home) {
26
+ if (/[\0\r\n]/.test(path))
27
+ throw new UsageError('Device path contains invalid characters.');
28
+ if (path === '~')
29
+ return resolve(home);
30
+ if (path.startsWith('~/'))
31
+ return resolve(home, path.slice(2));
32
+ if (!isAbsolute(path))
33
+ throw new UsageError('Device path must be absolute or start with ~/.');
34
+ return resolve(path);
35
+ }
36
+ /** Through every symlink of the part that exists; a path that does not exist yet keeps its missing tail. */
37
+ export function canonicalDevicePath(target) {
38
+ let ancestor = target;
39
+ while (!existsSync(ancestor)) {
40
+ const parent = dirname(ancestor);
41
+ if (parent === ancestor)
42
+ throw new UsageError('Device path is unavailable.');
43
+ ancestor = parent;
44
+ }
45
+ return resolve(realpathSync(ancestor), relative(ancestor, target));
46
+ }
47
+ /** `/Users/me/code` as `~/code`: how a person reads their own folders. */
48
+ export function displayPath(path, home) {
49
+ const h = resolve(home);
50
+ return path === h ? '~' : path.startsWith(h + sep) ? `~/${path.slice(h.length + 1)}` : path;
51
+ }
52
+ /** Never descended into: their contents are noise to a person choosing a folder, and unbounded. */
53
+ const OPAQUE = new Set(['.git', 'node_modules', '.shard']);
54
+ /** The listing must fit the result body (1 MiB); paths and remotes over the API's 1024 characters are left out. */
55
+ const LISTING_BYTES = 900 << 10, NAME_MAX = 1024, GIT_LOOKUPS = 200;
56
+ function gitRemote(dir, signal) {
57
+ return new Promise(resolveRemote => {
58
+ execFile('git', ['-C', dir, 'remote', 'get-url', 'origin'], { timeout: 5000, signal, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout) => {
59
+ if (error || !stdout.trim()) {
60
+ resolveRemote(undefined);
61
+ return;
62
+ }
63
+ // normalizeRemote drops the transport's user and password: a listing never carries credentials.
64
+ try {
65
+ const remote = normalizeRemote(stdout.trim(), dir);
66
+ resolveRemote(remote.length <= NAME_MAX ? remote : undefined);
67
+ }
68
+ catch {
69
+ resolveRemote(undefined);
70
+ }
71
+ });
72
+ });
73
+ }
74
+ /** Names, kinds, sizes and Git remotes under `root`, breadth first, bounded in depth, count and bytes. */
75
+ export async function listDeviceDir(root, options = {}, signal) {
76
+ const depth = Math.min(Math.max(options.depth ?? 1, 1), 3), max = Math.min(Math.max(options.max_entries ?? 500, 1), 2000);
77
+ if (!(await lstat(root)).isDirectory())
78
+ throw new UsageError('That path is not a folder.');
79
+ const entries = [], repos = [];
80
+ let truncated = false, bytes = 0, queue = [{ dir: root, rel: '', level: 1 }];
81
+ while (queue.length && !truncated) {
82
+ const next = [];
83
+ for (const { dir, rel, level } of queue) {
84
+ signal?.throwIfAborted();
85
+ let names;
86
+ try {
87
+ names = (await readdir(dir)).sort((a, b) => a.localeCompare(b));
88
+ }
89
+ catch {
90
+ continue;
91
+ } // an unreadable folder lists as itself
92
+ for (const name of names) {
93
+ if (!options.include_hidden && name.startsWith('.'))
94
+ continue;
95
+ const path = rel ? `${rel}/${name}` : name, full = join(dir, name);
96
+ if (path.length > NAME_MAX) {
97
+ truncated = true;
98
+ continue;
99
+ }
100
+ let info;
101
+ try {
102
+ info = await lstat(full);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const kind = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other';
108
+ const entry = { path, kind, ...(kind === 'file' ? { size: info.size } : {}) };
109
+ bytes += JSON.stringify(entry).length + 80;
110
+ if (entries.length >= max || bytes > LISTING_BYTES) {
111
+ truncated = true;
112
+ break;
113
+ }
114
+ entries.push(entry);
115
+ if (kind !== 'dir')
116
+ continue;
117
+ if (existsSync(join(full, '.git')) && repos.length < GIT_LOOKUPS)
118
+ repos.push({ index: entries.length - 1, dir: full });
119
+ if (level < depth && !OPAQUE.has(name))
120
+ next.push({ dir: full, rel: path, level: level + 1 });
121
+ }
122
+ if (truncated)
123
+ break;
124
+ }
125
+ queue = next;
126
+ }
127
+ // Git remotes are read eight at a time, each bounded: a folder of a hundred repositories lists in about a second.
128
+ const own = existsSync(join(root, '.git')) ? gitRemote(root, signal) : Promise.resolve(undefined);
129
+ for (let i = 0; i < repos.length; i += 8) {
130
+ await Promise.all(repos.slice(i, i + 8).map(async (repo) => { const remote = await gitRemote(repo.dir, signal); if (remote)
131
+ entries[repo.index].git_remote = remote; }));
132
+ }
133
+ const remote = await own;
134
+ return { path: root, entries, truncated, ...(remote ? { git_remote: remote } : {}) };
135
+ }
136
+ /**
137
+ * A new chat in `folder`, started the way `shard agents new --path` starts one, in its own process: the folder's
138
+ * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
139
+ * prompt. The brief goes in on standard input, never in a command line another user can read.
140
+ */
141
+ export const startFromFolder = (ctx, folder, harness, brief) => {
142
+ const node = ctx.runtime?.node ?? process.execPath, script = ctx.runtime?.script ?? process.argv[1];
143
+ if (!script)
144
+ return Promise.reject(new Error('The Shardflux CLI could not find itself to start the conversation.'));
145
+ return new Promise((resolveStart, reject) => {
146
+ const child = spawn(node, [script, 'agents', 'new', '--app', harness, '--path', folder, '--prompt-file', '-', '--json'], {
147
+ cwd: folder, stdio: ['pipe', 'pipe', 'pipe'],
148
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
149
+ });
150
+ const out = [], err = [];
151
+ const stop = () => child.kill('SIGTERM');
152
+ const timer = setTimeout(stop, 300_000);
153
+ ctx.signal.addEventListener('abort', stop, { once: true });
154
+ child.stdout.on('data', (b) => { if (out.length < 256)
155
+ out.push(b); });
156
+ child.stderr.on('data', (b) => { if (err.length < 64)
157
+ err.push(b); });
158
+ child.once('error', error => { clearTimeout(timer); reject(error); });
159
+ child.once('close', code => {
160
+ clearTimeout(timer);
161
+ ctx.signal.removeEventListener('abort', stop);
162
+ const text = Buffer.concat(out).toString('utf8');
163
+ try {
164
+ if (code !== 0)
165
+ throw new Error(lastLine(Buffer.concat(err).toString('utf8')) || `The conversation did not start (exit ${code}).`);
166
+ const started = JSON.parse(text.trim().split('\n').at(-1));
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 ?? '' });
171
+ }
172
+ catch (error) {
173
+ reject(error instanceof Error ? error : new Error(String(error)));
174
+ }
175
+ });
176
+ child.stdin.end(brief);
177
+ });
178
+ };
179
+ function lastLine(stderr) {
180
+ const text = stderr.trim();
181
+ try {
182
+ const doc = JSON.parse(text);
183
+ if (doc.error?.message)
184
+ return doc.error.message;
185
+ }
186
+ catch { /* plain text */ }
187
+ return text.split('\n').at(-1)?.slice(0, 2000) ?? '';
188
+ }
189
+ /**
190
+ * `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
191
+ * project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
192
+ * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
193
+ * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
194
+ */
195
+ export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder) {
196
+ const { folder } = projectIdentity(path);
197
+ await ensureProjectGit(ctx, folder);
198
+ const identity = projectIdentity(folder);
199
+ selectProject(ctx, folder);
200
+ const profile = ctx.store.profile(ctx.apiUrl);
201
+ let projectId = profile.project_id ?? profile.api_key?.project_id ?? options.projectId, conversationId;
202
+ if (options.brief) {
203
+ const started = await start(ctx, folder, options.harness ?? 'claude', options.brief);
204
+ conversationId = started.id;
205
+ projectId = started.project_id || projectId;
206
+ }
207
+ // An API key from the environment (the desktop app's sign-in) names its project only to the API.
208
+ if (!projectId && ctx.io.env.SHARDFLUX_API_KEY?.trim())
209
+ projectId = (await ctx.cloud().me()).api_key?.project_id;
210
+ if (!projectId)
211
+ throw new UsageError('Choose your default project first: shard projects use <id|slug>.');
212
+ const name = [...(identity.remote ? identity.slug : basename(folder))].slice(0, 200).join('') || identity.slug;
213
+ return { project_id: projectId, repo_remote: identity.remote ?? `file://${folder}`, folder, name, ...(conversationId ? { conversation_id: conversationId } : {}) };
214
+ }
215
+ /** `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
+ export const PROJECT_LINK_HANDLERS = {
217
+ async 'projects link'(ctx, values, [path]) {
218
+ if (!path)
219
+ throw new UsageError('Choose a local folder: shard projects link <path>.');
220
+ const app = str(values.app);
221
+ if (app !== undefined && app !== 'claude' && app !== 'codex')
222
+ throw new UsageError('--app must be claude or codex.');
223
+ const brief = await readInitialPrompt(ctx, values);
224
+ const project = await linkLocalProject(ctx, expandDevicePath(isDevicePath(path) ? path : resolve(path), deviceHome(ctx)), { ...(brief ? { brief } : {}), ...(app ? { harness: app } : {}) });
225
+ out(ctx, `Linked ${displayPath(project.folder, deviceHome(ctx))} as ${project.name} (${project.repo_remote}); it is prepared in the background.\n${project.conversation_id ? `Started conversation ${project.conversation_id}.\n` : ''}`, { project });
226
+ return 0;
227
+ },
228
+ };
@@ -3,3 +3,9 @@ import type { Ctx } from './commands.js';
3
3
  export declare function runDeviceHelper(ctx: Ctx, options?: {
4
4
  service?: boolean;
5
5
  }): Promise<number>;
6
+ /**
7
+ * The projects this device answers for besides its local conversations' (0.25.0+): the signed-in project (the API
8
+ * key's, or the default one), and with a person's own sign-in and no key, every project they can reach (at most 100).
9
+ * A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
10
+ */
11
+ export declare function deviceProjects(ctx: Ctx): Promise<string[]>;
@@ -1,5 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
- import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
2
+ import { DeviceExecutor, askConnectedAcp, askDeviceApprover } from "./device-bridge.js";
3
+ import { ShardfluxApiError } from '@shardflux/sdk';
4
+ import { allOrgs, allProjects } from "./account-scope.js";
3
5
  import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
4
6
  import { FilesMirrorState, mirrorFilesOnce } from "./files-mirror.js";
5
7
  import { withTargetLock } from "./target-lock.js";
@@ -44,6 +46,10 @@ export async function runDeviceHelper(ctx, options = {}) {
44
46
  const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
45
47
  if (acp)
46
48
  return acp;
49
+ // The desktop app, while it runs, shows it as a Needs-you item (0.25.0+); else the menu bar or the service files.
50
+ const app = await askDeviceApprover(ctx.store.dir, approval, signal);
51
+ if (app)
52
+ return app;
47
53
  if (options.service)
48
54
  return askLocalDeviceApproval(ctx, approval);
49
55
  return new Promise(resolve => {
@@ -141,22 +147,60 @@ export async function runDeviceHelper(ctx, options = {}) {
141
147
  await delay(wait, undefined, { signal }).catch(() => undefined);
142
148
  }
143
149
  })();
150
+ // Registered for every project an agent may ask from (deviceProjects); one poll covers them all where the API
151
+ // lists by device (0.25.0+), else one per project.
152
+ const registered = new Map();
153
+ let all, deviceWide = true;
154
+ const take = (rows) => {
155
+ for (const r of rows) {
156
+ if (r.device_id !== deviceId || working.has(r.id) || working.size >= 8)
157
+ continue;
158
+ const work = executor.execute(r).catch(report).finally(() => working.delete(r.id));
159
+ working.set(r.id, work);
160
+ }
161
+ };
144
162
  while (!signal.aborted) {
145
163
  try {
146
- for (const projectId of new Set(Object.values(localConversations(ctx)).map(r => r.project_id))) {
147
- await conversationClient(ctx).deviceRequests.heartbeat({ projectId, deviceId, leaseSeconds: 30 });
148
- let cursor;
149
- do {
150
- const page = await conversationClient(ctx).deviceRequests.list({ projectId, state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
151
- for (const r of page.data) {
152
- if (r.device_id !== deviceId || working.has(r.id) || working.size >= 8)
153
- continue;
154
- const work = executor.execute(r).catch(report).finally(() => working.delete(r.id));
155
- working.set(r.id, work);
156
- }
157
- cursor = page.nextCursor ?? undefined;
158
- } while (cursor && !signal.aborted);
164
+ if (!all || Date.now() - all.at > 300_000)
165
+ all = { ids: await deviceProjects(ctx).catch(() => all?.ids ?? []), at: Date.now() };
166
+ const projects = [...new Set([...Object.values(localConversations(ctx)).map(r => r.project_id), ...all.ids])];
167
+ for (const projectId of projects) {
168
+ if (Date.now() - (registered.get(projectId) ?? 0) < 10_000)
169
+ continue;
170
+ await conversationClient(ctx).deviceRequests.heartbeat({ ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30 }).then(() => registered.set(projectId, Date.now()), (e) => {
171
+ // A project this sign-in no longer reaches is skipped until the next listing, never the others.
172
+ if (!(e instanceof ShardfluxApiError && [403, 404].includes(e.status)))
173
+ throw e;
174
+ registered.set(projectId, Date.now() + 290_000);
175
+ });
176
+ }
177
+ let polled = false;
178
+ if (deviceWide && registered.size) {
179
+ try {
180
+ let cursor;
181
+ do {
182
+ const page = await conversationClient(ctx).deviceRequests.list({ deviceId, state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
183
+ take(page.data);
184
+ cursor = page.nextCursor ?? undefined;
185
+ } while (cursor && !signal.aborted);
186
+ polled = true;
187
+ }
188
+ catch (e) {
189
+ if (e instanceof ShardfluxApiError && [400, 422].includes(e.status))
190
+ deviceWide = false;
191
+ else
192
+ throw e;
193
+ }
159
194
  }
195
+ if (!polled)
196
+ for (const projectId of projects) {
197
+ let cursor;
198
+ do {
199
+ const page = await conversationClient(ctx).deviceRequests.list({ ...(projectId ? { projectId } : {}), state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
200
+ take(page.data);
201
+ cursor = page.nextCursor ?? undefined;
202
+ } while (cursor && !signal.aborted);
203
+ }
160
204
  await executor.reap();
161
205
  }
162
206
  catch (e) {
@@ -185,3 +229,32 @@ export async function runDeviceHelper(ctx, options = {}) {
185
229
  await input.catch(() => undefined);
186
230
  }
187
231
  }
232
+ /**
233
+ * The projects this device answers for besides its local conversations' (0.25.0+): the signed-in project (the API
234
+ * key's, or the default one), and with a person's own sign-in and no key, every project they can reach (at most 100).
235
+ * A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
236
+ */
237
+ export async function deviceProjects(ctx) {
238
+ const profile = ctx.store.profile(ctx.apiUrl), ids = new Set();
239
+ if (profile.project_id)
240
+ ids.add(profile.project_id);
241
+ if (profile.api_key?.project_id)
242
+ ids.add(profile.api_key.project_id);
243
+ const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || profile.api_key !== undefined;
244
+ const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || profile.session !== undefined;
245
+ if (key && !ids.size)
246
+ ids.add(''); // the key's own project: the API takes it from the key
247
+ if (!key && session) {
248
+ const account = ctx.account();
249
+ for (const org of await allOrgs(account)) {
250
+ for (const p of await allProjects(account, org.id)) {
251
+ ids.add(p.id);
252
+ if (ids.size >= 100)
253
+ break;
254
+ }
255
+ if (ids.size >= 100)
256
+ break;
257
+ }
258
+ }
259
+ return [...ids];
260
+ }
@@ -0,0 +1,25 @@
1
+ import type { DriveFile, ModelTemplate, Shardflux, ShardfluxAccount } from '@shardflux/sdk';
2
+ import type { Values } from './args.js';
3
+ import type { Ctx, Handler } from './commands.js';
4
+ /** A project API key is in use: SHARDFLUX_API_KEY, else the key `shard setup` saved for this API URL. */
5
+ export declare const hasApiKey: (ctx: Ctx) => boolean;
6
+ export interface ProjectScope {
7
+ client: Shardflux | ShardfluxAccount;
8
+ /** Undefined only when `needId` was false and the API key's own project is meant. */
9
+ projectId: string | undefined;
10
+ }
11
+ /**
12
+ * Who acts and in which project. `--project` acts as the signed-in person when there is a sign-in (their projects,
13
+ * by id or slug), else with the API key. Without it: the API key's own project, else the person's default project.
14
+ * `needId` false leaves the key's project unnamed (the API defaults to it), saving a lookup.
15
+ */
16
+ export declare function projectScope(ctx: Ctx, values: Values, needId?: boolean): Promise<ProjectScope>;
17
+ /** `drive ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
18
+ export declare function driveTable(rows: readonly DriveFile[]): string;
19
+ /**
20
+ * The model template as people read it: the primary provider and why, the connected accounts, then one row per role
21
+ * with what it runs and, for a role whose provider is out of usage, until when it waits and what the other provider
22
+ * would run instead.
23
+ */
24
+ export declare function modelTemplateText(t: ModelTemplate): string;
25
+ export declare const DRIVE_HANDLERS: Record<string, Handler>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The project drive and the model template (0.25.0+): `shard drive ls|get|put|rm` and `shard models`,
3
+ * `shard models primary`. Both act in one project: the API key's own, or `--project`; a person signed in without a
4
+ * key acts in their default project (`shard projects use`), or the only one there is.
5
+ */
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import { basename } from 'node:path';
8
+ import { encodeDrivePath } from '@shardflux/sdk';
9
+ import { UUID } from "./args.js";
10
+ import { resolveOrg, resolveProject } from "./account-scope.js";
11
+ import { out, principal, readInput, str } from "./commands.js";
12
+ import { AuthConfigError, UsageError } from "./errors.js";
13
+ import { humanBytes } from "./files-mirror.js";
14
+ import { json, table, time } from "./format.js";
15
+ /** A drive file of up to 5 GiB is read into memory to send it with its SHA-256. */
16
+ const MAX_PUT_BYTES = 5 * 1024 ** 3;
17
+ const SHA256 = /^[0-9a-f]{64}$/i;
18
+ /** A project API key is in use: SHARDFLUX_API_KEY, else the key `shard setup` saved for this API URL. */
19
+ export const hasApiKey = (ctx) => Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
20
+ /**
21
+ * Who acts and in which project. `--project` acts as the signed-in person when there is a sign-in (their projects,
22
+ * by id or slug), else with the API key. Without it: the API key's own project, else the person's default project.
23
+ * `needId` false leaves the key's project unnamed (the API defaults to it), saving a lookup.
24
+ */
25
+ export async function projectScope(ctx, values, needId = true) {
26
+ const given = str(values.project);
27
+ const signedIn = ctx.sessionSource() !== null;
28
+ if (signedIn && (given !== undefined || !hasApiKey(ctx))) {
29
+ const account = ctx.account();
30
+ return { client: account, projectId: (await resolveProject(ctx, account, values)).id };
31
+ }
32
+ const cloud = ctx.cloud();
33
+ if (given !== undefined) {
34
+ if (!UUID.test(given))
35
+ throw new UsageError(`--project with an API key takes the project id (got "${given}").`, 'or sign in to name projects by slug: shard auth login');
36
+ return { client: cloud, projectId: given };
37
+ }
38
+ if (!needId)
39
+ return { client: cloud, projectId: undefined };
40
+ return { client: cloud, projectId: ctx.apiKey().saved?.project_id ?? (await principal(ctx)).project_id };
41
+ }
42
+ /** A drive path checked as the SDK sends it: relative, no empty, `.` or `..` segments (exit 2 before any request). */
43
+ function drivePath(path, what = 'drive path') {
44
+ try {
45
+ encodeDrivePath(path);
46
+ }
47
+ catch (err) {
48
+ if (err instanceof TypeError)
49
+ throw new UsageError(`${what}: ${err.message}`);
50
+ throw err;
51
+ }
52
+ return path;
53
+ }
54
+ /** `drive ls` rows: size, saved time, type and path (the path last, as `files ls` puts the name). */
55
+ export function driveTable(rows) {
56
+ return table(['SIZE', 'SAVED', 'TYPE', 'PATH'], rows.map((f) => [humanBytes(f.size), time(f.created_at), f.content_type, f.path]));
57
+ }
58
+ const SOURCES = {
59
+ marked: 'the organization’s choice',
60
+ project: 'this project’s choice',
61
+ first_connected: 'connected first',
62
+ };
63
+ const ROLE_ORDER = ['master', 'builder', 'hard', 'light', 'reviewer'];
64
+ const providerName = (p) => (p === 'claude' ? 'Claude' : 'Codex');
65
+ /**
66
+ * The model template as people read it: the primary provider and why, the connected accounts, then one row per role
67
+ * with what it runs and, for a role whose provider is out of usage, until when it waits and what the other provider
68
+ * would run instead.
69
+ */
70
+ export function modelTemplateText(t) {
71
+ const lines = [];
72
+ if (t.primary === null)
73
+ lines.push('Connect Claude or Codex to run tasks: shard agents accounts add claude (or codex).');
74
+ else
75
+ lines.push(`Primary: ${t.primary} (${t.primary_source ? SOURCES[t.primary_source] : 'connected first'})${t.secondary ? `, secondary: ${t.secondary}` : ''}`);
76
+ if (t.connected.length)
77
+ lines.push(`Connected: ${t.connected.map((c) => `${c.provider}${c.plan_type ? ` (${c.plan_type})` : ''}`).join(', ')}`);
78
+ if (t.task_harness !== null || t.attempt > 0)
79
+ lines.push(`Task: ${[t.task_harness ? `on ${t.task_harness}` : '', t.attempt > 0 ? `retry ${t.attempt}` : ''].filter(Boolean).join(', ')}`);
80
+ const rows = ROLE_ORDER.map((role) => {
81
+ const r = t.roles[role];
82
+ const effort = `${r.effort ?? 'default'}${r.planning_effort ? ` (plans ${r.planning_effort})` : ''}`;
83
+ const wait = r.wait ? `${time(r.wait.until)}${r.wait.alternative ? `, or now: ${r.wait.alternative.label}` : ''}` : '-';
84
+ return [role, r.harness, r.model, effort, wait];
85
+ });
86
+ return `${lines.join('\n')}\n\n${table(['ROLE', 'HARNESS', 'MODEL', 'EFFORT', 'WAITS UNTIL'], rows)}`;
87
+ }
88
+ function harnessOf(values) {
89
+ const h = str(values.harness);
90
+ if (h !== undefined && h !== 'claude' && h !== 'codex')
91
+ throw new UsageError(`--harness must be claude or codex (got "${h}").`);
92
+ return h;
93
+ }
94
+ function attemptOf(values) {
95
+ const a = str(values.attempt);
96
+ if (a === undefined)
97
+ return undefined;
98
+ if (!/^\d+$/.test(a) || Number(a) > 10)
99
+ throw new UsageError(`--attempt must be 0 for a first run, or 1 to 10 for a retry (got "${a}").`);
100
+ return Number(a);
101
+ }
102
+ export const DRIVE_HANDLERS = {
103
+ async 'drive ls'(ctx, values, [prefix]) {
104
+ const { client, projectId } = await projectScope(ctx, values);
105
+ const limitText = str(values.limit);
106
+ const limit = limitText === undefined ? undefined : Number(limitText);
107
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 1000))
108
+ throw new UsageError('--limit must be 1 to 1000.');
109
+ const rows = [];
110
+ const seen = new Set();
111
+ let cursor = str(values.cursor), next;
112
+ do {
113
+ const page = await client.drive.list(projectId, { ...(prefix ? { prefix } : {}), ...(limit ? { limit } : values.all === true ? { limit: 1000 } : {}), ...(cursor ? { cursor } : {}), signal: ctx.signal });
114
+ rows.push(...page.data);
115
+ next = page.nextCursor;
116
+ cursor = next ?? undefined;
117
+ if (values.all === true && cursor) {
118
+ if (seen.has(cursor))
119
+ throw new UsageError('The drive listing repeated its cursor. Try again.');
120
+ seen.add(cursor);
121
+ }
122
+ } while (values.all === true && cursor);
123
+ out(ctx, rows.length ? driveTable(rows) : 'No files.\n', { data: rows, next_cursor: next });
124
+ if (!ctx.json && next)
125
+ ctx.io.stderr.write(`more: shard drive ls${prefix ? ` ${prefix}` : ''} --cursor ${next}\n`);
126
+ return 0;
127
+ },
128
+ async 'drive get'(ctx, values, [path]) {
129
+ drivePath(path);
130
+ const sha256 = str(values.sha256)?.toLowerCase();
131
+ if (sha256 !== undefined && !SHA256.test(sha256))
132
+ throw new UsageError('--sha256 takes the 64 hex characters of a version’s SHA-256 (shard drive ls --json shows them).');
133
+ const { client, projectId } = await projectScope(ctx, values);
134
+ const file = await client.drive.read(projectId, path, { ...(sha256 ? { sha256 } : {}), signal: ctx.signal });
135
+ const dest = str(values.out);
136
+ const meta = { path, sha256: file.sha256, size: file.size, content_type: file.contentType };
137
+ if (dest !== undefined) {
138
+ await writeFile(dest, file.bytes);
139
+ out(ctx, `Wrote ${path} to ${dest} (${humanBytes(file.size)}, sha256 ${file.sha256})\n`, { ...meta, out: dest });
140
+ }
141
+ else if (ctx.json) {
142
+ ctx.io.stdout.write(json({ ...meta, content_base64: Buffer.from(file.bytes).toString('base64') }));
143
+ }
144
+ else {
145
+ ctx.io.stdout.write(file.bytes);
146
+ }
147
+ return 0;
148
+ },
149
+ async 'drive put'(ctx, values, [local, target]) {
150
+ if (local === '-' && target === undefined)
151
+ throw new UsageError('Name the drive path when the file comes from standard input: shard drive put - <drive-path>');
152
+ const path = drivePath(target ?? basename(local), target === undefined ? `the file name "${basename(local)}"` : 'drive path');
153
+ const bytes = local === '-' ? await readInput(ctx, '-', MAX_PUT_BYTES, 'standard input') : await readFile(local).catch((e) => {
154
+ throw new UsageError(`cannot read "${local}" (${e.code === 'EISDIR' ? 'a folder; put its files one by one' : e.code ?? e.message})`);
155
+ });
156
+ const contentType = str(values['content-type']);
157
+ const { client, projectId } = await projectScope(ctx, values);
158
+ const file = await client.drive.save(projectId, path, bytes, { ...(contentType ? { contentType } : {}), signal: ctx.signal });
159
+ out(ctx, `Saved ${file.path} (${humanBytes(file.size)}, ${file.content_type})\nsha256 ${file.sha256}\n`, { file });
160
+ return 0;
161
+ },
162
+ async 'drive rm'(ctx, values, [path]) {
163
+ drivePath(path);
164
+ const { client, projectId } = await projectScope(ctx, values);
165
+ await client.drive.remove(projectId, path, { signal: ctx.signal });
166
+ out(ctx, `Removed ${path}\n`, { path, removed: true });
167
+ return 0;
168
+ },
169
+ async models(ctx, values) {
170
+ const harness = harnessOf(values), attempt = attemptOf(values);
171
+ const { client, projectId } = await projectScope(ctx, values, false);
172
+ const t = await client.modelTemplate.get({ ...(projectId ? { projectId } : {}), ...(harness ? { harness } : {}), ...(attempt !== undefined ? { attempt } : {}), signal: ctx.signal });
173
+ out(ctx, modelTemplateText(t), t);
174
+ return 0;
175
+ },
176
+ async 'models primary'(ctx, values, [choice]) {
177
+ const word = choice.toLowerCase();
178
+ if (!['claude', 'codex', 'clear'].includes(word))
179
+ throw new UsageError(`Choose claude, codex or clear (got "${choice}").`);
180
+ const provider = word === 'clear' ? null : word;
181
+ if (str(values.org) !== undefined && str(values.project) !== undefined)
182
+ throw new UsageError('Choose --org (the organization’s default) or --project (one project).');
183
+ let t, whose;
184
+ if (str(values.org) !== undefined) {
185
+ if (ctx.sessionSource() === null)
186
+ throw new AuthConfigError('The organization’s default is set by its owners and admins, signed in.', 'sign in: shard auth login (an API key sets its own project’s: shard models primary <claude|codex> without --org)');
187
+ const account = ctx.account();
188
+ const org = await resolveOrg(ctx, account, values);
189
+ t = await account.modelTemplate.setPrimary({ provider, organizationId: org.id, signal: ctx.signal });
190
+ whose = `${org.name}’s`;
191
+ }
192
+ else {
193
+ const { client, projectId } = await projectScope(ctx, values);
194
+ t = await client.modelTemplate.setPrimary({ provider, projectId: projectId, signal: ctx.signal });
195
+ whose = 'this project’s';
196
+ }
197
+ // The template that follows shows the primary now in effect and where it comes from.
198
+ out(ctx, `${provider ? `${providerName(provider)} is ${whose} primary.` : `Cleared ${whose} primary.`}\n${modelTemplateText(t)}`, t);
199
+ return 0;
200
+ },
201
+ };
@@ -41,7 +41,8 @@ async function templateSource(ctx, project) {
41
41
  export function filesystemLine(state) {
42
42
  const rebase = state.rebase_deferred ? ', follows your machine on next use' : state.rebase_pending ? ', rebase pending' : '';
43
43
  const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${rebase}` : '';
44
- return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
44
+ const volumes = state.volumes?.length ? `; volumes: ${state.volumes.map(v => `${v.name} at ${v.mount_path} (${v.mode})`).join(', ')}` : '';
45
+ return usageLine(state) + base + volumes + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
45
46
  }
46
47
  function usageLine(state) {
47
48
  const current = state.version > 0 ? `version ${state.version}` : '';
package/dist/handlers.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { DEVICE_HANDLERS } from "./device-approvals.js";
2
+ import { PROJECT_LINK_HANDLERS } from "./device-folders.js";
2
3
  import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
3
4
  import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
5
+ import { BOARD_HANDLERS } from "./board-commands.js";
6
+ import { TRANSCRIPT_HANDLERS } from "./conversation-transcript.js";
7
+ import { ROUTER_HANDLERS } from "./router-commands.js";
4
8
  /**
5
9
  * Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
6
10
  * commands.ts and the account plane of account-commands.ts (0.5.0). args.test.ts checks that it matches COMMANDS.
@@ -26,4 +30,5 @@ import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
26
30
  import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
27
31
  import { SETTINGS_HANDLERS } from "./client-settings.js";
28
32
  import { PROJECT_PREPARATION_HANDLERS } from "./project-preparation.js";
29
- export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS };
33
+ import { DRIVE_HANDLERS } from "./drive-commands.js";
34
+ export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...PROJECT_LINK_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...BOARD_HANDLERS, ...TRANSCRIPT_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS, ...ROUTER_HANDLERS, ...DRIVE_HANDLERS };
@@ -20,7 +20,7 @@ export interface LocalSession {
20
20
  export interface SessionState {
21
21
  /** A process has the session open right now. */
22
22
  running: boolean;
23
- /** working: a turn is in progress (never cut off); idle: between turns, or no process is running it. */
23
+ /** working: a turn is in progress; idle: between turns, or no process is running it. */
24
24
  turn: 'working' | 'idle';
25
25
  pid?: number;
26
26
  }
@@ -46,6 +46,12 @@ export declare function workingDirectory(recent: Rec[], fallback: string): strin
46
46
  /** Whether the transcript's last turn is still running, from its records (see the module comment). */
47
47
  export declare function claudeTurnFromRecords(records: Rec[]): 'working' | 'idle';
48
48
  export declare function codexTurnFromRecords(records: Rec[]): 'working' | 'idle';
49
+ /** The actual writer, never a guessed PID from the transcript. Codex TUI's writer may be its shared daemon. */
50
+ export declare function sessionProcessPid(s: Pick<LocalSession, 'harness' | 'session_id' | 'file'>, env: Env): number | undefined;
51
+ /** The selected thread's last started turn, from its native rollout (Codex 0.160.0). */
52
+ export declare function sessionTurnId(s: Pick<LocalSession, 'file'>): string | undefined;
53
+ /** Re-read the registry immediately before signalling: a PID reused after a session exits belongs to someone else. */
54
+ export declare function sameClaudeProcess(s: Pick<LocalSession, 'session_id'>, env: Env, pid: number): boolean;
49
55
  /**
50
56
  * Whether a session worked in `folder` (or below it): any working directory its records name (0.20.2+; before, only
51
57
  * the first). A session started in a scratch folder that went on in a repository is listed under the repository too.