@shardflux/cli 0.28.2 → 0.28.3

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.
@@ -9,7 +9,7 @@ const groups = [
9
9
  [["cli-proxy", "cli-proxy import", "cli-proxy export", "cli-proxy use", "cli-proxy forward"], async () => (await import("./cli-proxy.js")).CLI_PROXY_HANDLERS],
10
10
  [["agents computers"], async () => (await import("./thread-on-computer.js")).THREAD_HANDLERS],
11
11
  [["conversations resume", "conversations reader", "conversations list", "conversations feed", "conversations get", "conversations permissions", "conversations answer", "conversations watch", "conversations send", "conversations start", "conversations wait", "conversations land", "files pull", "conversations move", "conversations archive", "conversations unarchive", "conversations seen", "conversations delete"], async () => (await import("./conversation-commands.js")).CONVERSATION_HANDLERS],
12
- [["orchestrator say", "orchestrator list", "orchestrator retry", "orchestrator dismiss", "orchestrator restore"], async () => (await import("./orchestrator-commands.js")).ORCHESTRATOR_HANDLERS],
12
+ [["orchestrator show", "orchestrator say", "orchestrator list", "orchestrator retry", "orchestrator dismiss", "orchestrator restore"], async () => (await import("./orchestrator-commands.js")).ORCHESTRATOR_HANDLERS],
13
13
  [["board", "task add", "task show", "task edit", "task rm", "task steer", "task land", "task clear-done", "needs-you", "needs-you answer", "approvals", "approvals set", "approvals allow", "approvals remove"], async () => (await import("./board-commands.js")).BOARD_HANDLERS],
14
14
  [["conversations transcript", "conversations events"], async () => (await import("./conversation-transcript.js")).TRANSCRIPT_HANDLERS],
15
15
  [["conversations try"], async () => (await import("./conversation-local.js")).CONVERSATION_HANDLERS],
@@ -25,6 +25,7 @@ const groups = [
25
25
  [["agents targets", "agents terminal-run", "agents open claude-desktop", "agents open codex-app", "agents open t3", "agents open web", "ssh-proxy"], async () => (await import("./agent-target-commands.js")).TARGET_HANDLERS],
26
26
  [["agents merge", "agents keep", "agents discard", "agents worktree", "agents restore"], async () => (await import("./agent-git-commands.js")).AGENT_GIT_HANDLERS],
27
27
  [["menubar start", "menubar stop", "menubar status", "menubar account"], async () => (await import("./menubar.js")).MENUBAR_HANDLERS],
28
+ [["credentials setup"], async () => (await import("./credential-setup.js")).CREDENTIAL_SETUP_HANDLERS],
28
29
  [["machine", "machine open", "machine save", "machine rollback", "template", "template save", "template rollback", "template delete"], async () => (await import("./filesystem-commands.js")).FILESYSTEM_HANDLERS],
29
30
  [["machine setup"], async () => (await import("./machine-setup.js")).MACHINE_SETUP_HANDLERS],
30
31
  [["settings"], async () => (await import("./client-settings.js")).SETTINGS_HANDLERS],
package/dist/handlers.js CHANGED
@@ -30,8 +30,9 @@ import { ACCOUNT_POOL_HANDLERS, AGENT_MODELS_HANDLERS } from "./account-pool.js"
30
30
  import { MOVE_HANDLERS } from "./move.js";
31
31
  import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
32
32
  import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
33
+ import { CREDENTIAL_SETUP_HANDLERS } from "./credential-setup.js";
33
34
  import { SETTINGS_HANDLERS } from "./client-settings.js";
34
35
  import { PROJECT_PREPARATION_HANDLERS } from "./project-preparation.js";
35
36
  import { DRIVE_HANDLERS } from "./drive-commands.js";
36
37
  import { BRAIN_HANDLERS } from "./brain-commands.js";
37
- export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...PROJECT_LINK_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...ORCHESTRATOR_HANDLERS, ...BOARD_HANDLERS, ...TRANSCRIPT_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...AGENT_MODELS_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, ...BRAIN_HANDLERS };
38
+ export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...PROJECT_LINK_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...ORCHESTRATOR_HANDLERS, ...BOARD_HANDLERS, ...TRANSCRIPT_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...AGENT_MODELS_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...CREDENTIAL_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS, ...ROUTER_HANDLERS, ...TRANSCRIBE_HANDLERS, ...DRIVE_HANDLERS, ...BRAIN_HANDLERS };
package/dist/land.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { TwinCell } from './worktree-twin.js';
4
4
  /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
5
5
  export declare function hubScript(): string;
