@shardflux/cli 0.28.1 → 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.
@@ -10,7 +10,7 @@ export interface DeviceRequest {
10
10
  conversation_id: string;
11
11
  state: string;
12
12
  device_id: string | null;
13
- kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally' | 'dir_list' | 'project_link';
13
+ kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally' | 'dir_list' | 'project_link' | 'credentials_setup';
14
14
  exec?: {
15
15
  argv: string[];
16
16
  cwd_relative?: string;
@@ -36,6 +36,10 @@ export interface DeviceRequest {
36
36
  brief?: string;
37
37
  harness?: 'claude' | 'codex';
38
38
  };
39
+ credentials?: {
40
+ path?: string;
41
+ names?: string[];
42
+ };
39
43
  }
40
44
  export interface DeviceApproval {
41
45
  type: 'approval';
@@ -72,6 +76,9 @@ export interface DeviceApproval {
72
76
  choose_mode?: true;
73
77
  }
74
78
  export type Result = {
79
+ credential_setup?: {
80
+ plan_id: string;
81
+ };
75
82
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
76
83
  exit_code?: number;
77
84
  stdout_b64?: string;
@@ -1,3 +1,5 @@
1
+ import { captureLocalLoginEnvironment, discoverCredentialSources } from "./credential-sources.js";
2
+ import { transferCredentialSetup } from "./credential-setup.js";
1
3
  import { createHash } from 'node:crypto';
2
4
  import { spawn } from 'node:child_process';
3
5
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
@@ -17,7 +19,7 @@ import { checkConversationWorktree, rememberConversationWorktree } from "./conve
17
19
  import { yourComputer } from "./this-computer.js";
18
20
  import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
19
21
  import { ownConversation } from "./agent-config-return.js";
20
- import { deviceApprovalMode, recordDeviceActivity, runsWithoutAsking } from "./device-mode.js";
22
+ import { deviceApprovalMode, recordDeviceActivity, recordDeviceResult, runsWithoutAsking } from "./device-mode.js";
21
23
  import { setTimeout as delay } from 'node:timers/promises';
22
24
  /**
23
25
  * Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
@@ -247,7 +249,7 @@ export class DeviceExecutor {
247
249
  const auto = this.#auto.get(r.id);
248
250
  if (auto && result.state !== 'approved' && result.state !== 'running') {
249
251
  this.#auto.delete(r.id);
250
- recordDeviceActivity(this.#ctx.store.dir, { ...auto, at: new Date().toISOString(), state: result.state, ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}) });
252
+ recordDeviceResult(this.#ctx.store.dir, auto, { ...result, state: result.state });
251
253
  }
252
254
  await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}${again}`, signal: this.#ctx.signal });
253
255
  }
@@ -310,11 +312,52 @@ export class DeviceExecutor {
310
312
  await watch;
311
313
  }
312
314
  }
315
+ async #credentials(r) {
316
+ const ctx = this.#ctx, c = await getConversation(ctx, r.conversation_id), records = localConversations(ctx);
317
+ if (c.project_id !== r.project_id || !ownConversation(c, records, await this.#me()) || archived(c)) {
318
+ await this.result(r, { state: 'denied' });
319
+ return;
320
+ }
321
+ await this.result(r, { state: 'approved' });
322
+ try {
323
+ const matching = Object.values(records).filter(record => record.project_id === r.project_id), folders = new Set(matching.map(record => record.folder));
324
+ const folder = r.credentials?.path ? expandDevicePath(r.credentials.path, deviceHome(ctx)) : records[r.conversation_id]?.folder ?? (folders.size === 1 ? matching[0]?.folder : undefined);
325
+ const base = { home: deviceHome(ctx), env: ctx.io.env, projectId: r.project_id, ...(folder ? { projectPath: folder } : {}), signal: ctx.signal };
326
+ const env = await captureLocalLoginEnvironment(base), options = { ...base, env };
327
+ const requested = r.credentials?.names ? new Set(r.credentials.names) : null;
328
+ let sources = (await discoverCredentialSources(options)).filter(source => !requested || requested.has(source.name));
329
+ const cloud = ctx.sessionSource() ? ctx.account() : conversationClient(ctx);
330
+ if (!ctx.sessionSource())
331
+ sources = [...sources].sort((a, b) => Number(b.suggested_scope === 'project') - Number(a.suggested_scope === 'project')).map(source => ({ ...source, suggested_scope: 'project', project_id: r.project_id }));
332
+ const selected = new Set(), ids = [];
333
+ for (const source of sources)
334
+ if (source.preferred) {
335
+ const target = JSON.stringify([source.suggested_scope, source.project_id, source.name]);
336
+ if (!selected.has(target)) {
337
+ selected.add(target);
338
+ ids.push(source.id);
339
+ }
340
+ }
341
+ const plan = await cloud.credentialSetups.create({ projectId: r.project_id, deviceId: this.#deviceId, deviceRequestId: r.id, selectedItems: ids, items: sources.map(({ id, name, source, suggested_scope, project_id }) => ({ id, name, source, suggested_scope, ...(project_id ? { project_id } : {}) })) });
342
+ await this.result(r, { state: 'running', credential_setup: { plan_id: plan.id } });
343
+ let final = await transferCredentialSetup({ ...ctx, json: false }, cloud, plan, options);
344
+ while (final.state === 'approved' && !ctx.signal.aborted)
345
+ final = await cloud.credentialSetups.get(plan.id, { waitSeconds: 30, signal: ctx.signal });
346
+ const failed = final.state !== 'completed' || final.items.some(i => i.state === 'failed');
347
+ await this.result(r, { state: failed ? 'failed' : 'done', ...(final.state === 'completed' ? { credential_setup: { plan_id: plan.id } } : {}), ...(failed ? { exit_code: 1 } : {}) });
348
+ }
349
+ catch {
350
+ // No raw source/HTTP/CLI error is allowed into a device result or its conversation.
351
+ await this.result(r, { state: 'failed', exit_code: 1 });
352
+ }
353
+ }
313
354
  async execute(r) {
314
355
  const ctx = this.#ctx;
315
356
  safeId(r.id);
316
357
  if (r.state !== 'pending' || r.device_id !== this.#deviceId)
317
358
  return;
359
+ if (r.kind === 'credentials_setup')
360
+ return this.#credentials(r);
318
361
  if (folderRequest(r))
319
362
  return this.#folder(r);
320
363
  const records = localConversations(ctx);
@@ -585,7 +628,7 @@ export class DeviceExecutor {
585
628
  if (r.kind === 'dir_list')
586
629
  result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
587
630
  else if (r.kind === 'project_link')
588
- result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id, seed: true }, undefined, this.#seedHub) };
631
+ result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id, seed: true, fromConversationId: r.conversation_id }, undefined, this.#seedHub) };
589
632
  else {
590
633
  const f = r.files;
591
634
  if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
@@ -36,7 +36,7 @@ export interface LinkedProject {
36
36
  commit?: string;
37
37
  branch?: string;
38
38
  }
39
- type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
39
+ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string, fromConversationId?: string) => Promise<{
40
40
  id: string;
41
41
  project_id: string;
42
42
  }>;
@@ -65,6 +65,7 @@ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
65
65
  harness?: 'claude' | 'codex';
66
66
  projectId?: string;
67
67
  seed?: boolean;
68
+ fromConversationId?: string;
68
69
  }, start?: Starter, seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>): Promise<LinkedProject>;
69
70
  /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
70
71
  export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
@@ -139,14 +139,14 @@ export async function listDeviceDir(root, options = {}, signal) {
139
139
  * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
140
140
  * prompt. The brief goes in on standard input, never in a command line another user can read.
141
141
  */
142
- export const startFromFolder = (ctx, folder, harness, brief) => {
142
+ export const startFromFolder = (ctx, folder, harness, brief, fromConversationId) => {
143
143
  const node = ctx.runtime?.node ?? process.execPath, script = ctx.runtime?.script ?? process.argv[1];
144
144
  if (!script)
145
145
  return Promise.reject(new Error('The Shardflux CLI could not find itself to start the conversation.'));
146
146
  return new Promise((resolveStart, reject) => {
147
147
  const child = spawn(node, [script, 'agents', 'new', '--app', harness, '--path', folder, '--prompt-file', '-', '--json'], {
148
148
  cwd: folder, stdio: ['pipe', 'pipe', 'pipe'],
149
- env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
149
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1', ...(fromConversationId ? { SF_CONVERSATION_ID: fromConversationId } : {}) },
150
150
  });
151
151
  const out = [], err = [];
152
152
  let outBytes = 0;
@@ -209,7 +209,7 @@ export async function linkLocalProject(ctx, path, options = {}, start = startFro
209
209
  const profile = ctx.store.profile(ctx.apiUrl);
210
210
  let projectId = profile.project_id ?? profile.api_key?.project_id ?? options.projectId, conversationId;
211
211
  if (options.brief) {
212
- const started = await start(ctx, folder, options.harness ?? 'claude', options.brief);
212
+ const started = await start(ctx, folder, options.harness ?? 'claude', options.brief, options.fromConversationId);
213
213
  conversationId = started.id;
214
214
  projectId = started.project_id || projectId;
215
215
  }
@@ -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 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_…).' },