@shardflux/cli 0.20.0 → 0.23.0
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 +312 -1
- package/README.md +242 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-journal.js +58 -6
- package/dist/acp-login.d.ts +13 -5
- package/dist/acp-login.js +37 -8
- package/dist/acp-models.d.ts +6 -1
- package/dist/acp-models.js +29 -19
- package/dist/acp.js +120 -14
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +110 -42
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.d.ts +20 -1
- package/dist/agent-target-commands.js +73 -31
- package/dist/agent-targets.js +6 -1
- package/dist/args.d.ts +1 -1
- package/dist/args.js +95 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-hub.d.ts +61 -25
- package/dist/cli-proxy-hub.js +202 -54
- package/dist/cli-proxy.d.ts +44 -1
- package/dist/cli-proxy.js +177 -28
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +14 -4
- package/dist/codex-cloud.js +135 -31
- package/dist/codex-linux.d.ts +14 -0
- package/dist/codex-linux.js +69 -0
- package/dist/command-center-actions.js +18 -5
- package/dist/command-center-model.d.ts +2 -0
- package/dist/command-center-model.js +6 -1
- package/dist/command-center.js +3 -3
- package/dist/commands.js +173 -24
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +78 -13
- package/dist/conversations.d.ts +86 -1
- package/dist/conversations.js +306 -23
- package/dist/desktop-linux.js +16 -3
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +29 -2
- package/dist/errors.js +50 -7
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +133 -57
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/menubar.d.ts +6 -5
- package/dist/menubar.js +38 -28
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +69 -0
- package/dist/move-worktrees.js +252 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +111 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +63 -29
- package/dist/prepared-repository.d.ts +204 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1307 -0
- package/dist/project-preparation.d.ts +62 -0
- package/dist/project-preparation.js +316 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +22 -4
- package/dist/project-sync.js +34 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.d.ts +2 -1
- package/dist/t3-launch.js +12 -6
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/t3-proxy.d.ts +81 -0
- package/dist/t3-proxy.js +325 -0
- package/dist/terminal-apps.d.ts +168 -0
- package/dist/terminal-apps.js +574 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.d.ts +2 -0
- package/dist/update.js +43 -9
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
|
@@ -52,7 +52,9 @@ export function projectTemplateSlug(slug, identity) {
|
|
|
52
52
|
// Distinct dotted/underscored project keys must not collapse onto the same builder slug.
|
|
53
53
|
return `${slug.replace(/[._]/g, '-').slice(0, 89)}-${createHash('sha256').update(identity).digest('hex').slice(0, 10)}`;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/** The repository's dependency hash, its project template, whether setup runs (no template for this hash yet) and the
|
|
56
|
+
* labels a passed setup saves the project template from. The hash is read first, before any await. */
|
|
57
|
+
async function projectSetup(ctx, project) {
|
|
56
58
|
const depsHash = dependencyHash(project.folder);
|
|
57
59
|
const remote = project.remote ?? normalizeRemote(`file://${project.folder}`);
|
|
58
60
|
let binding;
|
|
@@ -68,13 +70,68 @@ export async function prepareProjectTemplate(ctx, project, existing) {
|
|
|
68
70
|
const setup = !binding || binding.deps_hash !== depsHash;
|
|
69
71
|
// The builder only accepts lowercase letters, digits and hyphens. Keep L4's workspace identity unchanged.
|
|
70
72
|
const templateSlug = binding?.template_slug ?? projectTemplateSlug(project.slug, remote);
|
|
73
|
+
return { depsHash, binding, setup,
|
|
74
|
+
labels: { shard_deps_hash: depsHash, shard_setup: String(setup), shard_setup_mode: 'assisted-v2', shard_template_slug: templateSlug, shard_repo_remote: remote } };
|
|
75
|
+
}
|
|
76
|
+
export async function prepareProjectTemplate(ctx, project, existing) {
|
|
77
|
+
const { depsHash, binding, setup, labels } = await projectSetup(ctx, project);
|
|
71
78
|
const machine = await savedMachine(ctx);
|
|
72
79
|
return {
|
|
73
80
|
template: existing?.template.slug ?? (binding ? `${binding.template_slug}@${binding.version}` : machine?.template ? `${machine.template.slug}@${machine.template.version}` : 'coding'),
|
|
74
81
|
setupHash: setup ? depsHash : undefined,
|
|
75
|
-
labels
|
|
82
|
+
labels,
|
|
76
83
|
};
|
|
77
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether a new conversation sets its repository up (0.21.1+): when the project has no template for the repository's
|
|
87
|
+
* current dependency hash. Read as the conversation starts, so the hash is of the files it is seeded with; the API
|
|
88
|
+
* read runs while its VM starts. Never fails the start: an unreadable answer is reported and the conversation runs
|
|
89
|
+
* without setup.
|
|
90
|
+
*/
|
|
91
|
+
export function readConversationSetup(ctx, project) {
|
|
92
|
+
let read;
|
|
93
|
+
try {
|
|
94
|
+
read = projectSetup(ctx, project);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
read = Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
98
|
+
}
|
|
99
|
+
return read.then(s => s.setup ? s : undefined, (error) => {
|
|
100
|
+
if (!ctx.signal.aborted)
|
|
101
|
+
ctx.io.stderr.write(`Project setup skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
102
|
+
return undefined;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The conversation's VM gets the labels a project workspace has, with its worktree as the repository, so the setup
|
|
107
|
+
* that passes there saves the project template the next conversation starts from. Runs beside the agent's start; the
|
|
108
|
+
* setup itself is queued once it is done. A failed label write is reported: setup still prepares this conversation.
|
|
109
|
+
*/
|
|
110
|
+
export function labelConversationSetup(ctx, ws, worktree, setup) {
|
|
111
|
+
if (!setup)
|
|
112
|
+
return undefined;
|
|
113
|
+
const labelled = (async () => {
|
|
114
|
+
// Labels are replaced as a whole: merge into the VM's current ones.
|
|
115
|
+
const current = await ctx.cloud().workspaces.get(ws.id, { signal: ctx.signal });
|
|
116
|
+
await current.setLabels({ ...current.labels, ...setup.labels, repo_path: worktree });
|
|
117
|
+
})().catch((error) => {
|
|
118
|
+
if (!ctx.signal.aborted)
|
|
119
|
+
ctx.io.stderr.write(`Project template will not be saved from this conversation: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
120
|
+
});
|
|
121
|
+
return { hash: setup.depsHash, labelled };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The hash a new conversation's worktree is set up with, recorded in its VM before the seed. Its guest starts the agent
|
|
125
|
+
* as soon as the repository appears, and with this record starts it with the setup's context and toolchains on PATH
|
|
126
|
+
* (services/guest-agent conversation_runtime.go setupIntent; a guest without it ignores the record).
|
|
127
|
+
*/
|
|
128
|
+
export async function recordSetupIntent(cell, c, depsHash, signal) {
|
|
129
|
+
if (!/^conv_[a-z0-9]{26}$/.test(c.id) || !/^[a-f0-9]{64}$/.test(depsHash))
|
|
130
|
+
throw new Error('invalid conversation or dependency hash');
|
|
131
|
+
const r = await cell.exec.run(['sh', '-c', 'umask 077; d=/home/user/.local/share/shardflux/conversation-setup; mkdir -p "$d" && printf \'{"deps_hash":"%s"}\' "$2" > "$d/$1.json"', 'shard-setup', c.id, depsHash], { timeoutMs: 15_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) });
|
|
132
|
+
if (r.exitCode !== 0)
|
|
133
|
+
throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
|
|
134
|
+
}
|
|
78
135
|
export const PROJECT_TEMPLATE_HANDLERS = {
|
|
79
136
|
async 'projects template'(ctx, _values, [path = './']) {
|
|
80
137
|
const project = projectIdentity(path);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
/** `secret`: a password field (multiple lines on request). `file`: a masked text area. `code`: a visible one-line code. */
|
|
3
|
+
export type AskKind = 'secret' | 'file' | 'code';
|
|
4
|
+
export interface AskPage {
|
|
5
|
+
kind: AskKind;
|
|
6
|
+
/** The page heading, e.g. "Add GITHUB_TOKEN". */
|
|
7
|
+
title: string;
|
|
8
|
+
/** One line: what it is for and where it goes. */
|
|
9
|
+
purpose: string;
|
|
10
|
+
/** The field's label. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** The submit button. */
|
|
13
|
+
submit: string;
|
|
14
|
+
/** The heading after the command's action succeeded, e.g. "Saved". */
|
|
15
|
+
done: string;
|
|
16
|
+
/** The heading after an error that ends the command, e.g. "Not saved". */
|
|
17
|
+
failed: string;
|
|
18
|
+
/** A link the person may need on the way (the claude.ai sign-in). */
|
|
19
|
+
link?: {
|
|
20
|
+
href: string;
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface AskOptions<T> extends AskPage {
|
|
25
|
+
/** Largest accepted value in UTF-8 bytes. */
|
|
26
|
+
maxBytes: number;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
/** Aborting closes the page; the result rejects with the signal's reason (an Error) or InterruptedError. */
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
/** The command's action with the value. Resolved: the page says `done`. Thrown: see `retry`. */
|
|
31
|
+
use(value: string): Promise<T>;
|
|
32
|
+
/** Whether the person can fix the error on the page (default: UsageError, and the API's 400/422 validation errors). */
|
|
33
|
+
retry?: (error: unknown) => boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface AskServer<T> {
|
|
36
|
+
/** http://127.0.0.1:<port>/<token> */
|
|
37
|
+
readonly url: string;
|
|
38
|
+
/** The value (in memory only) and the action's result, once the action succeeded. */
|
|
39
|
+
readonly result: Promise<{
|
|
40
|
+
value: string;
|
|
41
|
+
result: T;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
export declare const DEFAULT_ASK_TIMEOUT_MS: number;
|
|
45
|
+
/** `--timeout` of an asking command (default 10 minutes). */
|
|
46
|
+
export declare function askTimeout(values: Record<string, unknown>): number;
|
|
47
|
+
/** Starts the page's server (listening once this resolves); the caller opens `url`. */
|
|
48
|
+
export declare function startAsk<T>(opts: AskOptions<T>): Promise<AskServer<T>>;
|
|
49
|
+
/** What the person is told an asking command did: an NDJSON event with --json, else one line on stderr. */
|
|
50
|
+
export declare function announce(ctx: Pick<Ctx, 'json' | 'io'>, event: Record<string, unknown> & {
|
|
51
|
+
event: string;
|
|
52
|
+
url: string;
|
|
53
|
+
opened: boolean;
|
|
54
|
+
}, line: string): void;
|
|
55
|
+
/** Opens `url` in the browser; whether it opened (the link is printed either way). */
|
|
56
|
+
export declare function openInBrowser(ctx: Pick<Ctx, 'io'>, url: string): Promise<boolean>;
|
|
57
|
+
export interface BrowserAsk<T> extends Omit<AskOptions<T>, 'signal' | 'timeoutMs'> {
|
|
58
|
+
/** What the person does on the page, for the text line: "to type GITHUB_TOKEN". */
|
|
59
|
+
doing: string;
|
|
60
|
+
/** The NDJSON event name (default `open`). */
|
|
61
|
+
event?: string;
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Asks the person on the page and runs the command's action with the value: opens the page (printing its link), says
|
|
67
|
+
* so (`{"event":"open","url":...,"opened":...,"expires_in":...}` with --json), and resolves once the action succeeded.
|
|
68
|
+
*/
|
|
69
|
+
export declare function askInBrowser<T>(ctx: Ctx, ask: BrowserAsk<T>): Promise<{
|
|
70
|
+
value: string;
|
|
71
|
+
result: T;
|
|
72
|
+
}>;
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shard asks its user directly (0.20.1+): a value the coding agent running `shard` must never see (a secret, a secret
|
|
3
|
+
* file's contents, the code claude.ai shows after sign-in) is typed into a page on this computer instead of the chat.
|
|
4
|
+
*
|
|
5
|
+
* One small HTTP server listens on 127.0.0.1 at a random port and serves one page at an unguessable path (32 random
|
|
6
|
+
* bytes). The CLI opens it in the browser and always prints the link, so an agent can hand it to its user when no
|
|
7
|
+
* browser opened. The page is self-contained (inline styles from brand/tokens.css, no external requests, a strict CSP,
|
|
8
|
+
* no referrer, no caching). A POST is accepted only from that page: the path token, a per-page nonce, a same-origin
|
|
9
|
+
* `Origin` (or none), the form content type and this server's `Host`. The value goes to the command's own action
|
|
10
|
+
* (e.g. the API call that stores the secret); the page says "Saved" only after that action succeeded, or shows its
|
|
11
|
+
* error and lets the person try again. One success, a cancel or the timeout (default 10 minutes) closes the server.
|
|
12
|
+
* The value is never printed or logged, is added to the command's redaction list, and its request buffers are zeroed.
|
|
13
|
+
*/
|
|
14
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
17
|
+
import { parseDuration } from "./args.js";
|
|
18
|
+
import { openUrl } from "./browser.js";
|
|
19
|
+
import { CodedError, InterruptedError, UsageError } from "./errors.js";
|
|
20
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
|
|
21
|
+
/** `--timeout` of an asking command (default 10 minutes). */
|
|
22
|
+
export function askTimeout(values) {
|
|
23
|
+
return typeof values.timeout === 'string' ? parseDuration(values.timeout, 'timeout') : DEFAULT_ASK_TIMEOUT_MS;
|
|
24
|
+
}
|
|
25
|
+
/** Ran out of time (exit 5 like every other wait; code `ask_timeout`). */
|
|
26
|
+
class AskTimeoutError extends CodedError {
|
|
27
|
+
name = 'TimeoutError';
|
|
28
|
+
}
|
|
29
|
+
function defaultRetry(error) {
|
|
30
|
+
return error instanceof UsageError || (error instanceof ShardfluxApiError && (error.status === 400 || error.status === 422));
|
|
31
|
+
}
|
|
32
|
+
const FORM = /^application\/x-www-form-urlencoded(?:\s*;.*)?$/i;
|
|
33
|
+
function same(a, b) {
|
|
34
|
+
const x = Buffer.from(a), y = Buffer.from(b);
|
|
35
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
36
|
+
}
|
|
37
|
+
/** Starts the page's server (listening once this resolves); the caller opens `url`. */
|
|
38
|
+
export async function startAsk(opts) {
|
|
39
|
+
const token = randomBytes(32).toString('base64url');
|
|
40
|
+
const path = `/${token}`;
|
|
41
|
+
const retry = opts.retry ?? defaultRetry;
|
|
42
|
+
const nonces = new Set();
|
|
43
|
+
let state = 'open';
|
|
44
|
+
const closed = () => state === 'closed';
|
|
45
|
+
let lastError;
|
|
46
|
+
let expired = false;
|
|
47
|
+
let port = 0;
|
|
48
|
+
let settle;
|
|
49
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
50
|
+
// The command awaits it; a rejection before then must not be reported as unhandled.
|
|
51
|
+
result.catch(() => undefined);
|
|
52
|
+
const server = createServer((req, res) => {
|
|
53
|
+
handle(req, res).catch(() => {
|
|
54
|
+
if (!res.headersSent)
|
|
55
|
+
send(res, 500, page('Something went wrong', 'Close this tab and run the command again.'));
|
|
56
|
+
else
|
|
57
|
+
res.destroy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
/** Single use: stops listening at once; open connections (other tabs) end once `res`, the last answer, is sent. */
|
|
61
|
+
const finish = (outcome, res) => {
|
|
62
|
+
state = 'closed';
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
opts.signal.removeEventListener('abort', aborted);
|
|
65
|
+
server.close();
|
|
66
|
+
if (res && !res.writableFinished)
|
|
67
|
+
res.once('close', () => server.closeAllConnections());
|
|
68
|
+
else
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
if ('ok' in outcome)
|
|
71
|
+
settle.resolve(outcome.ok);
|
|
72
|
+
else
|
|
73
|
+
settle.reject(outcome.error);
|
|
74
|
+
};
|
|
75
|
+
const timeoutError = () => new AskTimeoutError('ask_timeout', `Nothing was entered in the page within ${duration(opts.timeoutMs)}.`, 'run the command again for a new page (--timeout sets how long it waits)');
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
expired = true;
|
|
78
|
+
// A submit in progress finishes first; its outcome decides.
|
|
79
|
+
if (state === 'open')
|
|
80
|
+
finish({ error: timeoutError() });
|
|
81
|
+
}, opts.timeoutMs);
|
|
82
|
+
const aborted = () => {
|
|
83
|
+
const reason = opts.signal.reason;
|
|
84
|
+
if (state !== 'closed')
|
|
85
|
+
finish({ error: reason instanceof Error && reason.name !== 'AbortError' ? reason : new InterruptedError('Interrupted.') });
|
|
86
|
+
};
|
|
87
|
+
function headers(nonce) {
|
|
88
|
+
const csp = nonce
|
|
89
|
+
? `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`
|
|
90
|
+
: "default-src 'none'; frame-ancestors 'none'";
|
|
91
|
+
return {
|
|
92
|
+
'content-security-policy': csp,
|
|
93
|
+
'referrer-policy': 'no-referrer',
|
|
94
|
+
'cache-control': 'no-store',
|
|
95
|
+
'x-content-type-options': 'nosniff',
|
|
96
|
+
'x-frame-options': 'DENY',
|
|
97
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
98
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function send(res, status, html, last = false) {
|
|
102
|
+
if (html === null) {
|
|
103
|
+
res.writeHead(status, { ...headers(), 'content-type': 'text/plain; charset=utf-8' }).end(status === 404 ? 'Not found.\n' : 'Refused.\n');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const nonce = randomBytes(16).toString('base64');
|
|
107
|
+
const body = html.replaceAll('%NONCE%', nonce);
|
|
108
|
+
res.writeHead(status, { ...headers(nonce), 'content-type': 'text/html; charset=utf-8', ...(last ? { connection: 'close' } : {}) }).end(body);
|
|
109
|
+
}
|
|
110
|
+
function form(error) {
|
|
111
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
112
|
+
nonces.add(nonce);
|
|
113
|
+
// A few tabs or reloads of the same page each get a nonce; older ones expire.
|
|
114
|
+
if (nonces.size > 8)
|
|
115
|
+
nonces.delete(nonces.values().next().value);
|
|
116
|
+
return formPage(opts, path, nonce, error, opts.timeoutMs);
|
|
117
|
+
}
|
|
118
|
+
function page(heading, text, tone = 'plain') {
|
|
119
|
+
return statusPage(heading, text, tone);
|
|
120
|
+
}
|
|
121
|
+
async function handle(req, res) {
|
|
122
|
+
// DNS rebinding: only this server's own address is served.
|
|
123
|
+
if (req.headers.host !== `127.0.0.1:${port}`)
|
|
124
|
+
return send(res, 421, null);
|
|
125
|
+
if (req.url === undefined || !same(req.url, path))
|
|
126
|
+
return send(res, 404, null);
|
|
127
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
128
|
+
if (state === 'closed')
|
|
129
|
+
return send(res, 410, page('This page was used', 'You can close this tab.'));
|
|
130
|
+
return send(res, 200, state === 'busy' ? page('Saving', 'Waiting for the result. Reload this tab in a moment.') : form(lastError));
|
|
131
|
+
}
|
|
132
|
+
if (req.method !== 'POST')
|
|
133
|
+
return send(res, 405, null);
|
|
134
|
+
// Only this page posts here: same origin, the form content type, a nonce it issued. Under no-referrer a browser
|
|
135
|
+
// posts the form with `Origin: null` (a sandboxed frame elsewhere does too), so null counts only with
|
|
136
|
+
// `Sec-Fetch-Site: same-origin`, which the browser sets from the real initiator.
|
|
137
|
+
const origin = req.headers.origin;
|
|
138
|
+
const site = req.headers['sec-fetch-site'];
|
|
139
|
+
if (site !== undefined && site !== 'same-origin')
|
|
140
|
+
return send(res, 403, null);
|
|
141
|
+
if (origin !== undefined && origin !== `http://127.0.0.1:${port}` && !(origin === 'null' && site === 'same-origin'))
|
|
142
|
+
return send(res, 403, null);
|
|
143
|
+
if (!FORM.test(req.headers['content-type'] ?? ''))
|
|
144
|
+
return send(res, 415, null);
|
|
145
|
+
const fields = await readForm(req, opts.maxBytes * 3 + 4096);
|
|
146
|
+
if (fields === null)
|
|
147
|
+
return send(res, 413, form(`The value is larger than ${opts.maxBytes} bytes.`), true);
|
|
148
|
+
const nonce = fields.get('nonce') ?? '';
|
|
149
|
+
const issued = [...nonces].find((n) => same(n, nonce));
|
|
150
|
+
if (issued === undefined)
|
|
151
|
+
return send(res, 403, page('This page expired', 'Reload it and try again.'));
|
|
152
|
+
if (state === 'busy')
|
|
153
|
+
return send(res, 409, page('Saving', 'The first submit is still being saved. Reload this tab in a moment.'));
|
|
154
|
+
if (state === 'closed')
|
|
155
|
+
return send(res, 410, page('This page was used', 'You can close this tab.'));
|
|
156
|
+
nonces.delete(issued);
|
|
157
|
+
if (fields.get('action') === 'cancel') {
|
|
158
|
+
send(res, 200, page('Canceled', 'Nothing was saved. You can close this tab.'), true);
|
|
159
|
+
const after = lastError ? `Canceled in the browser after: ${lastError}` : 'Canceled in the browser; nothing was saved.';
|
|
160
|
+
finish({ error: new CodedError('ask_canceled', after, 'ask your user whether to try again, then run the command again') }, res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let value = (fields.get('value') ?? '').replaceAll('\r\n', '\n');
|
|
164
|
+
if (opts.kind === 'code')
|
|
165
|
+
value = value.trim();
|
|
166
|
+
if (value.length === 0)
|
|
167
|
+
return send(res, 200, form('Type a value first.'));
|
|
168
|
+
if (Buffer.byteLength(value, 'utf8') > opts.maxBytes)
|
|
169
|
+
return send(res, 200, form(`The value is larger than ${opts.maxBytes} bytes.`));
|
|
170
|
+
state = 'busy';
|
|
171
|
+
try {
|
|
172
|
+
const out = await opts.use(value);
|
|
173
|
+
send(res, 200, page(opts.done, 'You can close this tab.', 'done'), true);
|
|
174
|
+
finish({ ok: { value, result: out } }, res);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const message = errorText(error, value);
|
|
178
|
+
// Closed meanwhile (interrupted, or the command's own abort): the page only reports it.
|
|
179
|
+
if (closed())
|
|
180
|
+
return send(res, 410, page(opts.failed, message, 'failed'), true);
|
|
181
|
+
if (retry(error) && !expired) {
|
|
182
|
+
state = 'open';
|
|
183
|
+
lastError = message;
|
|
184
|
+
return send(res, 200, form(message));
|
|
185
|
+
}
|
|
186
|
+
send(res, 200, page(opts.failed, `${message} You can close this tab; your coding agent sees the same error.`, 'failed'), true);
|
|
187
|
+
finish({ error: retry(error) && expired ? timeoutError() : error }, res);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
await new Promise((resolve, reject) => {
|
|
191
|
+
server.once('error', reject);
|
|
192
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
193
|
+
});
|
|
194
|
+
port = server.address().port;
|
|
195
|
+
opts.signal.addEventListener('abort', aborted, { once: true });
|
|
196
|
+
if (opts.signal.aborted)
|
|
197
|
+
aborted();
|
|
198
|
+
return { url: `http://127.0.0.1:${port}${path}`, result };
|
|
199
|
+
}
|
|
200
|
+
/** The urlencoded body, or null past `max` bytes. The raw request bytes are zeroed once parsed. */
|
|
201
|
+
async function readForm(req, max) {
|
|
202
|
+
const chunks = [];
|
|
203
|
+
let size = 0;
|
|
204
|
+
try {
|
|
205
|
+
for await (const chunk of req) {
|
|
206
|
+
size += chunk.length;
|
|
207
|
+
chunks.push(chunk);
|
|
208
|
+
if (size > max)
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const body = Buffer.concat(chunks);
|
|
212
|
+
try {
|
|
213
|
+
return new URLSearchParams(body.toString('utf8'));
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
body.fill(0);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
for (const chunk of chunks)
|
|
221
|
+
chunk.fill(0);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function errorText(error, value) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
const redacted = value.length >= 4 ? message.split(value).join('[redacted]') : message;
|
|
227
|
+
// CLI messages start lower case ("the secret value is empty"); on the page they are sentences.
|
|
228
|
+
const text = redacted.charAt(0).toUpperCase() + redacted.slice(1);
|
|
229
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
230
|
+
}
|
|
231
|
+
function duration(ms) {
|
|
232
|
+
if (ms % 60_000 === 0)
|
|
233
|
+
return `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}`;
|
|
234
|
+
return `${Math.max(1, Math.round(ms / 1000))} s`;
|
|
235
|
+
}
|
|
236
|
+
const esc = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
237
|
+
/** The Flux mark (brand/shardflux-mark.svg). */
|
|
238
|
+
const MARK = '<svg viewBox="0 0 32 32" width="20" height="20" fill="currentColor" aria-hidden="true"><g transform="rotate(18 16 16)"><polygon points="16,1 11.13,12.4 20.87,12.4 16,1"/><polygon points="10.71,13.4 9,17.4 9.77,18.9 22.23,18.9 23,17.4 21.29,13.4"/><polygon points="10.29,19.9 12.86,24.9 19.14,24.9 21.71,19.9" opacity="0.72"/><polygon points="13.38,25.9 16,31 16,31 18.63,25.9" opacity="0.45"/></g></svg>';
|
|
239
|
+
const FAVICON = `data:image/svg+xml,${encodeURIComponent(MARK.replace('fill="currentColor"', 'fill="#8B8F96"').replace(' aria-hidden="true"', ' xmlns="http://www.w3.org/2000/svg"'))}`;
|
|
240
|
+
// Values from brand/tokens.css (dark primary, light for app surfaces), inlined: the page makes no other request.
|
|
241
|
+
const STYLE = `
|
|
242
|
+
:root{color-scheme:dark;--sf-bg:#0A0A0B;--sf-raise:#111113;--sf-ink:#EDEBE7;--sf-muted:#8B8F96;--sf-dim:#5D6168;
|
|
243
|
+
--sf-line:rgba(237,235,231,.09);--sf-line-2:rgba(237,235,231,.16);--sf-line-3:rgba(237,235,231,.34);--sf-success:#7BC68A;--sf-danger:#F0645A;
|
|
244
|
+
--sf-body:"Funnel Sans",system-ui,-apple-system,"Segoe UI",sans-serif;--sf-display:"Funnel Display",system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
245
|
+
--sf-mono:"Fragment Mono",ui-monospace,Menlo,monospace;--sf-fast:150ms}
|
|
246
|
+
@media (prefers-color-scheme:light){:root{color-scheme:light;--sf-bg:#EDEBE7;--sf-raise:#F6F4F0;--sf-ink:#15161A;--sf-muted:#5B6068;--sf-dim:#8A8E94;
|
|
247
|
+
--sf-line:rgba(21,22,26,.10);--sf-line-2:rgba(21,22,26,.18);--sf-line-3:rgba(21,22,26,.36);--sf-success:#2F8A4A;--sf-danger:#C8372D}}
|
|
248
|
+
@media (prefers-reduced-motion:reduce){:root{--sf-fast:0ms}}
|
|
249
|
+
*{box-sizing:border-box}
|
|
250
|
+
html,body{margin:0;background:var(--sf-bg);color:var(--sf-ink);font:400 14px/1.5 var(--sf-body);-webkit-font-smoothing:antialiased}
|
|
251
|
+
main{max-width:480px;margin:0 auto;padding:64px 16px 48px;display:flex;flex-direction:column;gap:24px}
|
|
252
|
+
.lockup{display:flex;align-items:center;gap:8px;font:600 17px/1 var(--sf-display);letter-spacing:-.03em;color:var(--sf-ink)}
|
|
253
|
+
h1{margin:0;font:500 26px/1.05 var(--sf-display);letter-spacing:-.03em;text-wrap:balance;display:flex;align-items:center;gap:12px;overflow-wrap:anywhere}
|
|
254
|
+
p{margin:0;color:var(--sf-muted);font-size:15px;text-wrap:pretty;overflow-wrap:anywhere}
|
|
255
|
+
a{color:var(--sf-ink);text-underline-offset:3px}
|
|
256
|
+
form{display:flex;flex-direction:column;gap:8px}
|
|
257
|
+
.row{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
|
258
|
+
label{color:var(--sf-muted);font-size:13px}
|
|
259
|
+
.tools{display:flex;gap:12px}
|
|
260
|
+
.text{background:none;border:0;padding:0;color:var(--sf-muted);font:500 13px var(--sf-body);cursor:pointer;transition:color var(--sf-fast)}
|
|
261
|
+
.text:hover{color:var(--sf-ink)}
|
|
262
|
+
input,textarea{width:100%;background:transparent;color:var(--sf-ink);border:1px solid var(--sf-line-2);border-radius:6px;font:400 14px/1.5 var(--sf-mono);
|
|
263
|
+
font-variant-ligatures:none;padding:11px 12px;transition:border-color var(--sf-fast)}
|
|
264
|
+
input{height:44px}
|
|
265
|
+
textarea{min-height:168px;resize:vertical}
|
|
266
|
+
textarea.masked{-webkit-text-security:disc}
|
|
267
|
+
input:hover,textarea:hover{border-color:var(--sf-line-3)}
|
|
268
|
+
input:focus-visible,textarea:focus-visible,button:focus-visible,a:focus-visible{outline:2px solid var(--sf-ink);outline-offset:2px;border-color:var(--sf-line-3)}
|
|
269
|
+
.error{color:var(--sf-danger);font-size:14px}
|
|
270
|
+
.actions{display:flex;gap:12px;margin-top:8px}
|
|
271
|
+
.btn{height:44px;padding:0 18px;border-radius:6px;font:500 15px var(--sf-body);cursor:pointer;transition:border-color var(--sf-fast),opacity var(--sf-fast)}
|
|
272
|
+
.primary{background:var(--sf-ink);color:var(--sf-bg);border:1px solid var(--sf-ink)}
|
|
273
|
+
.primary:disabled{opacity:.6;cursor:default}
|
|
274
|
+
.ghost{background:transparent;color:var(--sf-ink);border:1px solid var(--sf-line-2)}
|
|
275
|
+
.ghost:hover{border-color:var(--sf-line-3)}
|
|
276
|
+
.note{color:var(--sf-dim);font-size:13px;border-top:1px solid var(--sf-line);padding-top:16px}
|
|
277
|
+
.ok{color:var(--sf-success);flex:none}
|
|
278
|
+
.bad{color:var(--sf-danger)}
|
|
279
|
+
[hidden]{display:none!important}
|
|
280
|
+
`;
|
|
281
|
+
function shell(title, body, script = '') {
|
|
282
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
283
|
+
`<meta name="referrer" content="no-referrer"><meta name="robots" content="noindex"><title>${esc(title)} · Shardflux</title>` +
|
|
284
|
+
`<link rel="icon" href="${FAVICON}"><style nonce="%NONCE%">${STYLE}</style></head><body><main>` +
|
|
285
|
+
`<div class="lockup">${MARK}<span>shardflux</span></div>${body}</main>${script ? `<script nonce="%NONCE%">${script}</script>` : ''}</body></html>`;
|
|
286
|
+
}
|
|
287
|
+
const CHECK = '<svg class="ok" viewBox="0 0 16 16" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3.2 3L13 4.5"/></svg>';
|
|
288
|
+
function statusPage(heading, text, tone) {
|
|
289
|
+
return shell(heading, `<h1>${tone === 'done' ? CHECK : ''}<span>${esc(heading)}</span></h1><p${tone === 'failed' ? ' class="bad"' : ''}>${esc(text)}</p>`);
|
|
290
|
+
}
|
|
291
|
+
// Show/hide, the multi-line switch for secrets, and one submit at a time. Everything works without it except Show.
|
|
292
|
+
const SCRIPT = `(()=>{const f=document.getElementById('f'),show=document.getElementById('show'),lines=document.getElementById('lines'),save=document.getElementById('save');
|
|
293
|
+
const field=()=>f.querySelector('[name=value]:not([disabled])');
|
|
294
|
+
if(show)show.addEventListener('click',()=>{const el=field();const hidden=el.tagName==='INPUT'?el.type==='password':el.classList.contains('masked');
|
|
295
|
+
if(el.tagName==='INPUT')el.type=hidden?'text':'password';else el.classList.toggle('masked',!hidden);
|
|
296
|
+
show.textContent=hidden?'Hide':'Show';show.setAttribute('aria-pressed',String(hidden));el.focus();});
|
|
297
|
+
if(lines)lines.addEventListener('click',()=>{const i=document.getElementById('v'),t=document.getElementById('t');
|
|
298
|
+
t.classList.toggle('masked',i.type==='password');t.value=i.value;i.value='';i.disabled=true;i.hidden=true;t.disabled=false;t.hidden=false;lines.hidden=true;t.focus();});
|
|
299
|
+
let sent=false;f.addEventListener('submit',e=>{if(sent){e.preventDefault();return;}sent=true;setTimeout(()=>{save.disabled=true;save.textContent=save.dataset.busy;});});})();`;
|
|
300
|
+
function formPage(p, action, nonce, error, timeoutMs) {
|
|
301
|
+
const common = 'name="value" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true"';
|
|
302
|
+
const field = p.kind === 'file'
|
|
303
|
+
? `<textarea id="v" class="masked" ${common} required autofocus></textarea>`
|
|
304
|
+
: p.kind === 'code'
|
|
305
|
+
? `<input id="v" type="text" ${common} required autofocus>`
|
|
306
|
+
: `<input id="v" type="password" ${common} required autofocus><textarea id="t" ${common} hidden disabled></textarea>`;
|
|
307
|
+
const tools = p.kind === 'code' ? '' : `<span class="tools">${p.kind === 'secret' ? '<button type="button" class="text" id="lines">Multiple lines</button>' : ''}<button type="button" class="text" id="show" aria-pressed="false">Show</button></span>`;
|
|
308
|
+
const link = p.link ? `<p><a href="${esc(p.link.href)}" target="_blank" rel="noreferrer noopener">${esc(p.link.text)}</a></p>` : '';
|
|
309
|
+
const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
|
|
310
|
+
return shell(p.title, `<h1><span>${esc(p.title)}</span></h1><p>${esc(p.purpose)}</p>${link}` +
|
|
311
|
+
`<form id="f" method="post" action="${esc(action)}" accept-charset="utf-8" autocomplete="off">` +
|
|
312
|
+
`<input type="hidden" name="nonce" value="${esc(nonce)}"><div class="row"><label for="v">${esc(p.label)}</label>${tools}</div>${field}` +
|
|
313
|
+
`${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}</form>` +
|
|
314
|
+
`<form id="c" method="post" action="${esc(action)}" hidden><input type="hidden" name="nonce" value="${esc(nonce)}"><input type="hidden" name="action" value="cancel"></form>` +
|
|
315
|
+
`<div class="actions"><button type="submit" form="f" class="btn primary" id="save" data-busy="${p.kind === 'code' ? 'Signing in…' : 'Saving…'}">${esc(p.submit)}</button>` +
|
|
316
|
+
`<button type="submit" form="c" class="btn ghost">Cancel</button></div>` +
|
|
317
|
+
`<p class="note">Served by shard on your computer. It closes after one use or in ${minutes} minute${minutes === 1 ? '' : 's'}.</p>`, SCRIPT);
|
|
318
|
+
}
|
|
319
|
+
/** What the person is told an asking command did: an NDJSON event with --json, else one line on stderr. */
|
|
320
|
+
export function announce(ctx, event, line) {
|
|
321
|
+
if (ctx.json)
|
|
322
|
+
ctx.io.stdout.write(`${JSON.stringify(event)}\n`);
|
|
323
|
+
else
|
|
324
|
+
ctx.io.stderr.write(`${line}\n`);
|
|
325
|
+
}
|
|
326
|
+
/** Opens `url` in the browser; whether it opened (the link is printed either way). */
|
|
327
|
+
export async function openInBrowser(ctx, url) {
|
|
328
|
+
try {
|
|
329
|
+
return await openUrl(url, ctx.io.env);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Asks the person on the page and runs the command's action with the value: opens the page (printing its link), says
|
|
337
|
+
* so (`{"event":"open","url":...,"opened":...,"expires_in":...}` with --json), and resolves once the action succeeded.
|
|
338
|
+
*/
|
|
339
|
+
export async function askInBrowser(ctx, ask) {
|
|
340
|
+
const server = await startAsk({
|
|
341
|
+
...ask,
|
|
342
|
+
signal: ask.signal ? AbortSignal.any([ctx.signal, ask.signal]) : ctx.signal,
|
|
343
|
+
use: async (value) => {
|
|
344
|
+
ctx.addSecret(value);
|
|
345
|
+
return ask.use(value);
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const opened = await openInBrowser(ctx, server.url);
|
|
349
|
+
announce(ctx, { event: ask.event ?? 'open', url: server.url, opened, expires_in: Math.round(ask.timeoutMs / 1000) }, opened ? `Opened a page in your browser ${ask.doing} (or open: ${server.url})` : `Open this page ${ask.doing}: ${server.url}`);
|
|
350
|
+
return server.result;
|
|
351
|
+
}
|
package/dist/seed-phases.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The start path's phase clock. SHARDFLUX_TRACE_SEED=1 prints how long each phase of a conversation start took
|
|
3
|
-
* (`seed <phase> <ms> ms
|
|
3
|
+
* (`seed <phase> <ms> ms @<epoch ms>`) on stderr, for measurements and support, up to the first prompt's Enter. The
|
|
4
|
+
* wall clock lets a measurement line the phases up with the server's logs. Silent otherwise.
|
|
4
5
|
*
|
|
5
6
|
* The same marks drive the launchers' phase line (0.19.0+): with SHARDFLUX_PROGRESS_FILE set to a private file the
|
|
6
7
|
* launcher created, a start rewrites that file with the phase it is in, as one JSON line
|
|
@@ -11,10 +12,14 @@ export type StartPhase = 'computer' | 'project' | 'checkout' | 'copy_changes' |
|
|
|
11
12
|
type App = 'claude' | 'codex';
|
|
12
13
|
export declare const startPhaseLine: (phase: StartPhase, app: App) => string;
|
|
13
14
|
export declare function seedPhase(name: string, env?: Record<string, string | undefined>): void;
|
|
15
|
+
/** A moment of the start that happened elsewhere (the VM's own clock), for the same trace: `seed <name> 0 ms @<epoch>`. */
|
|
16
|
+
export declare function seedEvent(name: string, epochMs: number, env?: Record<string, string | undefined>): void;
|
|
14
17
|
/** A conversation start begins: the launcher's file (if any) is remembered and the first phase is written. */
|
|
15
18
|
export declare function startProgress(app: App, env?: Record<string, string | undefined>): void;
|
|
16
19
|
/** Moves the current start to this phase; the file is rewritten only when the phase changes. */
|
|
17
20
|
export declare function startPhase(phase: StartPhase): void;
|
|
21
|
+
/** Accepted identity survives later phase updates and the foreground deadline. */
|
|
22
|
+
export declare function acceptedProgress(conversation: string, key: string): void;
|
|
18
23
|
/**
|
|
19
24
|
* The caller's own private file in the caller's own private directory: a regular file (a symlink is refused, never
|
|
20
25
|
* followed) owned by this user with no group or other access, in a directory owned by this user that nobody else can
|
|
@@ -28,5 +33,7 @@ export declare function privateProgressFile(file: string): boolean;
|
|
|
28
33
|
export declare function writeProgress(file: string, doc: {
|
|
29
34
|
phase: string;
|
|
30
35
|
line: string;
|
|
36
|
+
conversation_id?: string;
|
|
37
|
+
workspace_key?: string;
|
|
31
38
|
}): boolean;
|
|
32
39
|
export {};
|
package/dist/seed-phases.js
CHANGED
|
@@ -13,6 +13,7 @@ const NEXT = {
|
|
|
13
13
|
apply_history: 'checkout',
|
|
14
14
|
local_changes: 'checkout',
|
|
15
15
|
checkout_head: 'copy_changes',
|
|
16
|
+
prepared_transfer: 'checkout',
|
|
16
17
|
seed_done: 'harness',
|
|
17
18
|
};
|
|
18
19
|
let last = performance.now();
|
|
@@ -20,12 +21,17 @@ let target = null;
|
|
|
20
21
|
export function seedPhase(name, env = process.env) {
|
|
21
22
|
const now = performance.now();
|
|
22
23
|
if (env.SHARDFLUX_TRACE_SEED)
|
|
23
|
-
process.stderr.write(`seed ${name} ${Math.round(now - last)} ms\n`);
|
|
24
|
+
process.stderr.write(`seed ${name} ${Math.round(now - last)} ms @${Math.round(performance.timeOrigin + now)}\n`);
|
|
24
25
|
last = now;
|
|
25
26
|
const next = NEXT[name];
|
|
26
27
|
if (next)
|
|
27
28
|
startPhase(next);
|
|
28
29
|
}
|
|
30
|
+
/** A moment of the start that happened elsewhere (the VM's own clock), for the same trace: `seed <name> 0 ms @<epoch>`. */
|
|
31
|
+
export function seedEvent(name, epochMs, env = process.env) {
|
|
32
|
+
if (env.SHARDFLUX_TRACE_SEED && Number.isFinite(epochMs))
|
|
33
|
+
process.stderr.write(`seed ${name} 0 ms @${Math.round(epochMs)}\n`);
|
|
34
|
+
}
|
|
29
35
|
/** A conversation start begins: the launcher's file (if any) is remembered and the first phase is written. */
|
|
30
36
|
export function startProgress(app, env = process.env) {
|
|
31
37
|
const file = env.SHARDFLUX_PROGRESS_FILE;
|
|
@@ -37,7 +43,16 @@ export function startPhase(phase) {
|
|
|
37
43
|
if (!target || target.phase === phase)
|
|
38
44
|
return;
|
|
39
45
|
target.phase = phase;
|
|
40
|
-
writeProgress(target.file, { phase, line: startPhaseLine(phase, target.app) });
|
|
46
|
+
writeProgress(target.file, { phase, line: startPhaseLine(phase, target.app), ...(target.conversation_id ? { conversation_id: target.conversation_id, workspace_key: target.workspace_key } : {}) });
|
|
47
|
+
}
|
|
48
|
+
/** Accepted identity survives later phase updates and the foreground deadline. */
|
|
49
|
+
export function acceptedProgress(conversation, key) {
|
|
50
|
+
if (!target)
|
|
51
|
+
return;
|
|
52
|
+
target.conversation_id = conversation;
|
|
53
|
+
target.workspace_key = key;
|
|
54
|
+
const phase = target.phase ?? 'computer';
|
|
55
|
+
writeProgress(target.file, { phase, line: startPhaseLine(phase, target.app), conversation_id: conversation, workspace_key: key });
|
|
41
56
|
}
|
|
42
57
|
/**
|
|
43
58
|
* The caller's own private file in the caller's own private directory: a regular file (a symlink is refused, never
|
package/dist/setup-cache.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Workspace } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
3
|
export declare const SETUP_CACHE_PATH = "/mnt/shardflux-cache";
|
|
4
|
+
export declare const SETUP_CACHE_LOG = "setup-cache.log";
|
|
4
5
|
/** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
|
|
5
6
|
export declare function prepareSetupCache(ctx: Ctx, workspace: Workspace): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* The account cache is optional: without it the conversation runs and setup caches in its own workspace
|
|
9
|
+
* (`shard template` reports `cache_scope`). A failure is a JSON line in setup-cache.log of the config directory, with
|
|
10
|
+
* the API's code, reason and request id, never terminal output (0.21.1+). The log starts over past 64 KiB.
|
|
11
|
+
*/
|
|
12
|
+
export declare function recordSetupCacheFailure(ctx: Ctx, workspace: Workspace, error: unknown): void;
|
package/dist/setup-cache.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
1
4
|
const name = 'shardflux-setup-cache';
|
|
2
5
|
export const SETUP_CACHE_PATH = '/mnt/shardflux-cache';
|
|
6
|
+
export const SETUP_CACHE_LOG = 'setup-cache.log';
|
|
7
|
+
const LOG_MAX_BYTES = 64 * 1024;
|
|
3
8
|
/** Uses ordinary fenced shared-volume operations, after the managed PTY starts. */
|
|
4
9
|
export async function prepareSetupCache(ctx, workspace) {
|
|
5
10
|
const cloud = ctx.cloud();
|
|
@@ -9,3 +14,30 @@ export async function prepareSetupCache(ctx, workspace) {
|
|
|
9
14
|
throw new Error('account setup cache is not available');
|
|
10
15
|
await cloud.volumes.attach(workspace.id, { volumeId: volume.id, mountPath: SETUP_CACHE_PATH, wait });
|
|
11
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The account cache is optional: without it the conversation runs and setup caches in its own workspace
|
|
19
|
+
* (`shard template` reports `cache_scope`). A failure is a JSON line in setup-cache.log of the config directory, with
|
|
20
|
+
* the API's code, reason and request id, never terminal output (0.21.1+). The log starts over past 64 KiB.
|
|
21
|
+
*/
|
|
22
|
+
export function recordSetupCacheFailure(ctx, workspace, error) {
|
|
23
|
+
const api = error instanceof ShardfluxApiError ? error : undefined;
|
|
24
|
+
const line = JSON.stringify({
|
|
25
|
+
at: new Date().toISOString(), workspace_id: workspace.id, project_id: workspace.data.project_id,
|
|
26
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27
|
+
...(api ? { code: api.code, reason: api.reason ?? null, request_id: api.requestId || null } : {}),
|
|
28
|
+
}) + '\n';
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(ctx.store.dir, { recursive: true, mode: 0o700 });
|
|
31
|
+
const file = join(ctx.store.dir, SETUP_CACHE_LOG);
|
|
32
|
+
let size = 0;
|
|
33
|
+
try {
|
|
34
|
+
size = statSync(file).size;
|
|
35
|
+
}
|
|
36
|
+
catch { /* first failure */ }
|
|
37
|
+
if (size > LOG_MAX_BYTES)
|
|
38
|
+
writeFileSync(file, line, { mode: 0o600 });
|
|
39
|
+
else
|
|
40
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
catch { /* the trace is best effort, like the cache itself */ }
|
|
43
|
+
}
|
package/dist/t3-launch.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare function launchT3(ctx: Ctx, values: Values, path: string, explici
|
|
|
32
32
|
export declare function t3Fields(ws: Workspace, project: string, install: T3Install | undefined, started: T3ThreadResult | undefined, conversation?: string | null): Record<string, unknown>;
|
|
33
33
|
/**
|
|
34
34
|
* `agents open t3 <conversation>` for a conversation that runs on T3's Shardflux provider (ACP mode): T3 is brought to
|
|
35
|
-
* the front with that thread's id. A
|
|
35
|
+
* the front with that thread's id. A Codex Cloud conversation opens its Codex chat; a conversation of another route
|
|
36
|
+
* opens its VM as T3's SSH environment.
|
|
36
37
|
*/
|
|
37
38
|
export declare function openT3Conversation(ctx: Ctx, conversationId: string, values?: Values): Promise<number>;
|