mainflow 0.44.0-alpha.3 → 0.44.0-alpha.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.
@@ -1033,7 +1033,13 @@ const CONFIG_FILE = "config.yaml";
1033
1033
  const ISSUES_DIR = "issues";
1034
1034
  const FINDINGS_DIR = "findings";
1035
1035
  const GROUPS_DIR = "groups";
1036
+ /** The sessions settling a turn reads: those still running, paused, or ended while kept for their family (ENDED_SESSIONS_DIR). */
1036
1037
  const SESSIONS_DIR = "local/sessions";
1038
+ /**
1039
+ * The sessions that ended or were handed off, moved out of SESSIONS_DIR so the work a turn's end does follows the
1040
+ * sessions still under way, not every session the hub ever had. A session comes back to SESSIONS_DIR when it is resumed.
1041
+ */
1042
+ const ENDED_SESSIONS_DIR = `${SESSIONS_DIR}/ended`;
1037
1043
  /** The document inside each mainline and task directory: front matter for attributes, body for prose. */
1038
1044
  const MAINLINE_FILE = "mainline.md";
1039
1045
  const TASK_FILE = "task.md";
@@ -1079,7 +1085,16 @@ function decisionFileIn(hub, mainline, decision) {
1079
1085
  if (!file) throw new WriteError(`decision ${mainline}/${decision} not found`);
1080
1086
  return file;
1081
1087
  }
1088
+ /** Where the session `id` is kept while it is under way; one that ended may be in endedSessionFileIn instead. */
1082
1089
  const sessionFileIn = (id) => `${SESSIONS_DIR}/${id}.yaml`;
