@shardflux/cli 0.28.2 → 0.28.4

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.
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { DeviceExecutor, askConnectedAcp, askDeviceApprover } from "./device-bridge.js";
3
+ import { credentialHeartbeat } from "./credential-device-capability.js";
3
4
  import { ShardfluxApiError } from '@shardflux/sdk';
4
5
  import { allOrgs, allProjects } from "./account-scope.js";
5
6
  import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
@@ -17,13 +18,16 @@ import { AgentConfigReturnState, runAgentConfigLoop } from "./agent-config-retur
17
18
  import { askLocalDeviceApproval } from "./device-approvals.js";
18
19
  import { resumeCloudStartup, startCarried } from "./codex-cloud.js";
19
20
  import { runProjectPreparation } from "./project-preparation.js";
21
+ import { runTaskAutoLand } from "./task-auto-land.js";
20
22
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
21
23
  export async function runDeviceHelper(ctx, options = {}) {
22
24
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
23
25
  ctx = { ...ctx, signal };
24
26
  const pending = new Map();
25
- const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
26
- const input = options.service ? Promise.resolve() : (async () => {
27
+ // A service never reads approvals from stdin. In particular the Linux fallback may start after its command has
28
+ // already finished: creating an unconsumed stream with that aborted signal emits an uncaught AbortError.
29
+ const inputSource = options.service ? undefined : Readable.from(ctx.io.stdin, { objectMode: false, signal });
30
+ const input = inputSource ? (async () => {
27
31
  let tail = '';
28
32
  for await (const bytes of inputSource) {
29
33
  tail += Buffer.from(bytes).toString();
@@ -40,7 +44,7 @@ export async function runDeviceHelper(ctx, options = {}) {
40
44
  }
41
45
  }
42
46
  }
43
- })().finally(() => abort.abort());
47
+ })().finally(() => abort.abort()) : Promise.resolve();
44
48
  void input.catch(() => abort.abort());
45
49
  // `until` ends one question early: the person chose a mode that lets the request run by itself (device-mode.ts).
