maesttro 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -61,8 +61,11 @@ maesttro opencode # same, with opencode
61
61
  maesttro --strict [client] # same, with no GitHub token in the runner (no `gh`)
62
62
  maesttro bash # plain shell inside the runner, for debugging
63
63
  maesttro sessions # list this repo's kept sessions
64
+ maesttro sessions remove <id> # drop one (preserves branches; --force discards unbranched commits)
65
+ maesttro sessions remove --all # drop all of this repo's sessions
64
66
  maesttro resume <id> # continue one, in the mode it started with
65
67
  maesttro push [branch] # push a branch a session created (shows what goes out, asks first)
68
+ maesttro push [branch] --force # same, as a force push
66
69
 
67
70
  maesttro update # bump the package + rebuild image with refreshed configs
68
71
  ```
@@ -189,7 +192,7 @@ Declining only skips it for that run; the next start asks again.
189
192
  |---|---|---|
190
193
  | `/home/agent` | `maesttro-home` named volume | Per-client session state. Survives across runs, lives only in Docker. Holds no GitHub credential. |
191
194
  | `/home/agent/.config/gh-maesttro` (read-only) | per-session temp dir on host | The session's GitHub token (`hosts.yml`). The CLI refreshes it before its 1 h expiry and revokes it on exit. Not mounted in `--strict` sessions, or when GitHub access is unavailable. |
192
- | `<repo>/.git/objects` (read-only, same absolute path) | your repo's object store | Base history, borrowed by the clone through its `alternates`. The real `.git` is never mounted. |
195
+ | `/opt/maesttro-objects` (read-only) | your repo's `.git/objects` | Base history, borrowed by the clone through its `alternates`, which is pinned to this fixed path so it resolves the same on every host OS. The real `.git` is never mounted. |
193
196
  | `/workspace` | the session clone (`.worktrees/<id>`) | The working directory: a `git clone --shared` detached at the default branch. |
194
197
  | `/opt/maesttro`, `/opt/maesttro-host` (read-only) | this package; your instruction files | Used by `maesttro configure` at container start. |
195
198
  | `/home/agent/.claude/skills` (read-only) | `~/agent-shared/skills` on host, if present | Reusable skills/house-style files shared across sessions. Read-only. |
package/dist/maesttro.js CHANGED
@@ -1265,8 +1265,14 @@ Usage:
1265
1265
  Same, in strict mode: no GitHub token reaches the runner, so \`gh\` is off
1266
1266
  maesttro resume <id> Continue a kept session in its clone, in the mode it started with
1267
1267
  (\`--strict\` or \`--no-strict\` after the id changes it)
1268
- maesttro sessions List this repo's sessions (\`sessions remove <id>\` to drop one)
1269
- maesttro push [branch] Push a branch a session created, after showing what goes out
1268
+ maesttro sessions List this repo's sessions
1269
+ maesttro sessions remove <id>
1270
+ Drop one. Preserves its branches; asks before discarding commits that
1271
+ aren't on a branch (--force skips the ask)
1272
+ maesttro sessions remove --all
1273
+ Drop all of this repo's sessions
1274
+ maesttro push [branch] [--force]
1275
+ Push a branch a session created, after showing what goes out
1270
1276
  maesttro list Show available clients
1271
1277
  maesttro setup One-time: build image, create volume, log in to GitHub
1272
1278
  maesttro login Log in to GitHub (device flow) so sessions get repo access
@@ -1611,7 +1617,10 @@ async function openGitHubSession({
1611
1617
  ...identityEnv(author, initial.committer)
1612
1618
  };
1613
1619
  return {
1614
- dockerArgs: withEnvFlags(["-v", `${dir.ghDir}:${CONTAINER_GH_CONFIG_DIR}:ro`], env),
1620
+ dockerArgs: withEnvFlags([
1621
+ "--mount",
1622
+ `type=bind,source=${dir.ghDir},target=${CONTAINER_GH_CONFIG_DIR},readonly`
1623
+ ], env),
1615
1624
  env,
1616
1625
  close: async () => {
1617
1626
  refresher.stop();
@@ -1642,7 +1651,8 @@ import {
1642
1651
  lstatSync,
1643
1652
  mkdirSync as mkdirSync9,
1644
1653
  readdirSync as readdirSync3,
1645
- readFileSync as readFileSync6
1654
+ readFileSync as readFileSync6,
1655
+ writeFileSync as writeFileSync7
1646
1656
  } from "node:fs";
1647
1657
  import { join as join9 } from "node:path";
1648
1658
 
@@ -1837,6 +1847,31 @@ function copyObjectFiles(fromDir, intoDir) {
1837
1847
  copyFileSync(join9(fromDir, entry.name), dest);
1838
1848
  }
1839
1849
  }
1850
+ function pinAlternates(clonePath, containerObjects) {
1851
+ const gitDir = cloneGitDir(clonePath);
1852
+ if (!gitDir)
1853
+ return;
1854
+ const objects = join9(gitDir, "objects");
1855
+ const info = join9(objects, "info");
1856
+ if (!realDir(objects) || !realDir(info)) {
1857
+ throw new LaunchError(`Unsafe objects tree in the session clone: ${clonePath}`);
1858
+ }
1859
+ const alternates = join9(info, "alternates");
1860
+ let stat;
1861
+ try {
1862
+ stat = lstatSync(alternates);
1863
+ } catch {
1864
+ return;
1865
+ }
1866
+ if (!stat.isFile()) {
1867
+ throw new LaunchError(`Unsafe alternates file in the session clone: ${clonePath}`);
1868
+ }
1869
+ const desired = `${containerObjects}
1870
+ `;
1871
+ if (readFileSync6(alternates, "utf8") !== desired) {
1872
+ writeFileSync7(alternates, desired);
1873
+ }
1874
+ }
1840
1875
  function importObjects(run, root, clonePath, tip) {
1841
1876
  const gitDir = cloneGitDir(clonePath);
1842
1877
  if (!gitDir)
@@ -1864,7 +1899,7 @@ import {
1864
1899
  readFileSync as readFileSync7,
1865
1900
  renameSync as renameSync4,
1866
1901
  rmSync as rmSync5,
1867
- writeFileSync as writeFileSync7
1902
+ writeFileSync as writeFileSync8
1868
1903
  } from "node:fs";
1869
1904
  import { join as join10 } from "node:path";
1870
1905
  function newSessionId() {
@@ -1876,7 +1911,7 @@ function sessionsDir(home = maesttroHome()) {
1876
1911
  function saveSession(record, dir = sessionsDir()) {
1877
1912
  mkdirSync10(dir, { recursive: true, mode: 448 });
1878
1913
  const path = join10(dir, `${record.id}.json`);
1879
- writeFileSync7(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
1914
+ writeFileSync8(`${path}.tmp`, `${JSON.stringify(record, null, 2)}
1880
1915
  `);
1881
1916
  renameSync4(`${path}.tmp`, path);
1882
1917
  }
@@ -1929,6 +1964,7 @@ function workspaceAuthor(run, workspace) {
1929
1964
  // src/launch.ts
1930
1965
  var CONTAINER_REPO = "/workspace";
1931
1966
  var CONTAINER_PACKAGE = "/opt/maesttro";
1967
+ var CONTAINER_GIT_OBJECTS = "/opt/maesttro-objects";
1932
1968
  function resolveCommand(args) {
1933
1969
  const [first, ...rest] = args;
1934
1970
  if (first === undefined)
@@ -1951,8 +1987,8 @@ function sessionMounts({
1951
1987
  }) {
1952
1988
  const objects = join11(repoRoot, ".git", "objects");
1953
1989
  const mounts = [
1954
- { host: VOLUME, container: "/home/agent" },
1955
- { host: objects, container: objects, readOnly: true },
1990
+ { host: VOLUME, container: "/home/agent", type: "volume" },
1991
+ { host: objects, container: CONTAINER_GIT_OBJECTS, readOnly: true },
1956
1992
  { host: sessionDir, container: CONTAINER_REPO },
1957
1993
  { host: packageRoot, container: CONTAINER_PACKAGE, readOnly: true },
1958
1994
  ...stagedPaths.map((path) => ({
@@ -1982,9 +2018,9 @@ function dockerRunArgs({
1982
2018
  extra = [],
1983
2019
  name
1984
2020
  }) {
1985
- const volumes = mounts.flatMap(({ host, container, readOnly }) => [
1986
- "-v",
1987
- `${host}:${container}${readOnly ? ":ro" : ""}`
2021
+ const volumes = mounts.flatMap(({ host, container, readOnly, type = "bind" }) => [
2022
+ "--mount",
2023
+ `type=${type},source=${host},target=${container}${readOnly ? ",readonly" : ""}`
1988
2024
  ]);
1989
2025
  return [
1990
2026
  "run",
@@ -2222,6 +2258,7 @@ async function launch({
2222
2258
  "MAESTTRO_HOST_GID"
2223
2259
  ].concat(repo ? ["MAESTTRO_REPO"] : []).flatMap((name) => ["-e", name]);
2224
2260
  clearStaleContainer(saved.id);
2261
+ pinAlternates(saved.worktree, CONTAINER_GIT_OBJECTS);
2225
2262
  const child = spawn("docker", dockerRunArgs({
2226
2263
  command: containerCommand(resolveCommand(args)),
2227
2264
  mounts,
@@ -2246,6 +2283,32 @@ async function launch({
2246
2283
 
2247
2284
  // src/push.ts
2248
2285
  import { spawnSync as spawnSync3 } from "node:child_process";
2286
+ var CONFIRM_FLAGS = new Set(["--yes", "-y"]);
2287
+ var FORWARDED_GIT_FLAGS = new Set(["--force"]);
2288
+ function parsePushArgs(args) {
2289
+ const positionals = args.filter((arg) => !arg.startsWith("-"));
2290
+ if (positionals.length > 1) {
2291
+ return {
2292
+ ok: false,
2293
+ reason: `Expected at most one branch, got ${positionals.join(", ")}.`
2294
+ };
2295
+ }
2296
+ const flags = args.filter((arg) => arg.startsWith("-"));
2297
+ const unsupported = flags.filter((flag) => !CONFIRM_FLAGS.has(flag) && !FORWARDED_GIT_FLAGS.has(flag));
2298
+ if (unsupported.length > 0) {
2299
+ return {
2300
+ ok: false,
2301
+ reason: `maesttro push doesn't accept ${unsupported.join(", ")}; it takes --yes and --force.`
2302
+ };
2303
+ }
2304
+ const [requested] = positionals;
2305
+ return {
2306
+ ok: true,
2307
+ ...requested === undefined ? {} : { requested },
2308
+ yes: flags.some((flag) => CONFIRM_FLAGS.has(flag)),
2309
+ gitFlags: flags.filter((flag) => FORWARDED_GIT_FLAGS.has(flag))
2310
+ };
2311
+ }
2249
2312
  function pushCandidates(sessions) {
2250
2313
  const candidates = new Map;
2251
2314
  for (const session of sessions) {
@@ -2297,7 +2360,7 @@ async function choose(candidates) {
2297
2360
  const answer = await ask("Branch to push: ");
2298
2361
  return candidates[Number(answer) - 1];
2299
2362
  }
2300
- function describe(run, root, branch, base, tip) {
2363
+ function describe(run, root, branch, base, tip, gitFlags) {
2301
2364
  const commits = git(run, root, [
2302
2365
  "log",
2303
2366
  "--format= %h %s (%an / %cn)",
@@ -2305,7 +2368,7 @@ function describe(run, root, branch, base, tip) {
2305
2368
  ]).out;
2306
2369
  const stat = git(run, root, ["diff", "--stat", `${base}...${tip}`]).out;
2307
2370
  return [
2308
- `Pushing ${branch} to origin (compared with ${base}):`,
2371
+ `Pushing ${branch} to origin${gitFlags.length ? ` with ${gitFlags.join(" ")}` : ""} (compared with ${base}):`,
2309
2372
  stripControl(commits) || " (no new commits)",
2310
2373
  stat ? `
2311
2374
  ${stripControl(stat)}` : ""
@@ -2314,8 +2377,13 @@ ${stripControl(stat)}` : ""
2314
2377
  }
2315
2378
  async function push({ args }) {
2316
2379
  const run = runCommand;
2317
- const yes = args.includes("--yes") || args.includes("-y");
2318
- const requested = args.find((arg) => !arg.startsWith("-"));
2380
+ const parsed = parsePushArgs(args);
2381
+ if (!parsed.ok) {
2382
+ console.error(parsed.reason);
2383
+ process.exitCode = 1;
2384
+ return;
2385
+ }
2386
+ const { requested, yes, gitFlags } = parsed;
2319
2387
  let root;
2320
2388
  try {
2321
2389
  root = repoRoot(run, process.cwd());
@@ -2342,7 +2410,7 @@ async function push({ args }) {
2342
2410
  return;
2343
2411
  }
2344
2412
  git(run, root, ["update-ref", `refs/maesttro/${sessionId}/${branch}`, tip]);
2345
- console.log(describe(run, root, branch, base, tip));
2413
+ console.log(describe(run, root, branch, base, tip, gitFlags));
2346
2414
  if (!yes) {
2347
2415
  if (!process.stdin.isTTY) {
2348
2416
  console.error("Not a terminal; re-run with --yes to push without confirming.");
@@ -2356,7 +2424,7 @@ Push? [y/N] `);
2356
2424
  return;
2357
2425
  }
2358
2426
  }
2359
- const pushed = spawnSync3("git", ["-C", root, "push", "origin", `${tip}:refs/heads/${branch}`], { stdio: "inherit" });
2427
+ const pushed = spawnSync3("git", ["-C", root, "push", ...gitFlags, "origin", `${tip}:refs/heads/${branch}`], { stdio: "inherit" });
2360
2428
  if (pushed.status === 0) {
2361
2429
  console.log(`Pushed ${branch}. Base for comparison was ${baseSha.slice(0, 8)}.`);
2362
2430
  }
@@ -2365,32 +2433,88 @@ Push? [y/N] `);
2365
2433
 
2366
2434
  // src/sessions-command.ts
2367
2435
  import { existsSync as existsSync5, rmSync as rmSync7 } from "node:fs";
2368
- function sessionsCommand({ args }) {
2436
+ function parseRemoveArgs(args) {
2437
+ let all = false;
2438
+ let force = false;
2439
+ let id;
2440
+ for (const arg of args) {
2441
+ if (arg === "--all")
2442
+ all = true;
2443
+ else if (arg === "--force")
2444
+ force = true;
2445
+ else if (id === undefined)
2446
+ id = arg;
2447
+ }
2448
+ return { all, force, id };
2449
+ }
2450
+ async function removeSession(run, session, force, ask, interactive) {
2451
+ if (existsSync5(session.worktree)) {
2452
+ const preserved = preserveBranches(run, session);
2453
+ const head = cloneHead(session.worktree);
2454
+ const unbranched = preserved.length === 0 && head?.detached !== undefined && head.detached !== session.baseSha;
2455
+ if (unbranched && !force) {
2456
+ let proceed;
2457
+ if (interactive) {
2458
+ const answer = await ask(`Session ${session.id} has commits on a detached HEAD not on any branch. Removing it discards them. Remove anyway? [y/N] `);
2459
+ proceed = /^(y(es)?)?$/i.test(answer);
2460
+ } else {
2461
+ proceed = false;
2462
+ console.error(`Session ${session.id} has commits on a detached HEAD not on any branch. Resume and create a branch, or re-run with --force.`);
2463
+ }
2464
+ if (!proceed) {
2465
+ console.log(`Kept session ${session.id}.`);
2466
+ return "kept";
2467
+ }
2468
+ }
2469
+ rmSync7(session.worktree, { recursive: true, force: true });
2470
+ const kept = preserved.length > 0 ? ` Its branches are kept as refs/maesttro/${session.id}/*.` : "";
2471
+ console.log(`Removed session ${session.id}.${kept}`);
2472
+ } else {
2473
+ console.log(`Removed session ${session.id}.`);
2474
+ }
2475
+ deleteSession(session.id);
2476
+ return "removed";
2477
+ }
2478
+ async function sessionsCommand({
2479
+ args,
2480
+ ask: ask2 = ask,
2481
+ interactive = process.stdin.isTTY === true
2482
+ }) {
2369
2483
  const run = runCommand;
2370
2484
  const root = repoRoot(run, process.cwd());
2371
- const [action, id] = args;
2485
+ const [action, ...rest] = args;
2372
2486
  if (action === "remove") {
2373
- const session = id ? readSession(id) : undefined;
2374
- if (!session || session.repoRoot !== root) {
2375
- console.error(`No session ${id ?? "(missing id)"} in this repo.`);
2376
- process.exitCode = 1;
2377
- return;
2378
- }
2379
- if (existsSync5(session.worktree)) {
2380
- const preserved = preserveBranches(run, session);
2381
- const head = cloneHead(session.worktree);
2382
- if (preserved.length === 0 && head?.detached !== undefined && head.detached !== session.baseSha) {
2383
- console.error(`${session.id} has commits on a detached HEAD not on any branch; resume and create a branch first.`);
2487
+ const { all, force, id } = parseRemoveArgs(rest);
2488
+ if (all) {
2489
+ if (id !== undefined) {
2490
+ console.error("`maesttro sessions remove` takes either `--all` or a session id, not both.");
2384
2491
  process.exitCode = 1;
2385
2492
  return;
2386
2493
  }
2387
- rmSync7(session.worktree, { recursive: true, force: true });
2388
- const kept = preserved.length > 0 ? ` Its branches are kept as refs/maesttro/${session.id}/*.` : "";
2389
- console.log(`Removed session ${session.id}.${kept}`);
2390
- } else {
2391
- console.log(`Removed session ${session.id}.`);
2494
+ const sessions = sessionsForRepo(root);
2495
+ if (sessions.length === 0) {
2496
+ console.log("No maesttro sessions in this repo.");
2497
+ return;
2498
+ }
2499
+ for (const session of sessions) {
2500
+ await removeSession(run, session, force, ask2, interactive);
2501
+ }
2502
+ return;
2392
2503
  }
2393
- deleteSession(session.id);
2504
+ if (id === undefined) {
2505
+ console.error("Usage: maesttro sessions remove <id> | maesttro sessions remove --all");
2506
+ process.exitCode = 1;
2507
+ return;
2508
+ }
2509
+ const session = readSession(id);
2510
+ if (!session || session.repoRoot !== root) {
2511
+ console.error(`No session ${id} in this repo.`);
2512
+ process.exitCode = 1;
2513
+ return;
2514
+ }
2515
+ const result = await removeSession(run, session, force, ask2, interactive);
2516
+ if (result === "kept")
2517
+ process.exitCode = 1;
2394
2518
  return;
2395
2519
  }
2396
2520
  const sessions = sessionsForRepo(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maesttro",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Isolated Docker runner for local coding agents (opencode, claude, ...) under a dedicated bot GitHub identity, with curated configs baked in per client.",
5
5
  "type": "module",
6
6
  "bin": {