1090
+ /** Where the session `id` is kept once it ended or was handed off. */
1091
+ const endedSessionFileIn = (id) => `${ENDED_SESSIONS_DIR}/${id}.yaml`;
1092
+ /** The session a file in either session directory holds, by its path relative to the data directory; null for any other path. */
1093
+ function sessionIdOfPath(path) {
1094
+ const parts = path.split("/");
1095
+ const name = parts[0] === "local" && parts[1] === "sessions" ? parts.length === 3 ? parts[2] : parts.length === 4 && parts[2] === "ended" ? parts[3] : void 0 : void 0;
1096
+ return name?.endsWith(".yaml") && name.length > 5 ? name.slice(0, -5) : null;
1097
+ }
1083
1098
  function groupFileIn(hub, id) {
1084
1099
  const file = entryFileIn(hub, GROUPS_DIR, id);
1085
1100
  if (!file) throw new WriteError(`group ${id} not found`);
@@ -3258,18 +3273,27 @@ function removeFile(hub, input) {
3258
3273
  }
3259
3274
  //#endregion
3260
3275
  //#region ../core/dist/ops/sessions.js
3261
- /** Read every session; one that does not parse or validate is reported as a problem and skipped. */
3262
- function readSessionsIn(hub) {
3263
- const sessions = [];
3264
- const problems = [];
3265
- for (const { name } of hub.list(SESSIONS_DIR).filter((e) => e.name.endsWith(".yaml"))) {
3266
- const path = `${SESSIONS_DIR}/${name}`;
3276
+ /** The statuses of a session that is done with; such a session is kept in ENDED_SESSIONS_DIR (belongsInEnded). */
3277
+ const FINISHED = ["ended", "handed-off"];
3278
+ /** Read the sessions in `dir`, those named in `skip` aside; one that does not parse or validate is reported as a problem and skipped. */
3279
+ function readSessionDirIn(hub, dir, out, skip = () => false) {
3280
+ const names = hub.list(dir).filter((e) => !e.dir && e.name.endsWith(".yaml")).map((e) => e.name);
3281
+ for (const name of names) {
3282
+ const path = `${dir}/${name}`;
3267
3283
  const file = `${DATA_DIR}/${path}`;
3284
+ if (skip(name)) {
3285
+ out.problems.push({
3286
+ file,
3287
+ field: "",
3288
+ message: `the session is in ${DATA_DIR}/${SESSIONS_DIR}/ as well, which is the copy read; remove the copy that is out of date`
3289
+ });
3290
+ continue;
3291
+ }
3268
3292
  let data;
3269
3293
  try {
3270
3294
  data = parse$1(hub.read(path) ?? "");
3271
3295
  } catch (error) {
3272
- problems.push({
3296
+ out.problems.push({
3273
3297
  file,
3274
3298
  field: "",
3275
3299
  message: `cannot parse YAML: ${error.message}`
@@ -3277,27 +3301,124 @@ function readSessionsIn(hub) {
3277
3301
  continue;
3278
3302
  }
3279
3303
  const result = sessionSchema.safeParse(data);
3280
- if (result.success) sessions.push(result.data);
3281
- else for (const i of result.error.issues) problems.push({
3304
+ if (!result.success) {
3305
+ for (const i of result.error.issues) out.problems.push({
3306
+ file,
3307
+ field: i.path.join("."),
3308
+ message: i.message
3309
+ });
3310
+ continue;
3311
+ }
3312
+ if (dir === ENDED_SESSIONS_DIR && !FINISHED.includes(result.data.status)) out.problems.push({
3282
3313
  file,
3283
- field: i.path.join("."),
3284
- message: i.message
3314
+ field: "status",
3315
+ message: `${result.data.status}, but ${DATA_DIR}/${ENDED_SESSIONS_DIR}/ keeps only sessions that ended or were handed off; run "mainflow migrate" to move it back to ${DATA_DIR}/${SESSIONS_DIR}/`
3285
3316
  });
3317
+ out.sessions.push(result.data);
3286
3318
  }
3287
- return {
3288
- sessions,
3289
- problems
3319
+ return names;
3320
+ }
3321
+ /**
3322
+ * Read every session: those in local/sessions/ and, unless `ended` is false, those in local/sessions/ended/ too. One
3323
+ * that does not parse or validate is reported as a problem and skipped; so is a session in both directories, whose copy
3324
+ * in local/sessions/ is read. Settling a turn needs only the first directory; listing, checking and looking back over
3325
+ * past work need both.
3326
+ */
3327
+ function readSessionsIn(hub, options = {}) {
3328
+ const out = {
3329
+ sessions: [],
3330
+ problems: []
3290
3331
  };
3332
+ const names = new Set(readSessionDirIn(hub, SESSIONS_DIR, out));
3333
+ if (options.ended !== false) readSessionDirIn(hub, ENDED_SESSIONS_DIR, out, (name) => names.has(name));
3334
+ return out;
3335
+ }
3336
+ /** Where the session `id` is kept: local/sessions/ while under way, local/sessions/ended/ once ended; null for a session neither holds. */
3337
+ function sessionPathIn(hub, id) {
3338
+ for (const path of [sessionFileIn(id), endedSessionFileIn(id)]) if (hub.read(path) !== null) return path;
3339
+ return null;
3291
3340
  }
3292
3341
  function readSessionIn(hub, id) {
3293
- const path = sessionFileIn(id);
3294
- return hub.read(path) === null ? null : readYamlIn(hub, path, sessionSchema);
3342
+ const path = sessionPathIn(hub, id);
3343
+ return path === null ? null : readYamlIn(hub, path, sessionSchema);
3295
3344
  }
3296
3345
  function requireSession(hub, id) {
3297
- const path = sessionFileIn(id);
3298
- if (hub.read(path) === null) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
3346
+ const path = sessionPathIn(hub, id);
3347
+ if (path === null) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
3299
3348
  return path;
3300
3349
  }
3350
+ function sessionLookup(hub, known = []) {
3351
+ const cache = new Map(known.map((s) => [s.id, s]));
3352
+ const lookup = (id) => {
3353
+ if (!cache.has(id)) {
3354
+ let found = null;
3355
+ try {
3356
+ found = readSessionIn(hub, id);
3357
+ } catch {}
3358
+ cache.set(id, found);
3359
+ }
3360
+ return cache.get(id);
3361
+ };
3362
+ return Object.assign(lookup, { set: (session) => void cache.set(session.id, session) });
3363
+ }
3364
+ /**
3365
+ * Whether a session belongs in local/sessions/ended/: it ended or was handed off, and each session it was delegated
3366
+ * from, up to the main session, tracked an action. Settling a session reads the subagents under it in local/sessions/
3367
+ * only (settleSession), and a subagent left there is one that may still decide it: a session whose family tracked no
3368
+ * action is removed with the family. A subagent moves out only once every session above it tracked an action, and no
3369
+ * write takes an action away, so none of them can be removed any more and the subagent, which no longer runs, changes
3370
+ * nothing settling decides. A subagent whose parent is missing or does not read stays, since nothing tells.
3371
+ */
3372
+ function belongsInEnded(session, lookup) {
3373
+ if (!FINISHED.includes(session.status)) return false;
3374
+ const seen = /* @__PURE__ */ new Set([session.id]);
3375
+ for (let parent = session.delegatedFrom; parent && !seen.has(parent);) {
3376
+ seen.add(parent);
3377
+ const above = lookup(parent);
3378
+ if (!above || above.actions.length === 0) return false;
3379
+ parent = above.delegatedFrom;
3380
+ }
3381
+ return true;
3382
+ }
3383
+ /** The path the session belongs at (belongsInEnded). */
3384
+ const placeOf = (session, lookup) => belongsInEnded(session, lookup) ? endedSessionFileIn(session.id) : sessionFileIn(session.id);
3385
+ /**
3386
+ * Where the session at `from` goes when it belongs at `to`: `from` itself when another file is at `to` already, as when
3387
+ * an older mainflow started the session again in local/sessions/ while it was in local/sessions/ended/. Writing there
3388
+ * would replace that copy, and the record it holds, so the session stays and the write says so; check lists both copies.
3389
+ */
3390
+ function destination(draft, id, from, to) {
3391
+ if (to === from || draft.read(to) === null) return to;
3392
+ const message = `${DATA_DIR}/${to} holds session ${id} too, so ${DATA_DIR}/${from} stays where it is rather than replace it; keep the copy that is up to date and remove the other`;
3393
+ if (!draft.notices.some((n) => n.path === from && n.message === message)) draft.notice(from, message);
3394
+ return from;
3395
+ }
3396
+ /** Move the file at `from` to `to`, unchanged; nothing when they are the same or another file is at `to` (destination). */
3397
+ function moveFile(draft, id, from, to) {
3398
+ if (destination(draft, id, from, to) === from) return;
3399
+ draft.write(to, draft.read(from));
3400
+ draft.remove(from);
3401
+ }
3402
+ /**
3403
+ * Write `next`, the session read as `current` from `from`, to the directory it belongs in now (belongsInEnded), keeping
3404
+ * the fields its file has beyond the schema; a session that moves is removed from `from`. One whose place another file
3405
+ * holds is written at `from` (destination).
3406
+ */
3407
+ function writeSession(draft, from, current, next, lookup = sessionLookup(draft)) {
3408
+ const to = destination(draft, next.id, from, placeOf(next, lookup));
3409
+ draft.write(to, toYaml(yamlKeeping(draft, from, current, next)));
3410
+ if (to !== from) draft.remove(from);
3411
+ lookup.set(next);
3412
+ return next;
3413
+ }
3414
+ /** Mutate, validate and write a session, which moves to the directory its new status puts it in (writeSession). */
3415
+ function editSession(draft, id, mutate, lookup) {
3416
+ const from = requireSession(draft, id);
3417
+ const current = readYamlIn(draft, from, sessionSchema);
3418
+ const next = structuredClone(current);
3419
+ mutate(next);
3420
+ return writeSession(draft, from, current, validate(sessionSchema, next, draft.where(from)), lookup);
3421
+ }
3301
3422
  /** Split `claude-code-1a2b3c4d#a003` into its session and action ids. */
3302
3423
  function splitActionRef(ref) {
3303
3424
  const hash = ref.lastIndexOf("#");
@@ -3412,11 +3533,11 @@ function takeUnfinished(draft, ctx, from, to, existing, at) {
3412
3533
  doneAt: at
3413
3534
  };
3414
3535
  });
3415
- draft.write(previousFile, toYaml(yamlKeeping(draft, previousFile, previous, validate(sessionSchema, {
3536
+ writeSession(draft, previousFile, previous, validate(sessionSchema, {
3416
3537
  ...previous,
3417
3538
  status: "handed-off",
3418
3539
  actions: left
3419
- }, draft.where(previousFile)))));
3540
+ }, draft.where(previousFile)));
3420
3541
  return carried;
3421
3542
  }
3422
3543
  /**
@@ -3429,7 +3550,7 @@ function startSession(hub, input, ctx) {
3429
3550
  const { minute } = stamps(ctx);
3430
3551
  const draft = new Draft(hub);
3431
3552
  const file = sessionFileIn(id);
3432
- if (draft.read(file) !== null) throw new WriteError(`session ${id} already exists`);
3553
+ if (sessionPathIn(draft, id) !== null) throw new WriteError(`session ${id} already exists`);
3433
3554
  if (input.delegatedFrom) requireSession(draft, input.delegatedFrom);
3434
3555
  const carried = input.handoffFrom ? takeUnfinished(draft, ctx, input.handoffFrom, id, [], minute) : [];
3435
3556
  const session = validate(sessionSchema, {
@@ -3446,8 +3567,8 @@ function startSession(hub, input, ctx) {
3446
3567
  return draft.result(session);
3447
3568
  }
3448
3569
  /**
3449
- * Start the session unless it already exists. An existing paused or ended session becomes active again, and
3450
- * an archived one comes back into view, since resumed work should not stay hidden.
3570
+ * Start the session unless it already exists. An existing paused or ended session becomes active again, back in
3571
+ * local/sessions/, and an archived one comes back into view, since resumed work should not stay hidden.
3451
3572
  */
3452
3573
  function ensureSession(hub, input, ctx) {
3453
3574
  const existing = readSessionIn(hub, input.id);
@@ -3466,7 +3587,7 @@ function ensureSession(hub, input, ctx) {
3466
3587
  session: existing,
3467
3588
  created: false
3468
3589
  });
3469
- const session = editYaml(draft, requireSession(draft, input.id), sessionSchema, (s) => {
3590
+ const session = editSession(draft, input.id, (s) => {
3470
3591
  s.status = "active";
3471
3592
  delete s.archivedAt;
3472
3593
  });
@@ -3499,8 +3620,7 @@ function handoffSession(hub, input, ctx) {
3499
3620
  handoffFrom: from,
3500
3621
  actions: [...session.actions, ...carried]
3501
3622
  }, draft.where(file));
3502
- draft.write(file, toYaml(yamlKeeping(draft, file, session, next)));
3503
- return draft.result(next);
3623
+ return draft.result(writeSession(draft, file, session, next));
3504
3624
  }
3505
3625
  function setSessionInfo(hub, input) {
3506
3626
  const draft = new Draft(hub);
@@ -3527,7 +3647,7 @@ function setCheckReported(hub, input) {
3527
3647
  function setSessionStatus(hub, input) {
3528
3648
  const value = requireOneOf(input.status, SESSION_STATUS, "session status");
3529
3649
  const draft = new Draft(hub);
3530
- return draft.result(editYaml(draft, requireSession(draft, input.id), sessionSchema, (s) => {
3650
+ return draft.result(editSession(draft, input.id, (s) => {
3531
3651
  s.status = value;
3532
3652
  }));
3533
3653
  }
@@ -3536,28 +3656,99 @@ function setSessionStatus(hub, input) {
3536
3656
  * no record, since nothing in it points at the hub's work. Then every active main session with no action left to
3537
3657
  * do and no subagent still running ends, the others as well as this one: another session may have finished its
3538
3658
  * last action, or it was closed before its own turn could settle it. The next prompt or action brings it back.
3659
+ *
3660
+ * Only the sessions in local/sessions/ are read, and the session itself wherever it is. An ended session moves to
3661
+ * local/sessions/ended/, with the finished subagents under it and under this session that belong there now
3662
+ * (belongsInEnded); a subagent already there cannot change what settling decides, so it is not read.
3539
3663
  */
3540
3664
  function settleSession(hub, input) {
3541
3665
  const { id } = input;
3542
3666
  const draft = new Draft(hub);
3543
- const { sessions } = readSessionsIn(draft);
3544
- const subagents = (parent) => sessions.filter((s) => s.delegatedFrom === parent).flatMap((s) => [s, ...subagents(s.id)]);
3545
- const family = sessions.some((s) => s.id === id) ? [sessions.find((s) => s.id === id), ...subagents(id)] : [];
3667
+ const { sessions } = readSessionsIn(draft, { ended: false });
3668
+ const lookup = sessionLookup(draft, sessions);
3669
+ /** Whether `session` was delegated from `parent`, directly or through other subagents. */
3670
+ const isUnder = (session, parent) => {
3671
+ const seen = /* @__PURE__ */ new Set([session.id]);
3672
+ for (let above = session.delegatedFrom; above && !seen.has(above); above = lookup(above)?.delegatedFrom ?? null) {
3673
+ if (above === parent) return true;
3674
+ seen.add(above);
3675
+ }
3676
+ return false;
3677
+ };
3678
+ const subagents = (parent) => sessions.filter((s) => isUnder(s, parent));
3679
+ const self = lookup(id);
3680
+ const family = self ? [self, ...subagents(id)] : [];
3546
3681
  const removed = family.length > 0 && family.every((s) => s.actions.length === 0);
3547
- if (removed) for (const s of family) draft.remove(sessionFileIn(s.id));
3682
+ if (removed) for (const s of family) draft.remove(sessionPathIn(draft, s.id));
3548
3683
  let result = removed ? "removed" : "kept";
3684
+ const ended = [];
3549
3685
  for (const s of sessions) {
3550
3686
  if (s.status !== "active" || s.delegatedFrom || removed && s.id === id) continue;
3551
3687
  if (!s.actions.every((a) => a.status === "done" || a.status === "dropped") || subagents(s.id).some((x) => x.status === "active")) continue;
3552
3688
  if (s.id === id && s.actions.length === 0) continue;
3553
- draft.write(sessionFileIn(s.id), toYaml(yamlKeeping(draft, sessionFileIn(s.id), s, validate(sessionSchema, {
3689
+ writeSession(draft, sessionFileIn(s.id), s, validate(sessionSchema, {
3554
3690
  ...s,
3555
3691
  status: "ended"
3556
- }, draft.where(sessionFileIn(s.id))))));
3692
+ }, draft.where(sessionFileIn(s.id))), lookup);
3693
+ ended.push(s.id);
3557
3694
  if (s.id === id) result = "ended";
3558
3695
  }
3696
+ const roots = [...self && !removed ? [id] : [], ...ended];
3697
+ for (const s of sessions) {
3698
+ if (roots.includes(s.id) || !roots.some((root) => isUnder(s, root)) || draft.read(sessionFileIn(s.id)) === null) continue;
3699
+ moveFile(draft, s.id, sessionFileIn(s.id), placeOf(lookup(s.id), lookup));
3700
+ }
3701
+ if (self && !removed && sessionPathIn(draft, id) === sessionFileIn(id)) moveFile(draft, id, sessionFileIn(id), placeOf(lookup(id), lookup));
3559
3702
  return draft.result(result);
3560
3703
  }
3704
+ /**
3705
+ * Move every session file to the directory it belongs in (belongsInEnded), as `mainflow migrate` does for a hub that
3706
+ * kept every session in local/sessions/: at most `limit` files, and never more than RELOCATE_BATCH, so a large hub moves
3707
+ * in several writes; `more` says another file is to move after them. `dryRun` moves none and lists them all. A file that
3708
+ * does not read, or that both directories hold, stays where it is; check reports it. Running it again once nothing is
3709
+ * left to move changes nothing.
3710
+ */
3711
+ function relocateSessions$1(hub, input = {}) {
3712
+ const draft = new Draft(hub);
3713
+ const asked = typeof input.limit === "number" && Number.isFinite(input.limit) ? Math.floor(input.limit) : 500;
3714
+ const limit = input.dryRun ? Infinity : Math.min(Math.max(1, asked), 500);
3715
+ const lookup = sessionLookup(draft);
3716
+ const value = {
3717
+ ended: [],
3718
+ resumed: [],
3719
+ more: false
3720
+ };
3721
+ const names = (dir) => draft.list(dir).filter((e) => !e.dir && e.name.endsWith(".yaml")).map((e) => e.name.slice(0, -5));
3722
+ const active = names(SESSIONS_DIR);
3723
+ const ended = new Set(names(ENDED_SESSIONS_DIR));
3724
+ const moves = [];
3725
+ /** Plan the move of the file at `from`, if it is elsewhere than it belongs; false, with `more`, at a move past the limit. */
3726
+ const consider = (id, from, list) => {
3727
+ let session;
3728
+ try {
3729
+ session = readYamlIn(draft, from, sessionSchema);
3730
+ } catch {
3731
+ return true;
3732
+ }
3733
+ if (session.id !== id) return true;
3734
+ lookup.set(session);
3735
+ const to = placeOf(session, lookup);
3736
+ if (to === from) return true;
3737
+ if (moves.length >= limit) {
3738
+ value.more = true;
3739
+ return false;
3740
+ }
3741
+ moves.push([from, to]);
3742
+ list.push(id);
3743
+ return true;
3744
+ };
3745
+ for (const id of active) if (!ended.has(id) && !consider(id, sessionFileIn(id), value.ended)) break;
3746
+ if (!value.more) {
3747
+ for (const id of ended) if (!active.includes(id) && !consider(id, endedSessionFileIn(id), value.resumed)) break;
3748
+ }
3749
+ if (!input.dryRun) for (const [from, to] of moves) moveFile(draft, sessionIdOfPath(from), from, to);
3750
+ return draft.result(value);
3751
+ }
3561
3752
  /** Archive or unarchive a session; the status stays as it is, so an ended session is visible until archived. */
3562
3753
  function setSessionArchived(hub, input, ctx) {
3563
3754
  const { day } = stamps(ctx);
@@ -3581,7 +3772,7 @@ function addAction(hub, input, ctx) {
3581
3772
  requireServes(hub, input.serves);
3582
3773
  const draft = new Draft(hub);
3583
3774
  let created = "";
3584
- editYaml(draft, requireSession(draft, input.session), sessionSchema, (s) => {
3775
+ editSession(draft, input.session, (s) => {
3585
3776
  created = draft.number(ctx, {
3586
3777
  prefix: "a",
3587
3778
  taken: s.actions.map((a) => a.id),
@@ -3933,8 +4124,9 @@ function migrateHub$1(hub) {
3933
4124
  /**
3934
4125
  * The operations by name, for a server that receives them from elsewhere. The names are the protocol's
3935
4126
  * (OPERATION_NAMES in protocol.ts): the table must hold every one of them and nothing else. `migrate` and `init`
3936
- * are absent: migration rewrites the whole hub in one go and a new hub starts on the caller's machine, so neither
3937
- * is sent as an operation.
4127
+ * are absent: migrating the data format rewrites the whole hub in one go and a new hub starts on the caller's machine,
4128
+ * so neither is sent as an operation. Moving the session files to where they belong, which `migrate` also does, is
4129
+ * one (`session.relocate`), sent again until nothing is left to move.
3938
4130
  */
3939
4131
  const OPERATIONS = {
3940
4132
  "repo.add": addRepo,
@@ -3976,6 +4168,7 @@ const OPERATIONS = {
3976
4168
  "session.checked": setCheckReported,
3977
4169
  "session.set": setSessionStatus,
3978
4170
  "session.settle": settleSession,
4171
+ "session.relocate": relocateSessions$1,
3979
4172
  "session.archive": setSessionArchived,
3980
4173
  "action.add": addAction,
3981
4174
  "action.set": updateAction,
@@ -4015,18 +4208,20 @@ function partsOf(path, dir) {
4015
4208
  if (parts.some((part) => part === "" || part === "." || part === ".." || part === ".git")) return null;
4016
4209
  return (dir ? parts : parts.slice(0, -1)).some((part) => OWN_DIRS.has(part)) ? null : parts;
4017
4210
  }
4018
- /** Whether the file at `path` is synced: not in a directory never synced, not in local/ but local/sessions/, and not a transient name. */
4211
+ /** The directories of local/ whose files are synced: the sessions under way, and those that ended. */
4212
+ const SESSION_DIRS = [SESSIONS_DIR, ENDED_SESSIONS_DIR];
4213
+ /** Whether the file at `path` is synced: not in a directory never synced, not in local/ but the session directories, and not a transient name. */
4019
4214
  function syncedPath(path) {
4020
4215
  const parts = partsOf(path, false);
4021
4216
  if (parts === null) return false;
4022
- if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
4217
+ if (parts[0] === "local" && !SESSION_DIRS.includes(parts.slice(0, -1).join("/"))) return false;
4023
4218
  return !TRANSIENT.some((pattern) => pattern.test(parts.at(-1)));
4024
4219
  }
4025
- /** Whether the directory at `path` may hold synced files, so a scan walks into it: local/ itself and local/sessions/ but nothing else in local/. */
4220
+ /** Whether the directory at `path` may hold synced files, so a scan walks into it: local/ itself and the session directories but nothing else in local/. */
4026
4221
  function mayHoldSynced(path) {
4027
4222
  if (path === "") return true;
4028
4223
  if (partsOf(path, true) === null) return false;
4029
- return path === "local" || !path.startsWith("local/") || path === "local/sessions";
4224
+ return path === "local" || !path.startsWith("local/") || SESSION_DIRS.includes(path);
4030
4225
  }
4031
4226
  /** Whether the directory at `path` is synced: one that may hold synced files, apart from .mainflow/ and local/, which every machine has of its own. */
4032
4227
  const syncedDir = (path) => path !== "" && path !== "local" && mayHoldSynced(path);
@@ -4103,7 +4298,8 @@ const ENTRY = /^([mtdifw]\d{3})(-.+)$/;
4103
4298
  /** The scope of a file in the data directory, from its path: the mainline and task directories above it, or its session. */
4104
4299
  function scopeOfPath(path) {
4105
4300
  const [top, second, third, fourth] = path.split("/");
4106
- if (top === "local" && second === "sessions" && third?.endsWith(".yaml") && fourth === void 0) return { session: third.slice(0, -5) };
4301
+ const session = sessionIdOfPath(path);
4302
+ if (session !== null) return { session };
4107
4303
  if (top === "groups" && second && third === void 0) {
4108
4304
  const group = ENTRY.exec(second)?.[1];
4109
4305
  return group ? { group } : {};
@@ -4726,9 +4922,12 @@ function grantLines(dirs, session) {
4726
4922
  }
4727
4923
  //#endregion
4728
4924
  //#region ../core/dist/sessions.js
4729
- /** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
4730
- function loadSessions(dirs) {
4731
- return readSessionsIn(nodeStorage(dirs));
4925
+ /**
4926
+ * Read every session under `.mainflow/local/sessions/`, and under `.mainflow/local/sessions/ended/` unless `ended` is
4927
+ * false, as for what only sessions under way matter to; sessions are local and never version-controlled.
4928
+ */
4929
+ function loadSessions(dirs, options = {}) {
4930
+ return readSessionsIn(nodeStorage(dirs), options);
4732
4931
  }
4733
4932
  function readSession(dirs, id) {
4734
4933
  return readSessionIn(nodeStorage(dirs), id);
@@ -5078,7 +5277,7 @@ function duplicateClaimLines(dirs, sessionId, actionId) {
5078
5277
  * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
5079
5278
  */
5080
5279
  function handoffLines(dirs, session, cli) {
5081
- const others = loadSessions(dirs).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
5280
+ const others = loadSessions(dirs, { ended: false }).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
5082
5281
  if (others.length === 0) return [];
5083
5282
  return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(dirs, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isCarried).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
5084
5283
  }
@@ -9170,6 +9369,31 @@ function migrateHub(dirs, options = {}) {
9170
9369
  } : result;
9171
9370
  }).value;
9172
9371
  }
9372
+ /**
9373
+ * Move the session files of a hub on disk to the directory each belongs in: those that ended to local/sessions/ended/,
9374
+ * any resumed one back. The files do not depend on the data format, so this runs on a hub of any format; with `dryRun`
9375
+ * it only lists the moves.
9376
+ */
9377
+ function relocateSessions(dirs, options = {}) {
9378
+ const storage = nodeStorage(dirs);
9379
+ if (storage.read("config.yaml") === null) throw new WriteError(`no hub at ${dirs.root}; run mainflow init first`);
9380
+ if (options.dryRun) return applyOp(storage, (hub) => ({
9381
+ ...relocateSessions$1(hub, { dryRun: true }),
9382
+ changes: []
9383
+ })).value;
9384
+ const moved = {
9385
+ ended: [],
9386
+ resumed: [],
9387
+ more: false
9388
+ };
9389
+ for (let more = true; more;) {
9390
+ const batch = applyOp(storage, (hub) => relocateSessions$1(hub, { limit: 500 })).value;
9391
+ moved.ended.push(...batch.ended);
9392
+ moved.resumed.push(...batch.resumed);
9393
+ more = batch.more;
9394
+ }
9395
+ return moved;
9396
+ }
9173
9397
  //#endregion
9174
9398
  //#region ../core/dist/migrate-data.js
9175
9399
  /** What the original data directory is renamed to, inside the hub repository, once the hub reads from its new place. */
@@ -9701,4 +9925,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
9701
9925
  return true;
9702
9926
  }
9703
9927
  //#endregion
9704
- export { applyOperation as $, dataFile as $t, unhandledNotices as A, textBytes as At, attributeTrace as B, hubHoldingFile as Bt, duplicateClaimLines as C, ROUTES as Ct, markNoticesHandled as D, exceedsFileLimit as Dt, HANDLED_FILE as E, comparePaths as Et, matchGrants as F, MAINLINE_FILE as Ft, renumberFiles as G, registerHub as Gt, transcriptTitle as H, listHubs as Ht, decorateReply as I, SESSIONS_DIR as It, scopeOfPath as J, resolveHub as Jt, renumberPath as K, registeredName as Kt, endsWithMarker as L, TASK_FILE as Lt, readSession as M, hubFormatVersion as Mt, transcriptPath as N, hubRequirements as Nt, parseWarn as O, readsAsText as Ot, grantScope as P, versionMismatch as Pt, syncedPath as Q, currentBranch as Qt, headerLine as R, requireSlug as Rt, untilState as S, MAX_FILE_BYTES as St, turnReminder as T, VERSION_HEADER as Tt, applyOffline as U, notRegisteredMessage as Ut, traceTranscript as V, inRepositoryHubAt as Vt, renumberRequest as W, readRegistry as Wt, mayHoldSynced as X, userDataDir as Xt, hubDigest as Y, unregisterHub as Yt, syncedDir as Z, nodeStorage as Zt, receiverFromEnv as _, readYaml as _t, openInApp as a, DATA_DIR as an, loadMainlines as at, setArchivedMany as b, MAX_BLOB_BYTES as bt, siteBlock as c, splitFrontMatter as cn, mainlineEdges as ct, groupPlan as d, refIndex as dt, gitCheckout as en, checkHubName as et, listProgress as f, remoteMembers as ft, retroReportFromReceiver as g, readFrontMatter as gt, retroReport as h, nowMinute as ht, hostName as i, ConflictError as in, loadHub as it, loadSessions as j, formatMismatch as jt, readNotices as k, requestTime as kt, syncHost as l, linksInto as lt, migrateHub as m, taskFile as mt, DEFAULT_UI_PORT as n, realPath as nn, isOpenMainline as nt, removeDanglingLinks as o, WriteError as on, sectionText as ot, migrateHubData as p, unavailableHubs as pt, renumberText as q, registryFile as qt, caddySites as r, worktreesOf as rn, loadConfig as rt, removeHostLink as s, contentHash as sn, dependencyGraph as st, APP_BUNDLE_ID as t, hubPath as tn, HubError as tt, sessionView as u, memberOfGroup as ut, RETRY_RULES as v, ACCESS_HEADERS as vt, sessionBriefing as w, SOCKET_MAX_AGE_CLOSE as wt, initHub as x, MAX_CHANGE_PAGE as xt, ARCHIVE_KINDS as y, BLOB_HASH as yt, mayDecorate as z, hubAtRoot as zt };
9928
+ export { syncedPath as $, unregisterHub as $t, readNotices as A, requestTime as At, mayDecorate as B, endedSessionFileIn as Bt, untilState as C, MAX_FILE_BYTES as Ct, HANDLED_FILE as D, comparePaths as Dt, turnReminder as E, VERSION_HEADER as Et, grantScope as F, versionMismatch as Ft, renumberRequest as G, inRepositoryHubAt as Gt, traceTranscript as H, sessionFileIn as Ht, matchGrants as I, ENDED_SESSIONS_DIR as It, renumberText as J, readRegistry as Jt, renumberFiles as K, listHubs as Kt, decorateReply as L, MAINLINE_FILE as Lt, loadSessions as M, formatMismatch as Mt, readSession as N, hubFormatVersion as Nt, markNoticesHandled as O, exceedsFileLimit as Ot, transcriptPath as P, hubRequirements as Pt, syncedDir as Q, resolveHub as Qt, endsWithMarker as R, SESSIONS_DIR as Rt, initHub as S, MAX_CHANGE_PAGE as St, sessionBriefing as T, SOCKET_MAX_AGE_CLOSE as Tt, transcriptTitle as U, hubAtRoot as Ut, attributeTrace as V, requireSlug as Vt, applyOffline as W, hubHoldingFile as Wt, hubDigest as X, registeredName as Xt, scopeOfPath as Y, registerHub as Yt, mayHoldSynced as Z, registryFile as Zt, retroReportFromReceiver as _, readFrontMatter as _t, openInApp as a, hubPath as an, loadHub as at, ARCHIVE_KINDS as b, BLOB_HASH as bt, siteBlock as c, ConflictError as cn, dependencyGraph as ct, groupPlan as d, contentHash as dn, memberOfGroup as dt, userDataDir as en, applyOperation as et, listProgress as f, splitFrontMatter as fn, refIndex as ft, retroReport as g, nowMinute as gt, relocateSessions as h, taskFile as ht, hostName as i, gitCheckout as in, loadConfig as it, unhandledNotices as j, textBytes as jt, parseWarn as k, readsAsText as kt, syncHost as l, DATA_DIR as ln, mainlineEdges as lt, migrateHub as m, unavailableHubs as mt, DEFAULT_UI_PORT as n, currentBranch as nn, HubError as nt, removeDanglingLinks as o, realPath as on, loadMainlines as ot, migrateHubData as p, remoteMembers as pt, renumberPath as q, notRegisteredMessage as qt, caddySites as r, dataFile as rn, isOpenMainline as rt, removeHostLink as s, worktreesOf as sn, sectionText as st, APP_BUNDLE_ID as t, nodeStorage as tn, checkHubName as tt, sessionView as u, WriteError as un, linksInto as ut, receiverFromEnv as v, readYaml as vt, duplicateClaimLines as w, ROUTES as wt, setArchivedMany as x, MAX_BLOB_BYTES as xt, RETRY_RULES as y, ACCESS_HEADERS as yt, headerLine as z, TASK_FILE as zt };
package/dist/host.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as HubDirs } from "./index-jq7lN8V8.js";
1
+ import { o as HubDirs } from "./index-lsW80LDU.js";
2
2
  //#region src/host.d.ts
3
3
  export declare const DEFAULT_UI_PORT = 4320;
4
4
  export declare const hostName: (hub: string) => string;
package/dist/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as openInApp, c as siteBlock, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, o as removeDanglingLinks, r as caddySites, s as removeHostLink, t as APP_BUNDLE_ID } from "./host-CsZY5trM.js";
1
+ import { a as openInApp, c as siteBlock, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, o as removeDanglingLinks, r as caddySites, s as removeHostLink, t as APP_BUNDLE_ID } from "./host-CfBcUcg2.js";
2
2
  export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeDanglingLinks, removeHostLink, siteBlock, syncHost };
@@ -414,8 +414,8 @@ interface SessionInput {
414
414
  */
415
415
  declare function startSession(hub: HubReader, input: SessionInput, ctx: OpContext): OpResult<Session>;
416
416
  /**
417
- * Start the session unless it already exists. An existing paused or ended session becomes active again, and
418
- * an archived one comes back into view, since resumed work should not stay hidden.
417
+ * Start the session unless it already exists. An existing paused or ended session becomes active again, back in
418
+ * local/sessions/, and an archived one comes back into view, since resumed work should not stay hidden.
419
419
  */
420
420
  declare function ensureSession(hub: HubReader, input: SessionInput, ctx: OpContext): OpResult<{
421
421
  session: Session;
@@ -457,10 +457,31 @@ type Settled = 'removed' | 'ended' | 'kept';
457
457
  * no record, since nothing in it points at the hub's work. Then every active main session with no action left to
458
458
  * do and no subagent still running ends, the others as well as this one: another session may have finished its
459
459
  * last action, or it was closed before its own turn could settle it. The next prompt or action brings it back.
460
+ *
461
+ * Only the sessions in local/sessions/ are read, and the session itself wherever it is. An ended session moves to
462
+ * local/sessions/ended/, with the finished subagents under it and under this session that belong there now
463
+ * (belongsInEnded); a subagent already there cannot change what settling decides, so it is not read.
460
464
  */
461
465
  declare function settleSession(hub: HubReader, input: {
462
466
  id: string;
463
467
  }): OpResult<Settled>;
468
+ /** What relocating the session files moved: the sessions moved to local/sessions/ended/, those moved back, and whether more are left. */
469
+ interface Relocated {
470
+ ended: string[];
471
+ resumed: string[];
472
+ more: boolean;
473
+ }
474
+ /**
475
+ * Move every session file to the directory it belongs in (belongsInEnded), as `mainflow migrate` does for a hub that
476
+ * kept every session in local/sessions/: at most `limit` files, and never more than RELOCATE_BATCH, so a large hub moves
477
+ * in several writes; `more` says another file is to move after them. `dryRun` moves none and lists them all. A file that
478
+ * does not read, or that both directories hold, stays where it is; check reports it. Running it again once nothing is
479
+ * left to move changes nothing.
480
+ */
481
+ declare function relocateSessions(hub: HubReader, input?: {
482
+ dryRun?: boolean;
483
+ limit?: number;
484
+ }): OpResult<Relocated>;
464
485
  /** Archive or unarchive a session; the status stays as it is, so an ended session is visible until archived. */
465
486
  declare function setSessionArchived(hub: HubReader, input: {
466
487
  id: string;
@@ -495,15 +516,16 @@ declare function updateAction(hub: HubReader, input: {
495
516
  * (`item check` and `item uncheck` send `item.check`, with done true or false). The operations no command sends
496
517
  * are listed in COMMANDLESS_OPERATIONS with who sends them.
497
518
  */
498
- declare const OPERATION_NAMES: readonly ['repo.add', 'hub.add', 'mainline.new', 'mainline.set', 'mainline.title', 'mainline.archive', 'criterion.add', 'criterion.check', 'criterion.set', 'task.new', 'task.set', 'item.add', 'item.check', 'item.drop', 'item.undrop', 'decision.new', 'decision.accept', 'decision.supersede', 'issue.new', 'issue.set', 'issue.plan', 'issue.close', 'issue.reopen', 'issue.archive', 'finding.new', 'finding.set', 'finding.archive', 'group.new', 'group.set', 'ops.add', 'section.append', 'session.start', 'session.ensure', 'session.handoff', 'session.info', 'session.reminder', 'session.checked', 'session.set', 'session.settle', 'session.archive', 'action.add', 'action.set', 'file.remove'];
519
+ declare const OPERATION_NAMES: readonly ['repo.add', 'hub.add', 'mainline.new', 'mainline.set', 'mainline.title', 'mainline.archive', 'criterion.add', 'criterion.check', 'criterion.set', 'task.new', 'task.set', 'item.add', 'item.check', 'item.drop', 'item.undrop', 'decision.new', 'decision.accept', 'decision.supersede', 'issue.new', 'issue.set', 'issue.plan', 'issue.close', 'issue.reopen', 'issue.archive', 'finding.new', 'finding.set', 'finding.archive', 'group.new', 'group.set', 'ops.add', 'section.append', 'session.start', 'session.ensure', 'session.handoff', 'session.info', 'session.reminder', 'session.checked', 'session.set', 'session.settle', 'session.relocate', 'session.archive', 'action.add', 'action.set', 'file.remove'];
499
520
  type OperationName = (typeof OPERATION_NAMES)[number];
500
521
  //#endregion
501
522
  //#region ../core/dist/ops/operations.d.ts
502
523
  /**
503
524
  * The operations by name, for a server that receives them from elsewhere. The names are the protocol's
504
525
  * (OPERATION_NAMES in protocol.ts): the table must hold every one of them and nothing else. `migrate` and `init`
505
- * are absent: migration rewrites the whole hub in one go and a new hub starts on the caller's machine, so neither
506
- * is sent as an operation.
526
+ * are absent: migrating the data format rewrites the whole hub in one go and a new hub starts on the caller's machine,
527
+ * so neither is sent as an operation. Moving the session files to where they belong, which `migrate` also does, is
528
+ * one (`session.relocate`), sent again until nothing is left to move.
507
529
  */
508
530
  declare const OPERATIONS: {
509
531
  readonly 'repo.add': typeof addRepo;
@@ -545,6 +567,7 @@ declare const OPERATIONS: {
545
567
  readonly 'session.checked': typeof setCheckReported;
546
568
  readonly 'session.set': typeof setSessionStatus;
547
569
  readonly 'session.settle': typeof settleSession;
570
+ readonly 'session.relocate': typeof relocateSessions;
548
571
  readonly 'session.archive': typeof setSessionArchived;
549
572
  readonly 'action.add': typeof addAction;
550
573
  readonly 'action.set': typeof updateAction;
package/dist/launch.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { o as HubDirs } from "./index-jq7lN8V8.js";
1
+ import { o as HubDirs } from "./index-lsW80LDU.js";
2
2
  import { HostOptions } from "./host.js";
3
3
  import { ServerType } from "@hono/node-server";
4
4
  //#region src/launch.d.ts
package/dist/launch.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as hostName, l as syncHost, n as DEFAULT_UI_PORT, on as WriteError, s as removeHostLink } from "./host-CsZY5trM.js";
1
+ import { i as hostName, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink, un as WriteError } from "./host-CfBcUcg2.js";
2
2
  import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
3
3
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
@@ -273,7 +273,7 @@ async function launchUi(dirs, hub, configured, options) {
273
273
  }
274
274
  let server;
275
275
  if (!reuse) {
276
- const { serveUi } = await import("./ui-Bv29R0WG.js");
276
+ const { serveUi } = await import("./ui-CnNAOM_Y.js");
277
277
  server = await serveUi(dirs, choice.port);
278
278
  writeUiRecord(dirs, {
279
279
  port: choice.port,
@@ -1,4 +1,4 @@
1
- import { a as Renumbering, i as Notice, n as OperationValue, r as OperationName, t as OperationInput } from "./index-jq7lN8V8.js";
1
+ import { a as Renumbering, i as Notice, n as OperationValue, r as OperationName, t as OperationInput } from "./index-lsW80LDU.js";
2
2
  //#region src/remote/operation.d.ts
3
3
  /** What a write reports: the operation's value, and what its caller tells the user about it. */
4
4
  interface Written<N extends OperationName = OperationName> {
@@ -1,4 +1,4 @@
1
- import { $ as applyOperation, At as textBytes, Ct as ROUTES, Dt as exceedsFileLimit, Et as comparePaths, G as renumberFiles, J as scopeOfPath, K as renumberPath, Kt as registeredName, Nt as hubRequirements, Ot as readsAsText, Pt as versionMismatch, Q as syncedPath, St as MAX_FILE_BYTES, Tt as VERSION_HEADER, U as applyOffline, W as renumberRequest, X as mayHoldSynced, Y as hubDigest, Z as syncedDir, Zt as nodeStorage, bt as MAX_BLOB_BYTES, cn as splitFrontMatter, ht as nowMinute, in as ConflictError, kt as requestTime, on as WriteError, q as renumberText, sn as contentHash, vt as ACCESS_HEADERS, wt as SOCKET_MAX_AGE_CLOSE, yt as BLOB_HASH } from "./host-CsZY5trM.js";
1
+ import { $ as syncedPath, At as requestTime, Ct as MAX_FILE_BYTES, Dt as comparePaths, Et as VERSION_HEADER, Ft as versionMismatch, G as renumberRequest, J as renumberText, K as renumberFiles, Ot as exceedsFileLimit, Pt as hubRequirements, Q as syncedDir, Tt as SOCKET_MAX_AGE_CLOSE, W as applyOffline, X as hubDigest, Xt as registeredName, Y as scopeOfPath, Z as mayHoldSynced, bt as BLOB_HASH, cn as ConflictError, dn as contentHash, et as applyOperation, fn as splitFrontMatter, gt as nowMinute, jt as textBytes, kt as readsAsText, q as renumberPath, tn as nodeStorage, un as WriteError, wt as ROUTES, xt as MAX_BLOB_BYTES, yt as ACCESS_HEADERS } from "./host-CfBcUcg2.js";
2
2
  import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, watch, writeFileSync } from "node:fs";