@shardflux/cli 0.13.6 → 0.14.1
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 +42 -0
- package/README.md +44 -4
- package/dist/acp-catalog.d.ts +7 -0
- package/dist/acp-catalog.js +40 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +29 -0
- package/dist/acp.js +464 -0
- package/dist/agent-commands.d.ts +10 -2
- package/dist/agent-commands.js +37 -18
- package/dist/agent-machine.d.ts +6 -0
- package/dist/agent-machine.js +93 -0
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +42 -31
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +33 -12
- package/dist/agent-target-commands.d.ts +4 -1
- package/dist/agent-target-commands.js +110 -40
- package/dist/agent-targets.d.ts +6 -4
- package/dist/agent-targets.js +72 -31
- package/dist/args.d.ts +1 -1
- package/dist/args.js +53 -14
- package/dist/command-center-actions.js +26 -3
- package/dist/command-center.js +20 -4
- package/dist/conversation-commands.d.ts +3 -0
- package/dist/conversation-commands.js +36 -0
- package/dist/conversation-local-resume.d.ts +8 -0
- package/dist/conversation-local-resume.js +56 -0
- package/dist/conversation-local.d.ts +7 -0
- package/dist/conversation-local.js +61 -0
- package/dist/conversation-router.d.ts +12 -0
- package/dist/conversation-router.js +92 -0
- package/dist/conversation-stash.d.ts +11 -0
- package/dist/conversation-stash.js +158 -0
- package/dist/conversation-terminal.d.ts +3 -0
- package/dist/conversation-terminal.js +74 -0
- package/dist/conversation-worktree.d.ts +4 -0
- package/dist/conversation-worktree.js +22 -0
- package/dist/conversations.d.ts +53 -0
- package/dist/conversations.js +119 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +409 -0
- package/dist/device-helper.d.ts +3 -0
- package/dist/device-helper.js +104 -0
- package/dist/errors.js +5 -0
- package/dist/format.js +3 -2
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/dist/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/project-sync.d.ts +5 -1
- package/dist/project-sync.js +7 -6
- package/dist/t3-provider.d.ts +6 -0
- package/dist/t3-provider.js +99 -0
- package/dist/t3-runtime.d.ts +6 -0
- package/dist/t3-runtime.js +63 -0
- package/dist/target-lock.d.ts +3 -0
- package/dist/target-lock.js +143 -0
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -15,6 +15,10 @@ import { statSync } from 'node:fs';
|
|
|
15
15
|
import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
|
|
16
16
|
import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
|
|
17
17
|
import { commandCenter } from "./command-center.js";
|
|
18
|
+
import { launchConversationProject } from "./conversation-terminal.js";
|
|
19
|
+
import { runAcp } from "./acp.js";
|
|
20
|
+
import { registerInstalledT3 } from "./t3-provider.js";
|
|
21
|
+
import { runDeviceHelper } from "./device-helper.js";
|
|
18
22
|
import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
|
|
19
23
|
import { CredentialStore, configDir } from "./credentials.js";
|
|
20
24
|
import { recordInstall } from "./update.js";
|
|
@@ -365,6 +369,7 @@ function feedbackHint(info, credentialHint) {
|
|
|
365
369
|
}
|
|
366
370
|
export async function run(argv, io, opts = {}) {
|
|
367
371
|
const signal = opts.signal ?? new AbortController().signal;
|
|
372
|
+
await registerInstalledT3(io.env, configDir(io.env), signal);
|
|
368
373
|
const distribution = opts.distribution ?? CLI_DISTRIBUTION;
|
|
369
374
|
const rawKey = io.env.SHARDFLUX_API_KEY;
|
|
370
375
|
// Every credential this command loaded or received: redacted from anything printed on failure.
|
|
@@ -626,7 +631,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
626
631
|
},
|
|
627
632
|
};
|
|
628
633
|
// The update check runs next to the command (not for `version`, which reports it itself).
|
|
629
|
-
if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy'].includes(parsed.command.path[0])) {
|
|
634
|
+
if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0])) {
|
|
630
635
|
try {
|
|
631
636
|
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
632
637
|
updates.start();
|
|
@@ -639,10 +644,11 @@ export async function run(argv, io, opts = {}) {
|
|
|
639
644
|
catch (err) {
|
|
640
645
|
return report(err);
|
|
641
646
|
}
|
|
642
|
-
const handler = parsed.command === PROJECT_COMMAND
|
|
647
|
+
const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
643
648
|
if (!handler)
|
|
644
649
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
645
|
-
|
|
650
|
+
if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
|
|
651
|
+
await housekeeping(ctx);
|
|
646
652
|
const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
|
|
647
653
|
// On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
|
|
648
654
|
const interrupted = new Promise((resolve) => {
|
|
@@ -693,6 +699,12 @@ export async function main(opts = {}) {
|
|
|
693
699
|
process.exit(130);
|
|
694
700
|
controller.abort(new Error('interrupted'));
|
|
695
701
|
});
|
|
702
|
+
for (const name of ['SIGTERM', 'SIGHUP'])
|
|
703
|
+
process.once(name, () => controller.abort(new Error(name)));
|
|
704
|
+
// A reader that closed its end (ssh after a ProxyCommand session, `| head`) has nothing left to receive.
|
|
705
|
+
for (const stream of [process.stdout, process.stderr])
|
|
706
|
+
stream.on('error', (e) => { if (e.code !== 'EPIPE')
|
|
707
|
+
throw e; });
|
|
696
708
|
const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
|
|
697
709
|
const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
|
|
698
710
|
// The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
|
package/dist/project-sync.d.ts
CHANGED
|
@@ -35,4 +35,8 @@ export declare function projectReconciler(options: {
|
|
|
35
35
|
report: (message: string) => void;
|
|
36
36
|
signal?: AbortSignal;
|
|
37
37
|
}): () => Promise<void>;
|
|
38
|
-
export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity
|
|
38
|
+
export declare function startProjectSync(ctx: Ctx, ws: Workspace, project: ReturnType<typeof projectIdentity>, options?: {
|
|
39
|
+
root?: string;
|
|
40
|
+
agent?: string;
|
|
41
|
+
seed?: boolean;
|
|
42
|
+
}): Promise<ProjectSync>;
|
package/dist/project-sync.js
CHANGED
|
@@ -111,13 +111,14 @@ export function projectReconciler(options) {
|
|
|
111
111
|
};
|
|
112
112
|
return () => { const next = serial.then(run); serial = next.catch(() => undefined); return next; };
|
|
113
113
|
}
|
|
114
|
-
export async function startProjectSync(ctx, ws, project) {
|
|
115
|
-
if (ws.labels.agent && ws.labels.agent !== 'main')
|
|
116
|
-
throw new Error('two-way project sync
|
|
117
|
-
const root = projectWorkspacePath(project);
|
|
114
|
+
export async function startProjectSync(ctx, ws, project, options = {}) {
|
|
115
|
+
if (!options.root && ws.labels.agent && ws.labels.agent !== 'main')
|
|
116
|
+
throw new Error('two-way project sync requires a conversation worktree');
|
|
117
|
+
const root = options.root ?? projectWorkspacePath(project);
|
|
118
118
|
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
119
119
|
await cell.files.mkdir(root, { parents: true });
|
|
120
|
-
|
|
120
|
+
if (options.seed !== false)
|
|
121
|
+
await seedAgentRepository(ctx, ws, project.folder, { root });
|
|
121
122
|
const store = new AgentStateStore(ctx.store.dir);
|
|
122
123
|
const key = `${ws.id}:${project.folder}`;
|
|
123
124
|
const states = store.read().project_sync;
|
|
@@ -130,7 +131,7 @@ export async function startProjectSync(ctx, ws, project) {
|
|
|
130
131
|
});
|
|
131
132
|
};
|
|
132
133
|
const report = (message) => { ctx.io.stderr.write(`${message}\n`); };
|
|
133
|
-
const reconcile = projectReconciler({ folder: project.folder, root, agent: ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
|
|
134
|
+
const reconcile = projectReconciler({ folder: project.folder, root, agent: options.agent ?? ws.labels.agent ?? 'main', cell, state, save, report, signal: ctx.signal });
|
|
134
135
|
const abort = new AbortController();
|
|
135
136
|
const signal = ctx.signal ? AbortSignal.any([ctx.signal, abort.signal]) : abort.signal;
|
|
136
137
|
let stopped = false, dirty = false;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Every shard invocation registers the one installed T3 provider without adding terminal noise. */
|
|
2
|
+
export declare function registerInstalledT3(env: Record<string, string | undefined>, dir: string, signal?: AbortSignal): Promise<void>;
|
|
3
|
+
/** A version number alone is insufficient: the installed 0.0.45 desktop lacks the upstream ACP driver. */
|
|
4
|
+
export declare function t3BundleSupportsAcp(app: string): boolean;
|
|
5
|
+
/** Desktop 0.0.45 watches this server settings file, including symlink targets. No encrypted stores. */
|
|
6
|
+
export declare function configureT3Provider(env: Record<string, string | undefined>, dir: string, remove?: boolean, signal?: AbortSignal): Promise<boolean>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { writePrivateFile } from "./credentials.js";
|
|
5
|
+
import { withTargetLock } from "./target-lock.js";
|
|
6
|
+
import { installedTargets } from "./agent-targets.js";
|
|
7
|
+
const ID = 'shardflux';
|
|
8
|
+
const provider = { driver: 'acpRegistry', displayName: 'Shardflux', enabled: true, config: { source: 'local', commandPath: 'shard', commandArgs: ['acp'] } };
|
|
9
|
+
const support = new Map();
|
|
10
|
+
/** Every shard invocation registers the one installed T3 provider without adding terminal noise. */
|
|
11
|
+
export async function registerInstalledT3(env, dir, signal) {
|
|
12
|
+
if (process.platform !== 'darwin')
|
|
13
|
+
return;
|
|
14
|
+
if (!installedTargets(env).targets.includes('t3'))
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
await configureT3Provider(env, dir, false, signal);
|
|
18
|
+
}
|
|
19
|
+
catch { /* User-owned malformed or conflicting settings stay intact. */ }
|
|
20
|
+
}
|
|
21
|
+
/** A version number alone is insufficient: the installed 0.0.45 desktop lacks the upstream ACP driver. */
|
|
22
|
+
export function t3BundleSupportsAcp(app) {
|
|
23
|
+
const archive = join(app, 'Contents/Resources/app.asar');
|
|
24
|
+
let fd;
|
|
25
|
+
try {
|
|
26
|
+
const stat = statSync(archive), stamp = `${stat.size}:${stat.mtimeMs}`;
|
|
27
|
+
if (support.get(archive)?.stamp === stamp)
|
|
28
|
+
return support.get(archive).capable;
|
|
29
|
+
fd = openSync(archive, 'r');
|
|
30
|
+
const sizes = Buffer.alloc(16);
|
|
31
|
+
if (readSync(fd, sizes, 0, 16, 0) !== 16)
|
|
32
|
+
return false;
|
|
33
|
+
const headerSize = sizes.readUInt32LE(12);
|
|
34
|
+
if (headerSize < 1 || headerSize > 16 << 20)
|
|
35
|
+
return false;
|
|
36
|
+
const header = Buffer.alloc(headerSize);
|
|
37
|
+
if (readSync(fd, header, 0, headerSize, 16) !== headerSize)
|
|
38
|
+
return false;
|
|
39
|
+
let entry = JSON.parse(header.toString());
|
|
40
|
+
for (const part of ['apps', 'server', 'dist', 'bin.mjs']) {
|
|
41
|
+
const child = entry.files?.[part];
|
|
42
|
+
if (!child)
|
|
43
|
+
return false;
|
|
44
|
+
entry = child;
|
|
45
|
+
}
|
|
46
|
+
const size = entry.size, offset = Number(entry.offset) + 8 + sizes.readUInt32LE(4);
|
|
47
|
+
if (!Number.isSafeInteger(size) || !size || size < 1 || size > 64 << 20 || !Number.isSafeInteger(offset) || offset < 16 || offset + size > stat.size)
|
|
48
|
+
return false;
|
|
49
|
+
const bytes = entry.unpacked ? readFileSync(join(`${archive}.unpacked`, 'apps/server/dist/bin.mjs')) : Buffer.alloc(size);
|
|
50
|
+
if (!entry.unpacked && readSync(fd, bytes, 0, size, offset) !== size)
|
|
51
|
+
return false;
|
|
52
|
+
const capable = /ProviderDriverKind\.make\(["']acpRegistry["']\)/.test(bytes.toString());
|
|
53
|
+
support.set(archive, { stamp, capable });
|
|
54
|
+
return capable;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (fd !== undefined)
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Desktop 0.0.45 watches this server settings file, including symlink targets. No encrypted stores. */
|
|
65
|
+
export async function configureT3Provider(env, dir, remove = false, signal) {
|
|
66
|
+
return withTargetLock(dir, () => {
|
|
67
|
+
let file = join(env.T3CODE_HOME || join(env.HOME || homedir(), '.t3'), 'userdata', 'settings.json');
|
|
68
|
+
let raw = '{}';
|
|
69
|
+
try {
|
|
70
|
+
file = realpathSync(file);
|
|
71
|
+
raw = readFileSync(file, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'ENOENT')
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
79
|
+
throw new Error('T3 settings must be an object');
|
|
80
|
+
const doc = parsed;
|
|
81
|
+
if (doc.providerInstances !== undefined && (!doc.providerInstances || typeof doc.providerInstances !== 'object' || Array.isArray(doc.providerInstances)))
|
|
82
|
+
throw new Error('T3 providers must be an object');
|
|
83
|
+
const instances = doc.providerInstances ?? {};
|
|
84
|
+
const existing = instances[ID];
|
|
85
|
+
// A user-owned instance with this ID is never silently replaced or removed.
|
|
86
|
+
if (existing && !(existing.driver === provider.driver && existing.config?.source === 'local' && existing.config.commandPath === 'shard' && JSON.stringify(existing.config.commandArgs) === '["acp"]'))
|
|
87
|
+
throw new Error('T3 already has a different provider named shardflux');
|
|
88
|
+
const desired = existing ? { ...existing, config: { ...existing.config, ...provider.config } } : provider;
|
|
89
|
+
if (remove ? !existing : JSON.stringify(existing) === JSON.stringify(desired))
|
|
90
|
+
return false;
|
|
91
|
+
if (remove)
|
|
92
|
+
delete instances[ID];
|
|
93
|
+
else
|
|
94
|
+
instances[ID] = desired;
|
|
95
|
+
doc.providerInstances = instances;
|
|
96
|
+
writePrivateFile(dirname(file), file, JSON.stringify(doc, null, 2) + '\n');
|
|
97
|
+
return true;
|
|
98
|
+
}, signal);
|
|
99
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Workspace } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx } from './commands.js';
|
|
3
|
+
/** T3 v0.0.45 packages/ssh/src/tunnel.ts: identical cache, archive, checksum and completion marker. */
|
|
4
|
+
export declare function t3InstallScript(version: string): string;
|
|
5
|
+
/** Start a supervised guest exec session; only launch is awaited, never download or extraction. */
|
|
6
|
+
export declare function prewarmT3(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** T3 v0.0.45 packages/ssh/src/tunnel.ts: identical cache, archive, checksum and completion marker. */
|
|
2
|
+
export function t3InstallScript(version) {
|
|
3
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
|
|
4
|
+
throw new Error('T3 requires an exact release version');
|
|
5
|
+
return `set -eu
|
|
6
|
+
T3_ARCHIVE_VERSION='${version}'
|
|
7
|
+
T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"
|
|
8
|
+
t3_runtime_ready() {
|
|
9
|
+
[ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ]
|
|
10
|
+
}
|
|
11
|
+
t3_runtime_ready && exit 0
|
|
12
|
+
mkdir -p "$HOME/.t3/runtime/versions"
|
|
13
|
+
T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"
|
|
14
|
+
# A prewarm never waits for T3 or another prewarm. T3's own launcher joins a live installer.
|
|
15
|
+
if ! mkdir "$T3_LOCK" 2>/dev/null; then
|
|
16
|
+
T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)"
|
|
17
|
+
case "$T3_LOCK_OWNER" in ''|*[!0-9]*) exit 0 ;; esac
|
|
18
|
+
if kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then exit 0; fi
|
|
19
|
+
rm -rf "$T3_LOCK"
|
|
20
|
+
mkdir "$T3_LOCK" 2>/dev/null || exit 0
|
|
21
|
+
fi
|
|
22
|
+
trap 'rm -rf "$T3_LOCK"' EXIT
|
|
23
|
+
printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp"
|
|
24
|
+
mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"
|
|
25
|
+
t3_runtime_ready && exit 0
|
|
26
|
+
case "$(uname -s)" in Linux) T3_PLATFORM=linux ;; Darwin) T3_PLATFORM=darwin ;; *) exit 1 ;; esac
|
|
27
|
+
case "$(uname -m)" in arm64|aarch64) T3_ARCH=arm64 ;; x86_64|amd64) T3_ARCH=x64 ;; *) exit 1 ;; esac
|
|
28
|
+
T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"
|
|
29
|
+
T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")"
|
|
30
|
+
trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT
|
|
31
|
+
T3_RELEASE_BASE_URL=https://github.com/pingdotgg/t3code/releases/download
|
|
32
|
+
t3_fetch() {
|
|
33
|
+
if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2"
|
|
34
|
+
elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2"
|
|
35
|
+
else exit 1
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" 30
|
|
39
|
+
t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" 240
|
|
40
|
+
T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)"
|
|
41
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
42
|
+
T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
|
|
43
|
+
else
|
|
44
|
+
T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
|
|
45
|
+
fi
|
|
46
|
+
if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
|
|
47
|
+
printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
|
|
48
|
+
fi
|
|
49
|
+
tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
|
|
50
|
+
rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
|
|
51
|
+
"$T3_STAGING/t3" --version >/dev/null 2>&1
|
|
52
|
+
printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete"
|
|
53
|
+
rm -rf "$T3_RUNTIME_DIR"
|
|
54
|
+
mv "$T3_STAGING" "$T3_RUNTIME_DIR"
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
/** Start a supervised guest exec session; only launch is awaited, never download or extraction. */
|
|
58
|
+
export async function prewarmT3(ctx, ws, version) {
|
|
59
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
60
|
+
const session = await cell.exec.start({ argv: ['sh', '-c', t3InstallScript(version)], user: 'user', timeout_ms: 300_000 }, ctx.signal);
|
|
61
|
+
if (session.state === 'failed_to_start')
|
|
62
|
+
throw new Error('T3 runtime prewarm could not start');
|
|
63
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createConnection, createServer } from 'node:net';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
const missing = (e) => e.code === 'ENOENT';
|
|
7
|
+
function alive(pid) {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
if (e.code === 'ESRCH')
|
|
14
|
+
return false;
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function removeOwner(lock, name) {
|
|
19
|
+
// A contender can only remove this owner's unique record. rmdir is atomic
|
|
20
|
+
// and refuses a replacement owner's nonempty directory.
|
|
21
|
+
rmSync(join(lock, name), { force: true });
|
|
22
|
+
try {
|
|
23
|
+
rmdirSync(lock);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (!missing(e) && !['ENOTEMPTY', 'EEXIST'].includes(e.code ?? ''))
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function waitForOwner(owner, signal) {
|
|
31
|
+
signal?.throwIfAborted();
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
const socket = createConnection({ host: '127.0.0.1', port: owner.port });
|
|
34
|
+
let identity = '';
|
|
35
|
+
const finish = (error) => {
|
|
36
|
+
signal?.removeEventListener('abort', abort);
|
|
37
|
+
socket.destroy();
|
|
38
|
+
if (error)
|
|
39
|
+
reject(error instanceof Error ? error : new Error('target update aborted', { cause: error }));
|
|
40
|
+
else
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
const abort = () => finish(signal?.reason ?? new Error('target update aborted'));
|
|
44
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
45
|
+
socket.on('data', bytes => {
|
|
46
|
+
identity += String(bytes);
|
|
47
|
+
if (!`${owner.token}\n`.startsWith(identity))
|
|
48
|
+
finish(new Error('target lock owner identity changed'));
|
|
49
|
+
});
|
|
50
|
+
socket.once('end', () => finish());
|
|
51
|
+
socket.once('error', error => { if (['ECONNREFUSED', 'ECONNRESET'].includes(error.code ?? ''))
|
|
52
|
+
finish();
|
|
53
|
+
else
|
|
54
|
+
finish(error); });
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
abort();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** PID ownership is published atomically. A kernel connection wakes waiters on
|
|
60
|
+
* release or process death, including SIGKILL; there is no polling or deadline. */
|
|
61
|
+
export async function withTargetLock(dir, work, signal) {
|
|
62
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
63
|
+
const token = randomUUID(), name = `owner-${token}.json`;
|
|
64
|
+
const lock = join(dir, 'targets.lock'), candidate = join(dir, `.targets-lock-${token}`);
|
|
65
|
+
const peers = new Set();
|
|
66
|
+
let released = false;
|
|
67
|
+
const server = createServer(socket => {
|
|
68
|
+
peers.add(socket);
|
|
69
|
+
socket.once('close', () => peers.delete(socket));
|
|
70
|
+
socket.on('error', () => socket.destroy());
|
|
71
|
+
socket.write(`${token}\n`);
|
|
72
|
+
if (released)
|
|
73
|
+
socket.end();
|
|
74
|
+
});
|
|
75
|
+
let acquired = false;
|
|
76
|
+
try {
|
|
77
|
+
server.listen(0, '127.0.0.1');
|
|
78
|
+
await once(server, 'listening');
|
|
79
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
80
|
+
writeFileSync(join(candidate, name), JSON.stringify({ pid: process.pid, port: server.address().port, token }), { mode: 0o600 });
|
|
81
|
+
while (!acquired) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
try {
|
|
84
|
+
renameSync(candidate, lock);
|
|
85
|
+
acquired = true;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(e.code ?? ''))
|
|
89
|
+
throw e;
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = readdirSync(lock);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (missing(e))
|
|
96
|
+
continue;
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
for (const record of names) {
|
|
100
|
+
if (!/^owner-[0-9a-f-]+\.json$/.test(record))
|
|
101
|
+
throw new Error('targets.lock has no recoverable owner record', { cause: e });
|
|
102
|
+
let owner;
|
|
103
|
+
try {
|
|
104
|
+
owner = JSON.parse(readFileSync(join(lock, record), 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (missing(e))
|
|
108
|
+
continue;
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || !Number.isInteger(owner.port) || owner.port < 1 || owner.port > 65535 || record !== `owner-${owner.token}.json`)
|
|
112
|
+
throw new Error('invalid targets.lock owner', { cause: e });
|
|
113
|
+
if (alive(owner.pid))
|
|
114
|
+
await waitForOwner(owner, signal);
|
|
115
|
+
removeOwner(lock, record);
|
|
116
|
+
}
|
|
117
|
+
if (!names.length) {
|
|
118
|
+
try {
|
|
119
|
+
rmdirSync(lock);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
if (!missing(e) && e.code !== 'ENOTEMPTY')
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
return await work();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
released = true;
|
|
133
|
+
if (acquired)
|
|
134
|
+
removeOwner(lock, name);
|
|
135
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
136
|
+
const closed = new Promise(resolve => server.close(() => resolve()));
|
|
137
|
+
// A local peer need not answer our FIN. Do not let an unauthenticated
|
|
138
|
+
// half-open connection retain this process after its lock is released.
|
|
139
|
+
for (const peer of peers)
|
|
140
|
+
peer.destroy();
|
|
141
|
+
await closed;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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.19.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"typescript-eslint": "8.70.1"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@shardflux/menubar-darwin": "^0.
|
|
61
|
+
"@shardflux/menubar-darwin": "^0.2.0"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"build": "node scripts/build.mjs",
|