@brutalsystems/muster 3.3.0 → 3.3.1

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/guard.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { isAbsolute, join, relative, resolve } from "node:path";
@@ -425,16 +425,24 @@ export function resolveResume(req) {
425
425
  * tincan and the ledger could not say which one a message reaches. A fork
426
426
  * only reads the original, so it may resume a live one.
427
427
  */
428
- export function assertResumable(resume, env, alive = pidAlive) {
428
+ export function assertResumable(resume, env, cwd, alive = pidAlive) {
429
429
  const configDir = claudeConfigDir(env);
430
430
  const projects = join(configDir, "projects");
431
- let found = false;
431
+ let transcript;
432
432
  try {
433
- found = readdirSync(projects).some((dir) => existsSync(join(projects, dir, resume.id + ".jsonl")));
433
+ transcript = readdirSync(projects)
434
+ .map((dir) => join(projects, dir, resume.id + ".jsonl"))
435
+ .find((file) => existsSync(file));
434
436
  }
435
437
  catch { }
436
- if (!found)
438
+ if (!transcript)
437
439
  throw new Error(`No Claude conversation ${resume.id} in ${configDir}; --resume reopens a session recorded in the profile this launch runs in`);
440
+ // Claude 2.1.302 resumes a conversation from any directory, so this is
441
+ // muster's to refuse (#10): the history would describe one tree while the
442
+ // agent works in another. Unknown when the transcript names no cwd.
443
+ const recorded = transcriptCwd(transcript);
444
+ if (recorded !== undefined && realPath(recorded) !== realPath(cwd))
445
+ throw new Error(`Claude session ${resume.id} was recorded in ${recorded}, not ${cwd}; resume it with --cwd ${recorded}`);
438
446
  if (resume.fork)
439
447
  return;
440
448
  const sessions = claudeSessionsDir(env);
@@ -458,6 +466,43 @@ export function assertResumable(resume, env, alive = pidAlive) {
458
466
  throw new Error(`Claude session ${resume.id} is running (pid ${pid}); stop it before resuming it, or pass --fork-session to continue it under a new id`);
459
467
  }
460
468
  }
469
+ /** The first `cwd` a transcript records, read from its head: a transcript can
470
+ * run to many megabytes, and the cwd is on its first message. */
471
+ function transcriptCwd(file) {
472
+ let head = "";
473
+ try {
474
+ const fd = openSync(file, "r");
475
+ try {
476
+ const buf = Buffer.alloc(1 << 20);
477
+ head = buf.toString("utf8", 0, readSync(fd, buf, 0, buf.length, 0));
478
+ }
479
+ finally {
480
+ closeSync(fd);
481
+ }
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ for (const line of head.split("\n")) {
487
+ if (!line.includes('"cwd"'))
488
+ continue;
489
+ try {
490
+ const { cwd } = JSON.parse(line);
491
+ if (typeof cwd === "string" && cwd)
492
+ return cwd;
493
+ }
494
+ catch { }
495
+ }
496
+ return undefined;
497
+ }
498
+ function realPath(path) {
499
+ try {
500
+ return realpathSync(path);
501
+ }
502
+ catch {
503
+ return resolve(path);
504
+ }
505
+ }
461
506
  function pidAlive(pid) {
462
507
  try {
463
508
  process.kill(pid, 0);
package/dist/run.js CHANGED
@@ -802,6 +802,15 @@ export class Muster {
802
802
  ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline, req.prompt)
803
803
  : await resolveClaude(root.pid, resolveEnv, deadline);
804
804
  if (identity) {
805
+ // The answer #10 asks for: a resume that registered some other
806
+ // session is a launch that failed, never a new session reported as
807
+ // the old one. Not fatal at once, like every other diagnosis here,
808
+ // in case the registry is read mid-write.
809
+ const resume = req.runtime === "claude" ? resolveResume(req) : null;
810
+ if (resume && (identity.id === resume.id) === resume.fork)
811
+ throw new Error(resume.fork
812
+ ? `--fork-session registered the original session ${resume.id} instead of a new one`
813
+ : `Claude registered session ${identity.id} instead of resuming ${resume.id}`);
805
814
  diagnostic =
806
815
  req.runtime === "codex"
807
816
  ? "thread id reserved but no rollout"
@@ -923,7 +932,7 @@ export class Muster {
923
932
  throw new Error("--resume is available to a local requester only");
924
933
  if (identityName)
925
934
  throw new Error("--resume cannot be combined with an identity: the identity's config dir holds none of this profile's conversations");
926
- assertResumable(resume, agentBase);
935
+ assertResumable(resume, agentBase, effective.cwd);
927
936
  }
928
937
  let argv = plannedArgv;
929
938
  let codexPolicy = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {