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 +4 -1
- package/dist/maesttro.js +160 -36
- package/package.json +1 -1
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
|
-
|
|
|
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
|
|
1269
|
-
maesttro
|
|
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([
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
"
|
|
1987
|
-
|
|
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
|
|
2318
|
-
|
|
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
|
|
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,
|
|
2485
|
+
const [action, ...rest] = args;
|
|
2372
2486
|
if (action === "remove") {
|
|
2373
|
-
const
|
|
2374
|
-
if (
|
|
2375
|
-
|
|
2376
|
-
|
|
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
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|