@shardflux/cli 0.14.4 → 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 +5 -0
- package/README.md +1 -1
- package/dist/agent-commands.js +3 -2
- package/dist/agent-terminal.d.ts +24 -2
- package/dist/agent-terminal.js +82 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +1 -1
- package/dist/claude-cloud-login.js +2 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.14.4
|
|
4
9
|
|
|
5
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`.
|
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`.
|
package/dist/agent-commands.js
CHANGED
|
@@ -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(); };
|
package/dist/agent-terminal.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/agent-terminal.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
208
|
-
|
|
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
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.
|
|
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).' },
|
|
@@ -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
|
+
"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.
|
|
50
|
+
"@shardflux/sdk": "^0.20.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|