mainflow 0.8.0 → 0.9.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { A as closeIssue, D as sessionBriefing, E as updateAction, F as setFindingArchived, G as WriteError, H as MAINLINE_FILE, I as setFindingStatus, J as findHubRoot, L as setIssueArchived, M as createIssue, N as planIssue, P as reopenIssue, R as HubError, S as setSessionStatus, U as TASK_FILE, a as createDecision, b as setSessionArchived, c as dropChecklistItem, d as setChecklistItem, f as setCriterion, g as addAction, h as updateTask, i as addRepo, j as createFinding, k as appendOps, l as initHub, m as supersedeDecision, n as acceptDecision, o as createMainline, p as setMainlineStatus, r as addHub, s as createTask, t as migrateHub, u as setArchived, w as startSession, y as readSession, z as loadHub } from "./dist-DCafRe8c.js";
3
- import { r as launchUi, s as listProgress } from "./launch-V6oqJDGG.js";
2
+ import { A as appendOps, B as loadHub, C as setSessionStatus, D as updateAction, F as reopenIssue, I as setFindingArchived, K as WriteError, L as setFindingStatus, M as createFinding, N as createIssue, O as sessionBriefing, P as planIssue, R as setIssueArchived, T as startSession, U as MAINLINE_FILE, W as TASK_FILE, Y as findHubRoot, _ as addAction, a as createDecision, b as readSession, c as dropChecklistItem, d as setChecklistItem, f as setCriterion, g as updateTask, h as supersedeDecision, i as addRepo, j as closeIssue, l as initHub, n as acceptDecision, o as createMainline, p as setMainlineStatus, r as addHub, s as createTask, t as migrateHub, u as setArchived, x as setSessionArchived, z as HubError } from "./dist-_ArTiNbL.js";
3
+ import { c as listProgress, r as launchUi, s as CLI_VERSION } from "./launch-D2WJRvPD.js";
4
4
  import { COMMAND_SPECS, OPTIONS } from "./facts.js";
5
5
  import { runHook } from "./hooks.js";
6
6
  import { readFileSync, realpathSync } from "node:fs";
@@ -249,7 +249,7 @@ function run(argv, cwd) {
249
249
  return fail(`${error.message}\n\n${USAGE}`);
250
250
  }
251
251
  const { values: opts, positionals } = parsed;
252
- if (opts.version) return done(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
252
+ if (opts.version) return done(CLI_VERSION);
253
253
  if (opts.help || positionals.length === 0) {
254
254
  console.log(USAGE);
255
255
  return opts.help ? 0 : 1;
@@ -1324,6 +1324,15 @@ function setMainlineStatus(root, id, status, expectedVersion) {
1324
1324
  stampClosed(m, closes("mainline", next), today());
1325
1325
  }, expectedVersion);
1326
1326
  }
1327
+ /** Rename a mainline. References use its ID, so they keep resolving and show the new title. */
1328
+ function setMainlineTitle(root, id, title, expectedVersion) {
1329
+ requireRef(id, "mainline");
1330
+ const next = title.replace(/\s+/g, " ").trim();
1331
+ if (!next) throw new WriteError(`${id} needs a title, e.g. "mainflow 改發布到 npm 公開套件"`);
1332
+ return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
1333
+ m.title = next;
1334
+ }, expectedVersion);
1335
+ }
1327
1336
  function setArchived(root, id, archived, expectedVersion) {
1328
1337
  requireRef(id, "mainline");
1329
1338
  return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
@@ -1644,4 +1653,4 @@ function migrateHub(root, options = {}) {
1644
1653
  });
1645
1654
  }
1646
1655
  //#endregion
1647
- export { closeIssue as A, linksInto as B, settleSession as C, sessionBriefing as D, updateAction as E, setFindingArchived as F, WriteError as G, MAINLINE_FILE as H, setFindingStatus as I, findHubRoot as J, DATA_DIR as K, setIssueArchived as L, createIssue as M, planIssue as N, turnReminder as O, reopenIssue as P, HubError as R, setSessionStatus as S, transcriptPath as T, TASK_FILE as U, refIndex as V, ConflictError as W, worktreesOf as Y, ensureSession as _, createDecision as a, setSessionArchived as b, dropChecklistItem as c, setChecklistItem as d, setCriterion as f, addAction as g, updateTask as h, addRepo as i, createFinding as j, appendOps as k, initHub as l, supersedeDecision as m, acceptDecision as n, createMainline as o, setMainlineStatus as p, currentBranch as q, addHub as r, createTask as s, migrateHub as t, setArchived as u, loadSessions as v, startSession as w, setSessionInfo as x, readSession as y, loadHub as z };
1656
+ export { appendOps as A, loadHub as B, setSessionStatus as C, updateAction as D, transcriptPath as E, reopenIssue as F, ConflictError as G, refIndex as H, setFindingArchived as I, currentBranch as J, WriteError as K, setFindingStatus as L, createFinding as M, createIssue as N, sessionBriefing as O, planIssue as P, setIssueArchived as R, setSessionInfo as S, startSession as T, MAINLINE_FILE as U, linksInto as V, TASK_FILE as W, worktreesOf as X, findHubRoot as Y, addAction as _, createDecision as a, readSession as b, dropChecklistItem as c, setChecklistItem as d, setCriterion as f, updateTask as g, supersedeDecision as h, addRepo as i, closeIssue as j, turnReminder as k, initHub as l, setMainlineTitle as m, acceptDecision as n, createMainline as o, setMainlineStatus as p, DATA_DIR as q, addHub as r, createTask as s, migrateHub as t, setArchived as u, ensureSession as v, settleSession as w, setSessionArchived as x, loadSessions as y, HubError as z };
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as settleSession, D as sessionBriefing, J as findHubRoot, K as DATA_DIR, O as turnReminder, R as HubError, S as setSessionStatus, Y as worktreesOf, _ as ensureSession, q as currentBranch, x as setSessionInfo, y as readSession, z as loadHub } from "./dist-DCafRe8c.js";
1
+ import { B as loadHub, C as setSessionStatus, J as currentBranch, O as sessionBriefing, S as setSessionInfo, X as worktreesOf, Y as findHubRoot, b as readSession, k as turnReminder, q as DATA_DIR, v as ensureSession, w as settleSession, z as HubError } from "./dist-_ArTiNbL.js";
2
2
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  //#region src/hooks.ts
@@ -46,6 +46,11 @@ function recordSession(root, payload, id, refresh) {
46
46
  effort: payload.effort?.level
47
47
  });
48
48
  }
49
+ /** Settle the main session by the rules settleSession states; prints nothing. */
50
+ const settleOnEnd = (root, { payload }) => {
51
+ if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
52
+ return null;
53
+ };
49
54
  const CLAUDE_CODE_EVENTS = {
50
55
  "session-start": (root, { payload, envFile, cli }) => {
51
56
  if (!payload.session_id) return null;
@@ -61,10 +66,8 @@ const CLAUDE_CODE_EVENTS = {
61
66
  if (!payload.session_id) return null;
62
67
  return turnReminder(root, recordSession(root, payload, claudeCodeSessionId(payload.session_id), false), { cli });
63
68
  },
64
- stop: (root, { payload }) => {
65
- if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
66
- return null;
67
- },
69
+ stop: settleOnEnd,
70
+ "session-end": settleOnEnd,
68
71
  "subagent-start": (root, { payload }) => {
69
72
  if (!payload.agent_id) return null;
70
73
  const parent = payload.session_id ? claudeCodeSessionId(payload.session_id) : null;
package/dist/host.js CHANGED
@@ -1,4 +1,4 @@
1
- import { G as WriteError, K as DATA_DIR } from "./dist-DCafRe8c.js";
1
+ import { K as WriteError, q as DATA_DIR } from "./dist-_ArTiNbL.js";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
@@ -1,4 +1,4 @@
1
- import { B as linksInto, F as setFindingArchived, G as WriteError, K as DATA_DIR, L as setIssueArchived, T as transcriptPath, V as refIndex, W as ConflictError, b as setSessionArchived, d as setChecklistItem, f as setCriterion, h as updateTask, p as setMainlineStatus, u as setArchived, v as loadSessions, y as readSession, z as loadHub } from "./dist-DCafRe8c.js";
1
+ import { B as loadHub, E as transcriptPath, G as ConflictError, H as refIndex, I as setFindingArchived, K as WriteError, R as setIssueArchived, V as linksInto, b as readSession, d as setChecklistItem, f as setCriterion, g as updateTask, m as setMainlineTitle, p as setMainlineStatus, q as DATA_DIR, u as setArchived, x as setSessionArchived, y as loadSessions } from "./dist-_ArTiNbL.js";
2
2
  import { DEFAULT_UI_PORT, syncHost } from "./host.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, watch, writeFileSync } from "node:fs";
@@ -214,6 +214,10 @@ function listProgress(items) {
214
214
  };
215
215
  }
216
216
  //#endregion
217
+ //#region src/version.ts
218
+ /** This CLI's own version, from the package manifest one level above both src/ and dist/. */
219
+ const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
220
+ //#endregion
217
221
  //#region src/server.ts
218
222
  /** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
219
223
  function prefixedRefs(hub) {
@@ -323,6 +327,7 @@ function createApp(root) {
323
327
  const hub = loadHub(root);
324
328
  const body = {
325
329
  hub: hub.config.hub,
330
+ version: CLI_VERSION,
326
331
  repos: hub.config.repos,
327
332
  mainlines: hub.mainlines.map(summarize),
328
333
  openIssues: hub.issues.filter((i) => i.status !== "closed").length,
@@ -415,6 +420,10 @@ function createApp(root) {
415
420
  const { archived, version } = await c.req.json();
416
421
  return c.json(setArchived(root, c.req.param("m"), archived, version));
417
422
  });
423
+ app.post("/api/mainlines/:m/title", async (c) => {
424
+ const { title, version } = await c.req.json();
425
+ return c.json(setMainlineTitle(root, c.req.param("m"), title, version));
426
+ });
418
427
  app.post("/api/mainlines/:m/status", async (c) => {
419
428
  const { status, version } = await c.req.json();
420
429
  return c.json(setMainlineStatus(root, c.req.param("m"), status, version));
@@ -476,19 +485,24 @@ function serveUi(root, port) {
476
485
  //#region src/launch.ts
477
486
  /** How many ports above DEFAULT_UI_PORT are tried before giving up. */
478
487
  const PORT_SPAN = 100;
479
- /** Where the last port used is remembered: machine-specific, so under the unversioned local/ folder. */
480
- const lastPortFile = (root) => join(root, DATA_DIR, "local", "ui.json");
481
- function readLastPort(root) {
488
+ /** Where the last server started is remembered: machine-specific, so under the unversioned local/ folder. */
489
+ const uiRecordFile = (root) => join(root, DATA_DIR, "local", "ui.json");
490
+ function readUiRecord(root) {
482
491
  try {
483
- const port = JSON.parse(readFileSync(lastPortFile(root), "utf8")).port;
484
- return Number.isInteger(port) ? port : void 0;
492
+ const record = JSON.parse(readFileSync(uiRecordFile(root), "utf8"));
493
+ if (!Number.isInteger(record.port)) return void 0;
494
+ return {
495
+ port: record.port,
496
+ ...Number.isInteger(record.pid) && { pid: record.pid },
497
+ ...typeof record.version === "string" && { version: record.version }
498
+ };
485
499
  } catch {
486
500
  return;
487
501
  }
488
502
  }
489
- function writeLastPort(root, port) {
503
+ function writeUiRecord(root, record) {
490
504
  mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
491
- writeFileSync(lastPortFile(root), `${JSON.stringify({ port })}\n`);
505
+ writeFileSync(uiRecordFile(root), `${JSON.stringify(record)}\n`);
492
506
  }
493
507
  /**
494
508
  * Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
@@ -503,13 +517,18 @@ function portCandidates(options) {
503
517
  ...range
504
518
  ].filter((p) => p !== void 0))];
505
519
  }
520
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
506
521
  const networkProbe = {
507
- async hubAt(port) {
522
+ async serverAt(port) {
508
523
  try {
509
524
  const res = await fetch(`http://localhost:${port}/api/hub`, { signal: AbortSignal.timeout(500) });
510
525
  if (!res.ok) return null;
511
526
  const body = await res.json();
512
- return typeof body.hub === "string" ? body.hub : null;
527
+ if (typeof body.hub !== "string") return null;
528
+ return {
529
+ hub: body.hub,
530
+ ...typeof body.version === "string" && { version: body.version }
531
+ };
513
532
  } catch {
514
533
  return null;
515
534
  }
@@ -517,7 +536,29 @@ const networkProbe = {
517
536
  isFree: (port) => new Promise((done) => {
518
537
  const probe = createServer().once("error", () => done(false));
519
538
  probe.listen(port, () => probe.close(() => done(true)));
520
- })
539
+ }),
540
+ listenerOf(port) {
541
+ const out = spawnSync("lsof", [
542
+ "-nP",
543
+ `-iTCP:${port}`,
544
+ "-sTCP:LISTEN",
545
+ "-t"
546
+ ], { encoding: "utf8" });
547
+ const pid = Number(out.stdout?.split("\n")[0]);
548
+ return out.status === 0 && Number.isInteger(pid) && pid > 0 ? pid : null;
549
+ },
550
+ async stop(pid, port) {
551
+ try {
552
+ process.kill(pid, "SIGTERM");
553
+ } catch {
554
+ return false;
555
+ }
556
+ for (let i = 0; i < 25; i++) {
557
+ await sleep(200);
558
+ if (await networkProbe.isFree(port)) return true;
559
+ }
560
+ return false;
561
+ }
521
562
  };
522
563
  /**
523
564
  * A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
@@ -525,15 +566,15 @@ const networkProbe = {
525
566
  * probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
526
567
  */
527
568
  async function choosePort(hub, candidates, probe = networkProbe) {
528
- const owners = await Promise.all(candidates.map((port) => probe.hubAt(port)));
529
- const running = candidates.find((_, i) => owners[i] === hub);
530
- if (running !== void 0) return {
531
- port: running,
532
- running: true
569
+ const servers = await Promise.all(candidates.map((port) => probe.serverAt(port)));
570
+ const at = candidates.findIndex((_, i) => servers[i]?.hub === hub);
571
+ if (at >= 0) return {
572
+ port: candidates[at],
573
+ running: servers[at].version ?? ""
533
574
  };
534
- for (const [i, port] of candidates.entries()) if (owners[i] === null && await probe.isFree(port)) return {
575
+ for (const [i, port] of candidates.entries()) if (servers[i] === null && await probe.isFree(port)) return {
535
576
  port,
536
- running: false
577
+ running: void 0
537
578
  };
538
579
  const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
539
580
  throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
@@ -560,20 +601,35 @@ async function launchUi(root, hub, configured, options) {
560
601
  explicit = Number(options.port);
561
602
  if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
562
603
  }
604
+ const probe = options.probe ?? networkProbe;
605
+ const record = readUiRecord(root);
563
606
  const choice = await choosePort(hub, portCandidates({
564
607
  explicit,
565
608
  configured,
566
- last: readLastPort(root)
567
- }), options.probe);
609
+ last: record?.port
610
+ }), probe);
611
+ let reuse = choice.running !== void 0;
612
+ if (reuse && choice.running !== CLI_VERSION) {
613
+ const from = choice.running || "unknown";
614
+ const pid = record?.port === choice.port && record.pid !== void 0 ? record.pid : probe.listenerOf(choice.port);
615
+ if (pid !== null && await probe.stop(pid, choice.port)) {
616
+ log(`! restarted: mainflow ${from} → ${CLI_VERSION}`);
617
+ reuse = false;
618
+ } else log(`! localhost:${choice.port} still runs mainflow ${from}, not ${CLI_VERSION}; stop it with \`kill $(lsof -ti tcp:${choice.port})\` and run mainflow ui again`);
619
+ }
568
620
  let server;
569
- if (!choice.running) {
621
+ if (!reuse) {
570
622
  server = await serveUi(root, choice.port);
571
- writeLastPort(root, choice.port);
623
+ writeUiRecord(root, {
624
+ port: choice.port,
625
+ pid: process.pid,
626
+ version: CLI_VERSION
627
+ });
572
628
  }
573
629
  const host = await syncHost(root, hub, choice.port, options.host);
574
630
  if (host.url === null) log(`! host name not available: ${host.reason}`);
575
631
  const url = host.url ?? `http://localhost:${choice.port}`;
576
- log(`${choice.running ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
632
+ log(`${reuse ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
577
633
  if (options.open) openUrl(url);
578
634
  return {
579
635
  url,
@@ -581,4 +637,4 @@ async function launchUi(root, hub, configured, options) {
581
637
  };
582
638
  }
583
639
  //#endregion
584
- export { portCandidates as a, networkProbe as i, choosePort as n, readLastPort as o, launchUi as r, listProgress as s, PORT_SPAN as t };
640
+ export { portCandidates as a, listProgress as c, networkProbe as i, choosePort as n, readUiRecord as o, launchUi as r, CLI_VERSION as s, PORT_SPAN as t };
package/dist/launch.d.ts CHANGED
@@ -3,7 +3,13 @@ import { ServerType } from "@hono/node-server";
3
3
  //#region src/launch.d.ts
4
4
  /** How many ports above DEFAULT_UI_PORT are tried before giving up. */
5
5
  export declare const PORT_SPAN = 100;
6
- export declare function readLastPort(root: string): number | undefined;
6
+ /** The server this hub last started: its port, and the process and version when it recorded them. */
7
+ export interface UiRecord {
8
+ port: number;
9
+ pid?: number;
10
+ version?: string;
11
+ }
12
+ export declare function readUiRecord(root: string): UiRecord | undefined;
7
13
  /**
8
14
  * Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
9
15
  * the hub's configured port, then the last one used, then the default range.
@@ -13,15 +19,25 @@ export declare function portCandidates(options: {
13
19
  configured?: number;
14
20
  last?: number;
15
21
  }): number[];
22
+ /** What a mainflow interface on a port reports; servers older than 0.8.1 report no version. */
23
+ export interface ServerInfo {
24
+ hub: string;
25
+ version?: string;
26
+ }
16
27
  export interface PortProbe {
17
- /** The hub name a mainflow interface on this port reports, or null when nothing mainflow answers. */
18
- hubAt: (port: number) => Promise<string | null>;
28
+ /** The mainflow interface on this port, or null when nothing mainflow answers. */
29
+ serverAt: (port: number) => Promise<ServerInfo | null>;
19
30
  isFree: (port: number) => Promise<boolean>;
31
+ /** The process listening on this port, or null when it cannot be found. */
32
+ listenerOf: (port: number) => number | null;
33
+ /** Stop a process and wait until the port is free; false when it did not free up. */
34
+ stop: (pid: number, port: number) => Promise<boolean>;
20
35
  }
21
36
  export declare const networkProbe: PortProbe;
37
+ /** A port to serve on; `running` is the version already serving this hub there, '' when it reports none. */
22
38
  export type PortChoice = {
23
39
  port: number;
24
- running: boolean;
40
+ running?: string;
25
41
  };
26
42
  /**
27
43
  * A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
package/dist/launch.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as portCandidates, i as networkProbe, n as choosePort, o as readLastPort, r as launchUi, t as PORT_SPAN } from "./launch-V6oqJDGG.js";
2
- export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readLastPort };
1
+ import { a as portCandidates, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, t as PORT_SPAN } from "./launch-D2WJRvPD.js";
2
+ export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord };