@shardflux/cli 0.24.0 → 0.25.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +263 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/acp-permission-elsewhere.d.ts +38 -0
  7. package/dist/acp-permission-elsewhere.js +85 -0
  8. package/dist/acp-rpc.d.ts +2 -1
  9. package/dist/acp-rpc.js +11 -2
  10. package/dist/acp.js +95 -22
  11. package/dist/agent-credentials.d.ts +3 -0
  12. package/dist/agent-credentials.js +7 -0
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +172 -9
  15. package/dist/board-args.d.ts +6 -0
  16. package/dist/board-args.js +172 -0
  17. package/dist/board-commands.d.ts +62 -0
  18. package/dist/board-commands.js +412 -0
  19. package/dist/codex-cloud.d.ts +30 -3
  20. package/dist/codex-cloud.js +86 -12
  21. package/dist/conversation-commands.d.ts +30 -0
  22. package/dist/conversation-commands.js +328 -9
  23. package/dist/conversation-transcript.d.ts +44 -0
  24. package/dist/conversation-transcript.js +206 -0
  25. package/dist/conversations.d.ts +7 -3
  26. package/dist/conversations.js +20 -10
  27. package/dist/device-approvals.js +4 -3
  28. package/dist/device-bridge.d.ts +42 -1
  29. package/dist/device-bridge.js +223 -12
  30. package/dist/device-folders.d.ts +66 -0
  31. package/dist/device-folders.js +236 -0
  32. package/dist/device-helper.d.ts +6 -0
  33. package/dist/device-helper.js +98 -14
  34. package/dist/drive-commands.d.ts +25 -0
  35. package/dist/drive-commands.js +201 -0
  36. package/dist/errors.js +2 -1
  37. package/dist/format.js +6 -2
  38. package/dist/handlers.js +7 -1
  39. package/dist/land.d.ts +116 -0
  40. package/dist/land.js +238 -0
  41. package/dist/main.js +10 -1
  42. package/dist/move-files.d.ts +39 -0
  43. package/dist/move-files.js +167 -0
  44. package/dist/move.js +12 -11
  45. package/dist/onboarding.d.ts +10 -6
  46. package/dist/onboarding.js +24 -6
  47. package/dist/project-git.d.ts +7 -1
  48. package/dist/project-git.js +16 -3
  49. package/dist/project-preparation.d.ts +7 -0
  50. package/dist/project-preparation.js +14 -0
  51. package/dist/project-template-setup.js +32 -11
  52. package/dist/router-commands.d.ts +33 -0
  53. package/dist/router-commands.js +179 -0
  54. package/dist/task-land-hub.py +150 -0
  55. package/dist/transcribe-commands.d.ts +7 -0
  56. package/dist/transcribe-commands.js +80 -0
  57. package/dist/worktree-twin-engine.d.ts +8 -0
  58. package/dist/worktree-twin-engine.js +28 -22
  59. package/package.json +2 -2
@@ -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,71 @@ 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, links = true;
154
+ // This device's local conversations of a project (started here or opened here): their requests come here first.
155
+ const linked = (projectId) => Object.values(localConversations(ctx)).filter(r => !r.handed_off && (!projectId || r.project_id === projectId))
156
+ .sort((a, b) => (b.conversation.created_at ?? '').localeCompare(a.conversation.created_at ?? '')).slice(0, 500).map(r => r.conversation.id);
157
+ const beat = (projectId) => conversationClient(ctx).deviceRequests.heartbeat({ ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30, ...(links ? { conversationIds: linked(projectId) } : {}) })
158
+ .catch((e) => {
159
+ // An API before device links (0.25.0+ clients) refuses the list: register without it from then on.
160
+ if (!links || !(e instanceof ShardfluxApiError && [400, 422].includes(e.status)))
161
+ throw e;
162
+ links = false;
163
+ return conversationClient(ctx).deviceRequests.heartbeat({ ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30 });
164
+ });
165
+ const take = (rows) => {
166
+ for (const r of rows) {
167
+ if (r.device_id !== deviceId || working.has(r.id) || working.size >= 8)
168
+ continue;
169
+ const work = executor.execute(r).catch(report).finally(() => working.delete(r.id));
170
+ working.set(r.id, work);
171
+ }
172
+ };
144
173
  while (!signal.aborted) {
145
174
  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);
175
+ if (!all || Date.now() - all.at > 300_000)
176
+ all = { ids: await deviceProjects(ctx).catch(() => all?.ids ?? []), at: Date.now() };
177
+ const projects = [...new Set([...Object.values(localConversations(ctx)).map(r => r.project_id), ...all.ids])];
178
+ for (const projectId of projects) {
179
+ if (Date.now() - (registered.get(projectId) ?? 0) < 10_000)
180
+ continue;
181
+ await beat(projectId).then(() => registered.set(projectId, Date.now()), (e) => {
182
+ // A project this sign-in no longer reaches is skipped until the next listing, never the others.
183
+ if (!(e instanceof ShardfluxApiError && [403, 404].includes(e.status)))
184
+ throw e;
185
+ registered.set(projectId, Date.now() + 290_000);
186
+ });
187
+ }
188
+ let polled = false;
189
+ if (deviceWide && registered.size) {
190
+ try {
191
+ let cursor;
192
+ do {
193
+ const page = await conversationClient(ctx).deviceRequests.list({ deviceId, state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
194
+ take(page.data);
195
+ cursor = page.nextCursor ?? undefined;
196
+ } while (cursor && !signal.aborted);
197
+ polled = true;
198
+ }
199
+ catch (e) {
200
+ if (e instanceof ShardfluxApiError && [400, 422].includes(e.status))
201
+ deviceWide = false;
202
+ else
203
+ throw e;
204
+ }
159
205
  }
206
+ if (!polled)
207
+ for (const projectId of projects) {
208
+ let cursor;
209
+ do {
210
+ const page = await conversationClient(ctx).deviceRequests.list({ ...(projectId ? { projectId } : {}), state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
211
+ take(page.data);
212
+ cursor = page.nextCursor ?? undefined;
213
+ } while (cursor && !signal.aborted);
214
+ }
160
215
  await executor.reap();
161
216
  }
162
217
  catch (e) {
@@ -185,3 +240,32 @@ export async function runDeviceHelper(ctx, options = {}) {
185
240
  await input.catch(() => undefined);
186
241
  }
187
242
  }
243
+ /**
244
+ * The projects this device answers for besides its local conversations' (0.25.0+): the signed-in project (the API
245
+ * 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).
246
+ * A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
247
+ */
248
+ export async function deviceProjects(ctx) {
249
+ const profile = ctx.store.profile(ctx.apiUrl), ids = new Set();
250
+ if (profile.project_id)
251
+ ids.add(profile.project_id);
252
+ if (profile.api_key?.project_id)
253
+ ids.add(profile.api_key.project_id);
254
+ const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || profile.api_key !== undefined;
255
+ const session = Boolean(ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim()) || profile.session !== undefined;
256
+ if (key && !ids.size)
257
+ ids.add(''); // the key's own project: the API takes it from the key
258
+ if (!key && session) {
259
+ const account = ctx.account();
260
+ for (const org of await allOrgs(account)) {
261
+ for (const p of await allProjects(account, org.id)) {
262
+ ids.add(p.id);
263
+ if (ids.size >= 100)
264
+ break;
265
+ }
266
+ if (ids.size >= 100)
267
+ break;
268
+ }
269
+ }
270
+ return [...ids];
271
+ }
@@ -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
+ };
package/dist/errors.js CHANGED
@@ -266,7 +266,8 @@ export function describeError(err) {
266
266
  // details.reason: the operation error's reason (e.g. the scheduler's no_ready_hosts), printed after the message.
267
267
  const opDetails = err.operation.error?.details;
268
268
  const reason = typeof opDetails === 'object' && opDetails !== null ? opDetails.reason : undefined;
269
- const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}) };
269
+ const retryAfter = typeof opDetails === 'object' && opDetails !== null ? opDetails.retry_after_seconds : undefined;
270
+ const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}), ...(typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0 ? { retry_after_seconds: retryAfter } : {}) };
270
271
  // A suspend-when-idle canceled workspace_active (0.7.0+): the workspace was in use and keeps running.
271
272
  const message = err.workspaceActive ? `${err.message}: the workspace was in use, so the suspend-when-idle was canceled; it keeps running` : err.message;
272
273
  return { code: 'operation_failed', message, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}), operation: err.operation };
package/dist/format.js CHANGED
@@ -448,7 +448,7 @@ const hoursText = (h) => String(Math.round(h * 100) / 100);
448
448
  * while the GPU is stopped (cap_state gpu_blocked), why and what brings it back. Workspaces keep running meanwhile.
449
449
  * Nothing without `gpu` (plans without GPU-hours).
450
450
  */
451
- function gpuLines(gpu, resetsAt) {
451
+ function gpuLines(gpu, resetsAt, overageAvailable) {
452
452
  if (!isObject(gpu) || typeof gpu.used_hours !== 'number')
453
453
  return [];
454
454
  const included = typeof gpu.included_hours === 'number' ? gpu.included_hours : null;
@@ -470,6 +470,10 @@ function gpuLines(gpu, resetsAt) {
470
470
  rows.push(['GPU', `stopped (spend_cap_reached): workspaces keep running; raise the spend cap (shard billing overage cap <dollars>), or wait for the reset${resets}`]);
471
471
  break;
472
472
  default:
473
+ if (!overageAvailable) {
474
+ rows.push(['GPU', 'stopped (allowance_used): workspaces keep running; choose a paid plan (shard billing plans); overage is unavailable on the current plan']);
475
+ break;
476
+ }
473
477
  rows.push(['GPU', `stopped (allowance_used): workspaces keep running; turn on overage (shard billing overage on --cap <dollars>), upgrade (shard billing plans), or wait for the reset${resets}`]);
474
478
  }
475
479
  return rows;
@@ -507,7 +511,7 @@ export function usageText(summary) {
507
511
  head.push(['retained state', 'used up: new workspaces and forks are refused (delete workspaces you no longer need, or upgrade)']);
508
512
  }
509
513
  const resetsAt = isObject(s.period) ? [s.period.resets_at, s.period.end].find((t) => typeof t === 'string') : undefined;
510
- head.push(...gpuLines(s.gpu, resetsAt));
514
+ head.push(...gpuLines(s.gpu, resetsAt, !(isObject(s.spend_cap) && s.spend_cap.available === false)));
511
515
  for (const [k, v] of Object.entries(s))
512
516
  if (!USAGE_KNOWN.has(k) && (typeof v !== 'object' || v === null))
513
517
  head.push([k.replace(/_/g, ' '), scalar(v)]);
package/dist/handlers.js CHANGED
@@ -1,6 +1,11 @@
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";
8
+ import { TRANSCRIBE_HANDLERS } from "./transcribe-commands.js";
4
9
  /**
5
10
  * Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
6
11
  * commands.ts and the account plane of account-commands.ts (0.5.0). args.test.ts checks that it matches COMMANDS.
@@ -26,4 +31,5 @@ import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
26
31
  import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
27
32
  import { SETTINGS_HANDLERS } from "./client-settings.js";
28
33
  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 };
34
+ import { DRIVE_HANDLERS } from "./drive-commands.js";
35
+ 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, ...TRANSCRIBE_HANDLERS, ...DRIVE_HANDLERS };
package/dist/land.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { Ctx } from './commands.js';
2
+ import { conversationClient } from './conversation-commands.js';
3
+ import type { TwinCell } from './worktree-twin.js';
4
+ /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
5
+ export declare function hubScript(): string;
6
+ export interface HubLanding {
7
+ state: 'landed' | 'conflict' | 'missing' | 'raced' | 'error';
8
+ into: string;
9
+ before?: string | null;
10
+ after?: string;
11
+ result?: string;
12
+ method?: 'ff' | 'merge' | 'already' | 'created';
13
+ created_result?: boolean;
14
+ contained?: string[];
15
+ merge_base?: string | null;
16
+ stage?: 'integrate' | 'default';
17
+ conflicts?: string[];
18
+ source?: string;
19
+ message?: string;
20
+ reason?: string;
21
+ }
22
+ /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
23
+ export declare function landOnHub(cell: TwinCell, input: {
24
+ result: string;
25
+ sources: string[];
26
+ title: string;
27
+ into?: string;
28
+ hub?: string;
29
+ }, signal?: AbortSignal): Promise<HubLanding>;
30
+ export interface FolderLanding {
31
+ path: string;
32
+ into: string;
33
+ state: 'landed' | 'already' | 'conflict' | 'waiting' | 'skipped';
34
+ sha?: string;
35
+ method?: string;
36
+ files?: number;
37
+ kept?: string[];
38
+ conflicts?: string[];
39
+ backup?: string | null;
40
+ reason?: string;
41
+ }
42
+ export interface FolderInput {
43
+ folder: string;
44
+ into: string;
45
+ name: string;
46
+ title: string;
47
+ branch: string;
48
+ base: string;
49
+ device: string;
50
+ stateDir: string;
51
+ hub?: string;
52
+ quietMs?: number;
53
+ signal?: AbortSignal;
54
+ /** How long a busy folder (a file just saved, a Git operation) is waited for before the answer is "waiting". */
55
+ patienceMs?: number;
56
+ }
57
+ /** The branch to land in: the one checked out in the folder, else the hub's default branch when the folder has it. */
58
+ export declare function folderBranch(folder: string, fallback: string): Promise<string | null>;
59
+ /**
60
+ * The result into the folder through the twin's landing (from the hub): the branch moves, the person's uncommitted
61
+ * edits stay, and the landed commit is checked to be on the branch.
62
+ */
63
+ export declare function landInFolder(cell: TwinCell, f: FolderInput): Promise<FolderLanding>;
64
+ /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
65
+ export declare function linkedFolder(ctx: Pick<Ctx, 'store' | 'apiUrl'>, projectId: string, conversationId?: string): string | null;
66
+ export declare const displayPath: (path: string) => string;
67
+ export interface LandOutcome {
68
+ landed: true;
69
+ task_id?: string;
70
+ conversation_id?: string;
71
+ title: string;
72
+ result: {
73
+ branch: string;
74
+ sha: string;
75
+ };
76
+ hub: {
77
+ into: string;
78
+ sha: string;
79
+ method: string;
80
+ };
81
+ folder: (FolderLanding & {
82
+ display: string;
83
+ }) | null;
84
+ recorded: boolean;
85
+ record_error?: string;
86
+ /** One line for people: "Landed in ~/code/best-todo · 3f2a1c9". */
87
+ message: string;
88
+ }
89
+ /** What a landing needs: the API, a VM that reads the project hub, the person's folder for a project, this device. */
90
+ export interface LandDeps {
91
+ client: {
92
+ tasks: Pick<ReturnType<typeof conversationClient>['tasks'], 'get' | 'recordLanding' | 'steer'>;
93
+ conversations: Pick<ReturnType<typeof conversationClient>['conversations'], 'get' | 'land'>;
94
+ };
95
+ hubCell(projectId: string): Promise<{
96
+ cell: TwinCell;
97
+ close(): void;
98
+ }>;
99
+ folder(projectId: string, conversationId?: string): string | null;
100
+ device: string;
101
+ stateDir: string;
102
+ hub?: string;
103
+ quietMs?: number;
104
+ signal?: AbortSignal;
105
+ }
106
+ export declare function landDeps(ctx: Ctx): LandDeps;
107
+ /** What a landing did, for people: the line, then what was not recorded. */
108
+ export declare function landedText(o: LandOutcome): string;
109
+ /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
110
+ export declare function landTask(d: LandDeps, taskId: string, opts?: {
111
+ path?: string;
112
+ }): Promise<LandOutcome>;
113
+ /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
114
+ export declare function landConversation(d: LandDeps, id: string, opts?: {
115
+ path?: string;
116
+ }): Promise<LandOutcome>;