@shardflux/cli 0.24.0 → 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 (49) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +227 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-permission-elsewhere.d.ts +38 -0
  6. package/dist/acp-permission-elsewhere.js +85 -0
  7. package/dist/acp-rpc.d.ts +2 -1
  8. package/dist/acp-rpc.js +11 -2
  9. package/dist/acp.js +95 -22
  10. package/dist/agent-credentials.d.ts +3 -0
  11. package/dist/agent-credentials.js +7 -0
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +143 -8
  14. package/dist/board-args.d.ts +6 -0
  15. package/dist/board-args.js +155 -0
  16. package/dist/board-commands.d.ts +62 -0
  17. package/dist/board-commands.js +395 -0
  18. package/dist/codex-cloud.d.ts +30 -3
  19. package/dist/codex-cloud.js +86 -12
  20. package/dist/conversation-commands.d.ts +30 -0
  21. package/dist/conversation-commands.js +312 -9
  22. package/dist/conversation-transcript.d.ts +44 -0
  23. package/dist/conversation-transcript.js +206 -0
  24. package/dist/conversations.d.ts +5 -2
  25. package/dist/conversations.js +20 -10
  26. package/dist/device-approvals.js +4 -3
  27. package/dist/device-bridge.d.ts +42 -1
  28. package/dist/device-bridge.js +223 -12
  29. package/dist/device-folders.d.ts +58 -0
  30. package/dist/device-folders.js +228 -0
  31. package/dist/device-helper.d.ts +6 -0
  32. package/dist/device-helper.js +87 -14
  33. package/dist/drive-commands.d.ts +25 -0
  34. package/dist/drive-commands.js +201 -0
  35. package/dist/handlers.js +6 -1
  36. package/dist/main.js +3 -0
  37. package/dist/move-files.d.ts +39 -0
  38. package/dist/move-files.js +167 -0
  39. package/dist/move.js +12 -11
  40. package/dist/onboarding.d.ts +6 -2
  41. package/dist/onboarding.js +24 -6
  42. package/dist/project-git.d.ts +7 -1
  43. package/dist/project-git.js +16 -3
  44. package/dist/project-preparation.d.ts +7 -0
  45. package/dist/project-preparation.js +14 -0
  46. package/dist/project-template-setup.js +32 -11
  47. package/dist/router-commands.d.ts +22 -0
  48. package/dist/router-commands.js +179 -0
  49. 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,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
+ };
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 };
package/dist/main.js CHANGED
@@ -371,6 +371,9 @@ const NO_NUDGE_REASONS = new Set([
371
371
  // Inbound ports (0.9.0): the hint names the command to expose or close a port.
372
372
  'port_not_exposed',
373
373
  'port_limit',
374
+ // Permission requests (0.25.0+): answered already (here or where the agent runs), or its agent no longer waits.
375
+ 'permission_answered',
376
+ 'permission_canceled',
374
377
  ]);
