mainflow 0.44.0-alpha.2 → 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 } : {};
@@ -4435,6 +4631,17 @@ function pathOf(ref, target) {
4435
4631
  }
4436
4632
  }
4437
4633
  const sessionPath = (id) => `/sessions/${id}`;
4634
+ /** Where a mainline's file shows on the hub's interface, under the mainline's own address. */
4635
+ const mainlineFilePath = (mainline, file) => `/mainlines/${mainline}/files/${file}`;
4636
+ /** The interface address of every reference the hub has a page or panel for, keyed by the reference. */
4637
+ function refPaths(hub) {
4638
+ const paths = {};
4639
+ for (const [ref, target] of refIndex(hub)) {
4640
+ const path = pathOf(ref, target);
4641
+ if (path) paths[ref] = path;
4642
+ }
4643
+ return paths;
4644
+ }
4438
4645
  /** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
4439
4646
  const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
4440
4647
  /** A file under a mainline directory, or a reference to a mainline, task, decision, issue, finding or group. */
@@ -4443,7 +4650,7 @@ const MENTION = /\.mainflow\/(?:[a-z0-9-]+\/)?mainlines\/(m\d{3})-[\w-]+\/([^\s`
4443
4650
  function linkMentions(text, context) {
4444
4651
  const index = refIndex(context.hub);
4445
4652
  return text.split(PROTECTED).map((part, i) => i % 2 === 1 ? part : part.replace(MENTION, (all, m, file, ref) => {
4446
- if (m && file) return index.has(m) ? `[${m}/${file}](${context.link(`/mainlines/${m}/files/${file}`)})` : all;
4653
+ if (m && file) return index.has(m) ? `[${m}/${file}](${context.link(mainlineFilePath(m, file))})` : all;
4447
4654
  const target = ref ? index.get(ref) : void 0;
4448
4655
  const path = ref && target ? pathOf(ref, target) : null;
4449
4656
  return path ? `[${ref}](${context.link(path)})` : all;
@@ -4715,9 +4922,12 @@ function grantLines(dirs, session) {
4715
4922
  }
4716
4923
  //#endregion
4717
4924
  //#region ../core/dist/sessions.js
4718
- /** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
4719
- function loadSessions(dirs) {
4720
- 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);
4721
4931
  }
4722
4932
  function readSession(dirs, id) {
4723
4933
  return readSessionIn(nodeStorage(dirs), id);
@@ -5067,7 +5277,7 @@ function duplicateClaimLines(dirs, sessionId, actionId) {
5067
5277
  * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
5068
5278
  */
5069
5279
  function handoffLines(dirs, session, cli) {
5070
- 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));
5071
5281
  if (others.length === 0) return [];
5072
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(";")}`)];
5073
5283
  }
@@ -5145,9 +5355,10 @@ function sessionBriefing(dirs, session, options) {
5145
5355
  * The reminder for every user turn; null when there is nothing to say, or when it would be the same text as the
5146
5356
  * reminder the model was last given. Records a digest of the whole text in the session file, only when it changes;
5147
5357
  * `saveDigest` records it instead of writing the file here, as a host hook in remote mode sends it to the sync server.
5358
+ * `displayMod` leaves out the sentence that asks for the header line, for a host whose mod shows the current action.
5148
5359
  */
5149
5360
  function turnReminder(dirs, session, options) {
5150
- const text = reminderText(dirs, session, options.cli);
5361
+ const text = reminderText(dirs, session, options.cli, options.displayMod === true);
5151
5362
  if (text === null) return null;
5152
5363
  const digest = contentHash(text);
5153
5364
  if (session.reminderDigest === digest) return null;
@@ -5157,13 +5368,13 @@ function turnReminder(dirs, session, options) {
5157
5368
  } catch {}
5158
5369
  return text;
5159
5370
  }
5160
- function reminderText(dirs, session, cli) {
5371
+ function reminderText(dirs, session, cli, displayMod) {
5161
5372
  if (session.actions.length === 0) return openMainlines(dirs).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${cli} action add 列出行動並標為 doing,或用 ${cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
5162
5373
  const doing = current(session);
5163
5374
  const open = session.actions.filter(isOpen);
5164
5375
  const blocked = session.actions.filter((a) => a.status === "blocked");
5165
5376
  const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length > 0 ? `[mainflow] ${session.id} 目前沒有進行中的行動。` : blocked.length > 0 ? `[mainflow] ${session.id} 的行動在等外部條件:${blocked.map(waitingLine).join(";")}。有條件(until)的可以在背景執行 ${cli} wait <a編號> 等它成立。條件出現後用 ${cli} action set <a編號> --status doing 繼續;要先做別的事,用 ${cli} action add 列出行動。` : `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${cli} action add 列出下一個行動。`;
5166
- const header = headerLine(session);
5377
+ const header = displayMod ? null : headerLine(session);
5167
5378
  const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
5168
5379
  const grants = grantLines(dirs, session);
5169
5380
  const granted = grants.length > 0 ? ["- 目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants.map((g) => ` ${g}`)] : [];
@@ -9158,6 +9369,31 @@ function migrateHub(dirs, options = {}) {
9158
9369
  } : result;
9159
9370
  }).value;
9160
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
+ }
9161
9397
  //#endregion
9162
9398
  //#region ../core/dist/migrate-data.js
9163
9399
  /** What the original data directory is renamed to, inside the hub repository, once the hub reads from its new place. */
@@ -9330,6 +9566,15 @@ function copyData(source, target) {
9330
9566
  return target;
9331
9567
  }
9332
9568
  //#endregion
9569
+ //#region ../core/dist/progress.js
9570
+ function listProgress(items) {
9571
+ const counted = items.filter((item) => !item.dropped);
9572
+ return {
9573
+ done: counted.filter((item) => item.done).length,
9574
+ total: counted.length
9575
+ };
9576
+ }
9577
+ //#endregion
9333
9578
  //#region ../core/dist/group-plan.js
9334
9579
  /** Sessions that can still hold work: not archived, and active or paused. */
9335
9580
  const live = (s) => !s.archivedAt && (s.status === "active" || s.status === "paused");
@@ -9401,6 +9646,87 @@ function groupPlan(hub, sessions, groupId) {
9401
9646
  };
9402
9647
  }
9403
9648
  //#endregion
9649
+ //#region ../core/dist/session-view.js
9650
+ const unfinished = (a) => a.status === "todo" || a.status === "doing" || a.status === "blocked";
9651
+ /** The mainline and task a served reference points into; criteria of a group name neither. */
9652
+ function targetOf(ref) {
9653
+ const parsed = parseRef(ref);
9654
+ if (parsed?.kind === "checklistItem") return {
9655
+ mainline: parsed.mainline,
9656
+ task: parsed.task
9657
+ };
9658
+ if (parsed?.kind === "criterion") return { mainline: parsed.mainline };
9659
+ return null;
9660
+ }
9661
+ const taskProgress = (m) => {
9662
+ const parts = m.tasks.map((t) => listProgress(t.checklist));
9663
+ return {
9664
+ done: parts.reduce((n, p) => n + p.done, 0),
9665
+ total: parts.reduce((n, p) => n + p.total, 0)
9666
+ };
9667
+ };
9668
+ function sessionView({ hub, session, url, link, sync }) {
9669
+ const actions = session?.actions ?? [];
9670
+ const lead = actions.find((a) => a.status === "doing") ?? actions.find(unfinished);
9671
+ const doing = actions.find((a) => a.status === "doing");
9672
+ const focusTarget = lead?.serves.map(targetOf).find((t) => t !== null) ?? null;
9673
+ const focusMainline = focusTarget ? hub.mainlines.find((m) => m.id === focusTarget.mainline) : void 0;
9674
+ const serving = new Set((doing?.serves ?? []).flatMap((s) => targetOf(s) ?? []).filter((t) => t.task).map((t) => `${t.mainline}/${t.task}`));
9675
+ const mainlinePath = (id) => `/mainlines/${id}`;
9676
+ return {
9677
+ hub: {
9678
+ name: hub.config.hub,
9679
+ url,
9680
+ open: link("")
9681
+ },
9682
+ refs: refPaths(hub),
9683
+ session: session ? {
9684
+ id: session.id,
9685
+ actions: actions.map((a) => ({
9686
+ id: a.id,
9687
+ do: a.do,
9688
+ status: a.status,
9689
+ serves: a.serves,
9690
+ waiting: a.blockedBy ? {
9691
+ reason: a.blockedBy.reason,
9692
+ waitingFor: a.blockedBy.waitingFor ?? null,
9693
+ until: a.blockedBy.until ?? null
9694
+ } : null
9695
+ })),
9696
+ progress: {
9697
+ done: actions.filter((a) => a.status === "done").length,
9698
+ total: actions.filter((a) => a.status !== "dropped").length
9699
+ }
9700
+ } : null,
9701
+ focus: focusMainline ? {
9702
+ id: focusMainline.id,
9703
+ title: focusMainline.title,
9704
+ status: focusMainline.status,
9705
+ criteria: listProgress(focusMainline.criteria),
9706
+ checklist: taskProgress(focusMainline),
9707
+ link: link(mainlinePath(focusMainline.id))
9708
+ } : null,
9709
+ tasks: (focusMainline?.tasks ?? []).map((t) => ({
9710
+ mainline: focusMainline.id,
9711
+ id: t.id,
9712
+ title: t.title,
9713
+ status: t.status,
9714
+ checklist: listProgress(t.checklist),
9715
+ link: link(`${mainlinePath(focusMainline.id)}/tasks/${t.id}`),
9716
+ serving: serving.has(`${focusMainline.id}/${t.id}`)
9717
+ })),
9718
+ mainlines: hub.mainlines.filter(isOpenMainline).map((m) => ({
9719
+ id: m.id,
9720
+ title: m.title,
9721
+ status: m.status,
9722
+ group: m.group ?? null,
9723
+ checklist: taskProgress(m),
9724
+ link: link(mainlinePath(m.id))
9725
+ })),
9726
+ sync
9727
+ };
9728
+ }
9729
+ //#endregion
9404
9730
  //#region src/host.ts
9405
9731
  const DEFAULT_UI_PORT = 4320;
9406
9732
  const hostName = (hub) => `${hub}.mainflow.localhost`;
@@ -9599,4 +9925,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
9599
9925
  return true;
9600
9926
  }
