@martintrojer/mu 0.3.1 → 0.3.2
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/cli.js +747 -119
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +505 -72
- package/dist/index.js +601 -97
- package/dist/index.js.map +1 -1
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/ROADMAP.md +8 -6
- package/docs/USAGE_GUIDE.md +73 -9
- package/docs/VOCABULARY.md +6 -3
- package/package.json +1 -1
- package/skills/mu/SKILL.md +42 -15
package/dist/index.js
CHANGED
|
@@ -510,6 +510,18 @@ function formatClaimEvent(opts) {
|
|
|
510
510
|
const self = opts.anonymous ? "1" : "0";
|
|
511
511
|
return `${CLAIM_EVENT_PREFIX} ${opts.localId} actor=${opts.actor} self=${self} ${opts.prose}`;
|
|
512
512
|
}
|
|
513
|
+
function lastClaimEventAt(db, workstream, localId) {
|
|
514
|
+
const escaped = localId.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
515
|
+
const pattern = `${CLAIM_EVENT_PREFIX} ${escaped} %`;
|
|
516
|
+
const wsId = tryResolveWorkstreamId(db, workstream);
|
|
517
|
+
if (wsId === null) return null;
|
|
518
|
+
const row = db.prepare(
|
|
519
|
+
`SELECT created_at FROM agent_logs
|
|
520
|
+
WHERE workstream_id = ? AND kind = 'event' AND payload LIKE ? ESCAPE '\\'
|
|
521
|
+
ORDER BY seq DESC LIMIT 1`
|
|
522
|
+
).get(wsId, pattern);
|
|
523
|
+
return row ? row.created_at : null;
|
|
524
|
+
}
|
|
513
525
|
|
|
514
526
|
// src/detect.ts
|
|
515
527
|
var TAIL_WINDOW_LINES = 100;
|
|
@@ -569,6 +581,7 @@ __export(tmux_exports, {
|
|
|
569
581
|
newSessionWithPane: () => newSessionWithPane,
|
|
570
582
|
newWindow: () => newWindow,
|
|
571
583
|
paneExists: () => paneExists,
|
|
584
|
+
paneTTY: () => paneTTY,
|
|
572
585
|
parseAgentNameFromTitle: () => parseAgentNameFromTitle,
|
|
573
586
|
resetSleep: () => resetSleep,
|
|
574
587
|
resetTmuxExecutor: () => resetTmuxExecutor,
|
|
@@ -913,6 +926,24 @@ async function enableMuPaneBorders(target) {
|
|
|
913
926
|
await tmux(["set-option", "-w", "-t", target, "pane-active-border-style", "fg=cyan,bold"]);
|
|
914
927
|
await tmux(["set-option", "-w", "-t", target, "pane-border-style", "fg=brightblack"]);
|
|
915
928
|
}
|
|
929
|
+
async function paneTTY(paneId) {
|
|
930
|
+
assertValidPaneId(paneId);
|
|
931
|
+
const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_tty}"]);
|
|
932
|
+
if (result.exitCode !== 0) {
|
|
933
|
+
if (/can't find pane|pane not found/i.test(result.stderr)) {
|
|
934
|
+
throw new PaneNotFoundError(paneId);
|
|
935
|
+
}
|
|
936
|
+
throw new TmuxError(
|
|
937
|
+
["display-message", "-t", paneId, "-p", "#{pane_tty}"],
|
|
938
|
+
result.stderr,
|
|
939
|
+
result.stdout,
|
|
940
|
+
result.exitCode
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
const tty = result.stdout.trim();
|
|
944
|
+
if (tty === "") throw new PaneNotFoundError(paneId);
|
|
945
|
+
return tty;
|
|
946
|
+
}
|
|
916
947
|
async function getPaneTitle(paneId) {
|
|
917
948
|
if (!isValidPaneId(paneId)) return void 0;
|
|
918
949
|
const result = await currentExecutor(["display-message", "-t", paneId, "-p", "#{pane_title}"]);
|
|
@@ -1583,7 +1614,7 @@ var ClaimerNotRegisteredError = class extends Error {
|
|
|
1583
1614
|
* Three actionable resolutions in expected-frequency order:
|
|
1584
1615
|
* 1. --self : orchestrator pattern (working directly)
|
|
1585
1616
|
* 2. --for : dispatcher pattern (assigning to a worker)
|
|
1586
|
-
* 3. mu adopt: registration pattern (promote pane to worker)
|
|
1617
|
+
* 3. mu agent adopt: registration pattern (promote pane to worker)
|
|
1587
1618
|
*/
|
|
1588
1619
|
errorNextSteps() {
|
|
1589
1620
|
const steps = [
|
|
@@ -1591,9 +1622,9 @@ var ClaimerNotRegisteredError = class extends Error {
|
|
|
1591
1622
|
{ intent: "Dispatch to a worker", command: "mu task claim <id> --for <worker>" }
|
|
1592
1623
|
];
|
|
1593
1624
|
steps.push(
|
|
1594
|
-
this.paneId !== null ? { intent: "Register this pane", command: `mu adopt ${this.paneId}` } : {
|
|
1625
|
+
this.paneId !== null ? { intent: "Register this pane", command: `mu agent adopt ${this.paneId}` } : {
|
|
1595
1626
|
intent: "Register a pane",
|
|
1596
|
-
command: "mu adopt <pane-id> (must be in mu-<workstream> tmux session)"
|
|
1627
|
+
command: "mu agent adopt <pane-id> (must be in mu-<workstream> tmux session)"
|
|
1597
1628
|
}
|
|
1598
1629
|
);
|
|
1599
1630
|
return steps;
|
|
@@ -2162,16 +2193,6 @@ function listArchivedTasks(db, label, opts = {}) {
|
|
|
2162
2193
|
}
|
|
2163
2194
|
|
|
2164
2195
|
// src/exporting.ts
|
|
2165
|
-
var LegacyExportLayoutError = class extends Error {
|
|
2166
|
-
constructor(outDir) {
|
|
2167
|
-
super(
|
|
2168
|
-
`${outDir} was created with a pre-bucket export (mu < 0.3); the on-disk shape changed in 0.3 (top-level README/INDEX/manifest + per-source-ws subdirs). Re-export is not in-place; either pick a different --out or 'rm -rf ${outDir}' and re-run.`
|
|
2169
|
-
);
|
|
2170
|
-
this.outDir = outDir;
|
|
2171
|
-
}
|
|
2172
|
-
outDir;
|
|
2173
|
-
name = "LegacyExportLayoutError";
|
|
2174
|
-
};
|
|
2175
2196
|
function fenceForBody(body) {
|
|
2176
2197
|
const longestRun = (body.match(/`+/g) ?? []).reduce((m, s) => Math.max(m, s.length), 0);
|
|
2177
2198
|
return "`".repeat(Math.max(3, longestRun + 1));
|
|
@@ -2342,9 +2363,6 @@ function readManifest(path) {
|
|
|
2342
2363
|
if (obj.bucketVersion === 2 && typeof obj.sources === "object" && obj.sources !== null) {
|
|
2343
2364
|
return { kind: "v2", manifest: obj };
|
|
2344
2365
|
}
|
|
2345
|
-
if (typeof obj.workstream === "string" && Array.isArray(obj.tasks)) {
|
|
2346
|
-
return { kind: "legacy" };
|
|
2347
|
-
}
|
|
2348
2366
|
return { kind: "corrupt" };
|
|
2349
2367
|
}
|
|
2350
2368
|
function sha256Hex(content) {
|
|
@@ -2372,9 +2390,6 @@ function renderToBucket(input) {
|
|
|
2372
2390
|
}
|
|
2373
2391
|
const manifestPath = join3(outDir, "manifest.json");
|
|
2374
2392
|
const probe = readManifest(manifestPath);
|
|
2375
|
-
if (probe.kind === "legacy") {
|
|
2376
|
-
throw new LegacyExportLayoutError(outDir);
|
|
2377
|
-
}
|
|
2378
2393
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2379
2394
|
const muVersion = readMuVersion();
|
|
2380
2395
|
const previous = probe.kind === "v2" ? probe.manifest : void 0;
|
|
@@ -2548,7 +2563,6 @@ function exportSourcesForArchive(db, label) {
|
|
|
2548
2563
|
for (const [sourceName, taskList] of bySource) {
|
|
2549
2564
|
const tasks = taskList.map((t) => ({
|
|
2550
2565
|
name: t.originalLocalId,
|
|
2551
|
-
localId: t.originalLocalId,
|
|
2552
2566
|
workstreamName: t.sourceWorkstream,
|
|
2553
2567
|
title: t.title,
|
|
2554
2568
|
// Status as snapshotted; cast through the TaskStatus union by
|
|
@@ -2626,24 +2640,30 @@ var WorkspaceVcsRequiredError = class extends Error {
|
|
|
2626
2640
|
}
|
|
2627
2641
|
};
|
|
2628
2642
|
var WorkspaceDirtyError = class extends Error {
|
|
2629
|
-
constructor(workspacePath2, files) {
|
|
2643
|
+
constructor(workspacePath2, files, verb = "rebase") {
|
|
2630
2644
|
super(
|
|
2631
|
-
`workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to
|
|
2645
|
+
`workspace dirty (${files.length} uncommitted file(s)): ${workspacePath2}; refusing to ${verb}`
|
|
2632
2646
|
);
|
|
2633
2647
|
this.workspacePath = workspacePath2;
|
|
2634
2648
|
this.files = files;
|
|
2649
|
+
this.verb = verb;
|
|
2635
2650
|
}
|
|
2636
2651
|
workspacePath;
|
|
2637
2652
|
files;
|
|
2638
2653
|
name = "WorkspaceDirtyError";
|
|
2654
|
+
/** The verb that refused ("rebase", "recreate", ...). Used to make
|
|
2655
|
+
* the error message + nextSteps point the operator at the right
|
|
2656
|
+
* escape hatch (e.g. recreate's `--force`). Default "rebase" for
|
|
2657
|
+
* backward compatibility with the original rebaseTo call sites. */
|
|
2658
|
+
verb;
|
|
2639
2659
|
errorNextSteps() {
|
|
2640
|
-
|
|
2660
|
+
const steps = [
|
|
2641
2661
|
{
|
|
2642
2662
|
intent: "Inspect the dirty files",
|
|
2643
2663
|
command: `(cd ${this.workspacePath} && git status -s) # or jj st / sl st`
|
|
2644
2664
|
},
|
|
2645
2665
|
{
|
|
2646
|
-
intent:
|
|
2666
|
+
intent: `Commit them first, then retry ${this.verb}`,
|
|
2647
2667
|
command: `(cd ${this.workspacePath} && git add -A && git commit -m WIP)`
|
|
2648
2668
|
},
|
|
2649
2669
|
{
|
|
@@ -2651,6 +2671,13 @@ var WorkspaceDirtyError = class extends Error {
|
|
|
2651
2671
|
command: `(cd ${this.workspacePath} && git stash)`
|
|
2652
2672
|
}
|
|
2653
2673
|
];
|
|
2674
|
+
if (this.verb === "recreate") {
|
|
2675
|
+
steps.push({
|
|
2676
|
+
intent: "Or DISCARD all uncommitted changes (the lossy escape)",
|
|
2677
|
+
command: "mu workspace recreate <agent> --force"
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
return steps;
|
|
2654
2681
|
}
|
|
2655
2682
|
};
|
|
2656
2683
|
var WorkspaceConflictError = class extends Error {
|
|
@@ -2719,6 +2746,13 @@ var noneBackend = {
|
|
|
2719
2746
|
async commitsBehind(_workspacePath, _ref) {
|
|
2720
2747
|
return null;
|
|
2721
2748
|
},
|
|
2749
|
+
// none has no notion of clean — a cp -a snapshot doesn't track
|
|
2750
|
+
// committed vs uncommitted state. Returning true makes the
|
|
2751
|
+
// close-auto-free path silently free a none-workspace (consistent
|
|
2752
|
+
// with the fact that there are no commits to lose).
|
|
2753
|
+
async isClean(_workspacePath) {
|
|
2754
|
+
return true;
|
|
2755
|
+
},
|
|
2722
2756
|
// none has no upstream to rebase onto. Throw a typed error so the
|
|
2723
2757
|
// CLI's handle() maps it to exit 4 with a clean Next: hint.
|
|
2724
2758
|
async rebaseTo(workspacePath2, _fromRef) {
|
|
@@ -2728,6 +2762,11 @@ var noneBackend = {
|
|
|
2728
2762
|
// doesn't track history. Same typed error as rebaseTo.
|
|
2729
2763
|
async commitsSinceBase(workspacePath2, _baseRef) {
|
|
2730
2764
|
throw new WorkspaceVcsRequiredError("commits", workspacePath2);
|
|
2765
|
+
},
|
|
2766
|
+
// No VCS → nothing to compare against; "dirty" is unanswerable.
|
|
2767
|
+
// Caller (`recreateWorkspace`) treats [] as "clean" and proceeds.
|
|
2768
|
+
async listDirtyFiles(_workspacePath) {
|
|
2769
|
+
return [];
|
|
2731
2770
|
}
|
|
2732
2771
|
};
|
|
2733
2772
|
var gitBackend = {
|
|
@@ -2748,6 +2787,20 @@ var gitBackend = {
|
|
|
2748
2787
|
const sha = await run("git", ["rev-parse", "HEAD"], opts.workspacePath);
|
|
2749
2788
|
return { parentRef: sha };
|
|
2750
2789
|
},
|
|
2790
|
+
// Working-copy clean check: empty `git status --porcelain` output
|
|
2791
|
+
// means no working-tree, staged, or untracked-not-ignored changes.
|
|
2792
|
+
// Returns false on any failure (workspace path missing, git
|
|
2793
|
+
// explodes) — be conservative; auto-free should never "silently
|
|
2794
|
+
// succeed" because we couldn't check.
|
|
2795
|
+
async isClean(workspacePath2) {
|
|
2796
|
+
if (!existsSync3(workspacePath2)) return false;
|
|
2797
|
+
try {
|
|
2798
|
+
const files = await listGitDirtyFiles(workspacePath2);
|
|
2799
|
+
return files.length === 0;
|
|
2800
|
+
} catch {
|
|
2801
|
+
return false;
|
|
2802
|
+
}
|
|
2803
|
+
},
|
|
2751
2804
|
// Compute commits-behind as: count of commits reachable from main
|
|
2752
2805
|
// but not from `ref`. Resolves "main" via origin/HEAD (the symbolic
|
|
2753
2806
|
// ref the remote advertises), falling back to origin/main and then
|
|
@@ -2890,6 +2943,10 @@ var gitBackend = {
|
|
|
2890
2943
|
const result = { removed: true };
|
|
2891
2944
|
if (committedRef !== void 0) result.committedRef = committedRef;
|
|
2892
2945
|
return result;
|
|
2946
|
+
},
|
|
2947
|
+
async listDirtyFiles(workspacePath2) {
|
|
2948
|
+
if (!existsSync3(workspacePath2)) return [];
|
|
2949
|
+
return listGitDirtyFiles(workspacePath2);
|
|
2893
2950
|
}
|
|
2894
2951
|
};
|
|
2895
2952
|
async function resolveGitMainRef(workspacePath2) {
|
|
@@ -2998,6 +3055,23 @@ var jjBackend = {
|
|
|
2998
3055
|
if (committedRef !== void 0) result.committedRef = committedRef;
|
|
2999
3056
|
return result;
|
|
3000
3057
|
},
|
|
3058
|
+
// jj working-copy clean: @ has no diff from its parent.
|
|
3059
|
+
// `jj diff -r @ --summary` prints one line per changed file; empty
|
|
3060
|
+
// stdout = clean. jj's auto-snapshotting means there's no separate
|
|
3061
|
+
// "untracked" bucket — every working-tree change is already in @.
|
|
3062
|
+
async isClean(workspacePath2) {
|
|
3063
|
+
if (!existsSync3(workspacePath2)) return false;
|
|
3064
|
+
try {
|
|
3065
|
+
const out = await run(
|
|
3066
|
+
"jj",
|
|
3067
|
+
["diff", "-r", "@", "--summary", "--no-pager", "--color", "never"],
|
|
3068
|
+
workspacePath2
|
|
3069
|
+
);
|
|
3070
|
+
return out.length === 0;
|
|
3071
|
+
} catch {
|
|
3072
|
+
return false;
|
|
3073
|
+
}
|
|
3074
|
+
},
|
|
3001
3075
|
// Compute commits-behind via jj's `trunk()` revset, which resolves
|
|
3002
3076
|
// to the project's configured trunk (default-branch heuristic).
|
|
3003
3077
|
// Returns null when trunk() is unresolvable (e.g. fresh repo with
|
|
@@ -3118,6 +3192,13 @@ var jjBackend = {
|
|
|
3118
3192
|
workspacePath2
|
|
3119
3193
|
);
|
|
3120
3194
|
return parseNulRecords(out);
|
|
3195
|
+
},
|
|
3196
|
+
// jj is always-snapshotted: there is no "uncommitted" state. The
|
|
3197
|
+
// working copy is itself a commit; the next snapshot folds any
|
|
3198
|
+
// edits in. Surface that by returning [] so `recreateWorkspace`
|
|
3199
|
+
// never refuses a jj workspace as "dirty".
|
|
3200
|
+
async listDirtyFiles(_workspacePath) {
|
|
3201
|
+
return [];
|
|
3121
3202
|
}
|
|
3122
3203
|
};
|
|
3123
3204
|
function parseNulRecords(raw) {
|
|
@@ -3189,6 +3270,18 @@ var slBackend = {
|
|
|
3189
3270
|
if (committedRef !== void 0) result.committedRef = committedRef;
|
|
3190
3271
|
return result;
|
|
3191
3272
|
},
|
|
3273
|
+
// sl working-copy clean: empty `sl status` output. Same shape as
|
|
3274
|
+
// listSlDirtyFiles below but inlined to keep the failure-mode
|
|
3275
|
+
// boundary tight (any throw → not clean).
|
|
3276
|
+
async isClean(workspacePath2) {
|
|
3277
|
+
if (!existsSync3(workspacePath2)) return false;
|
|
3278
|
+
try {
|
|
3279
|
+
const out = await run("sl", ["status"], workspacePath2);
|
|
3280
|
+
return out.length === 0;
|
|
3281
|
+
} catch {
|
|
3282
|
+
return false;
|
|
3283
|
+
}
|
|
3284
|
+
},
|
|
3192
3285
|
// Same shape as the jj impl: count commits in trunk() not reachable
|
|
3193
3286
|
// from ref. Sapling's revset language is close enough to jj's that
|
|
3194
3287
|
// the same idiom works. Returns null when trunk() is unresolvable
|
|
@@ -3265,6 +3358,10 @@ var slBackend = {
|
|
|
3265
3358
|
workspacePath2
|
|
3266
3359
|
);
|
|
3267
3360
|
return parseNulRecords(out).reverse();
|
|
3361
|
+
},
|
|
3362
|
+
async listDirtyFiles(workspacePath2) {
|
|
3363
|
+
if (!existsSync3(workspacePath2)) return [];
|
|
3364
|
+
return listSlDirtyFiles(workspacePath2);
|
|
3268
3365
|
}
|
|
3269
3366
|
};
|
|
3270
3367
|
async function listSlDirtyFiles(workspacePath2) {
|
|
@@ -3307,6 +3404,10 @@ import { existsSync as existsSync4, readdirSync, rmSync as rmSync2 } from "fs";
|
|
|
3307
3404
|
import { homedir as homedir2 } from "os";
|
|
3308
3405
|
import { join as join5, resolve as resolve2 } from "path";
|
|
3309
3406
|
|
|
3407
|
+
// src/agents/spawn.ts
|
|
3408
|
+
import { execFile as execFile2 } from "child_process";
|
|
3409
|
+
import { promisify as promisify2 } from "util";
|
|
3410
|
+
|
|
3310
3411
|
// src/output.ts
|
|
3311
3412
|
import Table from "cli-table3";
|
|
3312
3413
|
import picocolors from "picocolors";
|
|
@@ -3333,10 +3434,55 @@ function maybeWarnNonConventionalAgentName(name) {
|
|
|
3333
3434
|
);
|
|
3334
3435
|
}
|
|
3335
3436
|
function resolveCliCommand(cli) {
|
|
3336
|
-
const envName =
|
|
3437
|
+
const envName = envVarNameForCli(cli);
|
|
3337
3438
|
const override = process.env[envName];
|
|
3338
3439
|
return override && override.trim() !== "" ? override : cli;
|
|
3339
3440
|
}
|
|
3441
|
+
function envVarNameForCli(cli) {
|
|
3442
|
+
return `MU_${cli.toUpperCase().replace(/-/g, "_")}_COMMAND`;
|
|
3443
|
+
}
|
|
3444
|
+
function resolveCliCommandWithSource(cli) {
|
|
3445
|
+
const envVar = envVarNameForCli(cli);
|
|
3446
|
+
const override = process.env[envVar];
|
|
3447
|
+
if (override !== void 0 && override.trim() !== "") {
|
|
3448
|
+
return { command: override, envVar, resolvedFromEnv: true };
|
|
3449
|
+
}
|
|
3450
|
+
return { command: cli, envVar, resolvedFromEnv: false };
|
|
3451
|
+
}
|
|
3452
|
+
var execFileP = promisify2(execFile2);
|
|
3453
|
+
async function defaultCommandResolver(command) {
|
|
3454
|
+
const binary = parseFirstToken(command);
|
|
3455
|
+
if (binary === "") return { ok: false, binary };
|
|
3456
|
+
try {
|
|
3457
|
+
const { stdout } = await execFileP("/bin/sh", ["-c", `command -v -- ${shellQuote(binary)}`], {
|
|
3458
|
+
env: process.env
|
|
3459
|
+
});
|
|
3460
|
+
const resolvedPath = stdout.trim();
|
|
3461
|
+
if (resolvedPath === "") return { ok: false, binary };
|
|
3462
|
+
return { ok: true, binary, resolvedPath };
|
|
3463
|
+
} catch {
|
|
3464
|
+
return { ok: false, binary };
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
function shellQuote(s) {
|
|
3468
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
3469
|
+
}
|
|
3470
|
+
function parseFirstToken(command) {
|
|
3471
|
+
const trimmed = command.trim();
|
|
3472
|
+
if (trimmed === "") return "";
|
|
3473
|
+
const match = trimmed.match(/^\S+/);
|
|
3474
|
+
return match ? match[0] : "";
|
|
3475
|
+
}
|
|
3476
|
+
var activeCommandResolver = defaultCommandResolver;
|
|
3477
|
+
function setCommandResolverForTests(resolver) {
|
|
3478
|
+
activeCommandResolver = resolver;
|
|
3479
|
+
}
|
|
3480
|
+
function resetCommandResolverForTests() {
|
|
3481
|
+
activeCommandResolver = defaultCommandResolver;
|
|
3482
|
+
}
|
|
3483
|
+
async function checkCommandResolvable(command) {
|
|
3484
|
+
return activeCommandResolver(command);
|
|
3485
|
+
}
|
|
3340
3486
|
async function spawnAgent(db, opts) {
|
|
3341
3487
|
if (!isValidAgentName(opts.name)) {
|
|
3342
3488
|
throw new TypeError(
|
|
@@ -3350,33 +3496,36 @@ async function spawnAgent(db, opts) {
|
|
|
3350
3496
|
const windowName = opts.tab ?? opts.name;
|
|
3351
3497
|
const cli = opts.cli ?? "pi";
|
|
3352
3498
|
const command = opts.command ?? resolveCliCommand(cli);
|
|
3499
|
+
if (opts.command === void 0) {
|
|
3500
|
+
const check = await checkCommandResolvable(command);
|
|
3501
|
+
if (!check.ok) {
|
|
3502
|
+
throw new AgentSpawnCliNotFoundError(cli, check.binary, envVarNameForCli(cli));
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3353
3505
|
const workspacePathStr = opts.workspace ? await prestageWorkspace(db, opts, cli) : void 0;
|
|
3354
3506
|
const paneEnv = {
|
|
3355
3507
|
MU_MANAGED_AGENT: "1",
|
|
3356
3508
|
MU_AGENT_NAME: opts.name,
|
|
3357
3509
|
MU_WORKSTREAM: opts.workstream
|
|
3358
3510
|
};
|
|
3359
|
-
const paneId = await createOrReusePane({
|
|
3360
|
-
session,
|
|
3361
|
-
windowName,
|
|
3362
|
-
command,
|
|
3363
|
-
cwd: workspacePathStr ?? opts.cwd,
|
|
3364
|
-
env: paneEnv
|
|
3365
|
-
});
|
|
3366
3511
|
const hasWorkspace = workspacePathStr !== void 0;
|
|
3512
|
+
let paneId;
|
|
3367
3513
|
let agent;
|
|
3368
3514
|
try {
|
|
3515
|
+
paneId = await createOrReusePane({
|
|
3516
|
+
session,
|
|
3517
|
+
windowName,
|
|
3518
|
+
command,
|
|
3519
|
+
cwd: workspacePathStr ?? opts.cwd,
|
|
3520
|
+
env: paneEnv
|
|
3521
|
+
});
|
|
3369
3522
|
await setPaneTitle(paneId, opts.name);
|
|
3370
3523
|
await enableMuPaneBordersForPane(paneId);
|
|
3371
3524
|
agent = finalizeAgentRow(db, { opts, cli, paneId, hasWorkspace });
|
|
3372
|
-
} catch (err) {
|
|
3373
|
-
await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
|
|
3374
|
-
throw err;
|
|
3375
|
-
}
|
|
3376
|
-
try {
|
|
3377
3525
|
await awaitSpawnLiveness(paneId, opts.name);
|
|
3378
3526
|
} catch (err) {
|
|
3379
3527
|
await rollbackSpawn(db, opts.name, paneId, hasWorkspace, opts.workstream);
|
|
3528
|
+
if (hasWorkspace) attachOrphanCleanupHint(err, opts.name, opts.workstream);
|
|
3380
3529
|
throw err;
|
|
3381
3530
|
}
|
|
3382
3531
|
emitEvent(
|
|
@@ -3436,7 +3585,7 @@ function finalizeAgentRow(db, args) {
|
|
|
3436
3585
|
return row;
|
|
3437
3586
|
}
|
|
3438
3587
|
async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
|
|
3439
|
-
await killPane(paneId).catch(() => {
|
|
3588
|
+
if (paneId !== void 0) await killPane(paneId).catch(() => {
|
|
3440
3589
|
});
|
|
3441
3590
|
if (hasWorkspace) {
|
|
3442
3591
|
await freeWorkspace(db, name, { workstream }).catch(() => {
|
|
@@ -3444,6 +3593,22 @@ async function rollbackSpawn(db, name, paneId, hasWorkspace, workstream) {
|
|
|
3444
3593
|
}
|
|
3445
3594
|
deleteAgent(db, name, workstream);
|
|
3446
3595
|
}
|
|
3596
|
+
function attachOrphanCleanupHint(err, agent, workstream) {
|
|
3597
|
+
if (typeof err !== "object" || err === null) return;
|
|
3598
|
+
const target = err;
|
|
3599
|
+
const existing = typeof target.errorNextSteps === "function" ? target.errorNextSteps.bind(target) : null;
|
|
3600
|
+
const orphanHints = [
|
|
3601
|
+
{
|
|
3602
|
+
intent: "Check for an orphan workspace dir (rollback is best-effort; may have failed)",
|
|
3603
|
+
command: `mu workspace orphans -w ${workstream}`
|
|
3604
|
+
},
|
|
3605
|
+
{
|
|
3606
|
+
intent: "Free the workspace if it survived the rollback (idempotent on missing)",
|
|
3607
|
+
command: `mu workspace free ${agent} -w ${workstream}`
|
|
3608
|
+
}
|
|
3609
|
+
];
|
|
3610
|
+
target.errorNextSteps = () => [...existing ? existing() : [], ...orphanHints];
|
|
3611
|
+
}
|
|
3447
3612
|
function defaultSpawnLivenessMs() {
|
|
3448
3613
|
const raw = process.env.MU_SPAWN_LIVENESS_MS;
|
|
3449
3614
|
if (raw === void 0) return 1500;
|
|
@@ -3451,13 +3616,51 @@ function defaultSpawnLivenessMs() {
|
|
|
3451
3616
|
if (Number.isNaN(parsed) || parsed < 0) return 1500;
|
|
3452
3617
|
return parsed;
|
|
3453
3618
|
}
|
|
3619
|
+
var STARTUP_ERROR_PATTERNS = [
|
|
3620
|
+
/No API key found for [\w-]+/i,
|
|
3621
|
+
/Error: invalid API key/i,
|
|
3622
|
+
/Authentication failed/i,
|
|
3623
|
+
/401 Unauthorized/i,
|
|
3624
|
+
/Could not authenticate/i,
|
|
3625
|
+
// fb_agent_spawn_no_validation part B: post-spawn detection of the
|
|
3626
|
+
// "binary not found at exec time" failure mode. The pre-flight check
|
|
3627
|
+
// above catches the common typo BEFORE any side effect, but a few
|
|
3628
|
+
// edge cases still slip through and only surface in the pane:
|
|
3629
|
+
// - `--command "..."` skips the pre-flight (operator opt-out).
|
|
3630
|
+
// - PATH inside the spawned shell differs from PATH in mu's
|
|
3631
|
+
// process (login shell rc files, /etc/paths.d, etc.).
|
|
3632
|
+
// - Race: binary on PATH at spawn time, gone 1.5s later.
|
|
3633
|
+
// Scoped to the FIRST 30 lines of scrollback (see
|
|
3634
|
+
// STARTUP_ERROR_TAIL_LINES) so a user's later `cat /no/such/file`
|
|
3635
|
+
// can't false-positive long after spawn.
|
|
3636
|
+
/command not found/i,
|
|
3637
|
+
/No such file or directory/i
|
|
3638
|
+
];
|
|
3639
|
+
var STARTUP_ERROR_TAIL_LINES = 30;
|
|
3640
|
+
function detectSpawnStartupError(scrollback) {
|
|
3641
|
+
const lines = scrollback.split(/\r?\n/);
|
|
3642
|
+
const tail = lines.slice(Math.max(0, lines.length - STARTUP_ERROR_TAIL_LINES));
|
|
3643
|
+
for (const line of tail) {
|
|
3644
|
+
for (const pattern of STARTUP_ERROR_PATTERNS) {
|
|
3645
|
+
if (pattern.test(line)) return line;
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
return void 0;
|
|
3649
|
+
}
|
|
3454
3650
|
async function awaitSpawnLiveness(paneId, agentName) {
|
|
3455
3651
|
const ms = defaultSpawnLivenessMs();
|
|
3456
3652
|
if (ms === 0) return;
|
|
3457
3653
|
await sleep(ms);
|
|
3458
3654
|
const scrollback = await capturePane(paneId, { lines: 50 }).catch(() => void 0);
|
|
3459
|
-
if (await paneExists(paneId))
|
|
3460
|
-
|
|
3655
|
+
if (!await paneExists(paneId)) {
|
|
3656
|
+
throw new AgentDiedOnSpawnError(agentName, paneId, scrollback);
|
|
3657
|
+
}
|
|
3658
|
+
if (scrollback !== void 0) {
|
|
3659
|
+
const matchedLine = detectSpawnStartupError(scrollback);
|
|
3660
|
+
if (matchedLine !== void 0) {
|
|
3661
|
+
throw new AgentSpawnStartupError(agentName, paneId, matchedLine, scrollback);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3461
3664
|
}
|
|
3462
3665
|
async function createOrReusePane(opts) {
|
|
3463
3666
|
if (!await sessionExists(opts.session)) {
|
|
@@ -3488,6 +3691,36 @@ async function createOrReusePane(opts) {
|
|
|
3488
3691
|
}
|
|
3489
3692
|
|
|
3490
3693
|
// src/agents/errors.ts
|
|
3694
|
+
var AgentSpawnCliNotFoundError = class extends Error {
|
|
3695
|
+
constructor(cli, binary, envVarChecked) {
|
|
3696
|
+
super(
|
|
3697
|
+
`--cli ${cli} resolved to binary "${binary}" which is not on PATH (and not an executable absolute/relative path). Refusing to spawn \u2014 would create a pane that dies immediately on "command not found".`
|
|
3698
|
+
);
|
|
3699
|
+
this.cli = cli;
|
|
3700
|
+
this.binary = binary;
|
|
3701
|
+
this.envVarChecked = envVarChecked;
|
|
3702
|
+
}
|
|
3703
|
+
cli;
|
|
3704
|
+
binary;
|
|
3705
|
+
envVarChecked;
|
|
3706
|
+
name = "AgentSpawnCliNotFoundError";
|
|
3707
|
+
errorNextSteps() {
|
|
3708
|
+
return [
|
|
3709
|
+
{
|
|
3710
|
+
intent: "Try the default CLI (the one mu's substrate ships against)",
|
|
3711
|
+
command: "mu agent spawn <name> --cli pi"
|
|
3712
|
+
},
|
|
3713
|
+
{
|
|
3714
|
+
intent: "If you meant a custom alias, set the env var to its real path",
|
|
3715
|
+
command: `export ${this.envVarChecked}="<absolute-path-to-binary> [args...]"`
|
|
3716
|
+
},
|
|
3717
|
+
{
|
|
3718
|
+
intent: "List installed CLIs typically supported by mu",
|
|
3719
|
+
command: "which pi pi-meta claude codex"
|
|
3720
|
+
}
|
|
3721
|
+
];
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3491
3724
|
var AgentExistsError = class extends Error {
|
|
3492
3725
|
constructor(agentName) {
|
|
3493
3726
|
super(`agent already exists in this workstream: ${agentName}`);
|
|
@@ -3608,6 +3841,51 @@ ${tail}
|
|
|
3608
3841
|
];
|
|
3609
3842
|
}
|
|
3610
3843
|
};
|
|
3844
|
+
var AgentSpawnStartupError = class extends Error {
|
|
3845
|
+
constructor(agentName, paneId, matchedLine, scrollback) {
|
|
3846
|
+
super(
|
|
3847
|
+
`agent ${agentName} reported a startup error within ${defaultSpawnLivenessMs()}ms of spawn (pane ${paneId}). The pane is alive but the spawned CLI parked at an error prompt instead of becoming a working agent.
|
|
3848
|
+
|
|
3849
|
+
Matched line: ${matchedLine.trim()}
|
|
3850
|
+
|
|
3851
|
+
--- pane scrollback ---
|
|
3852
|
+
${scrollback.trim()}
|
|
3853
|
+
--- end scrollback ---`
|
|
3854
|
+
);
|
|
3855
|
+
this.agentName = agentName;
|
|
3856
|
+
this.paneId = paneId;
|
|
3857
|
+
this.matchedLine = matchedLine;
|
|
3858
|
+
this.scrollback = scrollback;
|
|
3859
|
+
}
|
|
3860
|
+
agentName;
|
|
3861
|
+
paneId;
|
|
3862
|
+
matchedLine;
|
|
3863
|
+
scrollback;
|
|
3864
|
+
name = "AgentSpawnStartupError";
|
|
3865
|
+
errorNextSteps() {
|
|
3866
|
+
return [
|
|
3867
|
+
{
|
|
3868
|
+
intent: "Inspect the parked pane's scrollback for the full error",
|
|
3869
|
+
command: `mu agent read ${this.agentName} -n 100`
|
|
3870
|
+
},
|
|
3871
|
+
{
|
|
3872
|
+
// Most common today: the operator picked a model whose
|
|
3873
|
+
// provider has no credentials in this env. Default Anthropic
|
|
3874
|
+
// is the safe fallback for pi-meta.
|
|
3875
|
+
intent: "Re-spawn with a CLI command whose provider credentials are present",
|
|
3876
|
+
command: `mu agent spawn ${this.agentName} --command "pi-meta --no-solo" # default Anthropic`
|
|
3877
|
+
},
|
|
3878
|
+
{
|
|
3879
|
+
intent: "Or set the missing API key env var for the provider you wanted, then re-spawn",
|
|
3880
|
+
command: "export ANTHROPIC_API_KEY=... # or AWS_BEARER_TOKEN_BEDROCK, OPENAI_API_KEY, ..."
|
|
3881
|
+
},
|
|
3882
|
+
{
|
|
3883
|
+
intent: "Disable the startup-error scan if you actually wanted that prompt (CI / scripted recovery)",
|
|
3884
|
+
command: "export MU_SPAWN_LIVENESS_MS=0"
|
|
3885
|
+
}
|
|
3886
|
+
];
|
|
3887
|
+
}
|
|
3888
|
+
};
|
|
3611
3889
|
var WorkspacePreservedError = class extends Error {
|
|
3612
3890
|
constructor(agentName, workspacePath2) {
|
|
3613
3891
|
super(
|
|
@@ -3864,11 +4142,13 @@ async function createWorkspace(db, opts) {
|
|
|
3864
4142
|
});
|
|
3865
4143
|
throw err;
|
|
3866
4144
|
}
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4145
|
+
if (opts._suppressEvent !== true) {
|
|
4146
|
+
emitEvent(
|
|
4147
|
+
db,
|
|
4148
|
+
opts.workstream,
|
|
4149
|
+
`workspace create ${opts.agent} (backend=${backend.name}, path=${path}${created.parentRef ? `, parent=${created.parentRef.slice(0, 12)}` : ""})`
|
|
4150
|
+
);
|
|
4151
|
+
}
|
|
3872
4152
|
return {
|
|
3873
4153
|
agentName: opts.agent,
|
|
3874
4154
|
workstreamName: opts.workstream,
|
|
@@ -3934,10 +4214,30 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
3934
4214
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
3935
4215
|
return results;
|
|
3936
4216
|
}
|
|
4217
|
+
async function isWorkspaceClean(row) {
|
|
4218
|
+
const backend = backendByName(row.backend);
|
|
4219
|
+
let clean;
|
|
4220
|
+
try {
|
|
4221
|
+
clean = await backend.isClean(row.path);
|
|
4222
|
+
} catch {
|
|
4223
|
+
return false;
|
|
4224
|
+
}
|
|
4225
|
+
if (!clean) return false;
|
|
4226
|
+
if (row.backend === "none") return true;
|
|
4227
|
+
if (row.parentRef === null || row.parentRef.length === 0) return false;
|
|
4228
|
+
try {
|
|
4229
|
+
const commits = await backend.commitsSinceBase(row.path, row.parentRef);
|
|
4230
|
+
return commits.length === 0;
|
|
4231
|
+
} catch {
|
|
4232
|
+
return false;
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
3937
4235
|
async function freeWorkspace(db, agent, opts) {
|
|
3938
4236
|
const row = getWorkspaceForAgent(db, agent, opts.workstream);
|
|
3939
4237
|
if (!row) return { removed: false, rowDeleted: false };
|
|
3940
|
-
|
|
4238
|
+
if (opts._suppressEvent !== true) {
|
|
4239
|
+
captureSnapshot(db, `workspace free ${agent}`, row.workstreamName);
|
|
4240
|
+
}
|
|
3941
4241
|
const backend = backendByName(row.backend);
|
|
3942
4242
|
const result = await backend.freeWorkspace({
|
|
3943
4243
|
workspacePath: row.path,
|
|
@@ -3949,17 +4249,55 @@ async function freeWorkspace(db, agent, opts) {
|
|
|
3949
4249
|
WHERE agent_id = (SELECT id FROM agents WHERE name = ? AND workstream_id = ?)
|
|
3950
4250
|
AND workstream_id = ?`
|
|
3951
4251
|
).run(agent, wsIdForDel, wsIdForDel);
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
4252
|
+
if (opts._suppressEvent !== true) {
|
|
4253
|
+
emitEvent(
|
|
4254
|
+
db,
|
|
4255
|
+
row.workstreamName,
|
|
4256
|
+
`workspace free ${agent} (backend=${row.backend}, path=${row.path}${result.committedRef ? `, committed=${result.committedRef.slice(0, 12)}` : ""})`
|
|
4257
|
+
);
|
|
4258
|
+
}
|
|
3957
4259
|
return {
|
|
3958
4260
|
removed: result.removed,
|
|
3959
4261
|
rowDeleted: del.changes > 0,
|
|
3960
4262
|
...result.committedRef !== void 0 ? { committedRef: result.committedRef } : {}
|
|
3961
4263
|
};
|
|
3962
4264
|
}
|
|
4265
|
+
async function recreateWorkspace(db, agent, opts) {
|
|
4266
|
+
const row = getWorkspaceForAgent(db, agent, opts.workstream);
|
|
4267
|
+
if (!row) throw new WorkspaceNotFoundError(agent);
|
|
4268
|
+
if (opts.force !== true) {
|
|
4269
|
+
const oldBackend = backendByName(row.backend);
|
|
4270
|
+
const dirty = await oldBackend.listDirtyFiles(row.path);
|
|
4271
|
+
if (dirty.length > 0) {
|
|
4272
|
+
throw new WorkspaceDirtyError(row.path, dirty, "recreate");
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
captureSnapshot(db, `workspace recreate ${agent}`, row.workstreamName);
|
|
4276
|
+
await freeWorkspace(db, agent, {
|
|
4277
|
+
workstream: opts.workstream,
|
|
4278
|
+
commit: false,
|
|
4279
|
+
_suppressEvent: true
|
|
4280
|
+
});
|
|
4281
|
+
const createOpts = {
|
|
4282
|
+
agent,
|
|
4283
|
+
workstream: opts.workstream,
|
|
4284
|
+
_suppressEvent: true
|
|
4285
|
+
};
|
|
4286
|
+
if (opts.projectRoot !== void 0) createOpts.projectRoot = opts.projectRoot;
|
|
4287
|
+
if (opts.backend !== void 0) {
|
|
4288
|
+
createOpts.backend = opts.backend;
|
|
4289
|
+
} else {
|
|
4290
|
+
createOpts.backend = row.backend;
|
|
4291
|
+
}
|
|
4292
|
+
if (opts.parentRef !== void 0) createOpts.parentRef = opts.parentRef;
|
|
4293
|
+
const fresh = await createWorkspace(db, createOpts);
|
|
4294
|
+
emitEvent(
|
|
4295
|
+
db,
|
|
4296
|
+
opts.workstream,
|
|
4297
|
+
`workspace recreate ${agent} (backend=${fresh.backend}, path=${fresh.path}, old_parent=${row.parentRef ? row.parentRef.slice(0, 12) : "\u2014"}, new_parent=${fresh.parentRef ? fresh.parentRef.slice(0, 12) : "\u2014"})`
|
|
4298
|
+
);
|
|
4299
|
+
return { workspace: fresh, previousParentRef: row.parentRef };
|
|
4300
|
+
}
|
|
3963
4301
|
|
|
3964
4302
|
// src/workstream.ts
|
|
3965
4303
|
var WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
@@ -4218,7 +4556,6 @@ async function waitForTasks(db, input, opts) {
|
|
|
4218
4556
|
const stuckAfterMs = opts.stuckAfterMs ?? 3e5;
|
|
4219
4557
|
const onStall = opts.onStall ?? "warn";
|
|
4220
4558
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
|
|
4221
|
-
const startedAt = Date.now();
|
|
4222
4559
|
for (const ref of refs) {
|
|
4223
4560
|
if (getTask(db, ref.name, ref.workstreamName) === void 0)
|
|
4224
4561
|
throw new TaskNotFoundError(`${ref.workstreamName}/${ref.name}`);
|
|
@@ -4239,7 +4576,7 @@ async function waitForTasks(db, input, opts) {
|
|
|
4239
4576
|
return ageMs >= stuckAfterMs;
|
|
4240
4577
|
};
|
|
4241
4578
|
const snapshot = () => {
|
|
4242
|
-
const
|
|
4579
|
+
const refStates = refs.map((ref) => {
|
|
4243
4580
|
const row = getTask(db, ref.name, ref.workstreamName);
|
|
4244
4581
|
const status = row?.status ?? "OPEN";
|
|
4245
4582
|
const owner = row?.ownerName ?? null;
|
|
@@ -4271,16 +4608,15 @@ async function waitForTasks(db, input, opts) {
|
|
|
4271
4608
|
stuck
|
|
4272
4609
|
};
|
|
4273
4610
|
});
|
|
4274
|
-
const reachedCount = tasks.filter((t) => t.reachedTarget).length;
|
|
4275
4611
|
return {
|
|
4276
|
-
|
|
4277
|
-
allReached: reachedCount === tasks.length,
|
|
4278
|
-
anyReached: reachedCount > 0,
|
|
4279
|
-
elapsedMs: Date.now() - startedAt,
|
|
4612
|
+
refs: refStates,
|
|
4280
4613
|
timedOut: false
|
|
4281
4614
|
};
|
|
4282
4615
|
};
|
|
4283
|
-
const isDone = (snap2) =>
|
|
4616
|
+
const isDone = (snap2) => {
|
|
4617
|
+
const reached = snap2.refs.filter((r) => r.reachedTarget).length;
|
|
4618
|
+
return wantAny ? reached > 0 : reached === snap2.refs.length;
|
|
4619
|
+
};
|
|
4284
4620
|
if (opts.beforePoll) await opts.beforePoll();
|
|
4285
4621
|
let snap = snapshot();
|
|
4286
4622
|
if (isDone(snap)) return snap;
|
|
@@ -4341,7 +4677,15 @@ function closeTask(db, localId, opts) {
|
|
|
4341
4677
|
if (before && before.status !== "CLOSED") {
|
|
4342
4678
|
captureSnapshot(db, `task close ${localId}`, before.workstreamName);
|
|
4343
4679
|
}
|
|
4344
|
-
|
|
4680
|
+
const r = setTaskStatus(db, localId, "CLOSED", opts);
|
|
4681
|
+
if (r.changed && before && opts.evidence !== void 0 && opts.evidence !== "") {
|
|
4682
|
+
const noteOpts = {
|
|
4683
|
+
workstream: before.workstreamName
|
|
4684
|
+
};
|
|
4685
|
+
if (opts.author !== void 0 && opts.author !== "") noteOpts.author = opts.author;
|
|
4686
|
+
addNote(db, localId, `CLOSE: ${opts.evidence}`, noteOpts);
|
|
4687
|
+
}
|
|
4688
|
+
return r;
|
|
4345
4689
|
}
|
|
4346
4690
|
function openTask(db, localId, opts) {
|
|
4347
4691
|
return setTaskStatus(db, localId, "OPEN", opts);
|
|
@@ -4594,7 +4938,6 @@ var SELECT_NOTE_COLS = `
|
|
|
4594
4938
|
function rowFromDb4(row) {
|
|
4595
4939
|
return {
|
|
4596
4940
|
name: row.local_id,
|
|
4597
|
-
localId: row.local_id,
|
|
4598
4941
|
workstreamName: row.workstream,
|
|
4599
4942
|
title: row.title,
|
|
4600
4943
|
status: row.status,
|
|
@@ -4652,10 +4995,13 @@ function slugifyTitleVerbose(title) {
|
|
|
4652
4995
|
const lastSep = window.lastIndexOf("_");
|
|
4653
4996
|
trimmed = lastSep > 0 ? window.slice(0, lastSep) : window;
|
|
4654
4997
|
}
|
|
4655
|
-
const
|
|
4998
|
+
const applyPrefix = (s) => /^[a-z]/.test(s) ? s.slice(0, SLUG_HARD_CAP) : `t_${s}`.slice(0, SLUG_HARD_CAP);
|
|
4999
|
+
const slug = applyPrefix(trimmed);
|
|
5000
|
+
const originalSlug = applyPrefix(stripped);
|
|
4656
5001
|
return {
|
|
4657
5002
|
slug,
|
|
4658
5003
|
strippedLength: stripped.length,
|
|
5004
|
+
originalSlug,
|
|
4659
5005
|
truncated: trimmed.length < stripped.length
|
|
4660
5006
|
};
|
|
4661
5007
|
}
|
|
@@ -4663,11 +5009,12 @@ function idFromTitle(db, workstream, title) {
|
|
|
4663
5009
|
return idFromTitleVerbose(db, workstream, title).id;
|
|
4664
5010
|
}
|
|
4665
5011
|
function idFromTitleVerbose(db, workstream, title) {
|
|
4666
|
-
const { slug: base, truncated } = slugifyTitleVerbose(title);
|
|
4667
|
-
if (getTask(db, base, workstream) === void 0) return { id: base, truncated };
|
|
5012
|
+
const { slug: base, truncated, originalSlug } = slugifyTitleVerbose(title);
|
|
5013
|
+
if (getTask(db, base, workstream) === void 0) return { id: base, truncated, originalSlug };
|
|
4668
5014
|
for (let i = 2; i < 1e3; i++) {
|
|
4669
5015
|
const candidate = `${base}_${i}`.slice(0, SLUG_HARD_CAP);
|
|
4670
|
-
if (getTask(db, candidate, workstream) === void 0)
|
|
5016
|
+
if (getTask(db, candidate, workstream) === void 0)
|
|
5017
|
+
return { id: candidate, truncated, originalSlug };
|
|
4671
5018
|
}
|
|
4672
5019
|
throw new Error(`could not derive a unique id from title in workstream ${workstream}: ${title}`);
|
|
4673
5020
|
}
|
|
@@ -4765,14 +5112,26 @@ function listRecentClosed(db, workstream, limit = 5) {
|
|
|
4765
5112
|
).all(wsId, limit);
|
|
4766
5113
|
return rows.map(rowFromDb4);
|
|
4767
5114
|
}
|
|
4768
|
-
function listNotes(db, taskLocalId, workstream) {
|
|
5115
|
+
function listNotes(db, taskLocalId, workstream, opts = {}) {
|
|
4769
5116
|
const taskId = taskIdFor(db, taskLocalId, workstream);
|
|
4770
5117
|
if (taskId === null) return [];
|
|
4771
|
-
|
|
5118
|
+
let cutoff = opts.since;
|
|
5119
|
+
if (cutoff === void 0 && opts.sinceClaim === true) {
|
|
5120
|
+
const at = lastClaimEventAt(db, workstream, taskLocalId);
|
|
5121
|
+
if (at !== null) cutoff = at;
|
|
5122
|
+
}
|
|
5123
|
+
const rows = cutoff !== void 0 ? db.prepare(
|
|
4772
5124
|
`SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
|
|
4773
|
-
|
|
5125
|
+
WHERE n.task_id = ? AND n.created_at > ? ORDER BY n.id`
|
|
5126
|
+
).all(taskId, cutoff) : db.prepare(
|
|
5127
|
+
`SELECT ${SELECT_NOTE_COLS} FROM task_notes n JOIN tasks t ON t.id = n.task_id
|
|
5128
|
+
WHERE n.task_id = ? ORDER BY n.id`
|
|
4774
5129
|
).all(taskId);
|
|
4775
|
-
|
|
5130
|
+
const mapped = rows.map(noteFromDb);
|
|
5131
|
+
if (opts.tail !== void 0 && opts.tail >= 0) {
|
|
5132
|
+
return opts.tail === 0 ? [] : mapped.slice(-opts.tail);
|
|
5133
|
+
}
|
|
5134
|
+
return mapped;
|
|
4776
5135
|
}
|
|
4777
5136
|
function listTasksByOwner(db, workstream, owner, opts = {}) {
|
|
4778
5137
|
const filter = opts.includeClosed ? "" : "AND t.status NOT IN ('CLOSED', 'REJECTED', 'DEFERRED')";
|
|
@@ -5194,6 +5553,148 @@ async function adoptAgent(db, opts) {
|
|
|
5194
5553
|
};
|
|
5195
5554
|
}
|
|
5196
5555
|
|
|
5556
|
+
// src/agents/kick.ts
|
|
5557
|
+
var ALLOWED_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL"];
|
|
5558
|
+
function isKickSignal(s) {
|
|
5559
|
+
return ALLOWED_SIGNALS.includes(s);
|
|
5560
|
+
}
|
|
5561
|
+
var NoForegroundProcessError = class extends Error {
|
|
5562
|
+
constructor(agentName, tty, reason) {
|
|
5563
|
+
const detail = reason === "no-foreground" ? `no foreground process group on tty ${tty} (pane is idle)` : `the only foreground process on tty ${tty} is the agent's wrapping CLI itself; refusing to signal it (use \`mu agent close ${agentName}\` to close the agent)`;
|
|
5564
|
+
super(`agent ${agentName}: ${detail}`);
|
|
5565
|
+
this.agentName = agentName;
|
|
5566
|
+
this.tty = tty;
|
|
5567
|
+
this.reason = reason;
|
|
5568
|
+
}
|
|
5569
|
+
agentName;
|
|
5570
|
+
tty;
|
|
5571
|
+
reason;
|
|
5572
|
+
name = "NoForegroundProcessError";
|
|
5573
|
+
errorNextSteps() {
|
|
5574
|
+
return [
|
|
5575
|
+
{
|
|
5576
|
+
intent: "Inspect what's running in the pane",
|
|
5577
|
+
command: `mu agent show ${this.agentName} -n 50`
|
|
5578
|
+
},
|
|
5579
|
+
{
|
|
5580
|
+
intent: "Close the agent (kills the wrapping CLI + pane)",
|
|
5581
|
+
command: `mu agent close ${this.agentName}`
|
|
5582
|
+
}
|
|
5583
|
+
];
|
|
5584
|
+
}
|
|
5585
|
+
};
|
|
5586
|
+
var realExecutor2 = async (cmd, args) => {
|
|
5587
|
+
const { execa: execa2 } = await import("execa");
|
|
5588
|
+
const result = await execa2(cmd, [...args], { reject: false });
|
|
5589
|
+
return {
|
|
5590
|
+
stdout: result.stdout ?? "",
|
|
5591
|
+
stderr: result.stderr ?? "",
|
|
5592
|
+
exitCode: result.exitCode ?? null
|
|
5593
|
+
};
|
|
5594
|
+
};
|
|
5595
|
+
var currentExecutor2 = realExecutor2;
|
|
5596
|
+
function setKickProcessExecutor(executor) {
|
|
5597
|
+
const previous = currentExecutor2;
|
|
5598
|
+
currentExecutor2 = executor;
|
|
5599
|
+
return previous;
|
|
5600
|
+
}
|
|
5601
|
+
function resetKickProcessExecutor() {
|
|
5602
|
+
currentExecutor2 = realExecutor2;
|
|
5603
|
+
}
|
|
5604
|
+
function parsePsTtyOutput(output) {
|
|
5605
|
+
const rows = [];
|
|
5606
|
+
for (const raw of output.split("\n")) {
|
|
5607
|
+
const line = raw.trim();
|
|
5608
|
+
if (line === "") continue;
|
|
5609
|
+
const parts = line.split(/\s+/);
|
|
5610
|
+
if (parts.length < 4) continue;
|
|
5611
|
+
const [pidStr, pgidStr, stat2, ...commParts] = parts;
|
|
5612
|
+
if (pidStr === void 0 || pgidStr === void 0 || stat2 === void 0) continue;
|
|
5613
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
5614
|
+
const pgid = Number.parseInt(pgidStr, 10);
|
|
5615
|
+
if (!Number.isFinite(pid) || !Number.isFinite(pgid)) continue;
|
|
5616
|
+
rows.push({ pid, pgid, stat: stat2, comm: commParts.join(" ") });
|
|
5617
|
+
}
|
|
5618
|
+
return rows;
|
|
5619
|
+
}
|
|
5620
|
+
var WRAPPER_COMM_PREFIXES = [
|
|
5621
|
+
"pi",
|
|
5622
|
+
"claude",
|
|
5623
|
+
"codex",
|
|
5624
|
+
"bash",
|
|
5625
|
+
"zsh",
|
|
5626
|
+
"sh",
|
|
5627
|
+
"fish",
|
|
5628
|
+
"dash"
|
|
5629
|
+
];
|
|
5630
|
+
function isWrapperComm(comm) {
|
|
5631
|
+
const cleaned = comm.replace(/^-/, "").trim();
|
|
5632
|
+
if (cleaned === "") return false;
|
|
5633
|
+
for (const prefix of WRAPPER_COMM_PREFIXES) {
|
|
5634
|
+
if (cleaned === prefix) return true;
|
|
5635
|
+
if (cleaned.startsWith(`${prefix}-`)) return true;
|
|
5636
|
+
}
|
|
5637
|
+
return false;
|
|
5638
|
+
}
|
|
5639
|
+
async function foregroundPgid(tty) {
|
|
5640
|
+
const ttyShort = tty.startsWith("/dev/") ? tty.slice("/dev/".length) : tty;
|
|
5641
|
+
const result = await currentExecutor2("ps", ["-t", ttyShort, "-o", "pid=,pgid=,stat=,comm="]);
|
|
5642
|
+
if (result.exitCode !== 0 && result.stdout.trim() === "") {
|
|
5643
|
+
return { kind: "no-foreground", rows: [] };
|
|
5644
|
+
}
|
|
5645
|
+
const rows = parsePsTtyOutput(result.stdout);
|
|
5646
|
+
if (rows.length === 0) return { kind: "no-foreground", rows };
|
|
5647
|
+
const fg = rows.find((r) => r.stat.includes("+"));
|
|
5648
|
+
if (!fg) {
|
|
5649
|
+
return { kind: "no-foreground", rows };
|
|
5650
|
+
}
|
|
5651
|
+
if (isWrapperComm(fg.comm)) {
|
|
5652
|
+
return { kind: "shell-only", pgid: fg.pgid, fgRow: fg, rows };
|
|
5653
|
+
}
|
|
5654
|
+
return { kind: "ok", pgid: fg.pgid, fgRow: fg, rows };
|
|
5655
|
+
}
|
|
5656
|
+
async function killPgrp(pgid, signal) {
|
|
5657
|
+
const result = await currentExecutor2("kill", [`-${signal}`, `-${pgid}`]);
|
|
5658
|
+
if (result.exitCode !== 0) {
|
|
5659
|
+
if (/no such process/i.test(result.stderr)) return;
|
|
5660
|
+
throw new Error(
|
|
5661
|
+
`kill -${signal} -${pgid} failed (exit ${result.exitCode}): ${result.stderr.trim() || "no stderr"}`
|
|
5662
|
+
);
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5665
|
+
async function kickAgent(db, name, opts) {
|
|
5666
|
+
const signal = opts.signal ?? "SIGINT";
|
|
5667
|
+
const agent = getAgent(db, name, opts.workstream);
|
|
5668
|
+
if (!agent) throw new AgentNotFoundError(name, opts.workstream);
|
|
5669
|
+
const tty = await paneTTY(agent.paneId);
|
|
5670
|
+
const lookup = await foregroundPgid(tty);
|
|
5671
|
+
if (lookup.kind === "no-foreground") {
|
|
5672
|
+
throw new NoForegroundProcessError(name, tty, "no-foreground");
|
|
5673
|
+
}
|
|
5674
|
+
if (lookup.kind === "shell-only") {
|
|
5675
|
+
throw new NoForegroundProcessError(name, tty, "shell-only");
|
|
5676
|
+
}
|
|
5677
|
+
const pgid = lookup.pgid;
|
|
5678
|
+
const fgRow = lookup.fgRow;
|
|
5679
|
+
if (pgid === void 0 || fgRow === void 0) {
|
|
5680
|
+
throw new NoForegroundProcessError(name, tty, "no-foreground");
|
|
5681
|
+
}
|
|
5682
|
+
await killPgrp(pgid, signal);
|
|
5683
|
+
emitEvent(
|
|
5684
|
+
db,
|
|
5685
|
+
agent.workstreamName,
|
|
5686
|
+
`agent kick ${name} (signal=${signal}, pgid=${pgid}, comm=${fgRow.comm})`
|
|
5687
|
+
);
|
|
5688
|
+
return {
|
|
5689
|
+
agentName: name,
|
|
5690
|
+
paneId: agent.paneId,
|
|
5691
|
+
tty,
|
|
5692
|
+
signaledPgid: pgid,
|
|
5693
|
+
signal,
|
|
5694
|
+
foregroundComm: fgRow.comm
|
|
5695
|
+
};
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5197
5698
|
// src/agents.ts
|
|
5198
5699
|
var DEFAULT_IDLE_THRESHOLD_MS = 3e5;
|
|
5199
5700
|
function idleThresholdMs() {
|
|
@@ -5418,15 +5919,24 @@ function freeAgent(db, name, workstream) {
|
|
|
5418
5919
|
async function closeAgent(db, name, opts) {
|
|
5419
5920
|
const agent = getAgent(db, name, opts.workstream);
|
|
5420
5921
|
if (!agent) {
|
|
5421
|
-
return {
|
|
5922
|
+
return {
|
|
5923
|
+
killedPane: false,
|
|
5924
|
+
deletedRow: false,
|
|
5925
|
+
workspaceFreed: false,
|
|
5926
|
+
workspaceAutoFreedClean: false
|
|
5927
|
+
};
|
|
5422
5928
|
}
|
|
5423
5929
|
const ws = getWorkspaceForAgent(db, name, agent.workstreamName);
|
|
5930
|
+
let autoFreeClean = false;
|
|
5424
5931
|
if (ws !== void 0 && opts.discardWorkspace !== true) {
|
|
5425
|
-
|
|
5932
|
+
autoFreeClean = await isWorkspaceClean(ws);
|
|
5933
|
+
if (!autoFreeClean) {
|
|
5934
|
+
throw new WorkspacePreservedError(name, ws.path);
|
|
5935
|
+
}
|
|
5426
5936
|
}
|
|
5427
5937
|
captureSnapshot(db, `agent close ${name}`, agent.workstreamName);
|
|
5428
5938
|
let workspaceFreed = false;
|
|
5429
|
-
if (ws !== void 0 && opts.discardWorkspace === true) {
|
|
5939
|
+
if (ws !== void 0 && (opts.discardWorkspace === true || autoFreeClean)) {
|
|
5430
5940
|
await freeWorkspace(db, name, { commit: false, workstream: agent.workstreamName });
|
|
5431
5941
|
workspaceFreed = true;
|
|
5432
5942
|
}
|
|
@@ -5436,12 +5946,13 @@ async function closeAgent(db, name, opts) {
|
|
|
5436
5946
|
emitEvent(
|
|
5437
5947
|
db,
|
|
5438
5948
|
agent.workstreamName,
|
|
5439
|
-
`agent close ${name} (pane=${agent.paneId}${workspaceFreed ? ", workspace discarded" : ""})`
|
|
5949
|
+
`agent close ${name} (pane=${agent.paneId}${workspaceFreed ? autoFreeClean ? ", workspace auto-freed (clean)" : ", workspace discarded" : ""})`
|
|
5440
5950
|
);
|
|
5441
5951
|
return {
|
|
5442
5952
|
killedPane: true,
|
|
5443
5953
|
deletedRow,
|
|
5444
|
-
workspaceFreed
|
|
5954
|
+
workspaceFreed,
|
|
5955
|
+
workspaceAutoFreedClean: workspaceFreed && autoFreeClean
|
|
5445
5956
|
};
|
|
5446
5957
|
}
|
|
5447
5958
|
async function listLiveAgents(db, opts) {
|
|
@@ -5605,24 +6116,6 @@ var ImportSourceNotInBucketError = class extends Error {
|
|
|
5605
6116
|
];
|
|
5606
6117
|
}
|
|
5607
6118
|
};
|
|
5608
|
-
var ImportLegacyLayoutError = class extends Error {
|
|
5609
|
-
constructor(bucketDir) {
|
|
5610
|
-
super(
|
|
5611
|
-
`${bucketDir} is a pre-0.3 (single-workstream) export; mu workstream import requires bucketVersion 2. Re-export with mu \u2265 0.3, or run mu workstream import on a freshly-rendered bucket.`
|
|
5612
|
-
);
|
|
5613
|
-
this.bucketDir = bucketDir;
|
|
5614
|
-
}
|
|
5615
|
-
bucketDir;
|
|
5616
|
-
name = "ImportLegacyLayoutError";
|
|
5617
|
-
errorNextSteps() {
|
|
5618
|
-
return [
|
|
5619
|
-
{
|
|
5620
|
-
intent: "Re-export the source workstream into a new bucket",
|
|
5621
|
-
command: "mu workstream export -w <ws> --out <new-bucket-dir>"
|
|
5622
|
-
}
|
|
5623
|
-
];
|
|
5624
|
-
}
|
|
5625
|
-
};
|
|
5626
6119
|
var WorkstreamAlreadyExistsError = class extends Error {
|
|
5627
6120
|
constructor(workstream) {
|
|
5628
6121
|
super(
|
|
@@ -5906,9 +6399,6 @@ function walkBucket(bucketDir) {
|
|
|
5906
6399
|
if (probe.kind === "corrupt") {
|
|
5907
6400
|
throw new ImportBucketInvalidError(bucketDir, "manifest.json is unreadable / malformed");
|
|
5908
6401
|
}
|
|
5909
|
-
if (probe.kind === "legacy") {
|
|
5910
|
-
throw new ImportLegacyLayoutError(bucketDir);
|
|
5911
|
-
}
|
|
5912
6402
|
const tasksDir = join7(bucketDir, "tasks");
|
|
5913
6403
|
const looksLikeSourceWs = existsSync6(join7(bucketDir, "README.md")) && existsSync6(join7(bucketDir, "INDEX.md")) && existsSync6(tasksDir) && statSync3(tasksDir).isDirectory();
|
|
5914
6404
|
if (!looksLikeSourceWs) {
|
|
@@ -6127,6 +6617,8 @@ export {
|
|
|
6127
6617
|
AgentExistsError,
|
|
6128
6618
|
AgentNotFoundError,
|
|
6129
6619
|
AgentNotInWorkstreamError,
|
|
6620
|
+
AgentSpawnCliNotFoundError,
|
|
6621
|
+
AgentSpawnStartupError,
|
|
6130
6622
|
ArchiveAlreadyExistsError,
|
|
6131
6623
|
ArchiveLabelInvalidError,
|
|
6132
6624
|
ArchiveNotFoundError,
|
|
@@ -6139,8 +6631,7 @@ export {
|
|
|
6139
6631
|
ImportBucketInvalidError,
|
|
6140
6632
|
ImportEdgeRefMissingError,
|
|
6141
6633
|
ImportFrontmatterParseError,
|
|
6142
|
-
|
|
6143
|
-
LegacyExportLayoutError,
|
|
6634
|
+
NoForegroundProcessError,
|
|
6144
6635
|
PANE_ID_RE,
|
|
6145
6636
|
PaneNotFoundError,
|
|
6146
6637
|
PruneOptionsInvalidError,
|
|
@@ -6174,6 +6665,7 @@ export {
|
|
|
6174
6665
|
backendByName,
|
|
6175
6666
|
capturePane,
|
|
6176
6667
|
captureSnapshot,
|
|
6668
|
+
checkCommandResolvable,
|
|
6177
6669
|
claimTask,
|
|
6178
6670
|
closeAgent,
|
|
6179
6671
|
closeTask,
|
|
@@ -6198,11 +6690,13 @@ export {
|
|
|
6198
6690
|
emitEvent,
|
|
6199
6691
|
ensureWorkstream,
|
|
6200
6692
|
ensureWorkstreamStateDir,
|
|
6693
|
+
envVarNameForCli,
|
|
6201
6694
|
exportArchive,
|
|
6202
6695
|
exportSourceForWorkstream,
|
|
6203
6696
|
exportSourcesForArchive,
|
|
6204
6697
|
exportWorkstream,
|
|
6205
6698
|
extractTail,
|
|
6699
|
+
foregroundPgid,
|
|
6206
6700
|
freeAgent,
|
|
6207
6701
|
freeWorkspace,
|
|
6208
6702
|
gcMaxAgeDays,
|
|
@@ -6223,6 +6717,7 @@ export {
|
|
|
6223
6717
|
idFromTitleVerbose,
|
|
6224
6718
|
importBucket,
|
|
6225
6719
|
insertAgent,
|
|
6720
|
+
isKickSignal,
|
|
6226
6721
|
isStaleVersion,
|
|
6227
6722
|
isTaskStatus,
|
|
6228
6723
|
isValidAgentName,
|
|
@@ -6231,6 +6726,7 @@ export {
|
|
|
6231
6726
|
isValidTaskId,
|
|
6232
6727
|
isValidWorkstreamName,
|
|
6233
6728
|
jjBackend,
|
|
6729
|
+
kickAgent,
|
|
6234
6730
|
killPane,
|
|
6235
6731
|
killSession,
|
|
6236
6732
|
latestSeq,
|
|
@@ -6263,10 +6759,13 @@ export {
|
|
|
6263
6759
|
openDb,
|
|
6264
6760
|
openTask,
|
|
6265
6761
|
paneExists,
|
|
6762
|
+
paneTTY,
|
|
6266
6763
|
parseAgentNameFromTitle,
|
|
6764
|
+
parsePsTtyOutput,
|
|
6267
6765
|
pruneSnapshots,
|
|
6268
6766
|
readAgent,
|
|
6269
6767
|
reconcile,
|
|
6768
|
+
recreateWorkspace,
|
|
6270
6769
|
refreshAgentTitle,
|
|
6271
6770
|
rejectTask,
|
|
6272
6771
|
releaseTask,
|
|
@@ -6274,11 +6773,14 @@ export {
|
|
|
6274
6773
|
removeFromArchive,
|
|
6275
6774
|
renderToBucket,
|
|
6276
6775
|
reparentTask,
|
|
6776
|
+
resetCommandResolverForTests,
|
|
6777
|
+
resetKickProcessExecutor,
|
|
6277
6778
|
resetSleep,
|
|
6278
6779
|
resetTmuxExecutor,
|
|
6279
6780
|
resetWaitPollCount,
|
|
6280
6781
|
resolveActorIdentity,
|
|
6281
6782
|
resolveCliCommand,
|
|
6783
|
+
resolveCliCommandWithSource,
|
|
6282
6784
|
restoreSnapshot,
|
|
6283
6785
|
searchArchives,
|
|
6284
6786
|
searchTasks,
|
|
@@ -6286,6 +6788,8 @@ export {
|
|
|
6286
6788
|
sendToAgent,
|
|
6287
6789
|
sendToPane,
|
|
6288
6790
|
sessionExists,
|
|
6791
|
+
setCommandResolverForTests,
|
|
6792
|
+
setKickProcessExecutor,
|
|
6289
6793
|
setPaneTitle,
|
|
6290
6794
|
setSleepForTests,
|
|
6291
6795
|
setTaskStatus,
|