maesttro 0.5.0 → 0.6.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/dist/maesttro.js CHANGED
@@ -94,14 +94,53 @@ var require_src = __commonJS(function(exports, module) {
94
94
  // src/credential-store.ts
95
95
  import {
96
96
  chmodSync,
97
+ mkdirSync as mkdirSync2,
98
+ readFileSync as readFileSync2,
99
+ renameSync as renameSync2,
100
+ rmSync,
101
+ writeFileSync as writeFileSync2
102
+ } from "node:fs";
103
+ import { dirname, join as join2 } from "node:path";
104
+
105
+ // src/maesttro-home.ts
106
+ import {
107
+ appendFileSync,
97
108
  mkdirSync,
98
109
  readFileSync,
99
110
  renameSync,
100
- rmSync,
101
111
  writeFileSync
102
112
  } from "node:fs";
103
113
  import { homedir } from "node:os";
104
- import { dirname, join } from "node:path";
114
+ import { join } from "node:path";
115
+ function maesttroHome() {
116
+ return process.env.MAESTTRO_HOME ?? join(homedir(), ".config", "maesttro");
117
+ }
118
+ function configPath() {
119
+ return join(maesttroHome(), "config.json");
120
+ }
121
+ function readMaesttroConfig() {
122
+ try {
123
+ return JSON.parse(readFileSync(configPath(), "utf8"));
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+ function writeMaesttroConfig(config) {
129
+ mkdirSync(maesttroHome(), { recursive: true, mode: 448 });
130
+ const temporary = `${configPath()}.tmp`;
131
+ writeFileSync(temporary, `${JSON.stringify(config, null, 2)}
132
+ `);
133
+ renameSync(temporary, configPath());
134
+ }
135
+ function fileSessionLog(path = join(maesttroHome(), "maesttro.log")) {
136
+ return (event, detail) => {
137
+ try {
138
+ mkdirSync(maesttroHome(), { recursive: true, mode: 448 });
139
+ appendFileSync(path, `${new Date().toISOString()} ${event}${detail ? ` ${detail}` : ""}
140
+ `);
141
+ } catch {}
142
+ };
143
+ }
105
144
 
106
145
  // src/run-command.ts
107
146
  import { spawnSync } from "node:child_process";
@@ -120,98 +159,1375 @@ function commandExists(run, command) {
120
159
  return run("sh", ["-c", `command -v ${command}`]).status === 0;
121
160
  }
122
161
 
123
- // src/credential-store.ts
124
- var SERVICE = "maesttro";
125
- var ACCOUNT = "github";
126
- function encode(token) {
127
- return Buffer.from(JSON.stringify(token)).toString("base64");
162
+ // src/credential-store.ts
163
+ var SERVICE = "maesttro";
164
+ var GITHUB_CODEC = {
165
+ account: "github",
166
+ accepts: (value) => typeof value?.access_token === "string"
167
+ };
168
+ function encode(value) {
169
+ return Buffer.from(JSON.stringify(value)).toString("base64");
170
+ }
171
+ function decoderFor(codec) {
172
+ return (encoded) => {
173
+ try {
174
+ const value = JSON.parse(Buffer.from(encoded.trim(), "base64").toString("utf8"));
175
+ return codec.accepts(value) ? value : undefined;
176
+ } catch {
177
+ return;
178
+ }
179
+ };
180
+ }
181
+ function defaultFilePath(account) {
182
+ const name = account === "github" ? "credentials.json" : `credentials-${account}.json`;
183
+ return join2(maesttroHome(), name);
184
+ }
185
+ function keychainStore(run = runCommand, codec = GITHUB_CODEC) {
186
+ const identity = ["-s", SERVICE, "-a", codec.account];
187
+ const decode = decoderFor(codec);
188
+ return {
189
+ kind: "macOS keychain",
190
+ load: () => {
191
+ const result = run("security", [
192
+ "find-generic-password",
193
+ ...identity,
194
+ "-w"
195
+ ]);
196
+ return result.status === 0 ? decode(result.stdout) : undefined;
197
+ },
198
+ save: (value) => {
199
+ const command = `add-generic-password -U ${identity.join(" ")} -w ${encode(value)}
200
+ `;
201
+ const result = run("security", ["-i"], command);
202
+ if (result.status !== 0)
203
+ throw new Error("Could not write to keychain");
204
+ },
205
+ clear: () => {
206
+ run("security", ["delete-generic-password", ...identity]);
207
+ }
208
+ };
209
+ }
210
+ function secretToolStore(run = runCommand, codec = GITHUB_CODEC) {
211
+ const attributes = ["service", SERVICE, "account", codec.account];
212
+ const decode = decoderFor(codec);
213
+ return {
214
+ kind: "Secret Service (secret-tool)",
215
+ load: () => {
216
+ const result = run("secret-tool", ["lookup", ...attributes]);
217
+ return result.status === 0 ? decode(result.stdout) : undefined;
218
+ },
219
+ save: (value) => {
220
+ const result = run("secret-tool", ["store", `--label=${SERVICE}`, ...attributes], encode(value));
221
+ if (result.status !== 0) {
222
+ throw new Error("Could not write to the Secret Service");
223
+ }
224
+ },
225
+ clear: () => {
226
+ run("secret-tool", ["clear", ...attributes]);
227
+ }
228
+ };
229
+ }
230
+ function fileStore(path, codec = GITHUB_CODEC) {
231
+ const resolvedPath = path ?? defaultFilePath(codec.account);
232
+ const decode = decoderFor(codec);
233
+ return {
234
+ kind: `file (${resolvedPath})`,
235
+ load: () => {
236
+ try {
237
+ return decode(readFileSync2(resolvedPath, "utf8"));
238
+ } catch {
239
+ return;
240
+ }
241
+ },
242
+ save: (value) => {
243
+ mkdirSync2(dirname(resolvedPath), { recursive: true, mode: 448 });
244
+ const temporary = `${resolvedPath}.tmp`;
245
+ writeFileSync2(temporary, encode(value), { mode: 384 });
246
+ chmodSync(temporary, 384);
247
+ renameSync2(temporary, resolvedPath);
248
+ },
249
+ clear: () => {
250
+ rmSync(resolvedPath, { force: true });
251
+ }
252
+ };
253
+ }
254
+ function selectCredentialStore({
255
+ platform = process.platform,
256
+ env = process.env,
257
+ run = runCommand,
258
+ codec = GITHUB_CODEC
259
+ } = {}) {
260
+ if (env.MAESTTRO_CREDENTIAL_STORE === "file")
261
+ return fileStore(undefined, codec);
262
+ if (platform === "darwin")
263
+ return keychainStore(run, codec);
264
+ if (platform === "linux" && commandExists(run, "secret-tool")) {
265
+ return secretToolStore(run, codec);
266
+ }
267
+ return fileStore(undefined, codec);
268
+ }
269
+
270
+ // src/claude-credentials.ts
271
+ function oauthOf(value) {
272
+ const oauth = value?.claudeAiOauth;
273
+ return oauth && typeof oauth === "object" ? oauth : undefined;
274
+ }
275
+ function nonEmpty(value) {
276
+ return typeof value === "string" && value.length > 0;
277
+ }
278
+ function isClaudeCredentials(value) {
279
+ const oauth = oauthOf(value);
280
+ return oauth !== undefined && nonEmpty(oauth.accessToken) && typeof oauth.expiresAt === "number" && oauth.expiresAt > 0;
281
+ }
282
+ function isMasterLogin(value) {
283
+ return isClaudeCredentials(value) && nonEmpty(oauthOf(value)?.refreshToken);
284
+ }
285
+ function carriesRefreshToken(value) {
286
+ return nonEmpty(oauthOf(value)?.refreshToken);
287
+ }
288
+ function isBlankedLogin(value) {
289
+ const oauth = oauthOf(value);
290
+ if (!oauth)
291
+ return false;
292
+ return oauth.accessToken === "" || oauth.refreshToken === "" || typeof oauth.expiresAt === "number" && oauth.expiresAt <= 0;
293
+ }
294
+ function withoutRefreshToken(credentials) {
295
+ const { refreshToken: _, ...oauth } = credentials.claudeAiOauth;
296
+ return { claudeAiOauth: oauth };
297
+ }
298
+ function markedExpired({
299
+ credentials,
300
+ now
301
+ }) {
302
+ return {
303
+ claudeAiOauth: { ...credentials.claudeAiOauth, expiresAt: now - 60000 }
304
+ };
305
+ }
306
+ function acceptRefreshed({
307
+ previous,
308
+ candidate,
309
+ now
310
+ }) {
311
+ if (!isMasterLogin(candidate))
312
+ return;
313
+ if (candidate.claudeAiOauth.expiresAt <= now)
314
+ return;
315
+ if (candidate.claudeAiOauth.accessToken === previous.claudeAiOauth.accessToken)
316
+ return;
317
+ return candidate;
318
+ }
319
+ function markedRejected({
320
+ credentials,
321
+ now
322
+ }) {
323
+ return { ...credentials, rejectedAt: now };
324
+ }
325
+ function claudeLoginState({
326
+ credentials,
327
+ now
328
+ }) {
329
+ if (!credentials)
330
+ return "missing";
331
+ if (credentials.rejectedAt !== undefined)
332
+ return "expired";
333
+ const { refreshTokenExpiresAt } = credentials.claudeAiOauth;
334
+ if (refreshTokenExpiresAt !== undefined && refreshTokenExpiresAt <= now) {
335
+ return "expired";
336
+ }
337
+ return "active";
338
+ }
339
+ var LOGIN_WARNING_MS = 5 * 24 * 60 * 60 * 1000;
340
+ var DAY_MS = 24 * 60 * 60 * 1000;
341
+ function claudeLoginWarning({
342
+ credentials,
343
+ now
344
+ }) {
345
+ if (claudeLoginState({ credentials, now }) !== "active")
346
+ return;
347
+ const expiresAt = credentials?.claudeAiOauth.refreshTokenExpiresAt;
348
+ if (expiresAt === undefined || expiresAt - now > LOGIN_WARNING_MS) {
349
+ return;
350
+ }
351
+ const days = Math.floor((expiresAt - now) / DAY_MS);
352
+ const within = days === 0 ? "within a day" : `in ${days} day${days === 1 ? "" : "s"}`;
353
+ return `Claude login expires ${within} (${new Date(expiresAt).toLocaleString()}): run \`maesttro login claude\` before then`;
354
+ }
355
+ var CLAUDE_CODEC = {
356
+ account: "claude",
357
+ accepts: isMasterLogin
358
+ };
359
+ function claudeStore() {
360
+ return selectCredentialStore({ codec: CLAUDE_CODEC });
361
+ }
362
+
363
+ // src/claude-keeper.ts
364
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
365
+ import { randomBytes } from "node:crypto";
366
+ import {
367
+ chmodSync as chmodSync2,
368
+ mkdirSync as mkdirSync3,
369
+ mkdtempSync,
370
+ readFileSync as readFileSync3,
371
+ rmSync as rmSync2,
372
+ writeFileSync as writeFileSync3
373
+ } from "node:fs";
374
+ import { tmpdir } from "node:os";
375
+ import { join as join4 } from "node:path";
376
+
377
+ // src/host-identity.ts
378
+ function hostIdentityEnv() {
379
+ if (typeof process.getuid !== "function" || typeof process.getgid !== "function") {
380
+ return {};
381
+ }
382
+ return {
383
+ MAESTTRO_HOST_UID: String(process.getuid()),
384
+ MAESTTRO_HOST_GID: String(process.getgid())
385
+ };
386
+ }
387
+ function hostIdentityFlags() {
388
+ return Object.entries(hostIdentityEnv()).map(([name, value]) => `-e ${name}=${value}`).join(" ");
389
+ }
390
+
391
+ // src/paths.ts
392
+ import { dirname as dirname2, join as join3, resolve } from "node:path";
393
+ import { fileURLToPath } from "node:url";
394
+ var PACKAGE_ROOT = resolve(dirname2(fileURLToPath(import.meta.url)), "..");
395
+ var DOCKERFILE_DIR = join3(PACKAGE_ROOT, "docker");
396
+ var IMAGE = "maesttro-runner:latest";
397
+ var LEGACY_VOLUME = process.env.MAESTTRO_VOLUME?.trim() || "maesttro-home";
398
+
399
+ // src/claude-keeper.ts
400
+ var KEEPER_COMMAND = 'claude -p --model haiku "ok"';
401
+ var KEEPER_TIMEOUT_MS = 75 * 1000;
402
+ var KEEPER_REMOVE_TIMEOUT_MS = 10 * 1000;
403
+ var KEEPER_EXIT_GRACE_MS = 5 * 1000;
404
+ var LOGIN_COMMAND = "claude auth login";
405
+ var REASON_MAX_LENGTH = 200;
406
+ var dockerCli = (args, { interactive }) => {
407
+ const result = spawnSync2("docker", args, {
408
+ stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"]
409
+ });
410
+ return {
411
+ status: result.status ?? null,
412
+ stderr: result.stderr?.toString() ?? "",
413
+ error: result.error
414
+ };
415
+ };
416
+ function collect(child) {
417
+ let stdout = "";
418
+ let stderr = "";
419
+ child.stdout?.on("data", (chunk) => {
420
+ stdout += String(chunk);
421
+ });
422
+ child.stderr?.on("data", (chunk) => {
423
+ stderr += String(chunk);
424
+ });
425
+ return new Promise((resolve) => {
426
+ child.on("error", (error) => resolve({ status: null, stdout, stderr, error }));
427
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
428
+ });
429
+ }
430
+ var keeperDockerCli = {
431
+ start: (args) => {
432
+ const child = spawn("docker", args, {
433
+ stdio: ["ignore", "ignore", "pipe"],
434
+ detached: true
435
+ });
436
+ return { exited: collect(child), kill: () => child.kill("SIGKILL") };
437
+ },
438
+ exec: (args, timeoutMs) => collect(spawn("docker", args, {
439
+ stdio: ["ignore", "pipe", "pipe"],
440
+ detached: true,
441
+ timeout: timeoutMs,
442
+ killSignal: "SIGKILL"
443
+ }))
444
+ };
445
+ function keeperNamePrefix(uid = process.getuid?.()) {
446
+ return `claude-keeper-${uid ?? "user"}-`;
447
+ }
448
+ function keeperContainerName() {
449
+ return `${keeperNamePrefix()}${randomBytes(4).toString("hex")}`;
450
+ }
451
+ function keeperWorkDir() {
452
+ return join4(maesttroHome(), "claude-keeper");
453
+ }
454
+ function runArgs({
455
+ claudeDir,
456
+ identity,
457
+ interactive,
458
+ command,
459
+ name
460
+ }) {
461
+ return [
462
+ "run",
463
+ ...interactive ? ["-it"] : [],
464
+ "--rm",
465
+ ...Object.entries(identity).flatMap(([envName, value]) => [
466
+ "-e",
467
+ `${envName}=${value}`
468
+ ]),
469
+ ...name ? ["--name", name] : [],
470
+ "--mount",
471
+ `type=bind,source=${claudeDir},target=/home/agent/.claude`,
472
+ IMAGE,
473
+ command
474
+ ];
475
+ }
476
+ function keeperArgs({
477
+ claudeDir,
478
+ identity,
479
+ name
480
+ }) {
481
+ return runArgs({
482
+ claudeDir,
483
+ identity,
484
+ interactive: false,
485
+ command: KEEPER_COMMAND,
486
+ name
487
+ });
488
+ }
489
+ function loginArgs({
490
+ claudeDir,
491
+ identity
492
+ }) {
493
+ return runArgs({
494
+ claudeDir,
495
+ identity,
496
+ interactive: true,
497
+ command: LOGIN_COMMAND
498
+ });
499
+ }
500
+ function privateDir(parent = tmpdir()) {
501
+ mkdirSync3(parent, { recursive: true, mode: 448 });
502
+ const dir = mkdtempSync(join4(parent, "maesttro-claude-"));
503
+ chmodSync2(dir, 448);
504
+ return dir;
505
+ }
506
+ function readKeeperCredentials(dir) {
507
+ try {
508
+ return JSON.parse(readFileSync3(join4(dir, ".credentials.json"), "utf8"));
509
+ } catch {
510
+ return;
511
+ }
512
+ }
513
+ function inPrivateDir(makeDir, use) {
514
+ const dir = makeDir();
515
+ try {
516
+ return use(dir);
517
+ } finally {
518
+ rmSync2(dir, { recursive: true, force: true });
519
+ }
520
+ }
521
+ function lastLine(text) {
522
+ const lines = text.split(`
523
+ `).map((line) => line.trim()).filter(Boolean);
524
+ return (lines.at(-1) ?? "").slice(-REASON_MAX_LENGTH);
525
+ }
526
+ function failureReason({
527
+ result,
528
+ timedOut,
529
+ name
530
+ }) {
531
+ if (result.error && !timedOut) {
532
+ return result.error.message.replaceAll(name, "claude-keeper");
533
+ }
534
+ const summary = timedOut ? "the keeper timed out" : result.status === 0 ? "the keeper left the login unchanged" : `the keeper exited with status ${result.status}`;
535
+ const detail = lastLine(result.stderr.replaceAll(name, "claude-keeper"));
536
+ return detail ? `${summary}: ${detail}` : summary;
537
+ }
538
+ var TIMED_OUT = Symbol("timed out");
539
+ function sleepFor(ms) {
540
+ return new Promise((resolve) => setTimeout(resolve, ms));
541
+ }
542
+ async function runBounded({
543
+ docker,
544
+ args,
545
+ name,
546
+ sleep
547
+ }) {
548
+ const run = docker.start(args);
549
+ const first = await Promise.race([
550
+ run.exited,
551
+ sleep(KEEPER_TIMEOUT_MS).then(() => TIMED_OUT)
552
+ ]);
553
+ if (first !== TIMED_OUT)
554
+ return { result: first, timedOut: false, gone: true };
555
+ const removal = await docker.exec(["rm", "-f", name], KEEPER_REMOVE_TIMEOUT_MS);
556
+ const settled = await Promise.race([
557
+ run.exited,
558
+ sleep(KEEPER_EXIT_GRACE_MS).then(() => TIMED_OUT)
559
+ ]);
560
+ if (settled !== TIMED_OUT) {
561
+ return { result: settled, timedOut: true, gone: true };
562
+ }
563
+ run.kill();
564
+ return {
565
+ result: await run.exited,
566
+ timedOut: true,
567
+ gone: removal.status === 0
568
+ };
569
+ }
570
+ async function removeLingeringKeepers({
571
+ docker = keeperDockerCli
572
+ } = {}) {
573
+ const listed = await docker.exec(["ps", "-aq", "--filter", `name=^${keeperNamePrefix()}`], KEEPER_REMOVE_TIMEOUT_MS);
574
+ if (listed.status !== 0) {
575
+ throw new Error(lastLine(listed.stderr) || "docker ps failed");
576
+ }
577
+ const ids = (listed.stdout ?? "").split(`
578
+ `).filter(Boolean);
579
+ if (ids.length === 0)
580
+ return;
581
+ const removed = await docker.exec(["rm", "-f", ...ids], KEEPER_REMOVE_TIMEOUT_MS);
582
+ if (removed.status !== 0) {
583
+ throw new Error(lastLine(removed.stderr) || "docker rm failed");
584
+ }
585
+ }
586
+ async function refreshWithKeeper({
587
+ master,
588
+ now,
589
+ workDir = keeperWorkDir(),
590
+ docker = keeperDockerCli,
591
+ identity = hostIdentityEnv(),
592
+ sleep = sleepFor
593
+ }) {
594
+ const claudeDir = privateDir(workDir);
595
+ let outcome = {
596
+ kind: "inconclusive",
597
+ reason: "the keeper never ran"
598
+ };
599
+ let keepDir = false;
600
+ try {
601
+ writeFileSync3(join4(claudeDir, ".credentials.json"), JSON.stringify(markedExpired({ credentials: master, now })), { mode: 384 });
602
+ const name = keeperContainerName();
603
+ const { result, timedOut, gone } = await runBounded({
604
+ docker,
605
+ args: keeperArgs({ claudeDir, identity, name }),
606
+ name,
607
+ sleep
608
+ });
609
+ const candidate = readKeeperCredentials(claudeDir);
610
+ const refreshed = acceptRefreshed({ previous: master, candidate, now });
611
+ if (refreshed) {
612
+ outcome = { kind: "refreshed", credentials: refreshed };
613
+ return outcome;
614
+ }
615
+ if (!gone) {
616
+ keepDir = true;
617
+ return {
618
+ kind: "inconclusive",
619
+ reason: "the keeper couldn't be stopped; its result is kept for the next refresh"
620
+ };
621
+ }
622
+ outcome = isBlankedLogin(candidate) ? { kind: "rejected" } : {
623
+ kind: "inconclusive",
624
+ reason: failureReason({ result, timedOut, name })
625
+ };
626
+ return outcome;
627
+ } finally {
628
+ if (outcome.kind !== "refreshed" && !keepDir) {
629
+ rmSync2(claudeDir, { recursive: true, force: true });
630
+ }
631
+ }
632
+ }
633
+ function captureLogin({
634
+ docker = dockerCli,
635
+ makeDir = () => privateDir(),
636
+ identity = hostIdentityEnv()
637
+ } = {}) {
638
+ return inPrivateDir(makeDir, (claudeDir) => {
639
+ const { status } = docker(loginArgs({ claudeDir, identity }), {
640
+ interactive: true
641
+ });
642
+ if (status !== 0)
643
+ return;
644
+ const credentials = readKeeperCredentials(claudeDir);
645
+ return isMasterLogin(credentials) ? credentials : undefined;
646
+ });
647
+ }
648
+
649
+ // src/claude-refresher.ts
650
+ import { randomBytes as randomBytes4 } from "node:crypto";
651
+ import {
652
+ closeSync as closeSync2,
653
+ existsSync,
654
+ linkSync,
655
+ lstatSync as lstatSync3,
656
+ mkdirSync as mkdirSync7,
657
+ openSync as openSync2,
658
+ readdirSync as readdirSync2,
659
+ readFileSync as readFileSync7,
660
+ renameSync as renameSync5,
661
+ rmSync as rmSync5,
662
+ statSync,
663
+ utimesSync,
664
+ writeSync
665
+ } from "node:fs";
666
+ import { dirname as dirname4, join as join7 } from "node:path";
667
+
668
+ // src/held-signals.ts
669
+ var HELD_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
670
+ var processSignals = {
671
+ listenerCount: (signal) => process.listenerCount(signal),
672
+ on: (signal, handler) => {
673
+ process.on(signal, handler);
674
+ },
675
+ off: (signal, handler) => {
676
+ process.off(signal, handler);
677
+ },
678
+ raise: (signal) => {
679
+ process.kill(process.pid, signal);
680
+ }
681
+ };
682
+ function holdSignals({
683
+ host = processSignals,
684
+ onHeld = () => {}
685
+ }) {
686
+ let received;
687
+ let released = false;
688
+ const handlers = [];
689
+ const detach = () => {
690
+ for (const { signal, handler } of handlers)
691
+ host.off(signal, handler);
692
+ handlers.length = 0;
693
+ };
694
+ for (const signal of HELD_SIGNALS) {
695
+ if (host.listenerCount(signal) > 0)
696
+ continue;
697
+ const handler = () => {
698
+ if (received) {
699
+ released = true;
700
+ detach();
701
+ host.raise(signal);
702
+ return;
703
+ }
704
+ received = signal;
705
+ onHeld();
706
+ };
707
+ host.on(signal, handler);
708
+ handlers.push({ signal, handler });
709
+ }
710
+ return {
711
+ pending: () => received !== undefined,
712
+ release: () => {
713
+ if (released)
714
+ return;
715
+ released = true;
716
+ detach();
717
+ if (received)
718
+ host.raise(received);
719
+ }
720
+ };
721
+ }
722
+ function signalRouter({
723
+ base = processSignals,
724
+ onUnrouted
725
+ }) {
726
+ const routed = new Map;
727
+ const listeners = HELD_SIGNALS.map((signal) => {
728
+ const listener = () => {
729
+ const handlers = [...routed.get(signal) ?? []];
730
+ if (handlers.length === 0)
731
+ return onUnrouted(signal);
732
+ for (const handler of handlers)
733
+ handler();
734
+ };
735
+ base.on(signal, listener);
736
+ return { signal, listener };
737
+ });
738
+ return {
739
+ listenerCount: (signal) => routed.get(signal)?.size ?? 0,
740
+ on: (signal, handler) => {
741
+ routed.set(signal, (routed.get(signal) ?? new Set).add(handler));
742
+ },
743
+ off: (signal, handler) => {
744
+ routed.get(signal)?.delete(handler);
745
+ },
746
+ raise: (signal) => onUnrouted(signal),
747
+ dispose: () => {
748
+ for (const { signal, listener } of listeners)
749
+ base.off(signal, listener);
750
+ }
751
+ };
752
+ }
753
+
754
+ // src/refresher.ts
755
+ var REFRESH_LEAD_MS = 10 * 60 * 1000;
756
+ var WALL_CLOCK_TICK_MS = 60 * 1000;
757
+ var FIRST_BACKOFF_MS = 5 * 1000;
758
+ var MAX_BACKOFF_MS = 5 * 60 * 1000;
759
+ var nodeTimers = {
760
+ setTimeout: (callback, ms) => setTimeout(callback, ms).unref(),
761
+ clearTimeout: (handle) => clearTimeout(handle),
762
+ setInterval: (callback, ms) => setInterval(callback, ms).unref(),
763
+ clearInterval: (handle) => clearInterval(handle)
764
+ };
765
+ function refreshDueAt({
766
+ expiresAt,
767
+ now
768
+ }) {
769
+ const halfRemaining = (expiresAt - now) / 2;
770
+ return expiresAt - Math.min(REFRESH_LEAD_MS, Math.max(halfRemaining, 0));
771
+ }
772
+ function startRefresher({
773
+ initial,
774
+ expiresAtOf,
775
+ firstAttemptAt,
776
+ refresh,
777
+ onRefreshed,
778
+ onFailed,
779
+ now = Date.now,
780
+ timers = nodeTimers
781
+ }) {
782
+ let current = initial;
783
+ let nextAttemptAt = 0;
784
+ let backoffMs = FIRST_BACKOFF_MS;
785
+ let inFlight;
786
+ let stopped = false;
787
+ let pending;
788
+ function dueAt(token) {
789
+ return refreshDueAt({ expiresAt: expiresAtOf(token), now: now() });
790
+ }
791
+ nextAttemptAt = firstAttemptAt ?? dueAt(initial);
792
+ function scheduleAt(at) {
793
+ timers.clearTimeout(pending);
794
+ pending = timers.setTimeout(check, Math.max(at - now(), 0));
795
+ }
796
+ async function attempt() {
797
+ try {
798
+ const token = await refresh();
799
+ if (stopped)
800
+ return;
801
+ current = token;
802
+ backoffMs = FIRST_BACKOFF_MS;
803
+ nextAttemptAt = dueAt(token);
804
+ onRefreshed(token);
805
+ } catch (error) {
806
+ if (stopped)
807
+ return;
808
+ nextAttemptAt = now() + backoffMs;
809
+ onFailed(error, backoffMs);
810
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
811
+ }
812
+ scheduleAt(nextAttemptAt);
813
+ }
814
+ function check() {
815
+ if (stopped)
816
+ return Promise.resolve();
817
+ if (inFlight)
818
+ return inFlight;
819
+ if (now() < nextAttemptAt)
820
+ return Promise.resolve();
821
+ inFlight = attempt().finally(() => {
822
+ inFlight = undefined;
823
+ });
824
+ return inFlight;
825
+ }
826
+ const tick = timers.setInterval(check, WALL_CLOCK_TICK_MS);
827
+ scheduleAt(nextAttemptAt);
828
+ return {
829
+ current: () => current,
830
+ check,
831
+ stop: () => {
832
+ stopped = true;
833
+ timers.clearTimeout(pending);
834
+ timers.clearInterval(tick);
835
+ return inFlight ?? Promise.resolve();
836
+ },
837
+ busy: () => inFlight !== undefined
838
+ };
839
+ }
840
+
841
+ // src/session-home.ts
842
+ import { randomBytes as randomBytes3 } from "node:crypto";
843
+ import {
844
+ closeSync,
845
+ constants,
846
+ fchmodSync,
847
+ fstatSync,
848
+ lstatSync as lstatSync2,
849
+ mkdirSync as mkdirSync6,
850
+ openSync,
851
+ readFileSync as readFileSync6,
852
+ renameSync as renameSync4,
853
+ rmSync as rmSync4,
854
+ unlinkSync,
855
+ writeFileSync as writeFileSync6
856
+ } from "node:fs";
857
+ import { join as join6 } from "node:path";
858
+
859
+ // src/json-file.ts
860
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
861
+ import { dirname as dirname3 } from "node:path";
862
+ function isObject(value) {
863
+ return typeof value === "object" && value !== null && !Array.isArray(value);
864
+ }
865
+ function mergeJson(existing, curated) {
866
+ const merged = { ...existing };
867
+ for (const [key, value] of Object.entries(curated)) {
868
+ const current = merged[key];
869
+ merged[key] = isObject(current) && isObject(value) ? mergeJson(current, value) : value;
870
+ }
871
+ return merged;
872
+ }
873
+ function readJson(path) {
874
+ try {
875
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
876
+ return isObject(parsed) ? parsed : {};
877
+ } catch {
878
+ return {};
879
+ }
880
+ }
881
+ function writeJson(path, value) {
882
+ mkdirSync4(dirname3(path), { recursive: true });
883
+ writeFileSync4(path, `${JSON.stringify(value, null, 2)}
884
+ `);
885
+ }
886
+ function skipInsignificant(text, from) {
887
+ let index = from;
888
+ while (index < text.length) {
889
+ if (/\s/.test(text[index] ?? "")) {
890
+ index++;
891
+ } else if (text.startsWith("//", index)) {
892
+ while (index < text.length && text[index] !== `
893
+ `)
894
+ index++;
895
+ } else if (text.startsWith("/*", index)) {
896
+ const end = text.indexOf("*/", index + 2);
897
+ index = end === -1 ? text.length : end + 2;
898
+ } else {
899
+ return index;
900
+ }
901
+ }
902
+ return index;
903
+ }
904
+ function stripJsonc(text) {
905
+ let output = "";
906
+ let index = 0;
907
+ while (index < text.length) {
908
+ const char = text[index];
909
+ if (char === '"') {
910
+ const start = index;
911
+ index++;
912
+ while (index < text.length && text[index] !== '"') {
913
+ index += text[index] === "\\" ? 2 : 1;
914
+ }
915
+ index++;
916
+ output += text.slice(start, index);
917
+ continue;
918
+ }
919
+ const significant = skipInsignificant(text, index);
920
+ if (significant > index) {
921
+ output += " ";
922
+ index = significant;
923
+ continue;
924
+ }
925
+ const trailingComma = char === "," && /[}\]]/.test(text[skipInsignificant(text, index + 1)] ?? "");
926
+ if (!trailingComma)
927
+ output += char;
928
+ index++;
929
+ }
930
+ return output;
931
+ }
932
+ function parseJsonc(text) {
933
+ const parsed = JSON.parse(stripJsonc(text));
934
+ if (!isObject(parsed))
935
+ throw new Error("expected a JSON object");
936
+ return parsed;
937
+ }
938
+
939
+ // src/sessions.ts
940
+ import { randomBytes as randomBytes2 } from "node:crypto";
941
+ import {
942
+ chmodSync as chmodSync3,
943
+ lstatSync,
944
+ mkdirSync as mkdirSync5,
945
+ readdirSync,
946
+ readFileSync as readFileSync5,
947
+ renameSync as renameSync3,
948
+ rmSync as rmSync3,
949
+ writeFileSync as writeFileSync5
950
+ } from "node:fs";
951
+ import { join as join5 } from "node:path";
952
+ function newSessionId() {
953
+ return `maesttro-${randomBytes2(4).toString("hex")}`;
954
+ }
955
+ function sessionsDir(home = maesttroHome()) {
956
+ return join5(home, "sessions");
957
+ }
958
+ function saveSession(record, dir = sessionsDir()) {
959
+ mkdirSync5(dir, { recursive: true, mode: 448 });
960
+ const path = join5(dir, `${record.id}.json`);
961
+ writeFileSync5(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
962
+ `);
963
+ renameSync3(`${path}.tmp`, path);
964
+ }
965
+ function readSession(id, dir = sessionsDir()) {
966
+ try {
967
+ return JSON.parse(readFileSync5(join5(dir, `${id}.json`), "utf8"));
968
+ } catch {
969
+ return;
970
+ }
971
+ }
972
+ function unlockTree(path) {
973
+ let entry;
974
+ try {
975
+ entry = lstatSync(path);
976
+ } catch {
977
+ return;
978
+ }
979
+ if (!entry.isDirectory())
980
+ return;
981
+ chmodSync3(path, 448);
982
+ for (const name of readdirSync(path))
983
+ unlockTree(join5(path, name));
984
+ }
985
+ function removeTree(path) {
986
+ try {
987
+ rmSync3(path, { recursive: true, force: true });
988
+ } catch {
989
+ unlockTree(path);
990
+ rmSync3(path, { recursive: true, force: true });
991
+ }
992
+ }
993
+ function deleteSession(id, dir = sessionsDir()) {
994
+ removeTree(join5(dir, id));
995
+ rmSync3(join5(dir, `${id}.json`), { force: true });
996
+ }
997
+ function sessionsForRepo(repoRoot, dir = sessionsDir()) {
998
+ let names;
999
+ try {
1000
+ names = readdirSync(dir).filter((name) => name.endsWith(".json"));
1001
+ } catch {
1002
+ return [];
1003
+ }
1004
+ return names.map((name) => readSession(name.slice(0, -".json".length), dir)).filter((record) => record?.repoRoot === repoRoot).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1005
+ }
1006
+
1007
+ // src/session-home.ts
1008
+ var OPEN_DIRECTORY_NO_FOLLOW = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
1009
+ var CREATE_NEW_NO_FOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
1010
+ var READ_NO_FOLLOW_NO_BLOCK = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
1011
+ function sessionHome({
1012
+ id,
1013
+ dir = sessionsDir()
1014
+ }) {
1015
+ return join6(dir, id, "home");
1016
+ }
1017
+ function sessionClaudeDir({
1018
+ id,
1019
+ dir = sessionsDir()
1020
+ }) {
1021
+ return join6(dir, id, "claude");
1022
+ }
1023
+ function credentialsPath(claudeDir) {
1024
+ return join6(claudeDir, ".credentials.json");
1025
+ }
1026
+ function entryAt(path) {
1027
+ try {
1028
+ return lstatSync2(path);
1029
+ } catch (error) {
1030
+ if (error.code === "ENOENT")
1031
+ return;
1032
+ throw error;
1033
+ }
1034
+ }
1035
+ function requireRealDirectory(path) {
1036
+ if (!lstatSync2(path).isDirectory()) {
1037
+ throw new Error(`Not a directory: ${path}`);
1038
+ }
1039
+ }
1040
+ function withFd(fd, use) {
1041
+ try {
1042
+ return use(fd);
1043
+ } finally {
1044
+ closeSync(fd);
1045
+ }
1046
+ }
1047
+ function ensurePrivateDir(path) {
1048
+ const entry = entryAt(path);
1049
+ if (!entry?.isDirectory()) {
1050
+ if (entry)
1051
+ unlinkSync(path);
1052
+ mkdirSync6(path, { recursive: true, mode: 448 });
1053
+ }
1054
+ withFd(openSync(path, OPEN_DIRECTORY_NO_FOLLOW), (fd) => fchmodSync(fd, 448));
1055
+ }
1056
+ function ensureSessionHome({
1057
+ id,
1058
+ dir = sessionsDir()
1059
+ }) {
1060
+ const home = sessionHome({ id, dir });
1061
+ const claudeDir = sessionClaudeDir({ id, dir });
1062
+ ensurePrivateDir(home);
1063
+ ensurePrivateDir(claudeDir);
1064
+ ensurePrivateDir(join6(home, ".claude"));
1065
+ return { home, claudeDir };
1066
+ }
1067
+ function supersedes({
1068
+ credentials,
1069
+ current
1070
+ }) {
1071
+ if (carriesRefreshToken(current))
1072
+ return false;
1073
+ if (!isClaudeCredentials(current))
1074
+ return true;
1075
+ return credentials.claudeAiOauth.expiresAt > current.claudeAiOauth.expiresAt;
1076
+ }
1077
+ function writeSessionCredentials({
1078
+ claudeDir,
1079
+ credentials
1080
+ }) {
1081
+ requireRealDirectory(claudeDir);
1082
+ const current = readSessionCredentials({ claudeDir });
1083
+ if (!supersedes({ credentials, current }))
1084
+ return false;
1085
+ const temporary = join6(claudeDir, `.credentials.json.maesttro-${randomBytes3(8).toString("hex")}`);
1086
+ const fd = openSync(temporary, CREATE_NEW_NO_FOLLOW, 384);
1087
+ try {
1088
+ withFd(fd, () => {
1089
+ writeFileSync6(fd, JSON.stringify({
1090
+ ...isObject(current) ? current : {},
1091
+ ...withoutRefreshToken(credentials)
1092
+ }));
1093
+ fchmodSync(fd, 384);
1094
+ });
1095
+ renameSync4(temporary, credentialsPath(claudeDir));
1096
+ } catch (error) {
1097
+ rmSync4(temporary, { force: true });
1098
+ throw error;
1099
+ }
1100
+ return true;
128
1101
  }
129
- function decode(encoded) {
1102
+ function readSessionCredentials({
1103
+ claudeDir
1104
+ }) {
130
1105
  try {
131
- const token = JSON.parse(Buffer.from(encoded.trim(), "base64").toString("utf8"));
132
- return typeof token.access_token === "string" ? token : undefined;
1106
+ return withFd(openSync(credentialsPath(claudeDir), READ_NO_FOLLOW_NO_BLOCK), (fd) => fstatSync(fd).isFile() ? JSON.parse(readFileSync6(fd, "utf8")) : undefined);
133
1107
  } catch {
134
1108
  return;
135
1109
  }
136
1110
  }
137
- function keychainStore(run = runCommand) {
138
- const identity = ["-s", SERVICE, "-a", ACCOUNT];
139
- return {
140
- kind: "macOS keychain",
141
- load: () => {
142
- const result = run("security", [
143
- "find-generic-password",
144
- ...identity,
145
- "-w"
146
- ]);
147
- return result.status === 0 ? decode(result.stdout) : undefined;
148
- },
149
- save: (token) => {
150
- const command = `add-generic-password -U ${identity.join(" ")} -w ${encode(token)}
151
- `;
152
- const result = run("security", ["-i"], command);
153
- if (result.status !== 0)
154
- throw new Error("Could not write to keychain");
155
- },
156
- clear: () => {
157
- run("security", ["delete-generic-password", ...identity]);
1111
+ function removeHostWrittenCredentials({
1112
+ claudeDir
1113
+ }) {
1114
+ requireRealDirectory(claudeDir);
1115
+ const path = credentialsPath(claudeDir);
1116
+ const entry = entryAt(path);
1117
+ if (!entry)
1118
+ return;
1119
+ const inSessionLogin = entry.isFile() && carriesRefreshToken(readSessionCredentials({ claudeDir }));
1120
+ if (!inSessionLogin)
1121
+ unlinkSync(path);
1122
+ }
1123
+
1124
+ // src/claude-refresher.ts
1125
+ var LOCK_STALE_MS = 2 * 60 * 1000;
1126
+ var LOCK_HEARTBEAT_MS = 20 * 1000;
1127
+ var SESSION_SYNC_MS = 60 * 1000;
1128
+ var LAUNCH_RETRY_MS = 60 * 1000;
1129
+ var WAITING_FOR_REFRESH = "Waiting for another maesttro's Claude refresh…";
1130
+
1131
+ class ClaudeLoginExpired extends Error {
1132
+ constructor() {
1133
+ super("Claude login expired: run `maesttro login claude`");
1134
+ }
1135
+ }
1136
+
1137
+ class ClaudeRefreshFailed extends Error {
1138
+ constructor(reason) {
1139
+ super(`Couldn't refresh the Claude login (will retry): ${reason}`);
1140
+ }
1141
+ }
1142
+
1143
+ class ClaudeLoggedOut extends Error {
1144
+ constructor() {
1145
+ super("Not logged in to Claude: run `maesttro login claude`");
1146
+ }
1147
+ }
1148
+ function claudeLockPath() {
1149
+ return join7(maesttroHome(), "claude-refresh.lock");
1150
+ }
1151
+ function lockToken() {
1152
+ return `${process.pid}-${randomBytes4(8).toString("hex")}`;
1153
+ }
1154
+ function tryAcquire(path, token) {
1155
+ let fd;
1156
+ try {
1157
+ fd = openSync2(path, "wx", 384);
1158
+ } catch (error) {
1159
+ if (error.code === "EEXIST")
1160
+ return false;
1161
+ throw error;
1162
+ }
1163
+ try {
1164
+ writeSync(fd, token);
1165
+ } catch (error) {
1166
+ rmSync5(path, { force: true });
1167
+ throw error;
1168
+ } finally {
1169
+ closeSync2(fd);
1170
+ }
1171
+ return true;
1172
+ }
1173
+ function isStale(path, now, staleMs) {
1174
+ try {
1175
+ return now - statSync(path).mtimeMs > staleMs;
1176
+ } catch {
1177
+ return false;
1178
+ }
1179
+ }
1180
+ function reclaimStaleLock({
1181
+ path,
1182
+ now,
1183
+ staleMs
1184
+ }) {
1185
+ const staging = `${path}.stale-${lockToken()}`;
1186
+ try {
1187
+ renameSync5(path, staging);
1188
+ } catch (error) {
1189
+ if (error.code === "ENOENT")
1190
+ return;
1191
+ throw error;
1192
+ }
1193
+ if (!isStale(staging, now, staleMs)) {
1194
+ try {
1195
+ linkSync(staging, path);
1196
+ } catch {}
1197
+ }
1198
+ rmSync5(staging, { force: true });
1199
+ }
1200
+ function touchIfOwned(path, token) {
1201
+ try {
1202
+ if (readFileSync7(path, "utf8") === token) {
1203
+ const now = new Date;
1204
+ utimesSync(path, now, now);
158
1205
  }
159
- };
1206
+ } catch {}
160
1207
  }
161
- function secretToolStore(run = runCommand) {
162
- const attributes = ["service", SERVICE, "account", ACCOUNT];
163
- return {
164
- kind: "Secret Service (secret-tool)",
165
- load: () => {
166
- const result = run("secret-tool", ["lookup", ...attributes]);
167
- return result.status === 0 ? decode(result.stdout) : undefined;
168
- },
169
- save: (token) => {
170
- const result = run("secret-tool", ["store", `--label=${SERVICE}`, ...attributes], encode(token));
171
- if (result.status !== 0) {
172
- throw new Error("Could not write to the Secret Service");
1208
+ function releaseIfOwned(path, token) {
1209
+ try {
1210
+ if (readFileSync7(path, "utf8") === token)
1211
+ rmSync5(path, { force: true });
1212
+ } catch {}
1213
+ }
1214
+ function sleepFor2(ms) {
1215
+ return new Promise((resolve) => setTimeout(resolve, ms));
1216
+ }
1217
+ async function withRefreshLock({
1218
+ path,
1219
+ now = Date.now,
1220
+ staleMs = LOCK_STALE_MS,
1221
+ pollMs = 500,
1222
+ sleep = sleepFor2,
1223
+ onWait = () => {},
1224
+ timers = nodeTimers
1225
+ }, action) {
1226
+ mkdirSync7(dirname4(path), { recursive: true, mode: 448 });
1227
+ const token = lockToken();
1228
+ let waited = false;
1229
+ while (!tryAcquire(path, token)) {
1230
+ if (isStale(path, now(), staleMs)) {
1231
+ reclaimStaleLock({ path, now: now(), staleMs });
1232
+ } else {
1233
+ if (!waited)
1234
+ onWait();
1235
+ waited = true;
1236
+ await sleep(pollMs);
1237
+ }
1238
+ }
1239
+ const heartbeat = timers.setInterval(() => touchIfOwned(path, token), LOCK_HEARTBEAT_MS);
1240
+ try {
1241
+ return await action();
1242
+ } finally {
1243
+ timers.clearInterval(heartbeat);
1244
+ releaseIfOwned(path, token);
1245
+ }
1246
+ }
1247
+ function isDue({
1248
+ credentials,
1249
+ now
1250
+ }) {
1251
+ return credentials.claudeAiOauth.expiresAt - now <= REFRESH_LEAD_MS;
1252
+ }
1253
+ var FINISHING_REFRESH = "Finishing the Claude refresh before exiting… (press Ctrl-C again to abort)";
1254
+ var FINISHING_BACKGROUND_REFRESH = "Finishing a Claude refresh before exiting…";
1255
+ var runKeeper = ({ master, now, workDir }) => refreshWithKeeper({ master, now, workDir });
1256
+ function loadMaster(store) {
1257
+ const master = store.load();
1258
+ if (!master)
1259
+ throw new ClaudeLoggedOut;
1260
+ return master;
1261
+ }
1262
+ function keeperLeftovers(workDir) {
1263
+ try {
1264
+ return readdirSync2(workDir).map((name) => join7(workDir, name)).filter((path) => lstatSync3(path).isDirectory());
1265
+ } catch {
1266
+ return [];
1267
+ }
1268
+ }
1269
+ function discardLeftovers(workDir) {
1270
+ for (const dir of keeperLeftovers(workDir)) {
1271
+ rmSync5(dir, { recursive: true, force: true });
1272
+ }
1273
+ }
1274
+ function discardKeeperLeftovers({
1275
+ workDir = keeperWorkDir()
1276
+ } = {}) {
1277
+ discardLeftovers(workDir);
1278
+ }
1279
+ function saveRotated({
1280
+ store,
1281
+ credentials,
1282
+ workDir,
1283
+ onRotated
1284
+ }) {
1285
+ try {
1286
+ store.save(credentials);
1287
+ } catch (error) {
1288
+ onRotated(credentials);
1289
+ throw new ClaudeRefreshFailed(`couldn't save the refreshed login: ${messageOf(error)}`);
1290
+ }
1291
+ discardLeftovers(workDir);
1292
+ onRotated(credentials);
1293
+ }
1294
+ function adoptLeftover({
1295
+ master,
1296
+ workDir
1297
+ }) {
1298
+ let best;
1299
+ for (const dir of keeperLeftovers(workDir)) {
1300
+ const candidate = readKeeperCredentials(dir);
1301
+ if (!isMasterLogin(candidate))
1302
+ continue;
1303
+ const { accessToken, expiresAt } = candidate.claudeAiOauth;
1304
+ const newest = best ?? master;
1305
+ if (accessToken !== master.claudeAiOauth.accessToken && expiresAt > newest.claudeAiOauth.expiresAt) {
1306
+ best = { claudeAiOauth: candidate.claudeAiOauth };
1307
+ }
1308
+ }
1309
+ return best;
1310
+ }
1311
+ function sameLogin(current, expected) {
1312
+ return current?.claudeAiOauth.accessToken === expected.claudeAiOauth.accessToken;
1313
+ }
1314
+ function replacedMeanwhile({
1315
+ store,
1316
+ handed,
1317
+ workDir
1318
+ }) {
1319
+ const current = store.load();
1320
+ if (sameLogin(current, handed))
1321
+ return;
1322
+ if (!current)
1323
+ throw new ClaudeLoggedOut;
1324
+ discardLeftovers(workDir);
1325
+ return current;
1326
+ }
1327
+ async function clearEarlierKeepers(clearLingering) {
1328
+ try {
1329
+ await clearLingering();
1330
+ } catch (error) {
1331
+ throw new ClaudeRefreshFailed(`couldn't stop an earlier keeper: ${messageOf(error)}`);
1332
+ }
1333
+ }
1334
+ async function refreshMaster({
1335
+ store,
1336
+ keeper = runKeeper,
1337
+ lockPath = claudeLockPath(),
1338
+ workDir = keeperWorkDir(),
1339
+ clearLingering = () => removeLingeringKeepers(),
1340
+ now = Date.now,
1341
+ announce,
1342
+ sleep,
1343
+ signals,
1344
+ onRotated = () => {}
1345
+ }) {
1346
+ const loaded = loadMaster(store);
1347
+ if (keeperLeftovers(workDir).length === 0) {
1348
+ if (loaded.rejectedAt !== undefined)
1349
+ throw new ClaudeLoginExpired;
1350
+ if (!isDue({ credentials: loaded, now: now() }))
1351
+ return loaded;
1352
+ }
1353
+ const onWait = announce ? () => announce(WAITING_FOR_REFRESH) : undefined;
1354
+ const onHeld = announce ? () => announce(FINISHING_REFRESH) : undefined;
1355
+ let hold;
1356
+ try {
1357
+ return await withRefreshLock({ path: lockPath, now, sleep, onWait }, async () => {
1358
+ if (signals)
1359
+ hold = holdSignals({ host: signals, onHeld });
1360
+ await clearEarlierKeepers(clearLingering);
1361
+ let stored = store.load();
1362
+ if (!stored)
1363
+ throw new ClaudeLoggedOut;
1364
+ const adopted = adoptLeftover({ master: stored, workDir });
1365
+ if (adopted) {
1366
+ saveRotated({ store, credentials: adopted, workDir, onRotated });
1367
+ stored = adopted;
173
1368
  }
174
- },
175
- clear: () => {
176
- run("secret-tool", ["clear", ...attributes]);
1369
+ discardLeftovers(workDir);
1370
+ if (stored.rejectedAt !== undefined)
1371
+ throw new ClaudeLoginExpired;
1372
+ if (!isDue({ credentials: stored, now: now() }))
1373
+ return stored;
1374
+ if (hold?.pending()) {
1375
+ throw new ClaudeRefreshFailed("interrupted before the keeper ran");
1376
+ }
1377
+ const outcome = await keeper({ master: stored, now: now(), workDir });
1378
+ if (outcome.kind === "inconclusive") {
1379
+ throw new ClaudeRefreshFailed(outcome.reason);
1380
+ }
1381
+ const newer = replacedMeanwhile({ store, handed: stored, workDir });
1382
+ if (newer)
1383
+ return newer;
1384
+ if (outcome.kind === "rejected") {
1385
+ try {
1386
+ store.save(markedRejected({ credentials: stored, now: now() }));
1387
+ } catch {}
1388
+ throw new ClaudeLoginExpired;
1389
+ }
1390
+ saveRotated({
1391
+ store,
1392
+ credentials: outcome.credentials,
1393
+ workDir,
1394
+ onRotated
1395
+ });
1396
+ return outcome.credentials;
1397
+ });
1398
+ } finally {
1399
+ hold?.release();
1400
+ }
1401
+ }
1402
+ function runningSessionIds({
1403
+ run = runCommand
1404
+ } = {}) {
1405
+ const result = run("docker", ["ps", "--format", "{{.Names}}"]);
1406
+ if (result.status !== 0)
1407
+ return [];
1408
+ return result.stdout.split(`
1409
+ `).map((name) => name.trim()).filter((name) => name.startsWith("maesttro-"));
1410
+ }
1411
+ function messageOf(error) {
1412
+ return error instanceof Error ? error.message : String(error);
1413
+ }
1414
+ function pushCredentials({
1415
+ credentials,
1416
+ claudeDirs,
1417
+ announce
1418
+ }) {
1419
+ for (const claudeDir of new Set(claudeDirs)) {
1420
+ if (!existsSync(claudeDir))
1421
+ continue;
1422
+ try {
1423
+ writeSessionCredentials({ claudeDir, credentials });
1424
+ } catch (error) {
1425
+ announce(`Couldn't update the Claude login in ${claudeDir}: ${messageOf(error)}`);
177
1426
  }
178
- };
1427
+ }
179
1428
  }
180
- function fileStore(path = join(homedir(), ".config", "maesttro", "credentials.json")) {
1429
+ function announcingOnce(announce, alreadySaid) {
1430
+ let last = alreadySaid;
181
1431
  return {
182
- kind: `file (${path})`,
183
- load: () => {
184
- try {
185
- return decode(readFileSync(path, "utf8"));
186
- } catch {
1432
+ say: (message) => {
1433
+ if (message === last)
187
1434
  return;
188
- }
189
- },
190
- save: (token) => {
191
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
192
- const temporary = `${path}.tmp`;
193
- writeFileSync(temporary, encode(token), { mode: 384 });
194
- chmodSync(temporary, 384);
195
- renameSync(temporary, path);
1435
+ last = message;
1436
+ announce(message);
196
1437
  },
197
- clear: () => {
198
- rmSync(path, { force: true });
1438
+ reset: () => {
1439
+ last = undefined;
199
1440
  }
200
1441
  };
201
1442
  }
202
- function selectCredentialStore({
203
- platform = process.platform,
204
- env = process.env,
205
- run = runCommand
206
- } = {}) {
207
- if (env.MAESTTRO_CREDENTIAL_STORE === "file")
208
- return fileStore();
209
- if (platform === "darwin")
210
- return keychainStore(run);
211
- if (platform === "linux" && commandExists(run, "secret-tool")) {
212
- return secretToolStore(run);
213
- }
214
- return fileStore();
1443
+ function syncSessionCredentials({
1444
+ store,
1445
+ claudeDir,
1446
+ now = Date.now
1447
+ }) {
1448
+ const master = store.load();
1449
+ if (!master || master.claudeAiOauth.expiresAt <= now())
1450
+ return;
1451
+ writeSessionCredentials({ claudeDir, credentials: master });
1452
+ }
1453
+ function pushToRunningSessions({
1454
+ credentials,
1455
+ claudeDir,
1456
+ run = runCommand,
1457
+ claudeDirOf = (id) => sessionClaudeDir({ id }),
1458
+ announce
1459
+ }) {
1460
+ pushCredentials({
1461
+ credentials,
1462
+ claudeDirs: [claudeDir, ...runningSessionIds({ run }).map(claudeDirOf)],
1463
+ announce
1464
+ });
1465
+ }
1466
+ function startClaudeRefresher({
1467
+ claudeDir,
1468
+ store,
1469
+ keeper,
1470
+ lockPath,
1471
+ workDir,
1472
+ clearLingering,
1473
+ run = runCommand,
1474
+ claudeDirOf = (id) => sessionClaudeDir({ id }),
1475
+ announce,
1476
+ launchFailure,
1477
+ now = Date.now,
1478
+ timers = nodeTimers
1479
+ }) {
1480
+ const initial = store.load();
1481
+ if (!initial)
1482
+ return;
1483
+ const refreshNotices = announcingOnce(announce, launchFailure);
1484
+ const syncNotices = announcingOnce(announce);
1485
+ const refresher = startRefresher({
1486
+ initial,
1487
+ expiresAtOf: (credentials) => credentials.claudeAiOauth.expiresAt,
1488
+ firstAttemptAt: launchFailure === undefined ? undefined : now() + LAUNCH_RETRY_MS,
1489
+ refresh: () => refreshMaster({
1490
+ store,
1491
+ keeper,
1492
+ lockPath,
1493
+ workDir,
1494
+ clearLingering,
1495
+ now,
1496
+ onRotated: (credentials) => pushToRunningSessions({
1497
+ credentials,
1498
+ claudeDir,
1499
+ run,
1500
+ claudeDirOf,
1501
+ announce: refreshNotices.say
1502
+ })
1503
+ }),
1504
+ onRefreshed: (credentials) => {
1505
+ refreshNotices.reset();
1506
+ pushCredentials({
1507
+ credentials,
1508
+ claudeDirs: [claudeDir],
1509
+ announce: refreshNotices.say
1510
+ });
1511
+ },
1512
+ onFailed: (error) => refreshNotices.say(messageOf(error)),
1513
+ now,
1514
+ timers
1515
+ });
1516
+ const sync = timers.setInterval(() => {
1517
+ try {
1518
+ syncSessionCredentials({ store, claudeDir, now });
1519
+ syncNotices.reset();
1520
+ } catch (error) {
1521
+ syncNotices.say(messageOf(error));
1522
+ }
1523
+ }, SESSION_SYNC_MS);
1524
+ return {
1525
+ stop: () => {
1526
+ timers.clearInterval(sync);
1527
+ return refresher.stop();
1528
+ },
1529
+ busy: refresher.busy
1530
+ };
215
1531
  }
216
1532
 
217
1533
  // src/github-api.ts
@@ -403,7 +1719,7 @@ function loginDependencies() {
403
1719
  function describeExpiry(iso) {
404
1720
  return iso ? new Date(iso).toLocaleString() : "never";
405
1721
  }
406
- async function login() {
1722
+ async function loginGitHub() {
407
1723
  const dependencies = loginDependencies();
408
1724
  const token = await deviceLogin(dependencies);
409
1725
  const name = await githubLogin(token.access_token);
@@ -413,139 +1729,213 @@ async function ensureLogin() {
413
1729
  const dependencies = loginDependencies();
414
1730
  const token = await currentUserToken({ ...dependencies, interactive: true });
415
1731
  const name = token && await githubLogin(token.access_token);
416
- console.log(name ? `Logged in to GitHub as ${name}. Stored in ${dependencies.store.kind}.` : "GitHub login did not complete. Run `maesttro login` to retry.");
1732
+ console.log(name ? `Logged in to GitHub as ${name}. Stored in ${dependencies.store.kind}.` : "GitHub login did not complete. Run `maesttro login github` to retry.");
417
1733
  }
418
- function logout() {
1734
+ function logoutGitHub() {
419
1735
  const { store } = loginDependencies();
420
1736
  store.clear();
421
1737
  console.log(`Logged out. Removed GitHub credentials from ${store.kind}.`);
422
1738
  }
423
- async function status() {
424
- const dependencies = loginDependencies();
425
- const token = await currentUserToken({ ...dependencies, interactive: false });
426
- if (!token) {
427
- console.log("Not logged in to GitHub. Run `maesttro login`.");
1739
+ async function statusGitHub() {
1740
+ const dependencies = loginDependencies();
1741
+ const token = await currentUserToken({ ...dependencies, interactive: false });
1742
+ if (!token) {
1743
+ console.log("Not logged in to GitHub. Run `maesttro login github`.");
1744
+ process.exitCode = 1;
1745
+ return;
1746
+ }
1747
+ const name = await githubLogin(token.access_token);
1748
+ console.log([
1749
+ `GitHub: ${name ? `logged in as ${name}` : "token rejected — run `maesttro login github`"}`,
1750
+ `Store: ${dependencies.store.kind}`,
1751
+ `Access token expires: ${describeExpiry(token.expires_at)}`,
1752
+ `Refresh token expires: ${describeExpiry(token.refresh_expires_at)}`
1753
+ ].join(`
1754
+ `));
1755
+ if (!name)
1756
+ process.exitCode = 1;
1757
+ }
1758
+ function parseLoginTargets(args) {
1759
+ const [target, ...rest] = args;
1760
+ if (rest.length > 0)
1761
+ return;
1762
+ if (target === undefined)
1763
+ return ["github", "claude"];
1764
+ return target === "github" || target === "claude" ? [target] : undefined;
1765
+ }
1766
+ function claudeLogin({
1767
+ capture = () => captureLogin(),
1768
+ store = claudeStore(),
1769
+ forgetLeftovers = () => discardKeeperLeftovers(),
1770
+ announce = (message) => console.log(message)
1771
+ } = {}) {
1772
+ announce("Logging in to Claude in a throwaway runner (opens a URL; paste the code back)...");
1773
+ const credentials = capture();
1774
+ if (!credentials) {
1775
+ announce("Claude login did not complete. Run `maesttro login claude` to retry.");
1776
+ return false;
1777
+ }
1778
+ store.save(credentials);
1779
+ forgetLeftovers();
1780
+ announce(`Logged in to Claude. Stored in ${store.kind}.`);
1781
+ return true;
1782
+ }
1783
+ function describeClaudeStatus({
1784
+ credentials,
1785
+ now
1786
+ }) {
1787
+ if (!credentials) {
1788
+ return {
1789
+ loggedIn: false,
1790
+ lines: ["Claude: not logged in — run `maesttro login claude`"]
1791
+ };
1792
+ }
1793
+ const { expiresAt, refreshTokenExpiresAt } = credentials.claudeAiOauth;
1794
+ const loggedIn = claudeLoginState({ credentials, now }) === "active";
1795
+ const warning = claudeLoginWarning({ credentials, now });
1796
+ return {
1797
+ loggedIn,
1798
+ lines: [
1799
+ loggedIn ? "Claude: logged in" : "Claude: login expired — run `maesttro login claude`",
1800
+ ...warning ? [`Warning: ${warning}`] : [],
1801
+ `Access token expires: ${new Date(expiresAt).toLocaleString()}`,
1802
+ `Refresh token expires: ${refreshTokenExpiresAt ? new Date(refreshTokenExpiresAt).toLocaleString() : "unknown"}`
1803
+ ]
1804
+ };
1805
+ }
1806
+ function unknownTarget(command) {
1807
+ console.log(`Usage: maesttro ${command} [github|claude]`);
1808
+ process.exitCode = 1;
1809
+ }
1810
+ async function login({ args }) {
1811
+ const targets = parseLoginTargets(args);
1812
+ if (!targets)
1813
+ return unknownTarget("login");
1814
+ if (targets.includes("github"))
1815
+ await loginGitHub();
1816
+ if (targets.includes("claude") && !claudeLogin())
428
1817
  process.exitCode = 1;
1818
+ }
1819
+ function logout({ args }) {
1820
+ const targets = parseLoginTargets(args);
1821
+ if (!targets) {
1822
+ unknownTarget("logout");
429
1823
  return;
430
1824
  }
431
- const name = await githubLogin(token.access_token);
432
- console.log([
433
- `GitHub: ${name ? `logged in as ${name}` : "token rejected — run `maesttro login`"}`,
434
- `Store: ${dependencies.store.kind}`,
435
- `Access token expires: ${describeExpiry(token.expires_at)}`,
436
- `Refresh token expires: ${describeExpiry(token.refresh_expires_at)}`
437
- ].join(`
1825
+ if (targets.includes("github"))
1826
+ logoutGitHub();
1827
+ if (targets.includes("claude")) {
1828
+ const store = claudeStore();
1829
+ store.clear();
1830
+ discardKeeperLeftovers();
1831
+ console.log(`Logged out. Removed Claude credentials from ${store.kind}.`);
1832
+ }
1833
+ }
1834
+ async function status({ args }) {
1835
+ const targets = parseLoginTargets(args);
1836
+ if (!targets)
1837
+ return unknownTarget("status");
1838
+ if (targets.includes("github"))
1839
+ await statusGitHub();
1840
+ if (targets.includes("claude")) {
1841
+ const { loggedIn, lines } = describeClaudeStatus({
1842
+ credentials: claudeStore().load(),
1843
+ now: Date.now()
1844
+ });
1845
+ console.log(lines.join(`
438
1846
  `));
439
- if (!name)
440
- process.exitCode = 1;
1847
+ if (!loggedIn)
1848
+ process.exitCode = 1;
1849
+ }
1850
+ }
1851
+
1852
+ // src/clients.ts
1853
+ var CLIENTS = {
1854
+ claude: {
1855
+ label: "Claude Code",
1856
+ npmPackage: "@anthropic-ai/claude-code",
1857
+ binary: "claude",
1858
+ configDir: "/home/agent/.claude"
1859
+ },
1860
+ opencode: {
1861
+ label: "opencode",
1862
+ npmPackage: "opencode-ai",
1863
+ binary: "opencode",
1864
+ configDir: "/home/agent/.config/opencode"
1865
+ }
1866
+ };
1867
+ var DEFAULT_CLIENT = "opencode";
1868
+ function isClientKey(value) {
1869
+ return Object.hasOwn(CLIENTS, value);
441
1870
  }
442
1871
 
443
1872
  // src/configure.ts
444
1873
  import {
445
1874
  cpSync as cpSync2,
446
- existsSync as existsSync2,
447
- mkdirSync as mkdirSync4,
448
- readdirSync,
449
- readFileSync as readFileSync4,
450
- writeFileSync as writeFileSync4
1875
+ existsSync as existsSync3,
1876
+ mkdirSync as mkdirSync9,
1877
+ readdirSync as readdirSync3,
1878
+ readFileSync as readFileSync10,
1879
+ writeFileSync as writeFileSync9
451
1880
  } from "node:fs";
452
1881
  import { homedir as homedir2 } from "node:os";
453
- import { dirname as dirname5, join as join4 } from "node:path";
1882
+ import { dirname as dirname6, join as join10 } from "node:path";
454
1883
 
455
- // src/client-configs.ts
456
- import {
457
- chmodSync as chmodSync2,
458
- cpSync,
459
- existsSync,
460
- mkdirSync as mkdirSync3,
461
- mkdtempSync,
462
- readFileSync as readFileSync3,
463
- rmSync as rmSync2,
464
- writeFileSync as writeFileSync3
465
- } from "node:fs";
466
- import { tmpdir } from "node:os";
467
- import { dirname as dirname3, join as join2 } from "node:path";
1884
+ // src/claude-state.ts
1885
+ import { readFileSync as readFileSync8, renameSync as renameSync6, writeFileSync as writeFileSync7 } from "node:fs";
1886
+ import { join as join8 } from "node:path";
468
1887
 
469
- // src/json-file.ts
470
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
471
- import { dirname as dirname2 } from "node:path";
472
- function isObject(value) {
473
- return typeof value === "object" && value !== null && !Array.isArray(value);
474
- }
475
- function mergeJson(existing, curated) {
476
- const merged = { ...existing };
477
- for (const [key, value] of Object.entries(curated)) {
478
- const current = merged[key];
479
- merged[key] = isObject(current) && isObject(value) ? mergeJson(current, value) : value;
480
- }
481
- return merged;
1888
+ // src/runner-paths.ts
1889
+ var CONTAINER_REPO_PATH = "/workspace";
1890
+
1891
+ // src/claude-state.ts
1892
+ function seededClaudeState({
1893
+ existing,
1894
+ claudeVersion
1895
+ }) {
1896
+ const projects = isObject(existing.projects) ? existing.projects : {};
1897
+ return {
1898
+ ...existing,
1899
+ hasCompletedOnboarding: true,
1900
+ ...claudeVersion ? { lastOnboardingVersion: claudeVersion } : {},
1901
+ theme: existing.theme ?? "dark",
1902
+ projects: mergeJson(projects, {
1903
+ [CONTAINER_REPO_PATH]: { hasTrustDialogAccepted: true }
1904
+ })
1905
+ };
482
1906
  }
483
- function readJson(path) {
1907
+ function installedClaudeVersion(root = "/usr/local/lib/node_modules/@anthropic-ai/claude-code") {
484
1908
  try {
485
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
486
- return isObject(parsed) ? parsed : {};
1909
+ return JSON.parse(readFileSync8(join8(root, "package.json"), "utf8")).version;
487
1910
  } catch {
488
- return {};
1911
+ return;
489
1912
  }
490
1913
  }
491
- function writeJson(path, value) {
492
- mkdirSync2(dirname2(path), { recursive: true });
493
- writeFileSync2(path, `${JSON.stringify(value, null, 2)}
1914
+ function seedClaudeState({
1915
+ home,
1916
+ claudeVersion = installedClaudeVersion()
1917
+ }) {
1918
+ const path = join8(home, ".claude.json");
1919
+ const state = seededClaudeState({ existing: readJson(path), claudeVersion });
1920
+ writeFileSync7(`${path}.tmp`, `${JSON.stringify(state, null, 2)}
494
1921
  `);
495
- }
496
- function skipInsignificant(text, from) {
497
- let index = from;
498
- while (index < text.length) {
499
- if (/\s/.test(text[index] ?? "")) {
500
- index++;
501
- } else if (text.startsWith("//", index)) {
502
- while (index < text.length && text[index] !== `
503
- `)
504
- index++;
505
- } else if (text.startsWith("/*", index)) {
506
- const end = text.indexOf("*/", index + 2);
507
- index = end === -1 ? text.length : end + 2;
508
- } else {
509
- return index;
510
- }
511
- }
512
- return index;
513
- }
514
- function stripJsonc(text) {
515
- let output = "";
516
- let index = 0;
517
- while (index < text.length) {
518
- const char = text[index];
519
- if (char === '"') {
520
- const start = index;
521
- index++;
522
- while (index < text.length && text[index] !== '"') {
523
- index += text[index] === "\\" ? 2 : 1;
524
- }
525
- index++;
526
- output += text.slice(start, index);
527
- continue;
528
- }
529
- const significant = skipInsignificant(text, index);
530
- if (significant > index) {
531
- output += " ";
532
- index = significant;
533
- continue;
534
- }
535
- const trailingComma = char === "," && /[}\]]/.test(text[skipInsignificant(text, index + 1)] ?? "");
536
- if (!trailingComma)
537
- output += char;
538
- index++;
539
- }
540
- return output;
541
- }
542
- function parseJsonc(text) {
543
- const parsed = JSON.parse(stripJsonc(text));
544
- if (!isObject(parsed))
545
- throw new Error("expected a JSON object");
546
- return parsed;
1922
+ renameSync6(`${path}.tmp`, path);
547
1923
  }
548
1924
 
1925
+ // src/client-configs.ts
1926
+ import {
1927
+ chmodSync as chmodSync4,
1928
+ cpSync,
1929
+ existsSync as existsSync2,
1930
+ mkdirSync as mkdirSync8,
1931
+ mkdtempSync as mkdtempSync2,
1932
+ readFileSync as readFileSync9,
1933
+ rmSync as rmSync6,
1934
+ writeFileSync as writeFileSync8
1935
+ } from "node:fs";
1936
+ import { tmpdir as tmpdir2 } from "node:os";
1937
+ import { dirname as dirname5, join as join9 } from "node:path";
1938
+
549
1939
  // src/maesttro-endpoint.ts
550
1940
  var DEFAULT_MAESTTRO_URL = "http://100.64.0.2";
551
1941
  var ORCHESTRATOR_MODEL = "maesttro";
@@ -758,11 +2148,11 @@ function readHostFile({
758
2148
  parse,
759
2149
  warn
760
2150
  }) {
761
- const path = join2(home, file);
762
- if (!existsSync(path))
2151
+ const path = join9(home, file);
2152
+ if (!existsSync2(path))
763
2153
  return {};
764
2154
  try {
765
- return parse(readFileSync3(path, "utf8"));
2155
+ return parse(readFileSync9(path, "utf8"));
766
2156
  } catch {
767
2157
  warn(`maesttro: ignoring your ~/${file}, it doesn't parse`);
768
2158
  return {};
@@ -794,66 +2184,46 @@ function buildClientConfigs({
794
2184
  removed: opencode.removed.map((path) => `opencode ${path}`)
795
2185
  };
796
2186
  }
797
- function writeClientConfigDir(files, parent = tmpdir()) {
798
- const root = mkdtempSync(join2(parent, "maesttro-clients-"));
799
- chmodSync2(root, 448);
800
- const dir = join2(root, "home");
2187
+ function writeClientConfigDir(files, parent = tmpdir2()) {
2188
+ const root = mkdtempSync2(join9(parent, "maesttro-clients-"));
2189
+ chmodSync4(root, 448);
2190
+ const dir = join9(root, "home");
801
2191
  for (const [file, content] of Object.entries(files)) {
802
- const path = join2(dir, file);
803
- mkdirSync3(dirname3(path), { recursive: true, mode: 493 });
804
- writeFileSync3(path, content);
805
- chmodSync2(path, 420);
2192
+ const path = join9(dir, file);
2193
+ mkdirSync8(dirname5(path), { recursive: true, mode: 493 });
2194
+ writeFileSync8(path, content);
2195
+ chmodSync4(path, 420);
806
2196
  }
807
- chmodSync2(dir, 493);
808
- return { dir, remove: () => rmSync2(root, { recursive: true, force: true }) };
2197
+ chmodSync4(dir, 493);
2198
+ return { dir, remove: () => rmSync6(root, { recursive: true, force: true }) };
809
2199
  }
810
2200
  function installClientConfigs({
811
2201
  home,
812
2202
  source = CONTAINER_CLIENT_CONFIGS
813
2203
  }) {
814
- if (!existsSync(source))
2204
+ if (!existsSync2(source))
815
2205
  return false;
816
- const merged = MERGED_INTO_HOME.map((file) => join2(source, file));
2206
+ const merged = MERGED_INTO_HOME.map((file) => join9(source, file));
817
2207
  cpSync(source, home, {
818
2208
  recursive: true,
819
2209
  force: true,
820
2210
  filter: (path) => !merged.includes(path)
821
2211
  });
822
2212
  for (const file of MERGED_INTO_HOME) {
823
- const staged = join2(source, file);
824
- if (!existsSync(staged))
2213
+ const staged = join9(source, file);
2214
+ if (!existsSync2(staged))
825
2215
  continue;
826
- const target = join2(home, file);
2216
+ const target = join9(home, file);
827
2217
  writeJson(target, mergeJson(readJson(target), readJson(staged)));
828
2218
  }
829
2219
  return true;
830
2220
  }
831
2221
  function injectOpencodeTuiState({ home }) {
832
- const path = join2(home, OPENCODE_TUI_STATE);
2222
+ const path = join9(home, OPENCODE_TUI_STATE);
833
2223
  writeJson(path, mergeJson(readJson(path), CURATED_OPENCODE_TUI_STATE));
834
2224
  return path;
835
2225
  }
836
2226
 
837
- // src/clients.ts
838
- var CLIENTS = {
839
- claude: {
840
- label: "Claude Code",
841
- npmPackage: "@anthropic-ai/claude-code",
842
- binary: "claude",
843
- configDir: "/home/agent/.claude"
844
- },
845
- opencode: {
846
- label: "opencode",
847
- npmPackage: "opencode-ai",
848
- binary: "opencode",
849
- configDir: "/home/agent/.config/opencode"
850
- }
851
- };
852
- var DEFAULT_CLIENT = "opencode";
853
- function isClientKey(value) {
854
- return Object.hasOwn(CLIENTS, value);
855
- }
856
-
857
2227
  // src/host-instructions.ts
858
2228
  var HOST_STAGING_DIR = "/opt/maesttro-host";
859
2229
  var STAGED_DIRECTORIES = [
@@ -867,14 +2237,6 @@ var INSTRUCTION_FILES = [
867
2237
  ];
868
2238
  var STAGED_PATHS = [...INSTRUCTION_FILES, ...STAGED_DIRECTORIES];
869
2239
 
870
- // src/paths.ts
871
- import { dirname as dirname4, join as join3, resolve } from "node:path";
872
- import { fileURLToPath } from "node:url";
873
- var PACKAGE_ROOT = resolve(dirname4(fileURLToPath(import.meta.url)), "..");
874
- var DOCKERFILE_DIR = join3(PACKAGE_ROOT, "docker");
875
- var IMAGE = "maesttro-runner:latest";
876
- var VOLUME = process.env.MAESTTRO_VOLUME?.trim() || "maesttro-home";
877
-
878
2240
  // src/runner-context.ts
879
2241
  function githubMode(value) {
880
2242
  return value === "on" || value === "strict" ? value : "off";
@@ -927,7 +2289,7 @@ function runnerInstructions(context) {
927
2289
  "",
928
2290
  "- Preinstalled: Node 24 (npm, pnpm, yarn), TypeScript (`tsc`), Bun, Python 3.14 and uv (`uv venv`, `uv pip`, `uv tool`; plain `pip` and `python -m venv` work too), the .NET 10 SDK, a C/C++ build toolchain, and common CLI tools (jq, ripgrep, fd, unzip, zip, sqlite3).",
929
2291
  "- You can install more yourself without root: `npm i -g`, `bun add -g`, `pip install`, `uv tool install`, `uv python install`, `dotnet tool install -g`. They work as usual and are on `PATH` right away.",
930
- "- Installs last only until this container exits; install again when a new session needs them. Don't install into your home directory to make them stick.",
2292
+ "- Installs last only until this container exits; install again when a new session needs them. Your home directory belongs to this session: it survives `maesttro resume` but no other session sees it.",
931
2293
  "- System packages are unavailable: there is no root, `sudo` or `apt`. If a task needs one, tell the user which package and why.",
932
2294
  "",
933
2295
  "## Pushing",
@@ -954,24 +2316,24 @@ function withoutComments(value) {
954
2316
  }
955
2317
  function applyProviderConfigs({
956
2318
  home,
957
- configsDir = join4(DOCKERFILE_DIR, "configs")
2319
+ configsDir = join10(DOCKERFILE_DIR, "configs")
958
2320
  }) {
959
2321
  const written = [];
960
2322
  for (const [key, client] of Object.entries(CLIENTS)) {
961
- const source = join4(configsDir, key);
962
- if (!existsSync2(source))
2323
+ const source = join10(configsDir, key);
2324
+ if (!existsSync3(source))
963
2325
  continue;
964
- const targetDir = join4(home, client.configDir.replace(/^\/home\/agent\/?/, ""));
965
- for (const name of readdirSync(source)) {
2326
+ const targetDir = join10(home, client.configDir.replace(/^\/home\/agent\/?/, ""));
2327
+ for (const name of readdirSync3(source)) {
966
2328
  if (!name.endsWith(".json"))
967
2329
  continue;
968
- const curated = withoutComments(readJson(join4(source, name)));
2330
+ const curated = withoutComments(readJson(join10(source, name)));
969
2331
  if (Object.keys(curated).length === 0)
970
2332
  continue;
971
- const target = join4(targetDir, name);
972
- mkdirSync4(targetDir, { recursive: true });
2333
+ const target = join10(targetDir, name);
2334
+ mkdirSync9(targetDir, { recursive: true });
973
2335
  const merged = mergeJson(readJson(target), curated);
974
- writeFileSync4(target, `${JSON.stringify(merged, null, 2)}
2336
+ writeFileSync9(target, `${JSON.stringify(merged, null, 2)}
975
2337
  `);
976
2338
  written.push(target);
977
2339
  }
@@ -984,27 +2346,27 @@ function stageHostInstructions({
984
2346
  context
985
2347
  }) {
986
2348
  for (const directory of STAGED_DIRECTORIES) {
987
- const source = join4(staging, directory);
988
- if (!existsSync2(source))
2349
+ const source = join10(staging, directory);
2350
+ if (!existsSync3(source))
989
2351
  continue;
990
2352
  try {
991
- cpSync2(source, join4(home, directory), { recursive: true, force: true });
2353
+ cpSync2(source, join10(home, directory), { recursive: true, force: true });
992
2354
  } catch {}
993
2355
  }
994
2356
  const instructions = runnerInstructions(context);
995
2357
  for (const file of INSTRUCTION_FILES) {
996
- const source = join4(staging, file);
997
- const hostCopy = existsSync2(source) ? readFileSync4(source, "utf8") : "";
998
- const target = join4(home, file);
999
- mkdirSync4(dirname5(target), { recursive: true });
1000
- writeFileSync4(target, hostCopy ? `${hostCopy.trimEnd()}
2358
+ const source = join10(staging, file);
2359
+ const hostCopy = existsSync3(source) ? readFileSync10(source, "utf8") : "";
2360
+ const target = join10(home, file);
2361
+ mkdirSync9(dirname6(target), { recursive: true });
2362
+ writeFileSync9(target, hostCopy ? `${hostCopy.trimEnd()}
1001
2363
 
1002
2364
  ${instructions}` : instructions);
1003
2365
  }
1004
2366
  }
1005
2367
  function configure({
1006
2368
  home = homedir2(),
1007
- inRunner = existsSync2(RUNNER_MARKER),
2369
+ inRunner = existsSync3(RUNNER_MARKER),
1008
2370
  env = process.env,
1009
2371
  staging = HOST_STAGING_DIR,
1010
2372
  clientConfigs
@@ -1013,6 +2375,7 @@ function configure({
1013
2375
  if (!inRunner)
1014
2376
  return;
1015
2377
  installClientConfigs({ home, source: clientConfigs });
2378
+ seedClaudeState({ home });
1016
2379
  injectOpencodeTuiState({ home });
1017
2380
  stageHostInstructions({
1018
2381
  home,
@@ -1095,52 +2458,6 @@ async function revokeSessionToken(token, fetchImpl = fetch, apiUrl = githubApiUr
1095
2458
  } catch {}
1096
2459
  }
1097
2460
 
1098
- // src/host-identity.ts
1099
- function hostIdentityEnv() {
1100
- if (typeof process.getuid !== "function" || typeof process.getgid !== "function") {
1101
- return {};
1102
- }
1103
- return {
1104
- MAESTTRO_HOST_UID: String(process.getuid()),
1105
- MAESTTRO_HOST_GID: String(process.getgid())
1106
- };
1107
- }
1108
- function hostIdentityFlags() {
1109
- return Object.entries(hostIdentityEnv()).map(([name, value]) => `-e ${name}=${value}`).join(" ");
1110
- }
1111
-
1112
- // src/legacy-token.ts
1113
- import { execSync } from "node:child_process";
1114
- var LEGACY_HOSTS = "~/.config/gh/hosts.yml";
1115
- var PURGE_LEGACY_TOKEN = [
1116
- `if [ -e ${LEGACY_HOSTS} ]; then rm -f ${LEGACY_HOSTS}; echo FOUND; fi`,
1117
- "git config --global --remove-section credential.https://github.com 2>/dev/null || true",
1118
- "git config --global --remove-section credential.https://gist.github.com 2>/dev/null || true"
1119
- ].join("; ");
1120
- var DETECT_LEGACY_TOKEN = `if [ -e ${LEGACY_HOSTS} ]; then echo FOUND; else echo CLEAN; fi`;
1121
- function inVolume(command) {
1122
- return execSync(`docker run --rm ${hostIdentityFlags()} -v ${VOLUME}:/home/agent ${IMAGE} "${command}"`).toString();
1123
- }
1124
- function purgeLegacyToken() {
1125
- return inVolume(PURGE_LEGACY_TOKEN).includes("FOUND");
1126
- }
1127
- function hasLegacyToken() {
1128
- return inVolume(DETECT_LEGACY_TOKEN).includes("FOUND");
1129
- }
1130
- var REVOKE_NOTICE = "Removed a long-lived GitHub token that an older maesttro stored in the runner volume. Revoke it on GitHub too: https://github.com/settings/tokens?type=beta";
1131
- function purgeLegacyTokenOnce({
1132
- config,
1133
- saveConfig,
1134
- purge = purgeLegacyToken,
1135
- announce
1136
- }) {
1137
- if (config.legacyTokenPurged)
1138
- return;
1139
- if (purge())
1140
- announce(REVOKE_NOTICE);
1141
- saveConfig({ ...config, legacyTokenPurged: true });
1142
- }
1143
-
1144
2461
  // src/network-probe.ts
1145
2462
  import { connect } from "node:net";
1146
2463
  async function reachable(host, port, {
@@ -1166,8 +2483,8 @@ async function reachable(host, port, {
1166
2483
  }
1167
2484
 
1168
2485
  // src/repo.ts
1169
- import { appendFileSync, mkdirSync as mkdirSync5, realpathSync } from "node:fs";
1170
- import { join as join5 } from "node:path";
2486
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync10, realpathSync } from "node:fs";
2487
+ import { join as join11 } from "node:path";
1171
2488
  var MINIMUM_GIT = [2, 48];
1172
2489
  var WORKTREES_DIR = ".worktrees";
1173
2490
 
@@ -1210,7 +2527,7 @@ function repoRoot(run, cwd) {
1210
2527
  "--git-common-dir"
1211
2528
  ]).out;
1212
2529
  const root = realpathSync(top.out);
1213
- if (realpathSync(commonDir) !== join5(root, ".git")) {
2530
+ if (realpathSync(commonDir) !== join11(root, ".git")) {
1214
2531
  throw new LaunchError("This is a git worktree. Run maesttro from the main checkout; each session gets its own isolated clone.");
1215
2532
  }
1216
2533
  if (realpathSync(cwd) !== root) {
@@ -1236,57 +2553,144 @@ function defaultBranch(run, root) {
1236
2553
  }
1237
2554
  throw new LaunchError("Can't find the default branch of origin. Run `git remote set-head origin --auto`.");
1238
2555
  }
1239
- function ensureWorktreesDir(run, root) {
1240
- mkdirSync5(join5(root, WORKTREES_DIR), { recursive: true });
1241
- if (git(run, root, ["check-ignore", "-q", `${WORKTREES_DIR}/`]).ok)
2556
+ function ensureWorktreesDir(run, root) {
2557
+ mkdirSync10(join11(root, WORKTREES_DIR), { recursive: true });
2558
+ if (git(run, root, ["check-ignore", "-q", `${WORKTREES_DIR}/`]).ok)
2559
+ return;
2560
+ const info = join11(root, ".git", "info");
2561
+ mkdirSync10(info, { recursive: true });
2562
+ appendFileSync2(join11(info, "exclude"), `
2563
+ ${WORKTREES_DIR}/
2564
+ `);
2565
+ }
2566
+ function snapshotRefs(run, root) {
2567
+ const listed = git(run, root, [
2568
+ "for-each-ref",
2569
+ "--format=%(refname) %(objectname)",
2570
+ "refs/heads",
2571
+ "refs/tags"
2572
+ ]);
2573
+ const refs = {};
2574
+ for (const line of listed.out.split(`
2575
+ `)) {
2576
+ const [name, sha] = line.split(" ");
2577
+ if (name && sha)
2578
+ refs[name] = sha;
2579
+ }
2580
+ return refs;
2581
+ }
2582
+ function revision(run, root, rev) {
2583
+ const parsed = git(run, root, [
2584
+ "rev-parse",
2585
+ "--verify",
2586
+ "--quiet",
2587
+ `${rev}^{commit}`
2588
+ ]);
2589
+ return parsed.ok ? parsed.out : undefined;
2590
+ }
2591
+
2592
+ // src/volume-migration.ts
2593
+ import { execSync } from "node:child_process";
2594
+ var LEGACY_HOSTS = "~/.config/gh/hosts.yml";
2595
+ var PURGE_LEGACY_TOKEN = [
2596
+ `if [ -e ${LEGACY_HOSTS} ]; then rm -f ${LEGACY_HOSTS}; echo FOUND; fi`,
2597
+ "git config --global --remove-section credential.https://github.com 2>/dev/null || true",
2598
+ "git config --global --remove-section credential.https://gist.github.com 2>/dev/null || true"
2599
+ ].join("; ");
2600
+ var REVOKE_NOTICE = "Removed a long-lived GitHub token that an older maesttro stored in the runner volume. Revoke it on GitHub too: https://github.com/settings/tokens?type=beta";
2601
+ var OLD_SESSIONS_RUNNING = `Old sessions still use ${LEGACY_VOLUME}; will move the Claude login once they exit.`;
2602
+ function dockerExec(command) {
2603
+ try {
2604
+ return execSync(command, { stdio: ["ignore", "pipe", "pipe"] }).toString();
2605
+ } catch (error) {
2606
+ const stderr = error?.stderr;
2607
+ throw new Error(stderr ? stderr.toString().trim() : error.message);
2608
+ }
2609
+ }
2610
+ function legacyVolumeExists({
2611
+ exec = dockerExec
2612
+ } = {}) {
2613
+ try {
2614
+ exec(`docker volume inspect ${LEGACY_VOLUME}`);
2615
+ return true;
2616
+ } catch (error) {
2617
+ if (error instanceof Error && /no such volume/i.test(error.message)) {
2618
+ return false;
2619
+ }
2620
+ throw error;
2621
+ }
2622
+ }
2623
+ function dockerVolume({
2624
+ exec = dockerExec
2625
+ } = {}) {
2626
+ const inVolume = (command) => exec(`docker run --rm ${hostIdentityFlags()} -v ${LEGACY_VOLUME}:/home/agent ${IMAGE} "${command}"`);
2627
+ return {
2628
+ exists: () => legacyVolumeExists({ exec }),
2629
+ inUse: () => exec(`docker ps -q --filter volume=${LEGACY_VOLUME}`).trim().length > 0,
2630
+ readClaudeCredentials: () => {
2631
+ const output = inVolume("cat ~/.claude/.credentials.json 2>/dev/null || echo null");
2632
+ try {
2633
+ return JSON.parse(output);
2634
+ } catch {
2635
+ return;
2636
+ }
2637
+ },
2638
+ purgeGitHubToken: () => inVolume(PURGE_LEGACY_TOKEN).includes("FOUND")
2639
+ };
2640
+ }
2641
+ function migrateFromVolumeOnce({
2642
+ config,
2643
+ saveConfig,
2644
+ store,
2645
+ volume = dockerVolume(),
2646
+ announce
2647
+ }) {
2648
+ if (config.volumeMigrated)
2649
+ return;
2650
+ try {
2651
+ if (volume.exists()) {
2652
+ if (!config.legacyTokenPurged && volume.purgeGitHubToken()) {
2653
+ announce(REVOKE_NOTICE);
2654
+ }
2655
+ if (volume.inUse()) {
2656
+ announce(OLD_SESSIONS_RUNNING);
2657
+ saveConfig({ ...config, legacyTokenPurged: true });
2658
+ return;
2659
+ }
2660
+ const credentials = volume.readClaudeCredentials();
2661
+ if (!store.load() && isMasterLogin(credentials)) {
2662
+ store.save(credentials);
2663
+ announce(`Moved the runner's Claude login into ${store.kind}.`);
2664
+ }
2665
+ announce(`Sessions no longer use the shared ${LEGACY_VOLUME} volume. Remove it with \`docker volume rm ${LEGACY_VOLUME}\` once nothing runs.`);
2666
+ }
2667
+ } catch {
2668
+ announce(`Couldn't check the legacy ${LEGACY_VOLUME} volume; will retry next launch.`);
1242
2669
  return;
1243
- const info = join5(root, ".git", "info");
1244
- mkdirSync5(info, { recursive: true });
1245
- appendFileSync(join5(info, "exclude"), `
1246
- ${WORKTREES_DIR}/
1247
- `);
1248
- }
1249
- function snapshotRefs(run, root) {
1250
- const listed = git(run, root, [
1251
- "for-each-ref",
1252
- "--format=%(refname) %(objectname)",
1253
- "refs/heads",
1254
- "refs/tags"
1255
- ]);
1256
- const refs = {};
1257
- for (const line of listed.out.split(`
1258
- `)) {
1259
- const [name, sha] = line.split(" ");
1260
- if (name && sha)
1261
- refs[name] = sha;
1262
2670
  }
1263
- return refs;
1264
- }
1265
- function revision(run, root, rev) {
1266
- const parsed = git(run, root, [
1267
- "rev-parse",
1268
- "--verify",
1269
- "--quiet",
1270
- `${rev}^{commit}`
1271
- ]);
1272
- return parsed.ok ? parsed.out : undefined;
2671
+ saveConfig({ ...config, volumeMigrated: true });
1273
2672
  }
1274
2673
 
1275
2674
  // src/doctor.ts
1276
- async function check(label, probe) {
2675
+ class DoctorWarning extends Error {
2676
+ }
2677
+ async function check(label, probe, print = console.log) {
1277
2678
  try {
1278
2679
  const detail = await probe();
1279
- console.log(` [ok] ${label}${detail ? ` — ${detail}` : ""}`);
2680
+ print(` [ok] ${label}${detail ? ` — ${detail}` : ""}`);
1280
2681
  return true;
1281
2682
  } catch (error) {
2683
+ if (error instanceof DoctorWarning) {
2684
+ print(` [warn] ${label} — ${error.message}`);
2685
+ return true;
2686
+ }
1282
2687
  const reason = error instanceof Error && error.message ? error.message : "";
1283
- console.log(` [fail] ${label}${reason ? ` — ${reason}` : ""}`);
2688
+ print(` [fail] ${label}${reason ? ` — ${reason}` : ""}`);
1284
2689
  return false;
1285
2690
  }
1286
2691
  }
1287
- function inRunner(command, { withVolume = true } = {}) {
1288
- const volume = withVolume ? `-v ${VOLUME}:/home/agent ` : "";
1289
- return execSync2(`docker run --rm ${hostIdentityFlags()} ${volume}${IMAGE} "${command}"`).toString();
2692
+ function inRunner(command) {
2693
+ return execSync2(`docker run --rm ${hostIdentityFlags()} ${IMAGE} "${command}"`).toString();
1290
2694
  }
1291
2695
  async function doctor() {
1292
2696
  console.log(`== maesttro doctor ==
@@ -1310,19 +2714,6 @@ async function doctor() {
1310
2714
  "Runner image built",
1311
2715
  () => `built ${execSync2(`docker image inspect ${IMAGE} --format "{{.Created}}"`).toString().trim()}`
1312
2716
  ],
1313
- [
1314
- "Persistent volume exists",
1315
- () => {
1316
- execSync2(`docker volume inspect ${VOLUME}`, { stdio: "ignore" });
1317
- }
1318
- ],
1319
- [
1320
- "No long-lived GitHub token in the volume",
1321
- () => {
1322
- if (hasLegacyToken())
1323
- throw new Error("run `maesttro setup` to remove it");
1324
- }
1325
- ],
1326
2717
  [
1327
2718
  "Logged in to GitHub",
1328
2719
  async () => {
@@ -1333,13 +2724,36 @@ async function doctor() {
1333
2724
  interactive: false
1334
2725
  });
1335
2726
  if (!token)
1336
- throw new Error("run `maesttro login`");
2727
+ throw new Error("run `maesttro login github`");
1337
2728
  const name = await githubLogin(token.access_token);
1338
2729
  if (!name)
1339
- throw new Error("token rejected, run `maesttro login`");
2730
+ throw new Error("token rejected, run `maesttro login github`");
1340
2731
  return name;
1341
2732
  }
1342
2733
  ],
2734
+ [
2735
+ "Logged in to Claude",
2736
+ () => {
2737
+ const credentials = claudeStore().load();
2738
+ if (!credentials)
2739
+ throw new Error("run `maesttro login claude`");
2740
+ if (claudeLoginState({ credentials, now: Date.now() }) === "expired") {
2741
+ throw new ClaudeLoginExpired;
2742
+ }
2743
+ const warning = claudeLoginWarning({ credentials, now: Date.now() });
2744
+ if (warning)
2745
+ throw new DoctorWarning(warning);
2746
+ return `access token until ${new Date(credentials.claudeAiOauth.expiresAt).toLocaleString()}`;
2747
+ }
2748
+ ],
2749
+ [
2750
+ "Old shared volume removed",
2751
+ () => {
2752
+ if (legacyVolumeExists()) {
2753
+ throw new Error(`docker volume rm ${LEGACY_VOLUME}`);
2754
+ }
2755
+ }
2756
+ ],
1343
2757
  [
1344
2758
  "Token broker reachable",
1345
2759
  async () => {
@@ -1371,7 +2785,7 @@ async function doctor() {
1371
2785
  ...Object.values(CLIENTS).map((client) => [
1372
2786
  `Client available: ${client.label}`,
1373
2787
  () => {
1374
- inRunner(`which ${client.binary}`, { withVolume: false });
2788
+ inRunner(`which ${client.binary}`);
1375
2789
  }
1376
2790
  ])
1377
2791
  ];
@@ -1395,7 +2809,9 @@ Usage:
1395
2809
  maesttro <client> Same, with a specific client — see \`maesttro list\`
1396
2810
  maesttro --strict [client]
1397
2811
  Same, in strict mode: no GitHub token reaches the runner, so \`gh\` is off
1398
- maesttro resume <id> Continue a kept session in its clone, in the mode it started with
2812
+ maesttro resume [client] <id>
2813
+ Continue a kept session in its clone, in the mode it started with,
2814
+ with its last client unless one is given
1399
2815
  (\`--strict\` or \`--no-strict\` after the id changes it)
1400
2816
  maesttro sessions List this repo's sessions
1401
2817
  maesttro sessions remove <id>
@@ -1406,74 +2822,35 @@ Usage:
1406
2822
  maesttro push [branch] [--force]
1407
2823
  Push a branch a session created, after showing what goes out
1408
2824
  maesttro list Show available clients
1409
- maesttro setup One-time: build image, create volume, log in to GitHub
1410
- maesttro login Log in to GitHub (device flow) so sessions get repo access
1411
- maesttro logout Remove the stored GitHub credentials
1412
- maesttro status Show GitHub login status
2825
+ maesttro setup One-time: build image, move off the old shared volume, log in to GitHub and Claude
2826
+ maesttro login [github|claude]
2827
+ Log in to GitHub (device flow) and/or Claude; without a target, does both
2828
+ maesttro logout [github|claude]
2829
+ Remove the stored credentials; without a target, does both
2830
+ maesttro status [github|claude]
2831
+ Show login status; without a target, does both
1413
2832
  maesttro configure Apply maesttro's client configs (also runs inside the runner)
1414
2833
  maesttro update Update the package and rebuild the runner image with refreshed client configs
1415
- maesttro doctor Health check — Docker, image, volume, GitHub login, broker, isolation, clients
2834
+ maesttro doctor Health check — Docker, image, GitHub and Claude logins, old volume gone, broker, isolation, clients
1416
2835
  maesttro help Show this message
1417
2836
  `;
1418
2837
 
1419
2838
  // src/launch.ts
1420
- import { execSync as execSync3, spawn, spawnSync as spawnSync2 } from "node:child_process";
1421
- import { existsSync as existsSync4, rmSync as rmSync6 } from "node:fs";
1422
- import { constants, homedir as homedir4 } from "node:os";
1423
- import { join as join11 } from "node:path";
2839
+ import { execSync as execSync3, spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
2840
+ import { existsSync as existsSync5, rmSync as rmSync9 } from "node:fs";
2841
+ import { constants as constants2, homedir as homedir3 } from "node:os";
2842
+ import { join as join15 } from "node:path";
1424
2843
 
1425
2844
  // src/backup.ts
1426
2845
  import { createHash } from "node:crypto";
1427
- import { mkdirSync as mkdirSync7, readdirSync as readdirSync2, rmSync as rmSync3 } from "node:fs";
1428
- import { basename, join as join7 } from "node:path";
1429
-
1430
- // src/maesttro-home.ts
1431
- import {
1432
- appendFileSync as appendFileSync2,
1433
- mkdirSync as mkdirSync6,
1434
- readFileSync as readFileSync5,
1435
- renameSync as renameSync2,
1436
- writeFileSync as writeFileSync5
1437
- } from "node:fs";
1438
- import { homedir as homedir3 } from "node:os";
1439
- import { join as join6 } from "node:path";
1440
- function maesttroHome() {
1441
- return join6(homedir3(), ".config", "maesttro");
1442
- }
1443
- function configPath() {
1444
- return join6(maesttroHome(), "config.json");
1445
- }
1446
- function readMaesttroConfig() {
1447
- try {
1448
- return JSON.parse(readFileSync5(configPath(), "utf8"));
1449
- } catch {
1450
- return {};
1451
- }
1452
- }
1453
- function writeMaesttroConfig(config) {
1454
- mkdirSync6(maesttroHome(), { recursive: true, mode: 448 });
1455
- const temporary = `${configPath()}.tmp`;
1456
- writeFileSync5(temporary, `${JSON.stringify(config, null, 2)}
1457
- `);
1458
- renameSync2(temporary, configPath());
1459
- }
1460
- function fileSessionLog(path = join6(maesttroHome(), "maesttro.log")) {
1461
- return (event, detail) => {
1462
- try {
1463
- mkdirSync6(maesttroHome(), { recursive: true, mode: 448 });
1464
- appendFileSync2(path, `${new Date().toISOString()} ${event}${detail ? ` ${detail}` : ""}
1465
- `);
1466
- } catch {}
1467
- };
1468
- }
1469
-
1470
- // src/backup.ts
2846
+ import { mkdirSync as mkdirSync11, readdirSync as readdirSync4, rmSync as rmSync7 } from "node:fs";
2847
+ import { basename, join as join12 } from "node:path";
1471
2848
  var BACKUPS_KEPT = 5;
1472
2849
  function shortHash(value) {
1473
2850
  return createHash("sha256").update(value).digest("hex").slice(0, 8);
1474
2851
  }
1475
2852
  function backupDir(repoRoot, home = maesttroHome()) {
1476
- return join7(home, "backups", `${basename(repoRoot)}-${shortHash(repoRoot)}`);
2853
+ return join12(home, "backups", `${basename(repoRoot)}-${shortHash(repoRoot)}`);
1477
2854
  }
1478
2855
  function refsFingerprint(refs) {
1479
2856
  const sorted = Object.keys(refs).sort().map((ref) => `${ref} ${refs[ref]}`);
@@ -1491,119 +2868,41 @@ function backupRepo({
1491
2868
  }) {
1492
2869
  if (Object.keys(refs).length === 0)
1493
2870
  return;
1494
- mkdirSync7(dir, { recursive: true, mode: 448 });
2871
+ mkdirSync11(dir, { recursive: true, mode: 448 });
1495
2872
  const fingerprint = refsFingerprint(refs);
1496
- const existing = readdirSync2(dir).filter((name) => name.endsWith(".bundle")).sort();
2873
+ const existing = readdirSync4(dir).filter((name) => name.endsWith(".bundle")).sort();
1497
2874
  const latest = existing.at(-1);
1498
2875
  if (latest?.endsWith(`-${fingerprint}.bundle`)) {
1499
- return { path: join7(dir, latest), reused: true };
2876
+ return { path: join12(dir, latest), reused: true };
1500
2877
  }
1501
2878
  const stamp = now.toISOString().replace(/[:.]/g, "-");
1502
- const path = join7(dir, `${stamp}-${sessionId}-${fingerprint}.bundle`);
2879
+ const path = join12(dir, `${stamp}-${sessionId}-${fingerprint}.bundle`);
1503
2880
  if (!git(run, repoRoot, ["bundle", "create", "--quiet", path, "--all"]).ok) {
1504
2881
  return;
1505
2882
  }
1506
2883
  for (const stale of [...existing, basename(path)].sort().slice(0, -keep)) {
1507
- rmSync3(join7(dir, stale), { force: true });
2884
+ rmSync7(join12(dir, stale), { force: true });
1508
2885
  }
1509
2886
  return { path, reused: false };
1510
2887
  }
1511
2888
 
1512
- // src/refresher.ts
1513
- var REFRESH_LEAD_MS = 10 * 60 * 1000;
1514
- var WALL_CLOCK_TICK_MS = 60 * 1000;
1515
- var FIRST_BACKOFF_MS = 5 * 1000;
1516
- var MAX_BACKOFF_MS = 5 * 60 * 1000;
1517
- var nodeTimers = {
1518
- setTimeout: (callback, ms) => setTimeout(callback, ms).unref(),
1519
- clearTimeout: (handle) => clearTimeout(handle),
1520
- setInterval: (callback, ms) => setInterval(callback, ms).unref(),
1521
- clearInterval: (handle) => clearInterval(handle)
1522
- };
1523
- function startRefresher({
1524
- initial,
1525
- refresh,
1526
- onRefreshed,
1527
- onFailed,
1528
- now = Date.now,
1529
- timers = nodeTimers
1530
- }) {
1531
- let current = initial;
1532
- let nextAttemptAt = 0;
1533
- let backoffMs = FIRST_BACKOFF_MS;
1534
- let inFlight;
1535
- let stopped = false;
1536
- let pending;
1537
- function refreshDueAt(token) {
1538
- const expiresAt = Date.parse(token.expires_at);
1539
- const halfRemaining = (expiresAt - now()) / 2;
1540
- return expiresAt - Math.min(REFRESH_LEAD_MS, Math.max(halfRemaining, 0));
1541
- }
1542
- nextAttemptAt = refreshDueAt(initial);
1543
- function scheduleAt(at) {
1544
- timers.clearTimeout(pending);
1545
- pending = timers.setTimeout(check, Math.max(at - now(), 0));
1546
- }
1547
- async function attempt() {
1548
- try {
1549
- const token = await refresh();
1550
- if (stopped)
1551
- return;
1552
- current = token;
1553
- backoffMs = FIRST_BACKOFF_MS;
1554
- nextAttemptAt = refreshDueAt(token);
1555
- onRefreshed(token);
1556
- } catch (error) {
1557
- if (stopped)
1558
- return;
1559
- nextAttemptAt = now() + backoffMs;
1560
- onFailed(error, backoffMs);
1561
- backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
1562
- }
1563
- scheduleAt(nextAttemptAt);
1564
- }
1565
- function check() {
1566
- if (stopped)
1567
- return Promise.resolve();
1568
- if (inFlight)
1569
- return inFlight;
1570
- if (now() < nextAttemptAt)
1571
- return Promise.resolve();
1572
- inFlight = attempt().finally(() => {
1573
- inFlight = undefined;
1574
- });
1575
- return inFlight;
1576
- }
1577
- const tick = timers.setInterval(check, WALL_CLOCK_TICK_MS);
1578
- scheduleAt(nextAttemptAt);
1579
- return {
1580
- current: () => current,
1581
- check,
1582
- stop: () => {
1583
- stopped = true;
1584
- timers.clearTimeout(pending);
1585
- timers.clearInterval(tick);
1586
- }
1587
- };
1588
- }
1589
-
1590
2889
  // src/session-dir.ts
1591
2890
  import {
1592
- chmodSync as chmodSync3,
1593
- mkdirSync as mkdirSync8,
1594
- mkdtempSync as mkdtempSync2,
1595
- renameSync as renameSync3,
1596
- rmSync as rmSync4,
1597
- writeFileSync as writeFileSync6
2891
+ chmodSync as chmodSync5,
2892
+ mkdirSync as mkdirSync12,
2893
+ mkdtempSync as mkdtempSync3,
2894
+ renameSync as renameSync7,
2895
+ rmSync as rmSync8,
2896
+ writeFileSync as writeFileSync10
1598
2897
  } from "node:fs";
1599
- import { tmpdir as tmpdir2 } from "node:os";
1600
- import { join as join8 } from "node:path";
2898
+ import { tmpdir as tmpdir3 } from "node:os";
2899
+ import { join as join13 } from "node:path";
1601
2900
  var CONTAINER_GH_CONFIG_DIR = "/home/agent/.config/gh-maesttro";
1602
2901
  function writeAtomically(path, content) {
1603
2902
  const temporary = `${path}.tmp`;
1604
- writeFileSync6(temporary, content, { mode: 420 });
1605
- chmodSync3(temporary, 420);
1606
- renameSync3(temporary, path);
2903
+ writeFileSync10(temporary, content, { mode: 420 });
2904
+ chmodSync5(temporary, 420);
2905
+ renameSync7(temporary, path);
1607
2906
  }
1608
2907
  function hostsYaml(token, user) {
1609
2908
  return [
@@ -1615,20 +2914,20 @@ function hostsYaml(token, user) {
1615
2914
  ].join(`
1616
2915
  `);
1617
2916
  }
1618
- function createSessionDir(parent = tmpdir2()) {
1619
- const root = mkdtempSync2(join8(parent, "maesttro-"));
1620
- chmodSync3(root, 448);
1621
- const ghDir = join8(root, "gh");
1622
- mkdirSync8(ghDir);
1623
- chmodSync3(ghDir, 493);
1624
- writeAtomically(join8(ghDir, "config.yml"), `version: "1"
2917
+ function createSessionDir(parent = tmpdir3()) {
2918
+ const root = mkdtempSync3(join13(parent, "maesttro-"));
2919
+ chmodSync5(root, 448);
2920
+ const ghDir = join13(root, "gh");
2921
+ mkdirSync12(ghDir);
2922
+ chmodSync5(ghDir, 493);
2923
+ writeAtomically(join13(ghDir, "config.yml"), `version: "1"
1625
2924
  `);
1626
2925
  return {
1627
2926
  ghDir,
1628
- writeToken: (token, user) => writeAtomically(join8(ghDir, "hosts.yml"), hostsYaml(token, user)),
1629
- writeStatus: (status) => writeAtomically(join8(ghDir, "status"), `${status}
2927
+ writeToken: (token, user) => writeAtomically(join13(ghDir, "hosts.yml"), hostsYaml(token, user)),
2928
+ writeStatus: (status) => writeAtomically(join13(ghDir, "status"), `${status}
1630
2929
  `),
1631
- remove: () => rmSync4(root, { recursive: true, force: true })
2930
+ remove: () => rmSync8(root, { recursive: true, force: true })
1632
2931
  };
1633
2932
  }
1634
2933
 
@@ -1646,7 +2945,7 @@ function refusalNotice(code, repo) {
1646
2945
  return `${UNAVAILABLE}: your GitHub account cannot see ${repo} — ${WITHOUT}`;
1647
2946
  case "user_token_invalid":
1648
2947
  case "not_logged_in":
1649
- return `${UNAVAILABLE}: GitHub login expired, run \`maesttro login\` — ${WITHOUT}`;
2948
+ return `${UNAVAILABLE}: GitHub login expired, run \`maesttro login github\` — ${WITHOUT}`;
1650
2949
  default:
1651
2950
  return `${UNAVAILABLE} (${code}) — ${WITHOUT}`;
1652
2951
  }
@@ -1733,6 +3032,7 @@ async function openGitHubSession({
1733
3032
  log("session.token_minted", `${repo} expires ${initial.expires_at}`);
1734
3033
  const refresher = startRefresher({
1735
3034
  initial,
3035
+ expiresAtOf: (token) => Date.parse(token.expires_at),
1736
3036
  refresh: () => mint({ interactive: false }),
1737
3037
  onRefreshed: (token) => {
1738
3038
  publish(token);
@@ -1779,14 +3079,14 @@ function ask(question) {
1779
3079
  // src/session-clone.ts
1780
3080
  import {
1781
3081
  copyFileSync,
1782
- existsSync as existsSync3,
1783
- lstatSync,
1784
- mkdirSync as mkdirSync9,
1785
- readdirSync as readdirSync3,
1786
- readFileSync as readFileSync6,
1787
- writeFileSync as writeFileSync7
3082
+ existsSync as existsSync4,
3083
+ lstatSync as lstatSync4,
3084
+ mkdirSync as mkdirSync13,
3085
+ readdirSync as readdirSync5,
3086
+ readFileSync as readFileSync11,
3087
+ writeFileSync as writeFileSync11
1788
3088
  } from "node:fs";
1789
- import { join as join9 } from "node:path";
3089
+ import { join as join14 } from "node:path";
1790
3090
 
1791
3091
  // src/sanitize.ts
1792
3092
  function stripControl(text) {
@@ -1873,27 +3173,27 @@ function createSessionClone(run, root, clonePath, baseSha) {
1873
3173
  }
1874
3174
  function realDir(path) {
1875
3175
  try {
1876
- return lstatSync(path).isDirectory();
3176
+ return lstatSync4(path).isDirectory();
1877
3177
  } catch {
1878
3178
  return false;
1879
3179
  }
1880
3180
  }
1881
3181
  function dirents(dir) {
1882
3182
  try {
1883
- return readdirSync3(dir, { withFileTypes: true });
3183
+ return readdirSync5(dir, { withFileTypes: true });
1884
3184
  } catch {
1885
3185
  return [];
1886
3186
  }
1887
3187
  }
1888
3188
  function cloneGitDir(clonePath) {
1889
- const gitDir = join9(clonePath, ".git");
3189
+ const gitDir = join14(clonePath, ".git");
1890
3190
  return realDir(gitDir) ? gitDir : undefined;
1891
3191
  }
1892
3192
  function readSha(path) {
1893
3193
  try {
1894
- if (!lstatSync(path).isFile())
3194
+ if (!lstatSync4(path).isFile())
1895
3195
  return;
1896
- const text = readFileSync6(path, "utf8").trim();
3196
+ const text = readFileSync11(path, "utf8").trim();
1897
3197
  return SHA.test(text) ? text : undefined;
1898
3198
  } catch {
1899
3199
  return;
@@ -1903,7 +3203,7 @@ function packedRefs(gitDir) {
1903
3203
  const refs = new Map;
1904
3204
  let text;
1905
3205
  try {
1906
- text = readFileSync6(join9(gitDir, "packed-refs"), "utf8");
3206
+ text = readFileSync11(join14(gitDir, "packed-refs"), "utf8");
1907
3207
  } catch {
1908
3208
  return refs;
1909
3209
  }
@@ -1928,12 +3228,12 @@ function cloneBranches(clonePath) {
1928
3228
  continue;
1929
3229
  const name = prefix ? `${prefix}/${entry.name}` : entry.name;
1930
3230
  if (entry.isDirectory())
1931
- walk(join9(dir, entry.name), name);
3231
+ walk(join14(dir, entry.name), name);
1932
3232
  else if (entry.isFile())
1933
3233
  names.add(name);
1934
3234
  }
1935
3235
  };
1936
- walk(join9(gitDir, "refs", "heads"), "");
3236
+ walk(join14(gitDir, "refs", "heads"), "");
1937
3237
  for (const ref of packedRefs(gitDir).keys()) {
1938
3238
  if (ref.startsWith(HEADS_PREFIX))
1939
3239
  names.add(ref.slice(HEADS_PREFIX.length));
@@ -1946,7 +3246,7 @@ function branchTip(clonePath, branch) {
1946
3246
  const gitDir = cloneGitDir(clonePath);
1947
3247
  if (!gitDir)
1948
3248
  return;
1949
- return readSha(join9(gitDir, "refs", "heads", branch)) ?? packedRefs(gitDir).get(`${HEADS_PREFIX}${branch}`);
3249
+ return readSha(join14(gitDir, "refs", "heads", branch)) ?? packedRefs(gitDir).get(`${HEADS_PREFIX}${branch}`);
1950
3250
  }
1951
3251
  function cloneHead(clonePath) {
1952
3252
  const gitDir = cloneGitDir(clonePath);
@@ -1954,9 +3254,9 @@ function cloneHead(clonePath) {
1954
3254
  return;
1955
3255
  let text;
1956
3256
  try {
1957
- if (!lstatSync(join9(gitDir, "HEAD")).isFile())
3257
+ if (!lstatSync4(join14(gitDir, "HEAD")).isFile())
1958
3258
  return;
1959
- text = readFileSync6(join9(gitDir, "HEAD"), "utf8").trim();
3259
+ text = readFileSync11(join14(gitDir, "HEAD"), "utf8").trim();
1960
3260
  } catch {
1961
3261
  return;
1962
3262
  }
@@ -1977,29 +3277,29 @@ function copyObjectFiles(fromDir, intoDir) {
1977
3277
  for (const entry of dirents(fromDir)) {
1978
3278
  if (!entry.isFile())
1979
3279
  continue;
1980
- const dest = join9(intoDir, entry.name);
1981
- if (existsSync3(dest))
3280
+ const dest = join14(intoDir, entry.name);
3281
+ if (existsSync4(dest))
1982
3282
  continue;
1983
3283
  if (!created) {
1984
- mkdirSync9(intoDir, { recursive: true });
3284
+ mkdirSync13(intoDir, { recursive: true });
1985
3285
  created = true;
1986
3286
  }
1987
- copyFileSync(join9(fromDir, entry.name), dest);
3287
+ copyFileSync(join14(fromDir, entry.name), dest);
1988
3288
  }
1989
3289
  }
1990
3290
  function pinAlternates(clonePath, containerObjects) {
1991
3291
  const gitDir = cloneGitDir(clonePath);
1992
3292
  if (!gitDir)
1993
3293
  return;
1994
- const objects = join9(gitDir, "objects");
1995
- const info = join9(objects, "info");
3294
+ const objects = join14(gitDir, "objects");
3295
+ const info = join14(objects, "info");
1996
3296
  if (!realDir(objects) || !realDir(info)) {
1997
3297
  throw new LaunchError(`Unsafe objects tree in the session clone: ${clonePath}`);
1998
3298
  }
1999
- const alternates = join9(info, "alternates");
3299
+ const alternates = join14(info, "alternates");
2000
3300
  let stat;
2001
3301
  try {
2002
- stat = lstatSync(alternates);
3302
+ stat = lstatSync4(alternates);
2003
3303
  } catch {
2004
3304
  return;
2005
3305
  }
@@ -2008,73 +3308,29 @@ function pinAlternates(clonePath, containerObjects) {
2008
3308
  }
2009
3309
  const desired = `${containerObjects}
2010
3310
  `;
2011
- if (readFileSync6(alternates, "utf8") !== desired) {
2012
- writeFileSync7(alternates, desired);
3311
+ if (readFileSync11(alternates, "utf8") !== desired) {
3312
+ writeFileSync11(alternates, desired);
2013
3313
  }
2014
3314
  }
2015
3315
  function importObjects(run, root, clonePath, tip) {
2016
3316
  const gitDir = cloneGitDir(clonePath);
2017
3317
  if (!gitDir)
2018
3318
  return false;
2019
- const objects = join9(gitDir, "objects");
3319
+ const objects = join14(gitDir, "objects");
2020
3320
  if (!realDir(objects))
2021
3321
  return false;
2022
- const into = join9(root, ".git", "objects");
3322
+ const into = join14(root, ".git", "objects");
2023
3323
  for (const entry of dirents(objects)) {
2024
3324
  if (entry.name === "info" || entry.isSymbolicLink() || !entry.isDirectory()) {
2025
3325
  continue;
2026
3326
  }
2027
3327
  if (!OBJECT_SUBDIR.test(entry.name) && entry.name !== "pack")
2028
3328
  continue;
2029
- copyObjectFiles(join9(objects, entry.name), join9(into, entry.name));
3329
+ copyObjectFiles(join14(objects, entry.name), join14(into, entry.name));
2030
3330
  }
2031
3331
  return git(run, root, ["cat-file", "-e", `${tip}^{commit}`]).ok;
2032
3332
  }
2033
3333
 
2034
- // src/sessions.ts
2035
- import { randomBytes } from "node:crypto";
2036
- import {
2037
- mkdirSync as mkdirSync10,
2038
- readdirSync as readdirSync4,
2039
- readFileSync as readFileSync7,
2040
- renameSync as renameSync4,
2041
- rmSync as rmSync5,
2042
- writeFileSync as writeFileSync8
2043
- } from "node:fs";
2044
- import { join as join10 } from "node:path";
2045
- function newSessionId() {
2046
- return `maesttro-${randomBytes(4).toString("hex")}`;
2047
- }
2048
- function sessionsDir(home = maesttroHome()) {
2049
- return join10(home, "sessions");
2050
- }
2051
- function saveSession(record, dir = sessionsDir()) {
2052
- mkdirSync10(dir, { recursive: true, mode: 448 });
2053
- const path = join10(dir, `${record.id}.json`);
2054
- writeFileSync8(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
2055
- `);
2056
- renameSync4(`${path}.tmp`, path);
2057
- }
2058
- function readSession(id, dir = sessionsDir()) {
2059
- try {
2060
- return JSON.parse(readFileSync7(join10(dir, `${id}.json`), "utf8"));
2061
- } catch {
2062
- return;
2063
- }
2064
- }
2065
- function deleteSession(id, dir = sessionsDir()) {
2066
- rmSync5(join10(dir, `${id}.json`), { force: true });
2067
- }
2068
- function sessionsForRepo(repoRoot, dir = sessionsDir()) {
2069
- let names;
2070
- try {
2071
- names = readdirSync4(dir).filter((name) => name.endsWith(".json"));
2072
- } catch {
2073
- return [];
2074
- }
2075
- return names.map((name) => readSession(name.slice(0, -".json".length), dir)).filter((record) => record?.repoRoot === repoRoot).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2076
- }
2077
-
2078
3334
  // src/workspace-git.ts
2079
3335
  var GITHUB_REMOTE = /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/;
2080
3336
  function githubRepoFromRemote(remote) {
@@ -2102,9 +3358,18 @@ function workspaceAuthor(run, workspace) {
2102
3358
  }
2103
3359
 
2104
3360
  // src/launch.ts
2105
- var CONTAINER_REPO = "/workspace";
3361
+ var CONTAINER_REPO = CONTAINER_REPO_PATH;
2106
3362
  var CONTAINER_PACKAGE = "/opt/maesttro";
2107
3363
  var CONTAINER_GIT_OBJECTS = "/opt/maesttro-objects";
3364
+ function sessionClientArgs({
3365
+ args,
3366
+ session
3367
+ }) {
3368
+ const chosen = args.length > 0 ? args : session.client ? [session.client] : [];
3369
+ const [first] = chosen;
3370
+ const client = first === undefined ? DEFAULT_CLIENT : isClientKey(first) ? first : undefined;
3371
+ return { args: chosen, client };
3372
+ }
2108
3373
  function resolveCommand(args) {
2109
3374
  const [first, ...rest] = args;
2110
3375
  if (first === undefined)
@@ -2123,16 +3388,19 @@ function sessionMounts({
2123
3388
  hostHome,
2124
3389
  stagedPaths,
2125
3390
  clientConfigs,
2126
- sharedSkills
3391
+ sharedSkills,
3392
+ home,
3393
+ claudeDir
2127
3394
  }) {
2128
- const objects = join11(repoRoot, ".git", "objects");
3395
+ const objects = join15(repoRoot, ".git", "objects");
2129
3396
  const mounts = [
2130
- { host: VOLUME, container: "/home/agent", type: "volume" },
3397
+ { host: home, container: "/home/agent" },
3398
+ { host: claudeDir, container: "/home/agent/.claude" },
2131
3399
  { host: objects, container: CONTAINER_GIT_OBJECTS, readOnly: true },
2132
3400
  { host: sessionDir, container: CONTAINER_REPO },
2133
3401
  { host: packageRoot, container: CONTAINER_PACKAGE, readOnly: true },
2134
3402
  ...stagedPaths.map((path) => ({
2135
- host: join11(hostHome, path),
3403
+ host: join15(hostHome, path),
2136
3404
  container: `${HOST_STAGING_DIR}/${path}`,
2137
3405
  readOnly: true
2138
3406
  })),
@@ -2158,9 +3426,9 @@ function dockerRunArgs({
2158
3426
  extra = [],
2159
3427
  name
2160
3428
  }) {
2161
- const volumes = mounts.flatMap(({ host, container, readOnly, type = "bind" }) => [
3429
+ const volumes = mounts.flatMap(({ host, container, readOnly }) => [
2162
3430
  "--mount",
2163
- `type=${type},source=${host},target=${container}${readOnly ? ",readonly" : ""}`
3431
+ `type=bind,source=${host},target=${container}${readOnly ? ",readonly" : ""}`
2164
3432
  ]);
2165
3433
  return [
2166
3434
  "run",
@@ -2189,17 +3457,54 @@ Default: ${DEFAULT_CLIENT}. Run \`maesttro <client>\` to pick one, or \`maesttro
2189
3457
  function ensureReady() {
2190
3458
  try {
2191
3459
  execSync3(`docker image inspect ${IMAGE}`, { stdio: "ignore" });
2192
- execSync3(`docker volume inspect ${VOLUME}`, { stdio: "ignore" });
2193
3460
  } catch {
2194
3461
  throw new LaunchError("Runner isn't set up yet. Run `maesttro setup` first.");
2195
3462
  }
2196
3463
  }
2197
3464
  function clearStaleContainer(id) {
2198
- spawnSync2("docker", ["rm", "-f", id], { stdio: "ignore" });
3465
+ spawnSync3("docker", ["rm", "-f", id], { stdio: "ignore" });
3466
+ }
3467
+ async function establishClaudeCredentials({
3468
+ claudeDir,
3469
+ store,
3470
+ refresh = refreshMaster,
3471
+ onRotated = (credentials) => pushToRunningSessions({ credentials, claudeDir, announce }),
3472
+ signals = processSignals,
3473
+ announce,
3474
+ now = Date.now
3475
+ }) {
3476
+ const credentials = store.load();
3477
+ if (credentials && claudeLoginState({ credentials, now: now() }) === "active" && isDue({ credentials, now: now() })) {
3478
+ announce("Refreshing Claude login…");
3479
+ }
3480
+ try {
3481
+ writeSessionCredentials({
3482
+ claudeDir,
3483
+ credentials: await refresh({ store, announce, onRotated, signals })
3484
+ });
3485
+ return {};
3486
+ } catch (error) {
3487
+ if (error instanceof ClaudeLoggedOut) {
3488
+ announce(error.message);
3489
+ removeHostWrittenCredentials({ claudeDir });
3490
+ return { failure: error.message };
3491
+ }
3492
+ if (error instanceof ClaudeLoginExpired || error instanceof ClaudeRefreshFailed) {
3493
+ announce(error.message);
3494
+ const master = store.load();
3495
+ if (master && master.claudeAiOauth.expiresAt > now()) {
3496
+ writeSessionCredentials({ claudeDir, credentials: master });
3497
+ } else if (error instanceof ClaudeLoginExpired) {
3498
+ removeHostWrittenCredentials({ claudeDir });
3499
+ }
3500
+ return { failure: error.message };
3501
+ }
3502
+ throw error;
3503
+ }
2199
3504
  }
2200
3505
  function sharedSkillsDir() {
2201
- const dir = process.env.AGENT_SHARED_SKILLS ?? join11(homedir4(), "agent-shared", "skills");
2202
- return existsSync4(dir) ? dir : undefined;
3506
+ const dir = process.env.AGENT_SHARED_SKILLS ?? join15(homedir3(), "agent-shared", "skills");
3507
+ return existsSync5(dir) ? dir : undefined;
2203
3508
  }
2204
3509
  async function resolveAuthor(workspace) {
2205
3510
  const fromRepo = workspaceAuthor(runCommand, workspace);
@@ -2228,7 +3533,7 @@ function startSession(run, root) {
2228
3533
  if (!baseSha)
2229
3534
  throw new LaunchError(`Can't resolve ${base}.`);
2230
3535
  const id = newSessionId();
2231
- const sessionDir = join11(root, WORKTREES_DIR, id);
3536
+ const sessionDir = join15(root, WORKTREES_DIR, id);
2232
3537
  createSessionClone(run, root, sessionDir, baseSha);
2233
3538
  return {
2234
3539
  id,
@@ -2245,7 +3550,7 @@ function resumeSession(root, id) {
2245
3550
  if (!session || session.repoRoot !== root) {
2246
3551
  throw new LaunchError(`No session ${id} in this repo. See \`maesttro sessions\`.`);
2247
3552
  }
2248
- if (!existsSync4(session.worktree)) {
3553
+ if (!existsSync5(session.worktree)) {
2249
3554
  throw new LaunchError(`The clone for ${id} is gone: ${session.worktree}`);
2250
3555
  }
2251
3556
  return session;
@@ -2268,13 +3573,13 @@ function preserveBranches(run, session) {
2268
3573
  return preserved;
2269
3574
  }
2270
3575
  function finishSession(run, session) {
2271
- if (!existsSync4(session.worktree)) {
3576
+ if (!existsSync5(session.worktree)) {
2272
3577
  deleteSession(session.id);
2273
3578
  return;
2274
3579
  }
2275
3580
  const preserved = preserveBranches(run, session);
2276
3581
  if (!cloneHasWork(session.worktree, session.baseSha)) {
2277
- rmSync6(session.worktree, { recursive: true, force: true });
3582
+ rmSync9(session.worktree, { recursive: true, force: true });
2278
3583
  deleteSession(session.id);
2279
3584
  return;
2280
3585
  }
@@ -2303,9 +3608,45 @@ function runnerGitHubMode({
2303
3608
  function exitCodeOf(code, signal) {
2304
3609
  if (code !== null)
2305
3610
  return code;
2306
- return signal ? 128 + (constants.signals[signal] ?? 0) : 1;
3611
+ return signal ? 128 + (constants2.signals[signal] ?? 0) : 1;
2307
3612
  }
2308
3613
  var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
3614
+ var ABANDON_GRACE_MS = 3000;
3615
+ function closeSession({
3616
+ run,
3617
+ session
3618
+ }) {
3619
+ try {
3620
+ finishSession(run, session);
3621
+ } catch (error) {
3622
+ console.log(`
3623
+ Couldn't clean up session ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
3624
+ }
3625
+ }
3626
+ function claudeNoticeSink({
3627
+ log,
3628
+ print = (message) => console.log(message)
3629
+ }) {
3630
+ let quiet = false;
3631
+ let unseen;
3632
+ return {
3633
+ announce: (message) => {
3634
+ if (!quiet)
3635
+ return print(message);
3636
+ log("claude.notice", message);
3637
+ unseen = message;
3638
+ },
3639
+ quiet: () => {
3640
+ quiet = true;
3641
+ },
3642
+ replay: () => {
3643
+ if (unseen)
3644
+ print(`
3645
+ ${unseen}`);
3646
+ unseen = undefined;
3647
+ }
3648
+ };
3649
+ }
2309
3650
  async function launch({
2310
3651
  args,
2311
3652
  resume,
@@ -2315,9 +3656,10 @@ async function launch({
2315
3656
  ensureReady();
2316
3657
  requireGit(run);
2317
3658
  const root = repoRoot(run, process.cwd());
2318
- purgeLegacyTokenOnce({
3659
+ migrateFromVolumeOnce({
2319
3660
  config: readMaesttroConfig(),
2320
3661
  saveConfig: writeMaesttroConfig,
3662
+ store: claudeStore(),
2321
3663
  announce: (message) => console.log(message)
2322
3664
  });
2323
3665
  const started = resume ? resumeSession(root, resume) : startSession(run, root);
@@ -2328,13 +3670,55 @@ async function launch({
2328
3670
  sessionId: started.id
2329
3671
  });
2330
3672
  const strict = isStrict(requestedStrict, started);
3673
+ const client = sessionClientArgs({ args, session: started });
2331
3674
  const saved = {
2332
3675
  ...started,
2333
3676
  backup: started.backup ?? backup?.path,
2334
- strict
3677
+ strict,
3678
+ client: client.client ?? started.client
2335
3679
  };
2336
3680
  saveSession(saved);
3681
+ let github;
3682
+ let claudeRefresher;
3683
+ let clientConfigDir;
3684
+ let abandoning = false;
3685
+ const interrupts = signalRouter({
3686
+ onUnrouted: async (signal) => {
3687
+ const code = exitCodeOf(null, signal);
3688
+ if (abandoning)
3689
+ process.exit(code);
3690
+ abandoning = true;
3691
+ console.log(`
3692
+ Interrupted; cleaning up session ${saved.id}…`);
3693
+ claudeRefresher?.stop();
3694
+ clientConfigDir?.remove();
3695
+ if (github) {
3696
+ await Promise.race([
3697
+ github.close(),
3698
+ new Promise((resolve) => setTimeout(resolve, ABANDON_GRACE_MS))
3699
+ ]);
3700
+ }
3701
+ closeSession({ run, session: saved });
3702
+ process.exit(code);
3703
+ }
3704
+ });
2337
3705
  console.log(`Session ${saved.id}${strict ? " (strict)" : ""}: clone .worktrees/${saved.id} at ${saved.base}${backup ? `, backup ${backup.reused ? "reused" : "written"}` : ""}.`);
3706
+ const { home, claudeDir } = ensureSessionHome({ id: saved.id });
3707
+ const claude = claudeStore();
3708
+ const sessionLog = fileSessionLog();
3709
+ const claudeNotices = claudeNoticeSink({ log: sessionLog });
3710
+ const { failure } = await establishClaudeCredentials({
3711
+ claudeDir,
3712
+ store: claude,
3713
+ announce: claudeNotices.announce,
3714
+ signals: interrupts
3715
+ });
3716
+ claudeRefresher = startClaudeRefresher({
3717
+ claudeDir,
3718
+ store: claude,
3719
+ announce: claudeNotices.announce,
3720
+ launchFailure: failure
3721
+ });
2338
3722
  const repo = workspaceRepo(run, root);
2339
3723
  if (repo && !resume) {
2340
3724
  git(run, saved.worktree, [
@@ -2344,7 +3728,7 @@ async function launch({
2344
3728
  `https://github.com/${repo}.git`
2345
3729
  ]);
2346
3730
  }
2347
- const github = await openGitHubSession({
3731
+ github = await openGitHubSession({
2348
3732
  repo,
2349
3733
  author: await resolveAuthor(root),
2350
3734
  strict,
@@ -2354,11 +3738,11 @@ async function launch({
2354
3738
  store: selectCredentialStore(),
2355
3739
  announce: (message) => console.log(message)
2356
3740
  },
2357
- log: fileSessionLog()
3741
+ log: sessionLog
2358
3742
  });
2359
3743
  if (github.notice)
2360
3744
  console.log(github.notice);
2361
- const hostHome = homedir4();
3745
+ const hostHome = homedir3();
2362
3746
  const url = maesttroUrl();
2363
3747
  const orchestratorWindow = await fetchOrchestratorWindow({ url });
2364
3748
  if (orchestratorWindow === undefined) {
@@ -2372,15 +3756,17 @@ async function launch({
2372
3756
  if (clientConfigs.removed.length > 0) {
2373
3757
  console.log(`Kept out of the runner as secrets: ${clientConfigs.removed.join(", ")}`);
2374
3758
  }
2375
- const clientConfigDir = writeClientConfigDir(clientConfigs.files);
3759
+ clientConfigDir = writeClientConfigDir(clientConfigs.files);
2376
3760
  const { mounts, workdir } = sessionMounts({
2377
3761
  repoRoot: root,
2378
3762
  sessionDir: saved.worktree,
2379
3763
  packageRoot: PACKAGE_ROOT,
2380
3764
  hostHome,
2381
- stagedPaths: STAGED_PATHS.filter((path) => existsSync4(join11(hostHome, path))),
3765
+ stagedPaths: STAGED_PATHS.filter((path) => existsSync5(join15(hostHome, path))),
2382
3766
  clientConfigs: clientConfigDir.dir,
2383
- sharedSkills: sharedSkillsDir()
3767
+ sharedSkills: sharedSkillsDir(),
3768
+ home,
3769
+ claudeDir
2384
3770
  });
2385
3771
  const sessionEnv = {
2386
3772
  ...github.env,
@@ -2399,13 +3785,15 @@ async function launch({
2399
3785
  ].concat(repo ? ["MAESTTRO_REPO"] : []).flatMap((name) => ["-e", name]);
2400
3786
  clearStaleContainer(saved.id);
2401
3787
  pinAlternates(saved.worktree, CONTAINER_GIT_OBJECTS);
2402
- const child = spawn("docker", dockerRunArgs({
2403
- command: containerCommand(resolveCommand(args)),
3788
+ const child = spawn2("docker", dockerRunArgs({
3789
+ command: containerCommand(resolveCommand(client.args)),
2404
3790
  mounts,
2405
3791
  workdir,
2406
3792
  name: saved.id,
2407
3793
  extra: [...github.dockerArgs, ...envFlags]
2408
3794
  }), { stdio: "inherit", env: { ...process.env, ...sessionEnv } });
3795
+ interrupts.dispose();
3796
+ claudeNotices.quiet();
2409
3797
  const forward = (signal) => child.kill(signal);
2410
3798
  for (const signal of FORWARDED_SIGNALS)
2411
3799
  process.on(signal, forward);
@@ -2416,13 +3804,18 @@ async function launch({
2416
3804
  for (const signal of FORWARDED_SIGNALS)
2417
3805
  process.off(signal, forward);
2418
3806
  await github.close();
3807
+ if (claudeRefresher?.busy())
3808
+ console.log(`
3809
+ ${FINISHING_BACKGROUND_REFRESH}`);
3810
+ await claudeRefresher?.stop();
3811
+ claudeNotices.replay();
2419
3812
  clientConfigDir.remove();
2420
- finishSession(run, saved);
3813
+ closeSession({ run, session: saved });
2421
3814
  process.exit(exitCode);
2422
3815
  }
2423
3816
 
2424
3817
  // src/push.ts
2425
- import { spawnSync as spawnSync3 } from "node:child_process";
3818
+ import { spawnSync as spawnSync4 } from "node:child_process";
2426
3819
 
2427
3820
  // ../node_modules/.bun/@clack+core@1.5.1/node_modules/@clack/core/dist/index.mjs
2428
3821
  import { stdout, stdin } from "node:process";
@@ -3614,7 +5007,7 @@ async function push({ args }) {
3614
5007
  return;
3615
5008
  }
3616
5009
  }
3617
- const pushed = spawnSync3("git", ["-C", root, "push", ...gitFlags, "origin", `${tip}:refs/heads/${branch}`], { stdio: "inherit" });
5010
+ const pushed = spawnSync4("git", ["-C", root, "push", ...gitFlags, "origin", `${tip}:refs/heads/${branch}`], { stdio: "inherit" });
3618
5011
  if (pushed.status === 0) {
3619
5012
  const done = `Pushed ${branch}. Base for comparison was ${baseSha.slice(0, 8)}.`;
3620
5013
  if (tty && !yes)
@@ -3626,7 +5019,7 @@ async function push({ args }) {
3626
5019
  }
3627
5020
 
3628
5021
  // src/sessions-command.ts
3629
- import { existsSync as existsSync5, rmSync as rmSync7 } from "node:fs";
5022
+ import { existsSync as existsSync6, rmSync as rmSync10 } from "node:fs";
3630
5023
  function parseRemoveArgs(args) {
3631
5024
  let all = false;
3632
5025
  let force = false;
@@ -3642,7 +5035,7 @@ function parseRemoveArgs(args) {
3642
5035
  return { all, force, id };
3643
5036
  }
3644
5037
  async function removeSession(run, session, force, ask, interactive) {
3645
- if (existsSync5(session.worktree)) {
5038
+ if (existsSync6(session.worktree)) {
3646
5039
  const preserved = preserveBranches(run, session);
3647
5040
  const head = cloneHead(session.worktree);
3648
5041
  const unbranched = preserved.length === 0 && head?.detached !== undefined && head.detached !== session.baseSha;
@@ -3660,7 +5053,7 @@ async function removeSession(run, session, force, ask, interactive) {
3660
5053
  return "kept";
3661
5054
  }
3662
5055
  }
3663
- rmSync7(session.worktree, { recursive: true, force: true });
5056
+ rmSync10(session.worktree, { recursive: true, force: true });
3664
5057
  const kept = preserved.length > 0 ? ` Its branches are kept as refs/maesttro/${session.id}/*.` : "";
3665
5058
  console.log(`Removed session ${session.id}.${kept}`);
3666
5059
  } else {
@@ -3717,7 +5110,7 @@ async function sessionsCommand({
3717
5110
  return;
3718
5111
  }
3719
5112
  for (const session of sessions) {
3720
- const branches = existsSync5(session.worktree) ? cloneBranches(session.worktree) : undefined;
5113
+ const branches = existsSync6(session.worktree) ? cloneBranches(session.worktree) : undefined;
3721
5114
  const where = branches === undefined ? "clone missing" : branches.length > 0 ? `branches: ${branches.join(", ")}` : "no branches yet";
3722
5115
  const mode = session.strict ? " strict" : "";
3723
5116
  console.log(` ${session.id} ${session.createdAt.slice(0, 16)} ${where}${mode}`);
@@ -3745,23 +5138,31 @@ async function setup({ args }) {
3745
5138
  console.log("-> Building runner image (this pulls node:22-slim + installs CLIs, first run only)...");
3746
5139
  run(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`);
3747
5140
  console.log(`
3748
- -> Ensuring persistent volume exists...`);
3749
- execSync4(`docker volume create ${VOLUME}`, { stdio: "ignore" });
3750
- console.log(`
3751
- -> Removing any long-lived GitHub token from the volume...`);
3752
- if (purgeLegacyToken())
3753
- console.log(` ${REVOKE_NOTICE}`);
3754
- writeMaesttroConfig({ ...readMaesttroConfig(), legacyTokenPurged: true });
5141
+ -> Checking for the old shared runner volume...`);
5142
+ migrateFromVolumeOnce({
5143
+ config: readMaesttroConfig(),
5144
+ saveConfig: writeMaesttroConfig,
5145
+ store: claudeStore(),
5146
+ announce: (message) => console.log(` ${message}`)
5147
+ });
3755
5148
  if (args[0]) {
3756
- console.log(" Ignoring the token argument: sessions now get short-lived tokens through `maesttro login`.");
5149
+ console.log(" Ignoring the token argument: sessions now get short-lived tokens through `maesttro login github`.");
3757
5150
  }
3758
5151
  console.log(`
3759
5152
  -> Logging in to GitHub...`);
3760
5153
  await ensureLogin();
3761
5154
  console.log(`
5155
+ -> Logging in to Claude...`);
5156
+ const claude = claudeStore();
5157
+ if (claudeLoginState({ credentials: claude.load(), now: Date.now() }) === "active") {
5158
+ console.log(` Already logged in. Stored in ${claude.kind}.`);
5159
+ } else if (!claudeLogin({ announce: (message) => console.log(` ${message}`) })) {
5160
+ console.log(" Setup continues without it; Claude Code sessions will show their own login screen.");
5161
+ }
5162
+ console.log(`
3762
5163
  -> Verifying isolation...`);
3763
5164
  console.log(" personal SSH key should NOT be visible:");
3764
- run(`docker run --rm ${hostIdentityFlags()} -v ${VOLUME}:/home/agent ${IMAGE} "cat ~/.ssh/id_rsa 2>&1 || true"`);
5165
+ run(`docker run --rm ${hostIdentityFlags()} ${IMAGE} "cat ~/.ssh/id_rsa 2>&1 || true"`);
3765
5166
  console.log("\nSetup complete. Run `maesttro` from any project directory to start a session,");
3766
5167
  console.log("or `maesttro list` to see available clients.");
3767
5168
  }
@@ -3843,15 +5244,15 @@ function removeStaleRunnerImages({
3843
5244
 
3844
5245
  // src/version-check.ts
3845
5246
  import { execFile } from "node:child_process";
3846
- import { existsSync as existsSync6, readFileSync as readFileSync8 } from "node:fs";
3847
- import { join as join12 } from "node:path";
5247
+ import { existsSync as existsSync7, readFileSync as readFileSync12 } from "node:fs";
5248
+ import { join as join16 } from "node:path";
3848
5249
  var LOOKUP_TIMEOUT_MS = 3000;
3849
5250
  function installedManifest() {
3850
- return JSON.parse(readFileSync8(join12(PACKAGE_ROOT, "package.json"), "utf8"));
5251
+ return JSON.parse(readFileSync12(join16(PACKAGE_ROOT, "package.json"), "utf8"));
3851
5252
  }
3852
5253
  function canOfferUpdate({
3853
5254
  interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
3854
- inRunner = existsSync6(RUNNER_MARKER)
5255
+ inRunner = existsSync7(RUNNER_MARKER)
3855
5256
  } = {}) {
3856
5257
  return interactive && !inRunner;
3857
5258
  }
@@ -3945,6 +5346,13 @@ var MODE_FLAGS = {
3945
5346
  "--strict": true,
3946
5347
  "--no-strict": false
3947
5348
  };
5349
+ function clientFirst(args) {
5350
+ const [first, second, ...rest] = args;
5351
+ if (first !== undefined && second !== undefined && isClientKey(first)) {
5352
+ return [second, first, ...rest];
5353
+ }
5354
+ return args;
5355
+ }
3948
5356
  function takeModeFlags(args) {
3949
5357
  let strict;
3950
5358
  let index = 0;
@@ -3996,13 +5404,13 @@ async function main(argv, commands = defaultCommands) {
3996
5404
  await commands.doctor();
3997
5405
  return;
3998
5406
  case "login":
3999
- await commands.login();
5407
+ await commands.login({ args: rest });
4000
5408
  return;
4001
5409
  case "logout":
4002
- await commands.logout();
5410
+ await commands.logout({ args: rest });
4003
5411
  return;
4004
5412
  case "status":
4005
- await commands.status();
5413
+ await commands.status({ args: rest });
4006
5414
  return;
4007
5415
  case "push":
4008
5416
  await commands.push({ args: rest });
@@ -4012,11 +5420,11 @@ async function main(argv, commands = defaultCommands) {
4012
5420
  return;
4013
5421
  case "resume": {
4014
5422
  const beforeId = takeModeFlags(rest);
4015
- const [id, ...afterId] = beforeId.rest;
5423
+ const [id, ...afterId] = clientFirst(beforeId.rest);
4016
5424
  const afterIdFlags = takeModeFlags(afterId);
4017
5425
  const strict = afterIdFlags.strict ?? beforeId.strict ?? leading.strict;
4018
5426
  if (!id) {
4019
- commands.print("Usage: maesttro resume <session-id> [--strict|--no-strict] [client] [args...]");
5427
+ commands.print("Usage: maesttro resume [client] <session-id> [--strict|--no-strict] [client] [args...]");
4020
5428
  return;
4021
5429
  }
4022
5430
  await commands.launch({