@shardflux/cli 0.14.3 → 0.14.5

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.5
4
+
5
+ - An agent terminal no longer keeps its workspace awake. `shard ./` and `shard agents open` attach passively: while the agent waits, its workspace parks and suspends under the open terminal. Once it has gone to sleep, the terminal shows `asleep. Type to wake it.`, and your next keystroke wakes it and reaches the agent. A dropped connection reattaches without waking the workspace.
6
+ - Requires `@shardflux/sdk` 0.20.0: `shard ws tunnel` and the device bridge carry a reset on either side as a reset (`ECONNRESET`), never as a clean end of stream.
7
+
8
+ ## 0.14.4
9
+
10
+ - `shard <path> --codex` and `--claude` choose the agent like `shard <path> codex` and `claude`. An unknown option or app on `shard <path>` names the right form, e.g. `shard ./ codex`.
11
+
3
12
  ## 0.14.3
4
13
 
5
14
  - Poll device requests and deliver approvals independently of transcript mirroring, so a slow or failed copy cannot delay file-push approvals. Isolate mirror failures by conversation, retry with exponential backoff, and log each failure once per conversation revision with the affected entry name.
package/README.md CHANGED
@@ -33,7 +33,7 @@ shard ws tunnel customer-42/main --remote 8081 --to localhost:8081
33
33
  ```
34
34
 
35
35
  Commands in the workspace can then call `http://127.0.0.1:8081`. The target is dialed on the machine running
36
- `shard`. Ctrl-C closes the sockets and guest process. `--bind 0.0.0.0` selects all guest interfaces; loopback is the
36
+ `shard`. Half-close and resets pass through: a reset on either side reaches the other as `ECONNRESET` (0.14.5+). Ctrl-C closes the sockets and guest process. `--bind 0.0.0.0` selects all guest interfaces; loopback is the
37
37
  default. An open tunnel keeps the workspace awake, and persistent workspaces preserve it across explicit suspend/resume.
38
38
 
39
39
  The tunnel selects a streaming transport automatically when `pty` permission is granted. Choose `transport: 'pty'` or `transport: 'exec'` in the SDK, or `--transport pty|exec` in the CLI, to select it explicitly. The handle reports `transport`.
@@ -1163,7 +1163,7 @@ as ordinary files. Browser logins and Claude/Codex config belong to the machine.
1163
1163
  your agent uses the same save/rollback commands there. Every agent starts from both. Save or roll back with `shard machine save|rollback` and
1164
1164
  `shard template save|rollback`; changed files reach running agents live, software at the next start.
1165
1165
  `shard machine save --wait` and `shard template save --wait` return once the new version is ready (0.13.3+).
1166
- `shard ./ codex` (or `claude`) starts that agent directly; `shard agents targets <project>/<agent>` lists each
1166
+ `shard ./ codex` (or `claude`; `--codex` and `--claude` also work, 0.14.4+) starts that agent directly; `shard agents targets <project>/<agent>` lists each
1167
1167
  connection it configured and the file it is in (0.13.3+).
1168
1168
 
1169
1169
  `shard agents fork` or `f` in the command center copies the whole running state into an independent workspace and
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { ShardfluxApiError } from '@shardflux/sdk';
6
6
  import { UsageError } from "./errors.js";
7
7
  import { AgentStateStore } from "./agent-state.js";
8
- import { attachTerminal, cellConnector } from "./agent-terminal.js";
8
+ import { attachTerminal, cellConnector, workspaceAsleep } from "./agent-terminal.js";
9
9
  import { prepareProjectTemplate } from "./project-template-setup.js";
10
10
  import { projectIdentity, projectLabels } from "./project-identity.js";
11
11
  import { ensureProjectGit } from "./project-git.js";
@@ -120,8 +120,9 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
120
120
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
121
121
  const attached = attachTerminal({
122
122
  ...(credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
123
- io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id),
123
+ io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id, { passive: true }),
124
124
  wake: (signal) => ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal }),
125
+ asleep: () => workspaceAsleep(ws),
125
126
  invalidate: () => cell.tokens.invalidate(),
126
127
  sleep: (ms, signal) => new Promise((resolve) => {
127
128
  const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
@@ -1,4 +1,3 @@
1
- import { PtyConnection } from '@shardflux/sdk';
2
1
  import type { CellClient, PtyServerMessage } from '@shardflux/sdk';
3
2
  import type { Io } from './commands.js';
4
3
  export interface TerminalConnection {
@@ -7,6 +6,8 @@ export interface TerminalConnection {
7
6
  messages(): AsyncIterable<PtyServerMessage>;
8
7
  close(): Promise<void>;
9
8
  }
9
+ /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
10
+ export declare function sleepingAttach(error: unknown): boolean;
10
11
  export declare function retryableAttach(error: unknown): boolean;
11
12
  /** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
12
13
  export declare class PtyOutputCursor {
@@ -19,6 +20,12 @@ export interface AttachOptions {
19
20
  signal: AbortSignal;
20
21
  connect(offset: number, signal: AbortSignal): Promise<TerminalConnection>;
21
22
  wake(signal: AbortSignal): Promise<unknown>;
23
+ /**
24
+ * Whether the workspace went to sleep under this terminal (suspended or suspending), read without waking it. With
25
+ * it, a dropped terminal never wakes the workspace by itself: asleep, it waits until its user types; otherwise it
26
+ * reattaches, and the host wakes a parked VM for it. Without it, every reattach wakes the workspace first.
27
+ */
28
+ asleep?(signal: AbortSignal): Promise<boolean>;
22
29
  invalidate(): void;
23
30
  sleep(ms: number, signal: AbortSignal): Promise<void>;
24
31
  dimensions?: () => {
@@ -32,4 +39,19 @@ export interface AttachOptions {
32
39
  }
33
40
  /** Detaching closes only this attachment; the workspace PTY and agent keep running. */
34
41
  export declare function attachTerminal(options: AttachOptions): Promise<number>;
35
- export declare function cellConnector(cell: CellClient, sessionId: string): (offset: number, signal: AbortSignal) => Promise<PtyConnection>;
42
+ /** Reads the workspace without waking it: suspended, or on its way there. */
43
+ export declare function workspaceAsleep(ws: {
44
+ refresh(): Promise<unknown>;
45
+ state: string;
46
+ desiredState: string;
47
+ }): Promise<boolean>;
48
+ /** A host without passive attach refuses it before any output; the terminal then attaches as an ordinary one. */
49
+ export declare function passiveUnavailable(error: unknown): boolean;
50
+ /**
51
+ * Attaches a PTY. `passive` (the agent terminal, 0.14.4+) holds the workspace only while bytes move through it, so a
52
+ * waiting agent's workspace parks and suspends under an open terminal, and input still wakes it; on a host without
53
+ * passive attach the next attach is an ordinary one. An ordinary attach wakes a parked workspace and holds it.
54
+ */
55
+ export declare function cellConnector(cell: CellClient, sessionId: string, opts?: {
56
+ passive?: boolean;
57
+ }): (offset: number, signal: AbortSignal) => Promise<TerminalConnection>;
@@ -1,6 +1,11 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
2
  import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
+ /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
5
+ export function sleepingAttach(error) {
6
+ return error instanceof ShardfluxApiError && error.code === 'workspace_not_running';
7
+ }
8
+ const ASLEEP_NOTICE = '\r\n\x1b[2m· asleep. Type to wake it.\x1b[0m\r\n';
4
9
  export function retryableAttach(error) {
5
10
  if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
6
11
  return false;
@@ -50,6 +55,7 @@ export async function attachTerminal(options) {
50
55
  const size = dimensions();
51
56
  connection?.resize(Math.min(1000, Math.max(1, size.rows)), Math.min(1000, Math.max(1, size.cols)));
52
57
  };
58
+ let typed;
53
59
  const send = (bytes) => {
54
60
  if (!bytes.length)
55
61
  return;
@@ -75,10 +81,21 @@ export async function attachTerminal(options) {
75
81
  inputFailure = new UsageError('terminal input exceeded the reconnect buffer');
76
82
  controller.abort(inputFailure);
77
83
  }
78
- else
84
+ else {
79
85
  pending.push(bytes);
86
+ typed?.();
87
+ }
80
88
  }
81
89
  };
90
+ const untilTyped = () => new Promise((resolve) => {
91
+ if (pending.length || signal.aborted) {
92
+ resolve();
93
+ return;
94
+ }
95
+ const done = () => { typed = undefined; signal.removeEventListener('abort', done); resolve(); };
96
+ typed = done;
97
+ signal.addEventListener('abort', done, { once: true });
98
+ });
82
99
  const data = (value) => {
83
100
  const bytes = typeof value === 'string' ? Buffer.from(value) : value;
84
101
  if (promptAnswer) {
@@ -147,13 +164,25 @@ export async function attachTerminal(options) {
147
164
  controller.abort();
148
165
  } });
149
166
  let reconnects = 0;
167
+ let asleep = false;
150
168
  try {
151
169
  while (!signal.aborted) {
152
170
  try {
153
- if (reconnects) {
171
+ if (asleep) {
172
+ // Asleep: an open terminal is not a reason to wake the workspace. The first keystroke is.
173
+ asleep = false;
174
+ io.stderr.write(ASLEEP_NOTICE);
175
+ await untilTyped();
176
+ if (signal.aborted)
177
+ break;
154
178
  options.invalidate();
155
179
  await options.wake(signal);
156
180
  }
181
+ else if (reconnects) {
182
+ options.invalidate();
183
+ if (!options.asleep)
184
+ await options.wake(signal);
185
+ }
157
186
  connection = await options.connect(cursor.offset, signal);
158
187
  acceptingInput = true;
159
188
  resize();
@@ -179,6 +208,10 @@ export async function attachTerminal(options) {
179
208
  break;
180
209
  if (!retryableAttach(error))
181
210
  throw error;
211
+ if (options.asleep && (sleepingAttach(error) || await options.asleep(signal).catch(() => false))) {
212
+ asleep = true;
213
+ continue;
214
+ }
182
215
  }
183
216
  finally {
184
217
  const previous = connection;
@@ -204,6 +237,51 @@ export async function attachTerminal(options) {
204
237
  stdin.pause?.();
205
238
  }
206
239
  }
207
- export function cellConnector(cell, sessionId) {
208
- return (offset, signal) => PtyConnection.attach(cell, sessionId, offset, signal);
240
+ /** Reads the workspace without waking it: suspended, or on its way there. */
241
+ export async function workspaceAsleep(ws) {
242
+ await ws.refresh();
243
+ return ws.desiredState === 'suspended' || ws.state === 'suspending' || ws.state === 'suspended';
244
+ }
245
+ /** A host without passive attach refuses it before any output; the terminal then attaches as an ordinary one. */
246
+ export function passiveUnavailable(error) {
247
+ return error instanceof ShardfluxApiError && error.reason === 'host_feature_unavailable' && error.details?.feature === 'passive_pty';
248
+ }
249
+ /**
250
+ * Attaches a PTY. `passive` (the agent terminal, 0.14.4+) holds the workspace only while bytes move through it, so a
251
+ * waiting agent's workspace parks and suspends under an open terminal, and input still wakes it; on a host without
252
+ * passive attach the next attach is an ordinary one. An ordinary attach wakes a parked workspace and holds it.
253
+ */
254
+ export function cellConnector(cell, sessionId, opts = {}) {
255
+ let passive = opts.passive === true;
256
+ const fallback = (error) => {
257
+ if (!passive || !passiveUnavailable(error))
258
+ return error;
259
+ passive = false;
260
+ return new ShardfluxProtocolError('agent terminal host has no passive attach', 0, 'cell');
261
+ };
262
+ return async (offset, signal) => {
263
+ const { url, token } = await cell.pty.attachUrl(sessionId, offset);
264
+ const target = new URL(url);
265
+ if (passive)
266
+ target.searchParams.set('passive', 'true');
267
+ const connection = new PtyConnection(target.toString(), token, signal);
268
+ try {
269
+ await connection.ready;
270
+ }
271
+ catch (error) {
272
+ await connection.close();
273
+ throw fallback(error);
274
+ }
275
+ return {
276
+ input: (bytes) => connection.input(bytes), resize: (rows, cols) => connection.resize(rows, cols), close: () => connection.close(),
277
+ async *messages() {
278
+ try {
279
+ yield* connection.messages();
280
+ }
281
+ catch (error) {
282
+ throw fallback(error);
283
+ }
284
+ },
285
+ };
286
+ };
209
287
  }
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.14.3";
1
+ export declare const CLI_VERSION = "0.14.5";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.14.3';
15
+ export const CLI_VERSION = '0.14.5';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -1173,8 +1173,19 @@ const AGENT_OPTIONS = {
1173
1173
  export const PROJECT_COMMAND = {
1174
1174
  path: ['<path>'], summary: 'Start a conversation in its own cloud VM',
1175
1175
  description: 'Opens Claude Code or Codex directly. --resume continues a project conversation. With --json it starts detached and prints the session. Detach with Ctrl-] then d.',
1176
- positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: { ...AGENT_OPTIONS, new: { type: 'boolean', description: 'Start a new conversation.' }, resume: { type: 'boolean', description: 'Continue a conversation from this project.' } },
1176
+ positionals: [{ name: 'path', required: true }, { name: 'claude|codex' }], options: {
1177
+ ...AGENT_OPTIONS,
1178
+ new: { type: 'boolean', description: 'Start a new conversation.' },
1179
+ resume: { type: 'boolean', description: 'Continue a conversation from this project.' },
1180
+ claude: { type: 'boolean', description: 'Same as the claude argument (0.14.4+).' },
1181
+ codex: { type: 'boolean', description: 'Same as the codex argument (0.14.4+).' },
1182
+ },
1177
1183
  };
1184
+ /** `shard ./ codex`, quoting the path the way it was given when a shell would need it. */
1185
+ function projectExample(rest, app) {
1186
+ const path = rest.find((r, i) => !r.startsWith('-') && !GLOBAL_VALUE_FLAGS.has(rest[i - 1] ?? '')) ?? './';
1187
+ return `shard ${/^[\w./~@:+,=-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`} ${app}`;
1188
+ }
1178
1189
  export const CENTER_COMMAND = { path: ['<center>'], summary: 'Open the cloud agent command center', positionals: [], options: {} };
1179
1190
  const AGENT_COMMANDS = [
1180
1191
  { path: ['conversations', 'try'], summary: 'Try a conversation in a synced local worktree', positionals: [{ name: 'conversation', required: true }], options: { path: { type: 'string', value: '<path>', description: 'Local project checkout.' }, stop: { type: 'boolean', description: 'Stop the device helper’s local sync and ports.' } } },
@@ -1479,6 +1490,9 @@ export function parseCommandLine(argv, options = {}) {
1479
1490
  });
1480
1491
  }
1481
1492
  catch (err) {
1493
+ const unknown = command === PROJECT_COMMAND && err.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' ? /'([^']+)'/.exec(String(err.message))?.[1] : undefined;
1494
+ if (unknown !== undefined)
1495
+ throw new UsageError(`unknown option "${unknown}" for "shard <path>"; choose the agent with "${projectExample(rest, 'codex')}" or "${projectExample(rest, 'claude')}"`);
1482
1496
  throw new UsageError(err instanceof Error ? err.message : String(err));
1483
1497
  }
1484
1498
  const values = parsed.values;
@@ -1487,8 +1501,20 @@ export function parseCommandLine(argv, options = {}) {
1487
1501
  if (values.version === true)
1488
1502
  return { kind: 'version' };
1489
1503
  const positionals = parsed.positionals;
1490
- if (command === PROJECT_COMMAND && positionals[1] !== undefined && !['claude', 'codex'].includes(positionals[1]))
1491
- throw new UsageError('agent app must be claude or codex');
1504
+ if (command === PROJECT_COMMAND) {
1505
+ // --claude and --codex are spellings of the app argument; the handler only sees the argument.
1506
+ const flagged = ['claude', 'codex'].filter((app) => values[app] === true);
1507
+ delete values.claude;
1508
+ delete values.codex;
1509
+ if (positionals[1] !== undefined && !['claude', 'codex'].includes(positionals[1])) {
1510
+ throw new UsageError(`agent app must be claude or codex, as in "${projectExample(rest, 'codex')}"`);
1511
+ }
1512
+ const chosen = new Set([...flagged, ...(positionals[1] === undefined ? [] : [positionals[1]])]);
1513
+ if (chosen.size > 1)
1514
+ throw new UsageError(`choose one agent app: "${projectExample(rest, 'codex')}" or "${projectExample(rest, 'claude')}"`);
1515
+ if (positionals[1] === undefined && flagged[0] !== undefined && positionals.length === 1)
1516
+ positionals.push(flagged[0]);
1517
+ }
1492
1518
  const specs = command.positionals;
1493
1519
  const variadic = specs.at(-1)?.variadic === true;
1494
1520
  const required = specs.filter((s) => s.required && !s.variadic).length;
@@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
2
2
  import { ShardfluxApiError } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
4
  import { openUrl } from "./browser.js";
5
- import { attachTerminal, cellConnector, PtyOutputCursor } from "./agent-terminal.js";
5
+ import { attachTerminal, cellConnector, PtyOutputCursor, workspaceAsleep } from "./agent-terminal.js";
6
6
  const NEED_TERMINAL = 'Claude sign-in needed. Run shard agents credentials login claude in a terminal.';
7
7
  const VERSION = '2.1.289';
8
8
  export function requireClaudeTerminal(ctx) {
@@ -107,7 +107,7 @@ export async function loginClaudeInWorkspace(ctx, ws) {
107
107
  const opened = new Set();
108
108
  const cursor = new PtyOutputCursor();
109
109
  let output = '';
110
- const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), invalidate: () => { },
110
+ const code = await attachTerminal({ io: ctx.io, signal: ctx.signal, wake: signal => ws.wake({ signal }), asleep: () => workspaceAsleep(ws), invalidate: () => { },
111
111
  sleep: (ms, signal) => new Promise(resolve => {
112
112
  const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
113
113
  const timer = setTimeout(done, ms);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
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",
@@ -47,7 +47,7 @@
47
47
  "smol-toml": "1.9.0",
48
48
  "snappyjs": "0.7.0",
49
49
  "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.19.1"
50
+ "@shardflux/sdk": "^0.20.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@eslint/js": "10.0.1",