46
50
  const ask = async (approval, until) => {
@@ -81,11 +85,14 @@ export async function runDeviceHelper(ctx, options = {}) {
81
85
  // sign-in change later renews again and a refusal right after a renewal stops (never a loop).
82
86
  let renewedAt = 0;
83
87
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
84
- let mirroring, twin, returning, preparing, projects;
88
+ let mirroring, twin, returning, preparing, projects, taskLanding;
85
89
  try {
86
90
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
87
91
  // Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
88
92
  twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
93
+ // Board tasks (including those delegated from the phone) have no locally started conversation. Consume their
94
+ // finished results independently, through the exact same merge/check/receipt path as `shard task land`.
95
+ taskLanding = runTaskAutoLand(ctx).catch(report);
89
96
  // Skills, memories and CLAUDE.md lines saved in the cloud come back on their own cadence (agent-config-return.ts):
90
97
  // never behind a files-mirror pass, which takes minutes for an account with hundreds of conversations. Every 2 s
91
98
  // while one of this user's conversations is active, backing off to 60 s when none is.
@@ -161,13 +168,13 @@ export async function runDeviceHelper(ctx, options = {}) {
161
168
  // This device's local conversations of a project (started here or opened here): their requests come here first.
162
169
  const linked = (projectId) => Object.values(localConversations(ctx)).filter(r => !r.handed_off && (!projectId || r.project_id === projectId))
163
170
  .sort((a, b) => (b.conversation.created_at ?? '').localeCompare(a.conversation.created_at ?? '')).slice(0, 500).map(r => r.conversation.id);
164
- const beat = (projectId) => conversationClient(ctx).deviceRequests.heartbeat({ ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30, ...(links ? { conversationIds: linked(projectId) } : {}) })
171
+ const beat = (projectId) => credentialHeartbeat(ctx, { ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30, ...(links ? { conversationIds: linked(projectId) } : {}) })
165
172
  .catch((e) => {
166
173
  // An API before device links (0.25.0+ clients) refuses the list: register without it from then on.
167
174
  if (!links || !(e instanceof ShardfluxApiError && [400, 422].includes(e.status)))
168
175
  throw e;
169
176
  links = false;
170
- return conversationClient(ctx).deviceRequests.heartbeat({ ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30 });
177
+ return credentialHeartbeat(ctx, { ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30 });
171
178
  });
172
179
  const take = (rows) => {
173
180
  for (const r of rows) {
@@ -261,7 +268,7 @@ export async function runDeviceHelper(ctx, options = {}) {
261
268
  }
262
269
  finally {
263
270
  abort.abort();
264
- inputSource.destroy();
271
+ inputSource?.destroy();
265
272
  for (const resolve of pending.values())
266
273
  resolve('deny');
267
274
  pending.clear();
@@ -273,6 +280,7 @@ export async function runDeviceHelper(ctx, options = {}) {
273
280
  await twin;
274
281
  await preparing;
275
282
  await projects;
283
+ await taskLanding;
276
284
  await input.catch(() => undefined);
277
285
  }
278
286
  }
@@ -38,7 +38,14 @@ export interface DeviceActivity {
38
38
  local_path?: string;
39
39
  exit_code?: number;
40
40
  finished_at?: string;
41
+ error?: string;
41
42
  }
42
43
  export declare function recordDeviceActivity(dir: string, entry: DeviceActivity): void;
44
+ /** Persist the result before posting it: a stopped helper or failed API call must not erase the actual device error. */
45
+ export declare function recordDeviceResult(dir: string, entry: DeviceActivity, result: {
46
+ state: DeviceActivity['state'];
47
+ exit_code?: number;
48
+ stderr_b64?: string;
49
+ }): void;
43
50
  /** What ran without asking, newest first: one entry per request with its latest state. */
44
51
  export declare function deviceActivity(dir: string, limit?: number): DeviceActivity[];
@@ -87,6 +87,19 @@ export function recordDeviceActivity(dir, entry) {
87
87
  }
88
88
  catch { /* the request's own result still reaches its conversation */ }
89
89
  }
90
+ /** Persist the result before posting it: a stopped helper or failed API call must not erase the actual device error. */
91
+ export function recordDeviceResult(dir, entry, result) {
92
+ const bytes = result.stderr_b64 ? Buffer.from(result.stderr_b64, 'base64') : null;
93
+ let error;
94
+ if (bytes && bytes.toString('base64').replace(/=+$/, '') === result.stderr_b64.replace(/=+$/, '')) {
95
+ try {
96
+ error = new TextDecoder('utf-8', { fatal: true }).decode(bytes) || undefined;
97
+ }
98
+ catch { /* no readable error text */ }
99
+ }
100
+ recordDeviceActivity(dir, { ...entry, at: new Date().toISOString(), state: result.state,
101
+ ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}), ...(error !== undefined ? { error } : {}) });
102
+ }
90
103
  /** What ran without asking, newest first: one entry per request with its latest state. */
91
104
  export function deviceActivity(dir, limit = 20) {
92
105
  let text;
@@ -0,0 +1,3 @@
1
+ import type { DeviceRequestOutcome } from '@shardflux/sdk';
2
+ /** The actual request and its outcome, never shortened to a count or a generic failure. */
3
+ export declare function deviceOutcomeLines(requests: readonly DeviceRequestOutcome[]): string[];
@@ -0,0 +1,19 @@
1
+ /** The actual request and its outcome, never shortened to a count or a generic failure. */
2
+ export function deviceOutcomeLines(requests) {
3
+ if (!requests.length)
4
+ return [];
5
+ const ordered = [...requests].sort((a, b) => Number(b.failed) - Number(a.failed) || Date.parse(b.created_at) - Date.parse(a.created_at) || b.id.localeCompare(a.id));
6
+ return ['', 'Device requests', ...ordered.flatMap(r => {
7
+ const state = r.failed ? 'failed' : r.state;
8
+ const lines = [`${r.id}\t${state}${typeof r.exit_code === 'number' ? ` (exit ${r.exit_code})` : ''}\t${r.what}`];
9
+ if (r.path)
10
+ lines.push(` Local: ${r.path}`);
11
+ if (r.remote_path)
12
+ lines.push(` Remote: ${r.remote_path}`);
13
+ if (r.error)
14
+ lines.push(` ${r.error}`);
15
+ else if (r.failed)
16
+ lines.push(' Error text unavailable');
17
+ return lines;
18
+ })];
19
+ }
@@ -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 capabilities", "orchestrator show", "orchestrator say", "orchestrator list", "orchestrator history", "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,35 @@ 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', 'capabilities'],
8
+ summary: 'Read current input file support for your recipient',
9
+ positionals: [], options: ORG,
10
+ description: 'Reads your recipient metadata without creating or waking a computer. With --json: {capabilities: {input_files}}.',
11
+ examples: ['shard orchestrator capabilities --json'],
12
+ },
13
+ {
14
+ path: ['orchestrator', 'history'],
15
+ summary: 'Page through every input, including old and dismissed answers',
16
+ positionals: [],
17
+ options: { cursor: { type: 'string', value: '<cursor>', description: 'The previous page’s next_cursor.' }, limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 100).' }, ...ORG },
18
+ description: 'Reads one chronological page without starting work or waking a computer. Follow next_cursor to read earlier inputs. With --json: {inputs, next_cursor}.',
19
+ },
20
+ {
21
+ path: ['orchestrator', 'show'],
22
+ summary: 'Read an input and its device-request outcomes, failures first',
23
+ positionals: [{ name: 'input-id', required: true }],
24
+ options: ORG,
25
+ 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}.',
26
+ examples: ['shard orchestrator show in_abcdefghijklmnopqrstuvwxyz', 'shard orchestrator show in_abcdefghijklmnopqrstuvwxyz --json'],
27
+ },
6
28
  {
7
29
  path: ['orchestrator', 'say'],
8
30
  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 }],
31
+ positionals: [{ name: 'words', variadic: true, words: true, required: false }],
10
32
  options: {
33
+ file: { type: 'string', multiple: true, value: '<path|->', description: 'Attach a file (repeatable); - streams standard input as a file.' },
34
+ 'file-name': { type: 'string', value: '<name>', description: 'The name of the standard-input file (default stdin).' },
11
35
  task: { type: 'string', value: '<task-id>', description: 'Point at a task (task_…).' },
12
36
  report: { type: 'string', value: '<report-id>', description: 'Point at a report (rep_…).' },
13
37
  'needs-you': { type: 'string', value: '<item-id>', description: 'Point at something that waits on you (perm_…, oq_…).' },
@@ -6,13 +6,17 @@
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
- import { out, str } from "./commands.js";
14
+ import { listLimit, out, str } from "./commands.js";
12
15
  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,59 @@ const idOf = (id) => {
198
202
  return id;
199
203
  };
200
204
  export const ORCHESTRATOR_HANDLERS = {
205
+ async 'orchestrator capabilities'(ctx, values) {
206
+ const capabilities = await inScope(ctx, values, (client, scope) => client.orchestrator.capabilities({ ...orgOnly(scope), signal: ctx.signal }));
207
+ out(ctx, `input_files\t${capabilities.input_files}\n`, { capabilities });
208
+ return EXIT.ok;
209
+ },
210
+ async 'orchestrator show'(ctx, values, [id]) {
211
+ const detail = await inScope(ctx, values, (client, scope) => client.orchestrator.input(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
212
+ out(ctx, [`${detail.input.id}\t${stateLabel(detail.input)}`, resultText(detail.input), ...deviceOutcomeLines(detail.device_requests)].filter(Boolean).join('\n') + '\n', detail);
213
+ return EXIT.ok;
214
+ },
201
215
  async 'orchestrator say'(ctx, values, words) {
202
216
  const text = words.join(' ');
203
- if (!text.trim())
204
- throw new UsageError('missing <words...> for "shard orchestrator say"');
217
+ const paths = Array.isArray(values.file) ? values.file : typeof values.file === 'string' ? [values.file] : [];
218
+ if (!text.trim() && !paths.length)
219
+ throw new UsageError('missing <words> for "shard orchestrator say"');
205
220
  const selection = selectionOf(values);
206
221
  const wait = values.wait === true;
207
222
  const timeoutMs = str(values.timeout) === undefined ? SAY_WAIT_MS : parseDuration(str(values.timeout), 'timeout');
208
223
  if (!wait && str(values.timeout) !== undefined)
209
224
  throw new UsageError('--timeout goes with --wait.');
210
225
  return inScope(ctx, values, async (client, scope) => {
211
- const input = await client.orchestrator.say(text, { ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
226
+ let spool;
227
+ let input;
228
+ const files = [];
229
+ try {
230
+ for (const path of paths) {
231
+ if (path !== '-') {
232
+ files.push({ path, name: basename(path) });
233
+ continue;
234
+ }
235
+ if (!spool) {
236
+ spool = await mkdtemp(join(ctx.io.env.HOME ?? tmpdir(), 'shard-input-'));
237
+ const sink = await open(join(spool, 'stdin'), 'wx', 0o600);
238
+ try {
239
+ for await (const chunk of ctx.io.stdin) {
240
+ ctx.signal.throwIfAborted();
241
+ await sink.writeFile(chunk);
242
+ }
243
+ }
244
+ finally {
245
+ await sink.close();
246
+ }
247
+ }
248
+ files.push({ path: join(spool, 'stdin'), name: str(values['file-name']) ?? 'stdin' });
249
+ }
250
+ input = await client.orchestrator.say({ text, files, ...orgOnly(scope), ...(selection ? { selection } : {}), signal: ctx.signal });
251
+ }
252
+ finally {
253
+ if (spool) {
254
+ await unlink(join(spool, 'stdin')).catch(() => undefined);
255
+ await rmdir(spool);
256
+ }
257
+ }
212
258
  if (!wait) {
213
259
  out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
214
260
  return EXIT.ok;
@@ -242,6 +288,15 @@ export const ORCHESTRATOR_HANDLERS = {
242
288
  out(ctx, human, { inputs: page.inputs ?? [], cursor: page.cursor });
243
289
  return EXIT.ok;
244
290
  },
291
+ async 'orchestrator history'(ctx, values) {
292
+ const limit = listLimit(values, 200);
293
+ const page = await inScope(ctx, values, (client, scope) => client.orchestrator.history({ ...orgOnly(scope), cursor: str(values.cursor), ...(limit !== undefined ? { limit } : {}), signal: ctx.signal }));
294
+ const lines = page.inputs.map(inputLine);
295
+ if (page.nextCursor)
296
+ lines.push(`Next page: shard orchestrator history --cursor ${page.nextCursor}`);
297
+ out(ctx, lines.length ? `${lines.join('\n')}\n` : 'No input history yet.\n', { inputs: page.inputs, next_cursor: page.nextCursor });
298
+ return EXIT.ok;
299
+ },
245
300
  async 'orchestrator retry'(ctx, values, [id]) {
246
301
  const input = await inScope(ctx, values, (client, scope) => client.orchestrator.retry(idOf(id), { ...orgOnly(scope), signal: ctx.signal }));
247
302
  out(ctx, `${input.id}\t${stateLabel(input)}\n`, { input });
@@ -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 {};