@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +44 -4
  3. package/dist/acp-catalog.d.ts +7 -0
  4. package/dist/acp-catalog.js +40 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-models.d.ts +36 -0
  8. package/dist/acp-models.js +161 -0
  9. package/dist/acp-rpc.d.ts +41 -0
  10. package/dist/acp-rpc.js +115 -0
  11. package/dist/acp.d.ts +29 -0
  12. package/dist/acp.js +464 -0
  13. package/dist/agent-commands.d.ts +10 -2
  14. package/dist/agent-commands.js +37 -18
  15. package/dist/agent-machine.d.ts +6 -0
  16. package/dist/agent-machine.js +93 -0
  17. package/dist/agent-repository.d.ts +2 -0
  18. package/dist/agent-repository.js +42 -31
  19. package/dist/agent-state.d.ts +3 -0
  20. package/dist/agent-state.js +33 -12
  21. package/dist/agent-target-commands.d.ts +4 -1
  22. package/dist/agent-target-commands.js +110 -40
  23. package/dist/agent-targets.d.ts +6 -4
  24. package/dist/agent-targets.js +72 -31
  25. package/dist/args.d.ts +1 -1
  26. package/dist/args.js +53 -14
  27. package/dist/command-center-actions.js +26 -3
  28. package/dist/command-center.js +20 -4
  29. package/dist/conversation-commands.d.ts +3 -0
  30. package/dist/conversation-commands.js +36 -0
  31. package/dist/conversation-local-resume.d.ts +8 -0
  32. package/dist/conversation-local-resume.js +56 -0
  33. package/dist/conversation-local.d.ts +7 -0
  34. package/dist/conversation-local.js +61 -0
  35. package/dist/conversation-router.d.ts +12 -0
  36. package/dist/conversation-router.js +92 -0
  37. package/dist/conversation-stash.d.ts +11 -0
  38. package/dist/conversation-stash.js +158 -0
  39. package/dist/conversation-terminal.d.ts +3 -0
  40. package/dist/conversation-terminal.js +74 -0
  41. package/dist/conversation-worktree.d.ts +4 -0
  42. package/dist/conversation-worktree.js +22 -0
  43. package/dist/conversations.d.ts +53 -0
  44. package/dist/conversations.js +119 -0
  45. package/dist/device-bridge.d.ts +80 -0
  46. package/dist/device-bridge.js +409 -0
  47. package/dist/device-helper.d.ts +3 -0
  48. package/dist/device-helper.js +104 -0
  49. package/dist/errors.js +5 -0
  50. package/dist/format.js +3 -2
  51. package/dist/git-remote-bin.js +3 -1
  52. package/dist/git-remote.d.ts +3 -1
  53. package/dist/git-remote.js +2 -1
  54. package/dist/handlers.js +3 -1
  55. package/dist/main.js +15 -3
  56. package/dist/project-sync.d.ts +5 -1
  57. package/dist/project-sync.js +7 -6
  58. package/dist/t3-provider.d.ts +6 -0
  59. package/dist/t3-provider.js +99 -0
  60. package/dist/t3-runtime.d.ts +6 -0
  61. package/dist/t3-runtime.js +63 -0
  62. package/dist/target-lock.d.ts +3 -0
  63. package/dist/target-lock.js +143 -0
  64. 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 || parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
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
- await housekeeping(ctx);
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).
@@ -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>): Promise<ProjectSync>;
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>;
@@ -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 is only available for the main agent');
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
- await seedAgentRepository(ctx, ws, project.folder, { root });
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,3 @@
1
+ /** PID ownership is published atomically. A kernel connection wakes waiters on
2
+ * release or process death, including SIGKILL; there is no polling or deadline. */
3
+ export declare function withTargetLock<T>(dir: string, work: () => T, signal?: AbortSignal): Promise<T>;
@@ -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.13.6",
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.18.3"
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.1.3"
61
+ "@shardflux/menubar-darwin": "^0.2.0"
62
62
  },
63
63
  "scripts": {
64
64
  "build": "node scripts/build.mjs",