375
378
  /**
376
379
  * Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
@@ -0,0 +1,39 @@
1
+ import type { CellClient } from '@shardflux/sdk';
2
+ import type { SessionHarness } from './local-sessions.js';
3
+ export declare const VM_HOME = "/home/user";
4
+ export interface SessionFile {
5
+ path: string;
6
+ bytes: number;
7
+ mode: number;
8
+ why: 'handed' | 'opened' | 'named';
9
+ }
10
+ export interface SessionFiles {
11
+ files: SessionFile[];
12
+ left: string[];
13
+ }
14
+ /** Absolute and home paths in free text: `@"/a b/c.md"`, `/x/y.md`, `~/notes.md`. */
15
+ export declare function pathsIn(text: string): string[];
16
+ /** What the transcript says the session was handed and opened, as spelled there (a `~/` path is not expanded yet). */
17
+ export declare function sessionMentions(bytes: Buffer, harness: SessionHarness): {
18
+ handed: string[];
19
+ opened: string[];
20
+ };
21
+ /**
22
+ * The files to carry: regular files under `home`, outside every root (the moved checkout and the repository's other
23
+ * worktrees), not private, within the bounds. Handed files come first, then opened ones, then the ones a handed file
24
+ * names; `left` lists the ones past the bounds.
25
+ */
26
+ export declare function sessionFiles(bytes: Buffer, harness: SessionHarness, home: string, roots: readonly string[]): SessionFiles;
27
+ /** Where a carried file goes in the VM. */
28
+ export declare const vmPath: (home: string, path: string) => string;
29
+ /** `~/Downloads/handoff.md` for display. */
30
+ export declare const homeSpelling: (home: string, path: string) => string;
31
+ type FilesCell = Pick<CellClient, 'exec' | 'files'>;
32
+ export declare function writeChunks(cell: Pick<FilesCell, 'files'>, path: string, bytes: Uint8Array, mode?: string): Promise<void>;
33
+ /**
34
+ * Writes each file under the VM's home, then links this computer's home path to the VM's home when it is free there
35
+ * (`/Users/alex` → `/home/user`), so absolute paths in the history resolve. A VM without sudo keeps the home-relative
36
+ * copies; the link is a convenience, never a reason to fail the move.
37
+ */
38
+ export declare function placeSessionFiles(cell: FilesCell, found: SessionFiles, home: string, signal?: AbortSignal): Promise<boolean>;
39
+ export {};
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Files a moved session used outside its project (0.25.0+): what the user handed it (an @-mention, a path typed in a
3
+ * prompt), what it opened with Read, Edit or Write, and the files a handed text file names (a handoff's own
4
+ * references). Each goes into the VM under its home at the same home-relative path, and the VM links this computer's
5
+ * home there, so a path the history names (/Users/alex/Downloads/handoff.md) opens in the cloud as it did here.
6
+ * Credential stores and keys are never carried; files past the bounds stay on this computer and are listed.
7
+ */
8
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
9
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
10
+ import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
11
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
12
+ export const VM_HOME = '/home/user';
13
+ const MAX_FILES = 64, FILE_BYTES = 16 << 20, TOTAL_BYTES = 64 << 20;
14
+ /** A handed file is read for the paths it names when it is text of at most this size. */
15
+ const NAMES_BYTES = 1 << 20;
16
+ /** Claude Code tools whose input names a file the session opened. */
17
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
18
+ /** Home-relative stores of keys and sign-ins: never carried, whatever the session read. */
19
+ const PRIVATE = ['.ssh', '.aws', '.gnupg', '.password-store', '.azure', '.kube', '.docker', '.netrc', '.npmrc', '.pypirc',
20
+ '.git-credentials', '.config/gh', '.config/gcloud', '.config/shardflux', '.claude/.credentials.json', '.claude.json',
21
+ '.codex/auth.json', 'Library/Keychains', 'Library/Cookies'];
22
+ const PRIVATE_NAME = /^(\.env(\..*)?|id_[a-z0-9]+(\.pub)?|.*\.(pem|key|p12|pfx|keychain-db))$/i;
23
+ /** Absolute and home paths in free text: `@"/a b/c.md"`, `/x/y.md`, `~/notes.md`. */
24
+ export function pathsIn(text) {
25
+ const out = new Set();
26
+ for (const m of text.matchAll(/["'`](~?\/[^"'`\n]+)["'`]|(?:^|[\s(@[])(~?\/[^\s"'`()<>[\]]+)/g))
27
+ out.add(m[1] ?? m[2].replace(/[.,;:!?]+$/, ''));
28
+ return [...out];
29
+ }
30
+ /** What the transcript says the session was handed and opened, as spelled there (a `~/` path is not expanded yet). */
31
+ export function sessionMentions(bytes, harness) {
32
+ const handed = new Set(), opened = new Set();
33
+ for (const line of bytes.toString('utf8').split('\n')) {
34
+ let r;
35
+ try {
36
+ r = obj(JSON.parse(line));
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (harness === 'claude') {
42
+ const attachment = obj(r.attachment);
43
+ if (r.type === 'attachment' && attachment.type === 'file' && typeof attachment.filename === 'string')
44
+ handed.add(attachment.filename);
45
+ const message = obj(r.message);
46
+ if (r.type === 'user' && r.isMeta !== true && typeof message.content === 'string')
47
+ for (const p of pathsIn(message.content))
48
+ handed.add(p);
49
+ if (r.type === 'assistant' && Array.isArray(message.content)) {
50
+ for (const block of message.content.map(obj)) {
51
+ if (block.type !== 'tool_use' || !FILE_TOOLS.has(String(block.name)))
52
+ continue;
53
+ const input = obj(block.input), path = input.file_path ?? input.notebook_path;
54
+ if (typeof path === 'string')
55
+ opened.add(path);
56
+ }
57
+ }
58
+ }
59
+ else {
60
+ const p = obj(r.payload);
61
+ if (r.type !== 'response_item' || p.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content))
62
+ continue;
63
+ // Codex's own context blocks (<environment_context>, <turn_aborted>) are user-role records the user never typed.
64
+ for (const item of p.content.map(obj))
65
+ if (item.type === 'input_text' && typeof item.text === 'string' && !item.text.startsWith('<'))
66
+ for (const q of pathsIn(item.text))
67
+ handed.add(q);
68
+ }
69
+ }
70
+ return { handed: [...handed], opened: [...opened] };
71
+ }
72
+ const inside = (root, path) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`);
73
+ const realOr = (path) => { try {
74
+ return realpathSync(path);
75
+ }
76
+ catch {
77
+ return resolve(path);
78
+ } };
79
+ function isPrivate(home, path) {
80
+ const rel = relative(home, path);
81
+ return PRIVATE.some(p => rel === p || rel.startsWith(`${p}/`)) || rel.split('/').some(part => PRIVATE_NAME.test(part));
82
+ }
83
+ function isText(bytes) { return !bytes.subarray(0, 8192).includes(0); }
84
+ /**
85
+ * The files to carry: regular files under `home`, outside every root (the moved checkout and the repository's other
86
+ * worktrees), not private, within the bounds. Handed files come first, then opened ones, then the ones a handed file
87
+ * names; `left` lists the ones past the bounds.
88
+ */
89
+ export function sessionFiles(bytes, harness, home, roots) {
90
+ const realHome = realOr(home), realRoots = roots.map(realOr);
91
+ const expand = (p) => (p === '~' || p.startsWith('~/') ? home + p.slice(1) : p);
92
+ const files = [], left = [], seen = new Set();
93
+ let total = 0;
94
+ const consider = (spelled, why) => {
95
+ const path = resolve(expand(spelled));
96
+ if (!isAbsolute(expand(spelled)) || seen.has(path))
97
+ return undefined;
98
+ seen.add(path);
99
+ const real = realOr(path);
100
+ if (!inside(home, path) || !inside(realHome, real) || realRoots.some(root => inside(root, real)) || isPrivate(home, path) || isPrivate(realHome, real))
101
+ return undefined;
102
+ let st;
103
+ try {
104
+ st = statSync(real);
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ if (!st.isFile())
110
+ return undefined;
111
+ if (files.length >= MAX_FILES || st.size > FILE_BYTES || total + st.size > TOTAL_BYTES) {
112
+ left.push(path);
113
+ return undefined;
114
+ }
115
+ total += st.size;
116
+ const f = { path, bytes: st.size, mode: st.mode & 0o777, why };
117
+ files.push(f);
118
+ return f;
119
+ };
120
+ const { handed, opened } = sessionMentions(bytes, harness);
121
+ const given = handed.map(p => consider(p, 'handed')).filter((f) => !!f);
122
+ for (const p of opened)
123
+ consider(p, 'opened');
124
+ for (const f of given) {
125
+ if (f.bytes > NAMES_BYTES)
126
+ continue;
127
+ let text;
128
+ try {
129
+ text = readFileSync(f.path);
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (isText(text))
135
+ for (const p of pathsIn(text.toString('utf8')))
136
+ consider(p, 'named');
137
+ }
138
+ return { files, left };
139
+ }
140
+ /** Where a carried file goes in the VM. */
141
+ export const vmPath = (home, path) => `${VM_HOME}/${relative(home, path)}`;
142
+ /** `~/Downloads/handoff.md` for display. */
143
+ export const homeSpelling = (home, path) => (inside(home, path) ? `~/${relative(home, path)}` : path);
144
+ export async function writeChunks(cell, path, bytes, mode = '0600') {
145
+ if (!bytes.length) {
146
+ await cell.files.write(path, '', { mode });
147
+ return;
148
+ }
149
+ for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
150
+ await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode, append: at > 0 });
151
+ }
152
+ /**
153
+ * Writes each file under the VM's home, then links this computer's home path to the VM's home when it is free there
154
+ * (`/Users/alex` → `/home/user`), so absolute paths in the history resolve. A VM without sudo keeps the home-relative
155
+ * copies; the link is a convenience, never a reason to fail the move.
156
+ */
157
+ export async function placeSessionFiles(cell, found, home, signal) {
158
+ for (const f of found.files) {
159
+ const target = vmPath(home, f.path);
160
+ await cell.files.mkdir(dirname(target), { parents: true });
161
+ await writeChunks(cell, target, readFileSync(f.path), `0${f.mode.toString(8)}`);
162
+ }
163
+ if (!found.files.length || home === VM_HOME)
164
+ return false;
165
+ const linked = await cell.exec.run(['sh', '-c', 'test -e "$1" || { sudo -n mkdir -p "$(dirname "$1")" && sudo -n ln -s "$2" "$1"; }', 'sh', home, VM_HOME], { timeoutMs: 10_000, ...(signal ? { signal } : {}) }).catch(() => null);
166
+ return linked?.exitCode === 0;
167
+ }