@sentropic/remote-cli 0.0.3

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.
@@ -0,0 +1,730 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Live-session registry — the source of truth for `remote ls` / `remote
4
+ * restore`, so they stop GUESSING sessions from filesystem mtimes.
5
+ *
6
+ * Entries land here from:
7
+ * - `remote run` (source "run" — local tmux sessions),
8
+ * - Claude Code hooks (source "hook" — `remote enroll --hook claude-*`),
9
+ * - the restore scanner (source "scan" — legacy fallback),
10
+ * - the control-plane (source "remote" — reconciled by the caller).
11
+ *
12
+ * The file is `<configDir>/registry.json`, written atomically (tmp + rename).
13
+ * Every function takes an optional explicit path so tests never touch the real
14
+ * config dir (default path honors REMOTE_CLI_CONFIG_HOME like config.ts).
15
+ */
16
+ type RegistryTool = "claude" | "codex" | "agy";
17
+ type RegistryKind = "local-tmux" | "local" | "remote";
18
+ type RegistrySource = "run" | "hook" | "scan" | "remote";
19
+ /**
20
+ * Delegated-job extension (P1 of cross-type agent delegation). A job IS a
21
+ * RegistryEntry with `role: "job"` — same atomic-write, same liveness guards,
22
+ * same `listLive`. These fields are OPTIONAL so every existing entry stays a
23
+ * valid RegistryEntry (back-compat).
24
+ */
25
+ type RegistryRole = "job";
26
+ type JobState = "pending" | "running" | "throttled" | "done" | "failed";
27
+ /**
28
+ * Rate-limit ("throttled") bookkeeping for a HEADLESS LOCAL job whose agent CLI
29
+ * hit a TRANSIENT provider rate-limit (reliability slice 1). A throttled job
30
+ * KEEPS its concurrency slot (the limit is account-wide; admitting a replacement
31
+ * just burns the same quota) and is auto-resumed by the conductor on
32
+ * `nextRetryAt` with exponential backoff, up to a hard attempt cap. All fields
33
+ * are written under `withRegistryLock`; the whole object is optional so every
34
+ * existing entry stays a valid RegistryEntry (back-compat).
35
+ */
36
+ type ThrottleInfo = {
37
+ /** How many times this job has entered `throttled` (drives the backoff). */
38
+ attempts: number;
39
+ /** ISO ts of the FIRST throttle (for age / history windows). */
40
+ firstAt: string;
41
+ /** ISO ts the conductor may resume the job at (now + jitteredDelay(attempts)). */
42
+ nextRetryAt: string;
43
+ /** The signature tag that classified the last throttle (e.g. claude:rate-limited). */
44
+ lastSignature?: string;
45
+ };
46
+ type RegistryEntry = {
47
+ /** Stable key: claude session uuid / codex rollout id / remoteId / tmux slug. */
48
+ id: string;
49
+ tool: RegistryTool;
50
+ kind: RegistryKind;
51
+ cwd: string;
52
+ label?: string;
53
+ /** Conversation id usable with the CLI's --resume. */
54
+ convId?: string;
55
+ /** Control-plane session id (kind "remote"). */
56
+ remoteId?: string;
57
+ /** Full tmux session name (kind "local-tmux"), e.g. `remote-surch`. */
58
+ tmuxSession?: string;
59
+ /** Local process id (kind "local"); liveness = process.kill(pid, 0). */
60
+ pid?: number;
61
+ enrolledAt: string;
62
+ lastSeenAt: string;
63
+ endedAt?: string;
64
+ source: RegistrySource;
65
+ /** "job" marks a delegated agent (see `delegate.ts`); absent = a session. */
66
+ role?: RegistryRole;
67
+ /** Lifecycle of a delegated job (role "job" only). */
68
+ jobState?: JobState;
69
+ /** Parent job/session id that delegated this job. */
70
+ parent?: string;
71
+ /** The task the delegated agent was primed with. */
72
+ task?: string;
73
+ /** h2a instance to address the `job.done` callback to (P3); the delegating
74
+ * parent/master. Absent = no callback recipient (best-effort, no-op). */
75
+ callbackTo?: string;
76
+ /**
77
+ * P4 — queued-launch spec. A job over the concurrency cap is enrolled
78
+ * `pending` WITHOUT being launched; the conductor launches it later. These
79
+ * fields carry everything `startJob` needs to launch it from the queue (they
80
+ * are also set on an immediately-launched job, harmlessly). All optional so
81
+ * every existing entry stays a valid RegistryEntry (back-compat).
82
+ */
83
+ /** Run the job in a Pod (the remote control-plane URL), else a local tmux session. */
84
+ remoteTarget?: string;
85
+ /** Run-once-exit headless mode (claude -p / codex exec). */
86
+ headless?: boolean;
87
+ /** The cwd the delegate was invoked from (origin for the per-job worktree/logs). */
88
+ originCwd?: string;
89
+ /** Explicit `--cwd` override (local; used as-is, no worktree). */
90
+ explicitCwd?: string;
91
+ /** Remaining spawn-depth budget this job may spend if it re-delegates (P4 depth clamp). */
92
+ depthBudget?: number;
93
+ /** Track workpackage id to mirror this job under (`track item new --parent`). */
94
+ trackWp?: string;
95
+ /** Rate-limit backoff/resume bookkeeping (HEADLESS LOCAL only; reliability slice 1). */
96
+ throttle?: ThrottleInfo;
97
+ /** Model override passed to the CLI binary (--model for claude, -m for codex). */
98
+ model?: string;
99
+ /** Effort/reasoning override (claude --effort; not supported by codex). */
100
+ effort?: string;
101
+ /** Force a specific account from the pool (bypass selectAccountWithFallback). */
102
+ accountId?: string;
103
+ };
104
+
105
+ type PtyHandle = {
106
+ readonly cols: number;
107
+ readonly rows: number;
108
+ write(data: string): void;
109
+ resize(cols: number, rows: number): void;
110
+ kill(signal?: string): void;
111
+ onData(handler: (chunk: string) => void): {
112
+ dispose(): void;
113
+ };
114
+ onExit(handler: (event: {
115
+ exitCode: number;
116
+ signal?: number;
117
+ }) => void): {
118
+ dispose(): void;
119
+ };
120
+ };
121
+ type PtySpawner = (options: {
122
+ command: string;
123
+ args: ReadonlyArray<string>;
124
+ cwd: string;
125
+ env: Readonly<Record<string, string>>;
126
+ cols: number;
127
+ rows: number;
128
+ }) => PtyHandle;
129
+
130
+ type RunOptions = {
131
+ readonly profile: string;
132
+ readonly resume?: string | true | undefined;
133
+ readonly port?: number;
134
+ readonly cwd?: string;
135
+ readonly env?: Readonly<Record<string, string>>;
136
+ readonly startupArgs?: ReadonlyArray<string>;
137
+ readonly stdin?: NodeJS.ReadStream;
138
+ readonly stdout?: NodeJS.WriteStream;
139
+ readonly spawner?: PtySpawner;
140
+ readonly randomId?: (prefix: string) => string;
141
+ readonly clock?: () => Date;
142
+ readonly initialSize?: {
143
+ cols: number;
144
+ rows: number;
145
+ };
146
+ };
147
+ type RunResult = {
148
+ readonly sessionId: string;
149
+ readonly port: number;
150
+ readonly exit: Promise<{
151
+ exitCode: number;
152
+ signal?: number;
153
+ }>;
154
+ readonly stop: () => Promise<void>;
155
+ };
156
+ declare function run(options: RunOptions): Promise<RunResult>;
157
+
158
+ type InputRetryOptions = {
159
+ readonly maxAttempts?: number;
160
+ readonly baseDelayMs?: number;
161
+ readonly maxDelayMs?: number;
162
+ };
163
+ type AttachOptions = {
164
+ readonly baseUrl: string;
165
+ readonly sessionId: string;
166
+ readonly stdin?: NodeJS.ReadStream;
167
+ readonly stdout?: NodeJS.WriteStream;
168
+ readonly stderr?: NodeJS.WriteStream;
169
+ readonly fetchImpl?: typeof fetch;
170
+ readonly inputRetry?: InputRetryOptions;
171
+ };
172
+ type AttachResult = {
173
+ readonly close: () => Promise<void>;
174
+ readonly finished: Promise<void>;
175
+ };
176
+ declare function attach(options: AttachOptions): Promise<AttachResult>;
177
+ declare function listRemoteSessions(baseUrl: string, fetchImpl?: typeof fetch): Promise<ReadonlyArray<{
178
+ id: string;
179
+ profile: string;
180
+ target: string;
181
+ createdAt: string;
182
+ workspaceId?: string;
183
+ workspacePath?: string;
184
+ displayName?: string;
185
+ cliSessionId?: string;
186
+ }>>;
187
+ declare function stopRemoteSession(baseUrl: string, sessionId: string, reason?: string, fetchImpl?: typeof fetch): Promise<{
188
+ accepted: boolean;
189
+ }>;
190
+ declare function getRemoteSession(baseUrl: string, sessionId: string, fetchImpl?: typeof fetch): Promise<{
191
+ session: {
192
+ id: string;
193
+ profile: string;
194
+ };
195
+ }>;
196
+ declare function refreshRemoteSession(baseUrl: string, sessionId: string, credentials: Readonly<Record<string, string>>, fetchImpl?: typeof fetch, displayName?: string): Promise<{
197
+ sessionId: string;
198
+ accepted: boolean;
199
+ }>;
200
+ /**
201
+ * Rename a session's display name in the store without touching the Pod.
202
+ * Calls PATCH /sessions/:id with body { displayName }.
203
+ */
204
+ declare function renameRemoteSession(baseUrl: string, sessionId: string, displayName: string, fetchImpl?: typeof fetch): Promise<{
205
+ sessionId: string;
206
+ displayName: string;
207
+ accepted: boolean;
208
+ }>;
209
+ declare function createRemoteSession(baseUrl: string, body: {
210
+ profile: string;
211
+ target?: string;
212
+ resume?: string;
213
+ startupArgs?: readonly string[];
214
+ displayName?: string;
215
+ credentials?: Readonly<Record<string, string>>;
216
+ metadata?: Readonly<Record<string, unknown>>;
217
+ workspaceSync?: boolean;
218
+ workspaceExport?: boolean;
219
+ workspaceId?: string;
220
+ workspacePath?: string;
221
+ home?: string;
222
+ agentImage?: string;
223
+ }, fetchImpl?: typeof fetch): Promise<{
224
+ id: string;
225
+ }>;
226
+
227
+ declare const CLI_PROFILES: readonly ["shell", "codex", "opencode", "claude", "agy", "gemini", "mistral"];
228
+ type CliProfile = (typeof CLI_PROFILES)[number];
229
+ type SessionTarget = "docker" | "k3s" | "scaleway-kapsule" | "gke";
230
+
231
+ type AuthBundle = Readonly<Record<string, string>>;
232
+ declare class AuthBundleMissingError extends Error {
233
+ readonly profile: CliProfile;
234
+ readonly knownPaths: ReadonlyArray<string>;
235
+ readonly refreshHint: string;
236
+ constructor(profile: CliProfile, knownPaths: ReadonlyArray<string>, refreshHint: string);
237
+ }
238
+ type CollectAuthOptions = {
239
+ readonly home?: string;
240
+ readonly readFileImpl?: (path: string) => Promise<Uint8Array | Buffer>;
241
+ };
242
+ declare function collectProfileAuth(profile: CliProfile, options?: CollectAuthOptions): Promise<AuthBundle>;
243
+ declare function assertRequiredAuthBundle(profile: CliProfile, bundle: AuthBundle): void;
244
+
245
+ type CommandResult = {
246
+ readonly status: number;
247
+ readonly stdout: string;
248
+ readonly stderr: string;
249
+ readonly timedOut?: boolean;
250
+ };
251
+ type RunCommand = (command: string, args: ReadonlyArray<string>, options: {
252
+ timeoutMs: number;
253
+ }) => Promise<CommandResult>;
254
+ type AuthRefreshResult = {
255
+ readonly checked: true;
256
+ readonly command: string;
257
+ } | {
258
+ readonly checked: false;
259
+ readonly reason: "no-status-command";
260
+ };
261
+ type EnsureAuthFreshOptions = {
262
+ readonly timeoutMs?: number;
263
+ readonly runCommand?: RunCommand;
264
+ };
265
+ declare class AuthRefreshError extends Error {
266
+ readonly profile: CliProfile;
267
+ readonly refreshHint: string;
268
+ readonly result: CommandResult;
269
+ constructor(profile: CliProfile, refreshHint: string, result: CommandResult);
270
+ }
271
+ declare function ensureProfileAuthFresh(profile: CliProfile, options?: EnsureAuthFreshOptions): Promise<AuthRefreshResult>;
272
+
273
+ type AuthDiagnosticsStatus = AuthRefreshResult | {
274
+ readonly checked: false;
275
+ readonly reason: "skipped";
276
+ };
277
+ type AuthDiagnosticsResult = {
278
+ readonly profile: CliProfile;
279
+ readonly authStatus: AuthDiagnosticsStatus;
280
+ readonly bundledFiles: ReadonlyArray<string>;
281
+ };
282
+ type InspectProfileAuthOptions = CollectAuthOptions & {
283
+ readonly authRefresh?: boolean;
284
+ readonly runCommand?: RunCommand;
285
+ };
286
+ declare function inspectProfileAuth(profile: CliProfile, options?: InspectProfileAuthOptions): Promise<AuthDiagnosticsResult>;
287
+
288
+ type ProfileConfig = {
289
+ readonly profile: CliProfile;
290
+ readonly command: string;
291
+ readonly args: ReadonlyArray<string>;
292
+ };
293
+ declare function isCliProfile(value: string): value is CliProfile;
294
+ declare function coerceCliProfileName(value: string): CliProfile | undefined;
295
+ declare function resolveProfile(name: string): ProfileConfig;
296
+ declare function withResume(config: ProfileConfig, sessionId?: string | true): ProfileConfig;
297
+
298
+ type SmokeRemoteProfileOptions = {
299
+ readonly profile: CliProfile;
300
+ readonly baseUrl: string;
301
+ readonly target?: SessionTarget;
302
+ readonly displayName?: string;
303
+ readonly timeoutMs?: number;
304
+ readonly auth?: boolean;
305
+ readonly authRefresh?: boolean;
306
+ readonly fetchImpl?: typeof fetch;
307
+ readonly collectAuth?: (profile: CliProfile) => Promise<AuthBundle>;
308
+ readonly ensureAuthFresh?: (profile: CliProfile) => Promise<AuthRefreshResult>;
309
+ };
310
+ type SmokeRemoteProfileResult = {
311
+ readonly profile: CliProfile;
312
+ readonly sessionId: string;
313
+ readonly terminalId: string;
314
+ readonly shell: string;
315
+ };
316
+ declare function smokeRemoteProfile(options: SmokeRemoteProfileOptions): Promise<SmokeRemoteProfileResult>;
317
+
318
+ type OnConflict = "backup" | "keep-local" | "block";
319
+
320
+ /**
321
+ * remote migrate — convenience wrapper for round-tripping a local CLI session
322
+ * to a remote (SCW k8s) session and back.
323
+ *
324
+ * Forward (local → remote):
325
+ * 1. Resolve remote URL.
326
+ * 2. Ensure the cwd is linked to a workspace (reads .remote/workspace.json).
327
+ * 3. Push the workspace archive (project files, honours .gitignore).
328
+ * 4. Create a remote session for <profile> bound to that workspace.
329
+ * 5. Hand off the current terminal to the remote session via `attach`.
330
+ * The attach call blocks until the session ends or the user detaches
331
+ * (Ctrl+P Ctrl+Q). There is no separate process to kill — `migrate`
332
+ * itself IS the process holding the terminal, and attach takes it over.
333
+ *
334
+ * Back (remote → local):
335
+ * 1. Resolve URL + workspace.
336
+ * 2. Pull the workspace + conversation state (3-way merge).
337
+ * 3. Restore conversation state to the local HOME.
338
+ * 4. Stop the remote session.
339
+ * 5. Print the exact local CLI command to resume from the restored state.
340
+ * We do NOT spawn the CLI — we print the command so the user can run it.
341
+ */
342
+
343
+ type MigrateForwardOptions = {
344
+ /** CLI profile to start on the remote (e.g. "claude", "codex"). */
345
+ readonly profile: string;
346
+ /** Remote control-plane URL. Required — callers must resolve the default. */
347
+ readonly remoteUrl: string;
348
+ /**
349
+ * Workspace id override. When set, the cwd does not need an existing
350
+ * .remote/workspace.json (one is created/reused for that id).
351
+ * When absent, the .remote/workspace.json for the cwd is used or a new
352
+ * workspace is created and linked.
353
+ */
354
+ readonly workspaceId?: string;
355
+ /**
356
+ * Whether to pass a --resume/<conv-id> flag to the remote CLI.
357
+ * Pass `true` for the most-recent conversation, or a specific id string.
358
+ */
359
+ readonly resume?: string | true;
360
+ /**
361
+ * When true, do NOT hijack the current terminal: push + create the remote
362
+ * session, print the `remote attach` command, and return. Used to migrate
363
+ * many sessions non-interactively and to let YOUR terminal reconnect to the
364
+ * remote session itself (rather than this process taking it over).
365
+ */
366
+ readonly noAttach?: boolean;
367
+ /**
368
+ * Revive a session on the EXISTING workspace without re-pushing the project
369
+ * (preserves work done remotely) — for bringing a session back after an
370
+ * accidental exit. Path/HOME parity + resume still apply; the conversation is
371
+ * the one already on the retained PVC.
372
+ */
373
+ readonly reconnect?: boolean;
374
+ /** Tool CLIs whose local auth to also bundle into the Pod (scw, gh, aws, …). */
375
+ readonly tools?: ReadonlyArray<string>;
376
+ /** Inject a custom fetch for tests. */
377
+ readonly fetchImpl?: typeof fetch;
378
+ /** Override process.cwd() for tests. */
379
+ readonly cwd?: string;
380
+ /** Override process.stderr.write for tests. */
381
+ readonly stderr?: NodeJS.WriteStream;
382
+ };
383
+ type MigrateForwardResult = {
384
+ /** The workspace id that was used/created. */
385
+ readonly workspaceId: string;
386
+ /** The remote session id that was created. */
387
+ readonly sessionId: string;
388
+ };
389
+ type MigrateBackOptions = {
390
+ /** Remote control-plane URL. Required — callers must resolve the default. */
391
+ readonly remoteUrl: string;
392
+ /** Workspace id override; falls back to .remote/workspace.json. */
393
+ readonly workspaceId?: string;
394
+ /** Known remote session id (from lineage incarnation). When set, skips the
395
+ * list-sessions round-trip and targets this session directly. */
396
+ readonly sessionId?: string;
397
+ /**
398
+ * Conflict resolution for diverged conversations: "backup" | "keep-local".
399
+ * Defaults to "block" (leaves diverged files untouched, exits non-zero).
400
+ */
401
+ readonly onConflict?: OnConflict;
402
+ /** Inject a custom fetch for tests. */
403
+ readonly fetchImpl?: typeof fetch;
404
+ /** Override process.cwd() for tests. */
405
+ readonly cwd?: string;
406
+ /** Override HOME for session restore in tests. */
407
+ readonly home?: string;
408
+ /** Override process.stderr.write for tests. */
409
+ readonly stderr?: NodeJS.WriteStream;
410
+ /** Override process.stdout.write for tests. */
411
+ readonly stdout?: NodeJS.WriteStream;
412
+ };
413
+ type MigrateBackResult = {
414
+ /** The workspace id that was pulled. */
415
+ readonly workspaceId: string;
416
+ /** The session id that was stopped, if any. */
417
+ readonly stoppedSessionId?: string;
418
+ /** The resume command to print for the user. */
419
+ readonly resumeCommand: string;
420
+ /** Whether there were unresolved merge conflicts. */
421
+ readonly hasConflicts: boolean;
422
+ };
423
+ /**
424
+ * Forward: migrate the current local session to a remote k8s session.
425
+ *
426
+ * Steps: link workspace → push files → create remote session → attach terminal.
427
+ *
428
+ * Terminal handoff: `migrateForward` calls `attach`, which hijacks the current
429
+ * process's stdin/stdout in raw mode and blocks until the remote session ends
430
+ * or the user presses Ctrl+P Ctrl+Q to detach. There is no separate process
431
+ * involved — this function IS the process holding the terminal.
432
+ */
433
+ declare function migrateForward(options: MigrateForwardOptions): Promise<MigrateForwardResult>;
434
+ /**
435
+ * Back: pull the remote session back to local.
436
+ *
437
+ * Steps: pull workspace + conversation state → stop remote session → print
438
+ * resume command.
439
+ *
440
+ * We do NOT spawn the local CLI — we print the resume command so the user
441
+ * retains control of when and how they restart.
442
+ */
443
+ declare function migrateBack(options: MigrateBackOptions): Promise<MigrateBackResult>;
444
+
445
+ /**
446
+ * One MCP server provided by an installed plugin package.
447
+ *
448
+ * `command` is always "node" and `args` the script's realpath: some packages
449
+ * (track@0.2.0) have an entrypoint guard that breaks when the script is run
450
+ * through the npm-global bin symlink, so the bare bin name must never be
451
+ * registered — see plugin.ts.
452
+ */
453
+ type PluginMcp = {
454
+ name: string;
455
+ command: string;
456
+ args: string[];
457
+ /**
458
+ * Bin script path relative to the package dir (e.g. "dist/mcp.js") — used by
459
+ * `remote plugin sync` to recompute the realpath inside remote Pods, where
460
+ * the npm global root differs from the local one.
461
+ */
462
+ scriptRel?: string;
463
+ };
464
+ /**
465
+ * How a plugin is installed in a session Pod. Default (omitted) = `npm`
466
+ * (`npm i -g <pkg>@<version>`). `curl` pipes an installer script
467
+ * (`curl -fsSL <spec> | bash`) — e.g. a Go binary's install.sh. `script` runs
468
+ * an arbitrary shell line (from the user's own config). Lets non-npm tools be
469
+ * propagated the same way.
470
+ */
471
+ type PluginInstall = {
472
+ method: "npm" | "curl" | "script";
473
+ /** curl: the installer URL; script: the shell command. Unused for npm. */
474
+ spec?: string;
475
+ };
476
+ /** A plugin propagated to sessions (npm pkg, or curl/script installer) + MCP(s). */
477
+ type PluginEntry = {
478
+ pkg: string;
479
+ version: string;
480
+ mcp: PluginMcp[];
481
+ /** Install method; omitted ⇒ npm (pkg@version). */
482
+ install?: PluginInstall;
483
+ };
484
+
485
+ /**
486
+ * Plugin/MCP DESIRED-STATE manifest + drift diff (remote supervise slice 3) —
487
+ * ADDITIVE, PURE. No IO, no clock. The watch loop / `plugin sync --check` call
488
+ * these to RENDER the desired set, HASH it (to gate a sidecar push exactly like
489
+ * CREDS_HASH_FILE), and DIFF it against what a Pod actually has installed.
490
+ *
491
+ * Why a manifest at all: MCP servers + plugin CLIs (track, h2a, harness,
492
+ * graphify, skills) baked/installed into Pods drift away from the LOCAL
493
+ * source-of-truth — a Pod restart loses globally-installed plugins, a re-pin
494
+ * bumps the local version, and nothing today DETECTS the gap (`plugin ls`
495
+ * prints `REMOTE ?`). The manifest is the canonical "what every Pod SHOULD have"
496
+ * record; its sha256 is the cheap drift signal (one `kubectl exec cat`), and the
497
+ * diff is the per-item report.
498
+ *
499
+ * BIGGEST REMAINING DRIFT SOURCE (DEFERRED — converge-on-session-start): a
500
+ * freshly (re)created Pod boots with NO plugins until the next `plugin sync` /
501
+ * watch pass reconciles it, so its CLI starts WITHOUT track/h2a/etc. Closing
502
+ * that needs the session-agent / orchestrator to run the sync BEFORE the CLI
503
+ * launches — a session-agent change that is OUT OF SCOPE this slice. It hooks in
504
+ * at the session-agent startup path (packages/session-agent, the pane that
505
+ * launches `<cli>`): before exec'ing the CLI, run the equivalent of
506
+ * `buildPodSyncScript` for each desired plugin + write this manifest sidecar.
507
+ * TODO(slice 4): wire converge-on-session-start there; until then the watch-pass
508
+ * reconcile (compareManifestHash → re-run buildPodSyncScript) is the safety net.
509
+ *
510
+ * NOTHING here is a NEW push path: `plugin sync` (no --check) still converges via
511
+ * the EXISTING buildPodSyncScript, untouched. This module only adds a manifest
512
+ * artifact + a read-only drift report.
513
+ */
514
+
515
+ /** Drift status for one desired item in one Pod. */
516
+ type DriftStatus = "ok" | "version-drift" | "missing" | "mcp-unregistered";
517
+ /** One row of the drift report: a desired item's status in a Pod. */
518
+ type DriftRow = {
519
+ readonly pod: string;
520
+ /** "plugin:<pkg>" or "mcp:<name>". */
521
+ readonly item: string;
522
+ readonly status: DriftStatus;
523
+ /** Human detail (e.g. "local@1.2.0 vs pod@1.1.0"); empty for ok. */
524
+ readonly detail: string;
525
+ };
526
+
527
+ /**
528
+ * `remote plugin` — install npm "plugin" packages (a CLI + an MCP server, e.g.
529
+ * @sentropic/track shipping bins `track` and `track-mcp`) for every agent CLI
530
+ * (claude, codex, agy), both LOCALLY (npm i -g + MCP registration) and inside
531
+ * live REMOTE session Pods (`remote plugin sync`: kubectl exec → npm i -g +
532
+ * per-profile MCP registration).
533
+ *
534
+ * agy (Antigravity CLI) has NO `agy mcp` subcommand: MCP servers are declared
535
+ * in ~/.gemini/config/mcp_config.json, a Claude-style `{"mcpServers": {…}}`
536
+ * JSON file (the agy changelog 1.0.3 calls this the "migrated" path; the old
537
+ * ~/.gemini/antigravity/mcp_config.json is legacy). We merge idempotently and
538
+ * keep a one-shot `.bak.<epoch>` backup the first time we touch a non-empty
539
+ * file.
540
+ *
541
+ * KNOWN PITFALL — broken entrypoint guard through the npm-global symlink:
542
+ * some packages (track@0.2.0) guard their entry script with a
543
+ * "was-I-run-directly?" check that compares argv[1] with the module path;
544
+ * invoked through the npm-global bin SYMLINK the two differ and the guard
545
+ * never fires (the CLI/MCP silently does nothing). So MCP servers are ALWAYS
546
+ * registered as `node <realpathSync(script)>` — never the bare bin name.
547
+ *
548
+ * Baking plugins into the session image is a separate TODO that belongs in
549
+ * packages/session-agent/Dockerfile (left untouched here): until then a Pod
550
+ * restart loses globally-installed plugins and `remote plugin sync` must be
551
+ * re-run.
552
+ *
553
+ * DRIFT (remote supervise slice 3, ADDITIVE): a desired-state manifest
554
+ * (plugin-manifest.ts) + a sidecar pushed to each Pod (~/.remote-manifest.json /
555
+ * ~/.remote-manifest.sha256, gated EXACTLY like CREDS_HASH_FILE), a read-only
556
+ * `plugin sync --check` drift report, and a watch-pass reconcile that re-runs
557
+ * the EXISTING buildPodSyncScript on a hash mismatch (an extra TRIGGER, not a
558
+ * new push path). The plain `plugin add`/`plugin sync` push is UNCHANGED.
559
+ *
560
+ * TODO(slice 4 — converge-on-session-start, the BIGGEST remaining drift source):
561
+ * a freshly (re)created Pod boots with NO plugins until the next sync/watch pass,
562
+ * so its CLI launches WITHOUT track/h2a/etc. Closing that needs the session-agent
563
+ * startup path (packages/session-agent, the pane that launches `<cli>`) to run
564
+ * the sync + write the manifest sidecar BEFORE exec'ing the CLI — OUT OF SCOPE
565
+ * here (do not touch session-agent this slice). The watch-pass reconcile is the
566
+ * interim safety net.
567
+ */
568
+
569
+ type McpRequest = {
570
+ name: string;
571
+ bin: string;
572
+ };
573
+ /** Parse one `--mcp <name>=<bin>` spec. */
574
+ declare function parseMcpSpec(spec: string): McpRequest;
575
+ declare function parseMcpSpecs(specs: readonly string[]): McpRequest[];
576
+ /**
577
+ * Heuristic when no --mcp is given: every bin ending in `-mcp` is an MCP
578
+ * server named after the bin minus the suffix (track-mcp → track).
579
+ */
580
+ declare function detectMcpBins(bins: Readonly<Record<string, string>>): McpRequest[];
581
+ /**
582
+ * Idempotently upsert the `[mcp_servers.<name>]` section in a config.toml
583
+ * body: an existing section (up to the next `[…]` header) is replaced in
584
+ * place, otherwise the block is appended. Applying twice is a no-op.
585
+ */
586
+ declare function upsertCodexMcpServer(toml: string, name: string, command: string, args: readonly string[]): string;
587
+ /** Idempotent `mcpServers.<name>` merge for a ~/.claude.json body. */
588
+ declare function mergeClaudeMcpServers(json: string, name: string, command: string, args: readonly string[]): string;
589
+ /**
590
+ * Bash script run inside a session Pod (`kubectl exec … bash -lc`) by
591
+ * `remote plugin sync`: installs the plugin globally, recomputes each MCP
592
+ * script's realpath against the POD's npm global root (the local realpath is
593
+ * meaningless there), then registers the MCP server for the Pod's profile.
594
+ * Every step echoes one line — the CLI prints them as the per-session recap.
595
+ */
596
+ declare function buildPodSyncScript(plugin: PluginEntry, profile: string): string;
597
+ /**
598
+ * Production wiring used by the supervise/watch pass: reconcile manifest drift
599
+ * for ONE session via the configured tunnel. Thin — the decision + the reused
600
+ * buildPodSyncScript live in `reconcilePodManifest`. No-op (and no error) when
601
+ * no plugins are configured. Best-effort; never throws into the caller.
602
+ */
603
+ declare function reconcileSessionPlugins(sessionId: string, profile: string, stderr?: NodeJS.WriteStream): {
604
+ reconciled: boolean;
605
+ };
606
+ /**
607
+ * `remote plugin add <npmPkg> [--mcp name=bin]...` — npm i -g, register the
608
+ * MCP server(s) with claude + codex + agy, persist in the config.
609
+ */
610
+ declare function pluginAdd(npmSpec: string, mcpSpecs: readonly string[], stderr?: NodeJS.WriteStream): PluginEntry;
611
+ /**
612
+ * `remote plugin add <name> --curl <url>` / `--install "<shell>"` — register a
613
+ * NON-npm plugin (a tool installed by piping an https script, or an arbitrary
614
+ * shell command). No local install is run (unlike the npm path, which must read
615
+ * the package to detect MCP bins): the installer runs in each Pod on
616
+ * `remote plugin sync`. Validates the command up front so a bad URL fails now.
617
+ */
618
+ declare function pluginAddInstaller(name: string, install: PluginInstall, stderr?: NodeJS.WriteStream): PluginEntry;
619
+ /**
620
+ * `remote plugin ls` — pkg / version / MCPs / where (local ok/missing, remote).
621
+ *
622
+ * The REMOTE column is now REAL per-Pod drift status (slice 3), not the old
623
+ * guess-`?`: when a `url` is passed (the command is connected to the control-
624
+ * plane) we probe each live Pod via the same drift mechanism `--check` uses and
625
+ * summarize each plugin's worst per-Pod status across all Pods
626
+ * (ok / version-drift / missing). Without a url (offline) we still print `?`
627
+ * with the documented hint — the `?` only remains when we genuinely can't reach
628
+ * the cluster, never as guesswork when we can.
629
+ */
630
+ declare function pluginLs(stdout?: NodeJS.WriteStream, url?: string): Promise<void>;
631
+ /**
632
+ * `remote plugin sync` — for every live remote session: kubectl exec into the
633
+ * Pod, `npm i -g <pkg>@<version>`, then register the MCP servers for the
634
+ * Pod's profile. Prints a per-session recap. Needs the configured tunnel.
635
+ */
636
+ declare function pluginSync(url: string, stderr?: NodeJS.WriteStream): Promise<void>;
637
+ /**
638
+ * `remote plugin sync --check` — a READ-ONLY drift report (converges NOTHING).
639
+ * For each live Pod it probes the actual installed state (npm ls -g --json for
640
+ * the desired pkgs + the registered MCP server names from the config files the
641
+ * existing sync writes) via the argv-safe pod exec, then the pure
642
+ * `diffManifest` rows it: ok / version-drift / missing / mcp-unregistered.
643
+ * Prints a table and EXITS 1 when ANY row is drift (via process.exitCode). This
644
+ * REPLACES the `plugin ls` `REMOTE ?` guesswork with REAL per-Pod status. Pure
645
+ * decisions live in plugin-manifest.ts; this is the thin probe + printer.
646
+ */
647
+ declare function pluginSyncCheck(url: string, stdout?: NodeJS.WriteStream, stderr?: NodeJS.WriteStream): Promise<DriftRow[]>;
648
+
649
+ declare const packageName = "@sentropic/remote-cli";
650
+
651
+ /** Validate `--watch <minutes>`: a whole number of minutes >= 1. */
652
+ declare function parseWatchMinutes(raw: string): number;
653
+ /**
654
+ * Soft-refresh EVERY live remote session (profile carried by each session).
655
+ * Per-session errors don't stop the pass; ends with a recap (ok / unchanged /
656
+ * failed) and returns the failure count. `hashes` carries the previous pass's
657
+ * bundle hashes (sessionId -> sha256) so unchanged creds are a no-op WITHOUT
658
+ * respawning the Pod CLI.
659
+ */
660
+ declare function softRefreshAllSessions(url: string, opts: {
661
+ authRefresh?: boolean;
662
+ }, hashes: Map<string, string>): Promise<{
663
+ failed: number;
664
+ }>;
665
+ /**
666
+ * Foreground refresh loop for `--watch <minutes>`: pass, sleep, repeat. NO
667
+ * daemonization, no pid file — the user runs it in a dedicated tmux window.
668
+ * Each pass is timestamped on stderr; SIGINT (Ctrl-C) stops it cleanly with a
669
+ * message and exit 0. Pass failures are logged and the loop keeps going.
670
+ */
671
+ declare function watchRefreshLoop(minutes: number, pass: () => Promise<{
672
+ failed: number;
673
+ }>, signals?: {
674
+ on(event: "SIGINT", listener: () => void): unknown;
675
+ removeListener(event: "SIGINT", listener: () => void): unknown;
676
+ }): Promise<number>;
677
+ /**
678
+ * P4 — the conductor's FOREGROUND watch loop (NO daemon, NO pid file — run it in
679
+ * a dedicated tmux window, exactly like `watchRefreshLoop` / `h2a bridge
680
+ * --watch`). Each pass is timestamped; SIGINT (Ctrl-C) stops it cleanly with a
681
+ * message and exit 0. A pass failure is logged and the loop keeps going. The
682
+ * `pass` is the conductor pass (reconcile + start `pending` jobs under the cap);
683
+ * `signals` is injectable so tests never emit a real SIGINT.
684
+ */
685
+ declare function conductLoop(minutes: number, pass: () => Promise<{
686
+ started: number;
687
+ finished: number;
688
+ }>, signals?: {
689
+ on(event: "SIGINT", listener: () => void): unknown;
690
+ removeListener(event: "SIGINT", listener: () => void): unknown;
691
+ }): Promise<number>;
692
+ type StartJobResult = {
693
+ started: true;
694
+ target: "local" | "remote";
695
+ detail: string;
696
+ } | {
697
+ started: false;
698
+ error: string;
699
+ };
700
+ /**
701
+ * Launch a job that is enrolled in the registry (typically `pending`): spawn the
702
+ * agent (local detached tmux, or a Pod), advance the registry entry to
703
+ * `running`, mirror it under track (best-effort), and propagate the spawn-depth
704
+ * budget to the child via `REMOTE_DELEGATE_DEPTH`. The launch params are read
705
+ * from the entry's queued-launch fields (tool/task/headless/remoteTarget/
706
+ * originCwd/explicitCwd/depthBudget/trackWp), set at `delegate` time.
707
+ *
708
+ * Never throws: any spawn/registry error is returned as `{started:false}` so the
709
+ * conductor loop keeps going. For remote, the per-job workspace is created here
710
+ * (so a queued remote job doesn't hold a workspace while waiting).
711
+ */
712
+ declare function startJob(job: RegistryEntry): Promise<StartJobResult>;
713
+ /**
714
+ * Reliability slice 1 — RESUME a throttled HEADLESS LOCAL job. Relaunch the SAME
715
+ * job in the SAME `runCwd` it already ran in (its recorded `cwd` — NOT a fresh
716
+ * worktree, so it continues the prior conversation in place) with the tool's
717
+ * CONTINUE flag (`claude -p --continue` / `codex exec resume --last`, via the
718
+ * safe argv `buildThrottleResumeArgs` — never `bash -lc` concat), redirecting to
719
+ * the SAME result.json/output.log, then transition `throttled → running`. The
720
+ * throttle bookkeeping is PRESERVED so a re-throttle bumps `attempts` (the cap is
721
+ * enforced in reconcile). Never throws — a spawn error is returned as
722
+ * `{started:false}` so the conductor keeps going.
723
+ *
724
+ * SCOPE: headless local only. Interactive resume (send-keys) and remote resume
725
+ * (control-plane) are phase 2 — see the TODOs at the call site.
726
+ */
727
+ declare function resumeThrottledJob(job: RegistryEntry): StartJobResult;
728
+ declare function main(argv: ReadonlyArray<string>): Promise<number>;
729
+
730
+ export { type AttachOptions, type AttachResult, type AuthBundle, AuthBundleMissingError, type AuthDiagnosticsResult, type AuthDiagnosticsStatus, AuthRefreshError, type MigrateBackOptions, type MigrateBackResult, type MigrateForwardOptions, type MigrateForwardResult, type ProfileConfig, type RunOptions, type RunResult, type SmokeRemoteProfileOptions, type SmokeRemoteProfileResult, type StartJobResult, assertRequiredAuthBundle, attach, buildPodSyncScript, coerceCliProfileName, collectProfileAuth, conductLoop, createRemoteSession, detectMcpBins, ensureProfileAuthFresh, getRemoteSession, inspectProfileAuth, isCliProfile, listRemoteSessions, main, mergeClaudeMcpServers, migrateBack, migrateForward, packageName, parseMcpSpec, parseMcpSpecs, parseWatchMinutes, pluginAdd, pluginAddInstaller, pluginLs, pluginSync, pluginSyncCheck, reconcileSessionPlugins, refreshRemoteSession, renameRemoteSession, resolveProfile, resumeThrottledJob, run, smokeRemoteProfile, softRefreshAllSessions, startJob, stopRemoteSession, upsertCodexMcpServer, watchRefreshLoop, withResume };