9601
9927
  //#endregion
9602
- export { isOpenMainline as $, realPath as $t, transcriptPath as A, hubRequirements as At, applyOffline as B, notRegisteredMessage as Bt, turnReminder as C, comparePaths as Ct, readNotices as D, textBytes as Dt, parseWarn as E, requestTime as Et, headerLine as F, requireSlug as Ft, scopeOfPath as G, resolveHub as Gt, renumberFiles as H, registerHub as Ht, mayDecorate as I, hubAtRoot as It, syncedDir as J, nodeStorage as Jt, hubDigest as K, unregisterHub as Kt, attributeTrace as L, hubHoldingFile as Lt, matchGrants as M, MAINLINE_FILE as Mt, decorateReply as N, SESSIONS_DIR as Nt, loadSessions as O, formatMismatch as Ot, endsWithMarker as P, TASK_FILE as Pt, HubError as Q, hubPath as Qt, traceTranscript as R, inRepositoryHubAt as Rt, sessionBriefing as S, VERSION_HEADER as St, markNoticesHandled as T, readsAsText as Tt, renumberPath as U, registeredName as Ut, renumberRequest as V, readRegistry as Vt, renumberText as W, registryFile as Wt, applyOperation as X, dataFile as Xt, syncedPath as Y, currentBranch as Yt, checkHubName as Z, gitCheckout as Zt, ARCHIVE_KINDS as _, MAX_BLOB_BYTES as _t, openInApp as a, splitFrontMatter as an, mainlineEdges as at, untilState as b, ROUTES as bt, siteBlock as c, refIndex as ct, migrateHubData as d, taskFile as dt, worktreesOf as en, loadConfig as et, migrateHub as f, nowMinute as ft, RETRY_RULES as g, BLOB_HASH as gt, receiverFromEnv as h, ACCESS_HEADERS as ht, hostName as i, contentHash as in, dependencyGraph as it, grantScope as j, versionMismatch as jt, readSession as k, hubFormatVersion as kt, syncHost as l, remoteMembers as lt, retroReportFromReceiver as m, readYaml as mt, DEFAULT_UI_PORT as n, DATA_DIR as nn, loadMainlines as nt, removeDanglingLinks as o, linksInto as ot, retroReport as p, readFrontMatter as pt, mayHoldSynced as q, userDataDir as qt, caddySites as r, WriteError as rn, sectionText as rt, removeHostLink as s, memberOfGroup as st, APP_BUNDLE_ID as t, ConflictError as tn, loadHub as tt, groupPlan as u, unavailableHubs as ut, setArchivedMany as v, MAX_CHANGE_PAGE as vt, HANDLED_FILE as w, exceedsFileLimit as wt, duplicateClaimLines as x, SOCKET_MAX_AGE_CLOSE as xt, initHub as y, MAX_FILE_BYTES as yt, transcriptTitle as z, listHubs 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 };