6
6
  export interface HubLanding {
7
- state: 'landed' | 'conflict' | 'unrelated' | 'missing' | 'raced' | 'error';
7
+ state: 'landed' | 'conflict' | 'unrelated' | 'missing' | 'unready' | 'raced' | 'error';
8
8
  into: string;
9
9
  before?: string | null;
10
10
  after?: string;
@@ -18,6 +18,7 @@ export interface HubLanding {
18
18
  source?: string;
19
19
  message?: string;
20
20
  reason?: string;
21
+ unintegrated?: string[];
21
22
  }
22
23
  /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
23
24
  export declare function landOnHub(cell: TwinCell, input: {
@@ -26,6 +27,7 @@ export declare function landOnHub(cell: TwinCell, input: {
26
27
  title: string;
27
28
  into?: string;
28
29
  hub?: string;
30
+ requireResult?: boolean;
29
31
  }, signal?: AbortSignal): Promise<HubLanding>;
30
32
  export interface FolderLanding {
31
33
  path: string;
@@ -114,6 +116,7 @@ export declare function landedText(o: LandOutcome): string;
114
116
  /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
115
117
  export declare function landTask(d: LandDeps, taskId: string, opts?: {
116
118
  path?: string;
119
+ automatic?: boolean;
117
120
  }): Promise<LandOutcome>;
118
121
  /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
119
122
  export declare function landConversation(d: LandDeps, id: string, opts?: {
package/dist/land.js CHANGED
@@ -24,12 +24,15 @@ import { selectedProjects } from "./project-preparation.js";
24
24
  import { projectIdentity } from "./project-identity.js";
25
25
  import { HUB_PATH, backupRoot, deviceId, git, gitText, inspectWorktree, landingRound, listWorktrees } from "./worktree-twin.js";
26
26
  import { projectHubCell } from "./worktree-twin-engine.js";
27
+ import { createHash } from 'node:crypto';
28
+ import { withTargetLock } from "./target-lock.js";
29
+ import { TwinLedger } from "./worktree-twin.js";
27
30
  let script;
28
31
  /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
29
32
  export function hubScript() { return script ??= readFileSync(new URL('./task-land-hub.py', import.meta.url), 'utf8'); }
30
33
  /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
31
34
  export async function landOnHub(cell, input, signal) {
32
- const body = { hub: input.hub ?? HUB_PATH, result: input.result, sources: input.sources, title: input.title, ...(input.into ? { default: input.into } : {}) };
35
+ const body = { hub: input.hub ?? HUB_PATH, result: input.result, sources: input.sources, title: input.title, ...(input.into ? { default: input.into } : {}), ...(input.requireResult ? { require_result: true } : {}) };
33
36
  const r = await cell.exec.run(['python3', '-c', hubScript()], { stdin: JSON.stringify(body), timeoutMs: 300_000, maxOutputBytes: 1 << 20, ...(signal ? { signal } : {}) });
34
37
  const line = r.stdout.trim().split('\n').at(-1) ?? '';
35
38
  if (r.exitCode !== 0 || !line.startsWith('{'))
@@ -133,10 +136,14 @@ export function landDeps(ctx) {
133
136
  async function land(d, l, onConflict) {
134
137
  const vm = await d.hubCell(l.projectId, l.workspaceIds);
135
138
  try {
136
- const input = { result: l.result, sources: l.sources.map(s => s.branch), title: l.title, ...(d.hub ? { hub: d.hub } : {}) };
139
+ const input = { result: l.result, sources: l.sources.map(s => s.branch), title: l.title, ...(d.hub ? { hub: d.hub } : {}), ...(l.testedResult ? { requireResult: true } : {}) };
137
140
  let hub = await landOnHub(vm.cell, input, d.signal);
138
141
  if (hub.state === 'raced')
139
142
  hub = await landOnHub(vm.cell, input, d.signal);
143
+ if (hub.state === 'unready') {
144
+ await onConflict(hub);
145
+ throw new CodedError('land_result_unready', 'The task result is waiting for its master to merge, test and push every worker branch.', undefined, { reason: hub.reason, unintegrated: hub.unintegrated });
146
+ }
140
147
  if (hub.state === 'conflict') {
141
148
  await onConflict(hub);
142
149
  throw new CodedError('land_conflict', `Not landed: ${list(hub.conflicts ?? [])} changed on both sides (${hub.stage === 'integrate' ? `${hub.source} and ${l.result}` : `${l.result} and ${hub.into}`}).`, 'its master merges them and asks to land again', { stage: hub.stage, into: hub.into, conflicts: hub.conflicts ?? [] });
@@ -210,9 +217,17 @@ function currentResult(d) {
210
217
  const resultBranch = (taskId) => `shard/${taskId}`;
211
218
  /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
212
219
  export async function landTask(d, taskId, opts = {}) {
220
+ // The desktop board, service and a terminal can notice the same result together. Serialize their real merge and
221
+ // re-read its current done report inside the lock; Git and the API still check the commit/report on every device.
222
+ const key = createHash('sha256').update(taskId).digest('hex');
223
+ return withTargetLock(join(d.stateDir, 'task-land-locks', key), () => landTaskUnlocked(d, taskId, opts), d.signal);
224
+ }
225
+ async function landTaskUnlocked(d, taskId, opts) {
213
226
  const client = d.client, signal = d.signal ? { signal: d.signal } : {};
214
227
  const detail = await client.tasks.get(taskId, signal);
215
228
  const task = detail.task, title = task.title ?? task.text.split('\n')[0].slice(0, 80);
229
+ if (opts.automatic && new TwinLedger(d.stateDir).mode(task.project_id) !== 'auto')
230
+ throw new CodedError('land_automatic_off', 'Automatic landing is off on this computer or for this project.');
216
231
  const report = currentResult(detail);
217
232
  if (!report || (task.state !== 'done' && task.state !== 'unverified'))
218
233
  throw new CodedError('task_not_done', 'This task has no finished result to land yet.', 'land it after its master’s done report', { state: task.state });
@@ -227,11 +242,25 @@ export async function landTask(d, taskId, opts = {}) {
227
242
  // The repository the result is of: its workers' (or its master's), so it lands only in a folder of that repository.
228
243
  const repository = workers.find(w => w.repo_remote)?.repo_remote ?? (task.master_id ? (await client.conversations.get(task.master_id).catch(() => null))?.repo_remote : undefined) ?? null;
229
244
  const folder = opts.path ?? d.folder(task.project_id, undefined, repository ?? undefined);
245
+ // A task created on the phone has no local conversation record. Wait for its actual linked repository; never
246
+ // wake a VM or mark a hub-only merge as delivery to an arbitrary folder on this computer.
247
+ if (opts.automatic && !folder)
248
+ throw new CodedError('land_device_unlinked', 'This task has no linked repository folder on this computer.');
230
249
  const done = await land(d, { projectId: task.project_id, title, name: taskId, result: resultBranch(taskId), sources: workers.map(w => ({ branch: w.branch, conversation_id: w.id })),
231
- base: workers.find(w => w.base_commit)?.base_commit ?? null, folder, repository, workspaceIds: workers.map(w => w.workspace_id).filter(Boolean) }, async (hub) => {
250
+ base: workers.find(w => w.base_commit)?.base_commit ?? null, folder, repository, workspaceIds: workers.map(w => w.workspace_id).filter(Boolean), testedResult: opts.automatic }, async (hub) => {
251
+ if (hub.state === 'unready') {
252
+ await client.tasks.steer(taskId, `Auto land is waiting for the tested task result: ${resultBranch(taskId)} ${hub.reason === 'result_missing' ? 'has not been pushed' : `does not contain ${list(hub.unintegrated ?? [])}`}. Merge every worker branch into it, resolve conflicts, run the tests on that merged result, push it to the project hub and seal done again.`, signal);
253
+ return;
254
+ }
232
255
  const what = hub.stage === 'integrate' ? `${hub.source} into ${resultBranch(taskId)}` : `${resultBranch(taskId)} into ${hub.into}`;
233
256
  await client.tasks.steer(taskId, `Land could not merge ${what}: ${list(hub.conflicts ?? [])} changed on both sides. Merge ${hub.into} from the project hub and every worker branch into ${resultBranch(taskId)}, resolve the conflicts so both changes work (ask the person only if the choice is theirs), run the tests, push ${resultBranch(taskId)} to the hub and seal done again.`, signal);
234
257
  });
258
+ if (opts.automatic && done.folder?.state !== 'landed' && done.folder?.state !== 'already') {
259
+ const conflict = done.folder?.state === 'conflict';
260
+ throw new CodedError(conflict ? 'land_conflict' : 'land_folder_busy', conflict
261
+ ? `Not landed in your folder: ${list(done.folder?.conflicts ?? [])} changed on both sides.`
262
+ : 'The linked folder is busy; its finished task will land when it is ready.');
263
+ }
235
264
  let recorded = false, record_error;
236
265
  try {
237
266
  await client.tasks.recordLanding(taskId, { sha: done.result.sha, into: done.hub.into, branch: done.result.branch, reportId: report, conversationIds: done.contained }, signal);
@@ -173,6 +173,7 @@ export interface SetupDeps {
173
173
  state: AgentFilesystem;
174
174
  ws: Workspace;
175
175
  }>;
176
+ credentials?: (ctx: Ctx) => Promise<void>;
176
177
  }
177
178
  export declare function machineSetup(ctx: Ctx, values: Values, deps?: SetupDeps): Promise<number>;
178
179
  /**
@@ -707,6 +707,8 @@ export async function machineSetup(ctx, values, deps = {}) {
707
707
  const claim = claimRun(store, ctx.apiUrl, org, values['if-needed'] === true, startRec);
708
708
  if (!claim.claimed) {
709
709
  const { doc, text } = await status();
710
+ if (claim.record.state === 'ready')
711
+ await deps.credentials?.(ctx);
710
712
  return output(ctx, { ...doc, skipped: true }, claim.record.state === 'running' ? `${text} (already running)` : text);
711
713
  }
712
714
  const phase = phaseWriter(ctx);
@@ -781,6 +783,7 @@ export async function machineSetup(ctx, values, deps = {}) {
781
783
  writeRecord(store, ctx.apiUrl, org, rec);
782
784
  phase('ready', line);
783
785
  const row = machineRow(saved, rec);
786
+ await deps.credentials?.(ctx);
784
787
  return output(ctx, { ...statusDoc(row, rec), workspace_key: ws.key }, rowText(row));
785
788
  }
786
789
  catch (error) {
@@ -808,7 +811,7 @@ export function startMachineSetupInBackground(ctx) {
808
811
  mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
809
812
  const out = openSync(join(ctx.store.dir, 'machine-setup.out'), 'w', 0o600);
810
813
  try {
811
- const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'machine', 'setup', '--from-this-computer', '--if-needed', '--json'], {
814
+ const child = spawn(ctx.runtime.node, [ctx.runtime.script, 'machine', 'setup', '--from-this-computer', '--if-needed', '--with-credentials', '--json'], {
812
815
  detached: true, stdio: ['ignore', out, out],
813
816
  env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1' }
814
817
  });
@@ -825,5 +828,5 @@ export function startMachineSetupInBackground(ctx) {
825
828
  }
826
829
  }
827
830
  export const MACHINE_SETUP_HANDLERS = {
828
- 'machine setup': (ctx, values) => machineSetup(ctx, values),
831
+ 'machine setup': async (ctx, values) => machineSetup(ctx, values, values['with-credentials'] === true ? { credentials: (await import("./credential-setup.js")).credentialSetupAfterMachine } : {}),
829
832
  };
package/dist/main.js CHANGED
@@ -403,6 +403,24 @@ function feedbackHint(info, credentialHint) {
403
403
  }
404
404
  return `the feedback was not sent; email it to ${FEEDBACK_EMAIL} instead`;
405
405
  }
406
+ function credentialFailure(info) {
407
+ const status = info.status;
408
+ const known = {
409
+ 400: ['validation_failed', 'Check the credential name and scope, then try again.'],
410
+ 401: ['unauthenticated', 'Sign in again, then retry the credential operation.'],
411
+ 403: ['forbidden', 'This account cannot change credentials in that scope.'],
412
+ 404: ['not_found', 'The credential or its request is no longer available.'],
413
+ 409: ['conflict', 'The credential already exists or its request was answered. Refresh and try again.'],
414
+ 413: ['validation_failed', 'The credential exceeds the secret store’s existing value size.'],
415
+ 422: ['validation_failed', 'Check the credential name and scope, then try again.'],
416
+ 429: ['rate_limited', 'The credential service is busy. Try again shortly.'],
417
+ };
418
+ const [code, message] = status === undefined ? ['credential_failed', 'The credential operation could not finish. Try again.']
419
+ : known[status] ?? ['dependency_unavailable', 'The credential service could not finish the operation. Try again shortly.'];
420
+ return { code, message, ...(status !== undefined ? { status } : {}),
421
+ ...(info.request_id && /^req-[A-Za-z0-9_-]+$/.test(info.request_id) ? { request_id: info.request_id } : {}),
422
+ ...(typeof info.retryable === 'boolean' ? { retryable: info.retryable } : {}) };
423
+ }
406
424
  export async function run(argv, io, opts = {}) {
407
425
  const signal = opts.signal ?? new AbortController().signal;
408
426
  await registerInstalledT3(io.env, configDir(io.env), signal);
@@ -410,6 +428,7 @@ export async function run(argv, io, opts = {}) {
410
428
  const rawKey = io.env.SHARDFLUX_API_KEY;
411
429
  // Every credential this command loaded or received: redacted from anything printed on failure.
412
430
  const secrets = [rawKey ?? '', io.env.SHARDFLUX_SESSION_TOKEN ?? ''].filter((s) => s.trim().length >= 8).map((s) => s.trim());
431
+ let writeOnlyValueLoaded = false;
413
432
  const wantsJson = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length).includes('--json');
414
433
  const cred = { keySource: null, sessionSource: null, sessionRejected: false, keyRejected: false, email: null };
415
434
  const store = new CredentialStore(configDir(io.env), (m) => void io.stderr.write(`${m}\n`));
@@ -421,7 +440,10 @@ export async function run(argv, io, opts = {}) {
421
440
  let command = undefined;
422
441
  const report = (err) => {
423
442
  const code = exitCodeFor(err, signal.aborted);
424
- const info = describeError(err);
443
+ const described = describeError(err);
444
+ // A proxy or validation error can echo any submitted value, including a one-byte credential or JSON escapes.
445
+ // Keep structured output valid and preserve the status/request id without copying remote prose or details.
446
+ const info = writeOnlyValueLoaded ? credentialFailure(described) : described;
425
447
  if (code === EXIT.interrupted && info.code !== 'interrupted') {
426
448
  info.code = 'interrupted';
427
449
  info.message = 'Interrupted.';
@@ -439,7 +461,7 @@ export async function run(argv, io, opts = {}) {
439
461
  }
440
462
  }
441
463
  // An error that knows the command to continue with (an interrupted or still running execution) brings its own hint.
442
- const own = ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
464
+ const own = writeOnlyValueLoaded ? undefined : ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
443
465
  const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file, command)) : (own ?? hintFor(info, cred, store.file, command));
444
466
  // --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
445
467
  // --json a `timing` field next to `error`.
@@ -669,6 +691,12 @@ export async function run(argv, io, opts = {}) {
669
691
  if (value.length >= 8)
670
692
  secrets.push(value);
671
693
  },
694
+ addWriteOnlyValue(value) {
695
+ if (value.length)
696
+ writeOnlyValueLoaded = true;
697
+ if (value.length >= 8)
698
+ secrets.push(value);
699
+ },
672
700
  async versionStatus(force) {
673
701
  const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent, registry: npmRegistry(io.env) });
674
702
  if (!force && updateCheckDisabled(io.env)) {
@@ -3,11 +3,21 @@ const ORG = {
3
3
  };
4
4
  const WHO = 'Your orchestrator is yours: one agent on its own Shardflux computer, the same on every device. Uses your signed-in session, or SHARDFLUX_API_KEY when it is set (a project key you created: a new orchestrator is filed under its project).';
5
5
  export const ORCHESTRATOR_COMMANDS = [
6
+ {
7
+ path: ['orchestrator', 'show'],
8
+ summary: 'Read an input and its device-request outcomes, failures first',
9
+ positionals: [{ name: 'input-id', required: true }],
10
+ options: ORG,
11
+ description: 'Shows the input and durable requests made by its thread, including what ran, local and remote paths, exit codes and recorded errors. Reading starts no computer. A thread can also hear follow-ups; each JSON request identifies its conversation. With --json: {input, device_requests}.',
12
+ examples: ['shard orchestrator show in_abcdefghijklmnopqrstuvwxyz', 'shard orchestrator show in_abcdefghijklmnopqrstuvwxyz --json'],
13
+ },
6
14
  {
7
15
  path: ['orchestrator', 'say'],
8
16
  summary: 'Say something to your orchestrator; one of its threads hears each input at once (0.27.0+)',
9
- positionals: [{ name: 'words', variadic: true, words: true, required: true }],
17
+ positionals: [{ name: 'words', variadic: true, words: true, required: false }],
10
18
  options: {
19
+ file: { type: 'string', multiple: true, value: '<path|->', description: 'Attach a file (repeatable); - streams standard input as a file.' },
20
+ 'file-name': { type: 'string', value: '<name>', description: 'The name of the standard-input file (default stdin).' },
11
21
  task: { type: 'string', value: '<task-id>', description: 'Point at a task (task_…).' },
12
22
  report: { type: 'string', value: '<report-id>', description: 'Point at a report (rep_…).' },
13
23
  'needs-you': { type: 'string', value: '<item-id>', description: 'Point at something that waits on you (perm_…, oq_…).' },
@@ -6,6 +6,9 @@
6
6
  * a session) reaches the orchestrator of the member who created that key (person-scope.ts). Text output is one line
7
7
  * per input, tab-separated, id first; --json prints the API's objects.
8
8
  */
9
+ import { mkdtemp, open, unlink, rmdir } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, basename } from 'node:path';
9
12
  import { inputSettled } from '@shardflux/sdk';
10
13
  import { parseDuration } from "./args.js";
11
14
  import { out, str } from "./commands.js";
@@ -13,6 +16,7 @@ import { EXIT, UsageError } from "./errors.js";
13
16
  import { humanBytes } from "./files-mirror.js";
14
17
  import { inScope } from "./person-scope.js";
15
18
  import { progress } from "./progress.js";
19
+ import { deviceOutcomeLines } from "./device-outcomes.js";
16
20
  /** --wait's default: how long `say --wait` follows an input before it stops (the input goes on). */
17
21
  export const SAY_WAIT_MS = 10 * 60_000;
18
22
  const oneLine = (s, max) => {
@@ -198,17 +202,54 @@ const idOf = (id) => {
198
202
  return id;
199
203
  };
200
204
  export const ORCHESTRATOR_HANDLERS = {
205
+ async 'orchestrator show'(ctx, values, [id]) {
206
+ const detail = await inScope(ctx, values, (client, scope) => client.orchestrator.input(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
207
+ out(ctx, [`${detail.input.id}\t${stateLabel(detail.input)}`, resultText(detail.input), ...deviceOutcomeLines(detail.device_requests)].filter(Boolean).join('\n') + '\n', detail);
208
+ return EXIT.ok;
209
+ },
201
210
  async 'orchestrator say'(ctx, values, words) {
202
211
  const text = words.join(' ');
203
- if (!text.trim())
204
- throw new UsageError('missing <words...> for "shard orchestrator say"');
212
+ const paths = Array.isArray(values.file) ? values.file : typeof values.file === 'string' ? [values.file] : [];
213
+ if (!text.trim() && !paths.length)
214
+ throw new UsageError('missing <words> for "shard orchestrator say"');
205
215
  const selection = selectionOf(values);
206
216
  const wait = values.wait === true;
207
217
  const timeoutMs = str(values.timeout) === undefined ? SAY_WAIT_MS : parseDuration(str(values.timeout), 'timeout');
208
218
  if (!wait && str(values.timeout) !== undefined)
209
219
  throw new UsageError('--timeout goes with --wait.');
210
220
  return inScope(ctx, values, async (client, scope) => {
211
- const input = await client.orchestrator.say(text, { ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
221
+ let spool;
222
+ let input;
223
+ const files = [];
224
+ try {
225
+ for (const path of paths) {
226
+ if (path !== '-') {
227
+ files.push({ path, name: basename(path) });
228
+ continue;
229
+ }
230
+ if (!spool) {
231
+ spool = await mkdtemp(join(ctx.io.env.HOME ?? tmpdir(), 'shard-input-'));
232
+ const sink = await open(join(spool, 'stdin'), 'wx', 0o600);
233
+ try {
234
+ for await (const chunk of ctx.io.stdin) {
235
+ ctx.signal.throwIfAborted();
236
+ await sink.writeFile(chunk);
237
+ }
238
+ }
239
+ finally {
240
+ await sink.close();
241
+ }
242
+ }
243
+ files.push({ path: join(spool, 'stdin'), name: str(values['file-name']) ?? 'stdin' });
244
+ }
245
+ input = await client.orchestrator.say({ text, files, ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
246
+ }
247
+ finally {
248
+ if (spool) {
249
+ await unlink(join(spool, 'stdin')).catch(() => undefined);
250
+ await rmdir(spool);
251
+ }
252
+ }
212
253
  if (!wait) {
213
254
  out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
214
255
  return EXIT.ok;
@@ -0,0 +1,22 @@
1
+ import type { NeedsYouItem } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import type { LandDeps } from './land.js';
4
+ interface TaskAutoLandOptions {
5
+ needs(signal: AbortSignal): Promise<NeedsYouItem[]>;
6
+ land: LandDeps;
7
+ enabled(project: string): boolean;
8
+ log(line: string): void;
9
+ now?: () => number;
10
+ stateDir?: string;
11
+ }
12
+ export declare class TaskAutoLand {
13
+ #private;
14
+ readonly options: TaskAutoLandOptions;
15
+ constructor(options: TaskAutoLandOptions);
16
+ /** One serial pass. A conflict waits for a new result; a disconnected/busy folder or failed receipt retries. */
17
+ tick(signal: AbortSignal): Promise<void>;
18
+ }
19
+ /** Only projects actually linked on this computer; a task's repository is checked again before any VM is opened. */
20
+ export declare function taskLandProjects(ctx: Ctx): string[];
21
+ export declare function runTaskAutoLand(ctx: Ctx): Promise<void>;
22
+ export {};
@@ -0,0 +1,162 @@
1
+ /** Finished board tasks reach their linked folders even with the desktop board closed (next CLI release). */
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { conversationClient } from "./conversation-commands.js";
4
+ import { localConversations } from "./conversations.js";
5
+ import { landDeps, landTask } from "./land.js";
6
+ import { selectedProjects } from "./project-preparation.js";
7
+ import { TwinLedger } from "./worktree-twin.js";
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { writePrivateFile } from "./credentials.js";
11
+ export class TaskAutoLand {
12
+ #attempts = new Map();
13
+ options;
14
+ constructor(options) {
15
+ this.options = options;
16
+ if (options.stateDir) {
17
+ try {
18
+ const rows = JSON.parse(readFileSync(join(options.stateDir, 'task-auto-land.json'), 'utf8'));
19
+ for (const [id, row] of Object.entries(rows))
20
+ if (typeof row.key === 'string' && Number.isSafeInteger(row.tries)
21
+ && Number.isFinite(row.retryAt) && typeof row.blocked === 'boolean')
22
+ this.#attempts.set(id, row);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== 'ENOENT')
26
+ throw error;
27
+ }
28
+ }
29
+ }
30
+ #save() {
31
+ if (this.options.stateDir)
32
+ writePrivateFile(this.options.stateDir, join(this.options.stateDir, 'task-auto-land.json'), JSON.stringify(Object.fromEntries(this.#attempts)) + '\n');
33
+ }
34
+ /** One serial pass. A conflict waits for a new result; a disconnected/busy folder or failed receipt retries. */
35
+ async tick(signal) {
36
+ const now = this.options.now ?? Date.now, needs = await this.options.needs(signal);
37
+ // Keep the result key through disconnects and service restarts. A missing discovery page is not permission to
38
+ // repeat a conflict's steer or an already recorded landing. A master's next done report has a new key.
39
+ for (const item of needs) {
40
+ if (signal.aborted)
41
+ return;
42
+ if (item.kind !== 'land' || !item.task_id || !item.project_id || !this.options.enabled(item.project_id))
43
+ continue;
44
+ const key = `${item.id}@${item.since}`, prior = this.#attempts.get(item.id);
45
+ if (prior?.key === key && (prior.blocked || prior.retryAt > now()))
46
+ continue;
47
+ try {
48
+ const result = await landTask({ ...this.options.land, signal }, item.task_id, { automatic: true });
49
+ if (!result.recorded)
50
+ throw new Error(result.record_error ?? 'The task landing receipt was not recorded.');
51
+ this.#attempts.set(item.id, { key, tries: 0, retryAt: 0, blocked: true });
52
+ this.#save();
53
+ this.options.log(result.message);
54
+ }
55
+ catch (error) {
56
+ if (signal.aborted)
57
+ return;
58
+ const code = error.code;
59
+ const tries = prior?.key === key ? prior.tries + 1 : 1;
60
+ const blocked = code === 'land_conflict' || code === 'land_unrelated' || code === 'land_result_unready';
61
+ this.#attempts.set(item.id, { key, tries, blocked, retryAt: now() + Math.min(600_000, 1000 * 2 ** Math.min(tries, 9)) });
62
+ this.#save();
63
+ // Off and a folder on another computer need no warning. The existing one-per-task land item stays intact.
64
+ if (code !== 'land_automatic_off' && code !== 'land_device_unlinked')
65
+ this.options.log(`Task ${item.task_id}: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ /** Only projects actually linked on this computer; a task's repository is checked again before any VM is opened. */
71
+ export function taskLandProjects(ctx) {
72
+ const profile = ctx.store.profile(ctx.apiUrl), fallback = profile.project_id ?? profile.api_key?.project_id;
73
+ return [...new Set([
74
+ ...selectedProjects(ctx).map(p => p.project_id ?? fallback),
75
+ ...Object.values(localConversations(ctx)).filter(c => !c.handed_off).map(c => c.project_id),
76
+ ].filter((p) => Boolean(p)))];
77
+ }
78
+ export async function runTaskAutoLand(ctx) {
79
+ if (ctx.signal.aborted)
80
+ return;
81
+ const client = conversationClient(ctx), ledger = new TwinLedger(ctx.store.dir);
82
+ const profile = ctx.store.profile(ctx.apiUrl);
83
+ const key = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || profile.api_key !== undefined;
84
+ const log = (line) => { ctx.io.stderr.write(line + '\n'); };
85
+ const needs = new Map();
86
+ const watches = new Map();
87
+ const engine = new TaskAutoLand({
88
+ land: landDeps(ctx), enabled: project => ledger.mode(project) === 'auto', log, stateDir: ctx.store.dir,
89
+ needs: () => Promise.resolve([...needs.values()].flat()),
90
+ });
91
+ let landing, again = false;
92
+ const kick = () => {
93
+ again = true;
94
+ if (!landing)
95
+ landing = (async () => {
96
+ while (again && !ctx.signal.aborted) {
97
+ again = false;
98
+ await engine.tick(ctx.signal);
99
+ }
100
+ })().catch(error => { if (!ctx.signal.aborted)
101
+ log(`Task landing: ${error instanceof Error ? error.message : String(error)}`); })
102
+ .finally(() => { landing = undefined; });
103
+ return landing;
104
+ };
105
+ const follow = async (projectId, signal) => {
106
+ let failures = 0;
107
+ while (!signal.aborted) {
108
+ try {
109
+ const organizationId = key ? undefined : (await ctx.account().projects.get(projectId)).organization_id;
110
+ const scope = { projectId, ...(organizationId ? { organizationId } : {}), signal };
111
+ // Read once (including results that finished while offline), then use the held feed. No repeated whole-board
112
+ // reads while idle, and a slow project's feed cannot delay another project's landing or device heartbeats.
113
+ const board = await client.board.get(scope);
114
+ needs.set(projectId, board.needs_you);
115
+ void kick();
116
+ failures = 0;
117
+ for await (const update of client.board.watch({ ...scope, cursor: board.cursor })) {
118
+ if (update.reset)
119
+ needs.set(projectId, update.board.needs_you);
120
+ else if (update.needs_you !== null)
121
+ needs.set(projectId, update.needs_you);
122
+ void kick();
123
+ }
124
+ if (!signal.aborted)
125
+ throw new Error('The board feed ended before its device helper stopped.');
126
+ }
127
+ catch (error) {
128
+ if (signal.aborted)
129
+ return;
130
+ log(`Task landing discovery: ${error instanceof Error ? error.message : String(error)}`);
131
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(failures++, 6)), undefined, { signal }).catch(() => undefined);
132
+ }
133
+ }
134
+ };
135
+ try {
136
+ while (!ctx.signal.aborted) {
137
+ const projects = new Set(taskLandProjects(ctx).filter(p => ledger.mode(p) === 'auto'));
138
+ for (const [project, watch] of watches)
139
+ if (!projects.has(project)) {
140
+ watch.abort.abort();
141
+ await watch.work;
142
+ watches.delete(project);
143
+ needs.delete(project);
144
+ }
145
+ for (const project of projects)
146
+ if (!watches.has(project)) {
147
+ const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
148
+ const work = follow(project, signal);
149
+ watches.set(project, { abort, work });
150
+ }
151
+ // The local setting and deferred folder/receipt retries do not wait for a remote board event.
152
+ await kick();
153
+ await delay(2000, undefined, { signal: ctx.signal }).catch(() => undefined);
154
+ }
155
+ }
156
+ finally {
157
+ for (const watch of watches.values())
158
+ watch.abort.abort();
159
+ await Promise.all([...watches.values()].map(w => w.work));
160
+ await landing;
161
+ }
162
+ }
@@ -106,6 +106,12 @@ def land(a):
106
106
  have = {b: tip(work, 'refs/heads/' + b) for b in wanted}
107
107
  before = have.get(into)
108
108
  t, created = have.get(result), False
109
+ if a.get('require_result'):
110
+ if not t:
111
+ return {'state': 'unready', 'into': into, 'reason': 'result_missing'}
112
+ missing = [s for s in sources if not have.get(s) or not ancestor(work, have[s], t)]
113
+ if missing:
114
+ return {'state': 'unready', 'into': into, 'reason': 'workers_not_integrated', 'unintegrated': missing}
109
115
  if not t:
110
116
  # A master that did not merge its workers: merge them here, each only when it merges cleanly.
111
117
  for s in sources:
package/dist/vault.js CHANGED
@@ -28,20 +28,15 @@ export function vaultOutput(vault, name, options = {}) {
28
28
  return new Promise((resolve, reject) => {
29
29
  const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], env: options.env, signal: options.signal });
30
30
  const chunks = [];
31
- let size = 0;
32
31
  let failed = false;
33
32
  const timer = setTimeout(() => {
34
33
  failed = true;
35
34
  child.kill('SIGKILL');
36
35
  }, 30_000);
37
36
  child.stdout.on('data', (chunk) => {
38
- size += chunk.length;
39
- if (size > 65_538) {
40
- failed = true;
41
- child.kill('SIGKILL');
42
- }
43
- else
44
- chunks.push(chunk);
37
+ // Provider JSON includes metadata as well as the selected value. Enforce the secret store's value limit
38
+ // after extraction; a transport cap here rejected valid small values in larger items.
39
+ chunks.push(chunk);
45
40
  });
46
41
  child.on('error', () => {
47
42
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.28.2",
3
+ "version": "0.28.3",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -53,7 +53,7 @@
53
53
  "snappyjs": "0.7.0",
54
54
  "ws": "8.22.0",
55
55
  "yaml": "2.9.1",
56
- "@shardflux/sdk": "^0.30.2"
56
+ "@shardflux/sdk": "^0.30.3"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@eslint/js": "10.0.1",
@@ -65,10 +65,10 @@
65
65
  "typescript-eslint": "8.70.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@shardflux/desktop-darwin-arm64": "0.9.3",
69
- "@shardflux/desktop-darwin-x64": "0.9.3",
70
- "@shardflux/desktop-linux-arm64": "0.9.3",
71
- "@shardflux/desktop-linux-x64": "0.9.3"
68
+ "@shardflux/desktop-darwin-arm64": "0.9.4",
69
+ "@shardflux/desktop-darwin-x64": "0.9.4",
70
+ "@shardflux/desktop-linux-arm64": "0.9.4",
71
+ "@shardflux/desktop-linux-x64": "0.9.4"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "node scripts/build.mjs",