mainflow 0.8.0 → 0.10.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,8 +1,9 @@
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 { $ as MAINLINE_FILE, A as setSessionStatus, B as appendOps, C as updateTask, D as readSession, F as sessionBriefing, G as reopenIssue, H as createFinding, J as setIssueArchived, K as setFindingArchived, M as startSession, O as setSessionArchived, P as updateAction, S as supersedeDecision, U as createIssue, V as closeIssue, W as planIssue, X as loadHub, Y as HubError, _ as setArchived, at as findHubRoot, b as setMainlineStatus, c as migrateHub, d as addRepo, et as TASK_FILE, f as createDecision, g as initHub, h as dropChecklistItem, l as acceptDecision, m as createTask, nt as WriteError, p as createMainline, q as setFindingStatus, u as addHub, v as setChecklistItem, w as addAction, y as setCriterion } from "./host-BFGfpTDq.js";
3
+ import { t as listProgress } from "./progress-1BfPg2zN.js";
4
4
  import { COMMAND_SPECS, OPTIONS } from "./facts.js";
5
5
  import { runHook } from "./hooks.js";
6
+ import { c as CLI_VERSION, r as launchUi, s as startUiInBackground } from "./launch-D5uHpqkh.js";
6
7
  import { readFileSync, realpathSync } from "node:fs";
7
8
  import { join, relative } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -89,7 +90,8 @@ const RUNS = {
89
90
  const { stdout, error } = runHook(args[0], args[1], {
90
91
  payload,
91
92
  envFile: process.env.CLAUDE_ENV_FILE,
92
- cli: CLI
93
+ cli: CLI,
94
+ startUi: startUiInBackground
93
95
  });
94
96
  if (error) console.error(error);
95
97
  if (stdout) console.log(stdout);
@@ -249,7 +251,7 @@ function run(argv, cwd) {
249
251
  return fail(`${error.message}\n\n${USAGE}`);
250
252
  }
251
253
  const { values: opts, positionals } = parsed;
252
- if (opts.version) return done(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
254
+ if (opts.version) return done(CLI_VERSION);
253
255
  if (opts.help || positionals.length === 0) {
254
256
  console.log(USAGE);
255
257
  return opts.help ? 0 : 1;
package/dist/hooks.d.ts CHANGED
@@ -11,6 +11,10 @@ export interface ClaudeCodePayload {
11
11
  effort?: {
12
12
  level?: string;
13
13
  };
14
+ /** Stop: true when the turn is already continuing because a Stop hook asked it to */
15
+ stop_hook_active?: boolean;
16
+ /** MessageDisplay: the lines of an assistant message about to be shown */
17
+ delta?: string;
14
18
  /** PostToolUse: the tool that ran and its input */
15
19
  tool_name?: string;
16
20
  tool_input?: {
@@ -26,9 +30,13 @@ export interface HookContext {
26
30
  envFile?: string;
27
31
  /** How the executor should invoke the CLI inside the hub, used in the texts it reads */
28
32
  cli: string;
33
+ /** Start the hub's interface without waiting; given by the CLI entry point, left out by callers that must not spawn */
34
+ startUi?: (root: string) => void;
29
35
  }
30
36
  export declare const claudeCodeSessionId: (raw: string) => string;
31
37
  type Handler = (root: string, ctx: HookContext) => string | null;
38
+ /** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
39
+ export declare const openLink: (hub: string) => (path: string) => string;
32
40
  export declare const HOOK_PLATFORMS: Record<string, Record<string, Handler>>;
33
41
  /**
34
42
  * Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
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 { A as setSessionStatus, D as readSession, F as sessionBriefing, I as turnReminder, L as decorateReply, R as endsWithMarker, T as ensureSession, X as loadHub, Y as HubError, at as findHubRoot, i as hostName, it as currentBranch, j as settleSession, k as setSessionInfo, ot as worktreesOf, rt as DATA_DIR, z as mayDecorate } from "./host-BFGfpTDq.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
@@ -23,8 +23,9 @@ const brief = (text) => {
23
23
  const shortId = (raw) => raw.replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8);
24
24
  const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
25
25
  /**
26
- * One handler per Claude Code event; each returns what to print for the model, or null. SessionStart and
27
- * UserPromptSubmit take plain text; PostToolUse takes the hook JSON, since its plain stdout never reaches the model.
26
+ * One handler per Claude Code event; each returns what to print, or null. SessionStart and UserPromptSubmit take
27
+ * plain text for the model; PostToolUse, Stop and MessageDisplay take the hook JSON, since their plain stdout
28
+ * never reaches the model or the screen.
28
29
  */
29
30
  /**
30
31
  * Record the main session: create it when it is missing, since a turn that tracked nothing left no record, and
@@ -46,12 +47,55 @@ function recordSession(root, payload, id, refresh) {
46
47
  effort: payload.effort?.level
47
48
  });
48
49
  }
50
+ /** Settle the main session by the rules settleSession states; prints nothing. */
51
+ const settleOnEnd = (root, { payload }) => {
52
+ if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
53
+ return null;
54
+ };
55
+ /** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
56
+ const openLink = (hub) => (path) => `https://${hostName(hub)}/open${path}`;
57
+ /**
58
+ * MessageDisplay: link the references in the lines about to be shown and expand a trailing end marker into the
59
+ * current action line. Display only, so the model keeps reading what it wrote. Text with nothing to change
60
+ * returns before the hub is loaded, because Claude Code holds every batch until the hook answers.
61
+ */
62
+ const displayReply = (root, { payload }) => {
63
+ const text = payload.delta;
64
+ if (!text || !mayDecorate(text)) return null;
65
+ const hub = loadHub(root, { remotes: false });
66
+ const session = payload.session_id ? readSession(root, claudeCodeSessionId(payload.session_id)) : null;
67
+ const shown = decorateReply(text, {
68
+ hub,
69
+ session,
70
+ link: openLink(hub.config.hub)
71
+ });
72
+ return shown === text ? null : JSON.stringify({ hookSpecificOutput: {
73
+ hookEventName: "MessageDisplay",
74
+ displayContent: shown
75
+ } });
76
+ };
77
+ /**
78
+ * Stop: settle the session, then ask for the end marker when a session with an action in progress ended its
79
+ * turn without one. Only the model knows which message closes a turn, so the marker is its to write; this makes
80
+ * a forgotten one cost a single extra message. stop_hook_active stops it from asking twice in a row.
81
+ */
82
+ const settleAndCheckMarker = (root, context) => {
83
+ settleOnEnd(root, context);
84
+ const { payload } = context;
85
+ if (!payload.session_id || payload.stop_hook_active || endsWithMarker(payload.last_assistant_message ?? "")) return null;
86
+ if (!readSession(root, claudeCodeSessionId(payload.session_id))?.actions.some((a) => a.status === "doing")) return null;
87
+ return JSON.stringify({ hookSpecificOutput: {
88
+ hookEventName: "Stop",
89
+ additionalContext: `[mainflow] 這一輪最後一則回覆沒有以單獨一行的 ▸ 結尾。請只回一則內容為 ▸ 的訊息,不加其他文字。`
90
+ } });
91
+ };
49
92
  const CLAUDE_CODE_EVENTS = {
50
- "session-start": (root, { payload, envFile, cli }) => {
93
+ "session-start": (root, { payload, envFile, cli, startUi }) => {
51
94
  if (!payload.session_id) return null;
52
95
  const id = claudeCodeSessionId(payload.session_id);
53
96
  const session = recordSession(root, payload, id, true);
54
97
  if (envFile) appendFileSync(envFile, `export MAINFLOW_SESSION=${id}\n`);
98
+ startUi?.(root);
55
99
  return sessionBriefing(root, session, {
56
100
  cli,
57
101
  envNote: envFile ? "已設定環境變數 MAINFLOW_SESSION,指令不必再帶 --session" : `執行指令時帶 --session ${id}`
@@ -61,10 +105,8 @@ const CLAUDE_CODE_EVENTS = {
61
105
  if (!payload.session_id) return null;
62
106
  return turnReminder(root, recordSession(root, payload, claudeCodeSessionId(payload.session_id), false), { cli });
63
107
  },
64
- stop: (root, { payload }) => {
65
- if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
66
- return null;
67
- },
108
+ stop: settleAndCheckMarker,
109
+ "session-end": settleOnEnd,
68
110
  "subagent-start": (root, { payload }) => {
69
111
  if (!payload.agent_id) return null;
70
112
  const parent = payload.session_id ? claudeCodeSessionId(payload.session_id) : null;
@@ -90,7 +132,8 @@ const CLAUDE_CODE_EVENTS = {
90
132
  setSessionStatus(root, id, "ended");
91
133
  return null;
92
134
  },
93
- "post-tool-use": checkAfterWrite
135
+ "post-tool-use": checkAfterWrite,
136
+ "message-display": displayReply
94
137
  };
95
138
  /** How many problems outside the written file the reminder lists before it only gives the count. */
96
139
  const OTHER_PROBLEMS_SHOWN = 5;
@@ -181,4 +224,4 @@ function runHook(platform, event, ctx) {
181
224
  }
182
225
  }
183
226
  //#endregion
184
- export { HOOK_PLATFORMS, claudeCodeSessionId, runHook, subagentTranscript };
227
+ export { HOOK_PLATFORMS, claudeCodeSessionId, openLink, runHook, subagentTranscript };
@@ -1,7 +1,7 @@
1
1
  import { A as mainlineSchema, C as TASK_SECTIONS, D as findingSchema, E as decisionSchema, F as refMainline, I as sessionSchema, L as taskSchema, M as parseOpsLine, N as parseRef, O as formatOpsEntry, P as refExample, S as TASK_REQUIRED_SECTIONS, T as configSchema, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as opsEntrySchema, k as issueSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as WORK_STATUS, x as SLUG, y as RESERVED_HUB_NAMES } from "./dist-D2NK-QQP.js";
2
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
2
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { parse, stringify } from "yaml";
7
7
  //#region ../core/dist/paths.js
@@ -909,6 +909,68 @@ function appendOps(root, input) {
909
909
  return entry;
910
910
  });
911
911
  }
912
+ /** Where the hub's interface shows a reference; null for kinds it has no page or panel for. */
913
+ function pathOf(ref, target) {
914
+ const [m, child] = ref.split("/");
915
+ switch (target.kind) {
916
+ case "mainline": return `/mainlines/${m}`;
917
+ case "task": return `/mainlines/${m}/tasks/${child}`;
918
+ case "decision": return `/mainlines/${m}/decisions/${child}`;
919
+ case "issue": return `/issues?peek=${ref}`;
920
+ case "finding": return `/findings?peek=${ref}`;
921
+ default: return null;
922
+ }
923
+ }
924
+ const sessionPath = (id) => `/sessions/${id}`;
925
+ /** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
926
+ const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
927
+ /** A file under a mainline directory, or a reference to a mainline, task, decision, issue or finding. */
928
+ const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[if]\d{3})(?![\w\]])/g;
929
+ /** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
930
+ function linkMentions(text, context) {
931
+ const index = refIndex(context.hub);
932
+ return text.split(PROTECTED).map((part, i) => i % 2 === 1 ? part : part.replace(MENTION, (all, m, file, ref) => {
933
+ if (m && file) return index.has(m) ? `[${m}/${file}](${context.link(`/mainlines/${m}/files/${file}`)})` : all;
934
+ const target = ref ? index.get(ref) : void 0;
935
+ const path = ref && target ? pathOf(ref, target) : null;
936
+ return path ? `[${ref}](${context.link(path)})` : all;
937
+ })).join("");
938
+ }
939
+ /** `▸ m001 title › t001 title › a001 do`, each id linked; null when the session has no action in progress. */
940
+ function currentActionLine(context) {
941
+ const action = context.session?.actions.find((a) => a.status === "doing");
942
+ if (!context.session || !action) return null;
943
+ const index = refIndex(context.hub);
944
+ const parts = [];
945
+ const served = action.serves.map((s) => parseRef(s)).find((r) => r !== null);
946
+ if (served && "mainline" in served) {
947
+ const mainline = index.get(served.mainline);
948
+ parts.push(`[${served.mainline}](${context.link(`/mainlines/${served.mainline}`)}) ${mainline?.title ?? ""}`.trimEnd());
949
+ if ("task" in served) {
950
+ const ref = `${served.mainline}/${served.task}`;
951
+ parts.push(`[${served.task}](${context.link(`/mainlines/${served.mainline}/tasks/${served.task}`)}) ${index.get(ref)?.title ?? ""}`.trimEnd());
952
+ }
953
+ }
954
+ parts.push(`[${action.id}](${context.link(sessionPath(context.session.id))}) ${action.do}`);
955
+ return `▸ ${parts.join(" › ")}`;
956
+ }
957
+ /** Whether a message ends with the marker line, as the turn's last message should. */
958
+ const endsWithMarker = (text) => text.trimEnd().split("\n").at(-1)?.trim() === "▸";
959
+ /**
960
+ * The text to display for a piece of a reply: mentions linked, and a trailing marker line replaced with the
961
+ * current action line (or removed when there is none). Returns the input unchanged when there is nothing to do.
962
+ */
963
+ function decorateReply(text, context) {
964
+ let shown = linkMentions(text, context);
965
+ if (endsWithMarker(shown)) {
966
+ const body = shown.trimEnd().slice(0, -1).trimEnd();
967
+ const line = currentActionLine(context);
968
+ shown = line ? `${body}\n\n${line}\n` : `${body}\n`;
969
+ }
970
+ return shown;
971
+ }
972
+ /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
973
+ const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
912
974
  //#endregion
913
975
  //#region ../core/dist/briefing.js
914
976
  function describe(root, ref) {
@@ -977,7 +1039,11 @@ function turnReminder(root, session, options) {
977
1039
  if (session.actions.length === 0) return null;
978
1040
  const doing = current(session);
979
1041
  const open = session.actions.filter((a) => a.status === "todo" || a.status === "doing");
980
- return [doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`, ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)].join("\n");
1042
+ return [
1043
+ doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`,
1044
+ ...doing ? [`- 這一輪最後一則回覆的最後一行只寫 ▸,回覆範例等其他內容都放在它前面。畫面上它會換成目前行動與連結。`] : [],
1045
+ ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
1046
+ ].join("\n");
981
1047
  }
982
1048
  //#endregion
983
1049
  //#region ../core/dist/sessions.js
@@ -1324,6 +1390,15 @@ function setMainlineStatus(root, id, status, expectedVersion) {
1324
1390
  stampClosed(m, closes("mainline", next), today());
1325
1391
  }, expectedVersion);
1326
1392
  }
1393
+ /** Rename a mainline. References use its ID, so they keep resolving and show the new title. */
1394
+ function setMainlineTitle(root, id, title, expectedVersion) {
1395
+ requireRef(id, "mainline");
1396
+ const next = title.replace(/\s+/g, " ").trim();
1397
+ if (!next) throw new WriteError(`${id} needs a title, e.g. "mainflow 改發布到 npm 公開套件"`);
1398
+ return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
1399
+ m.title = next;
1400
+ }, expectedVersion);
1401
+ }
1327
1402
  function setArchived(root, id, archived, expectedVersion) {
1328
1403
  requireRef(id, "mainline");
1329
1404
  return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
@@ -1644,4 +1719,119 @@ function migrateHub(root, options = {}) {
1644
1719
  });
1645
1720
  }
1646
1721
  //#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 };
1722
+ //#region src/host.ts
1723
+ const DEFAULT_UI_PORT = 4320;
1724
+ const hostName = (hub) => `${hub}.mainflow.localhost`;
1725
+ function siteBlock(hub, port) {
1726
+ return [
1727
+ `# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
1728
+ `${hostName(hub)} {`,
1729
+ " tls internal",
1730
+ `\treverse_proxy localhost:${port}`,
1731
+ "}",
1732
+ ""
1733
+ ].join("\n");
1734
+ }
1735
+ /** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
1736
+ function caddySites() {
1737
+ if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
1738
+ const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
1739
+ if (prefix.error || prefix.status !== 0) return null;
1740
+ return join(prefix.stdout.trim(), "etc/caddy/sites");
1741
+ }
1742
+ /** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
1743
+ function reloadCaddy(sites) {
1744
+ const caddyfile = join(sites, "..", "..", "Caddyfile");
1745
+ const reload = spawnSync("caddy", [
1746
+ "reload",
1747
+ "--config",
1748
+ caddyfile
1749
+ ], { encoding: "utf8" });
1750
+ if (reload.error) return reload.error.message;
1751
+ return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
1752
+ }
1753
+ /**
1754
+ * Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
1755
+ * Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
1756
+ * refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
1757
+ * running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
1758
+ */
1759
+ async function caddyRunning() {
1760
+ const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
1761
+ try {
1762
+ await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
1763
+ return true;
1764
+ } catch {
1765
+ return false;
1766
+ }
1767
+ }
1768
+ const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
1769
+ /**
1770
+ * Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
1771
+ * port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
1772
+ * different hub of the same name stops here: overwriting it would silently take that hub's host name.
1773
+ */
1774
+ async function syncHost(root, hub, port, options = {}) {
1775
+ const sites = caddySites();
1776
+ if (!sites || !existsSync(sites)) return {
1777
+ url: null,
1778
+ reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
1779
+ };
1780
+ const dir = join(root, DATA_DIR, "local", "caddy");
1781
+ const file = join(dir, `${hub}.caddy`);
1782
+ const link = join(sites, `mainflow-${hub}.caddy`);
1783
+ const linked = lstatSync(link, { throwIfNoEntry: false });
1784
+ if (linked) {
1785
+ const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
1786
+ if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
1787
+ }
1788
+ const block = siteBlock(hub, port);
1789
+ if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
1790
+ mkdirSync(dir, { recursive: true });
1791
+ writeFileSync(file, block);
1792
+ if (linked) rmSync(link);
1793
+ symlinkSync(file, link);
1794
+ const failure = (options.reload ?? reloadCaddy)(sites);
1795
+ if (failure) return {
1796
+ url: null,
1797
+ reason: `caddy reload failed: ${failure}`
1798
+ };
1799
+ } else if (!await (options.running ?? caddyRunning)()) return {
1800
+ url: null,
1801
+ reason: "Caddy is not running; start it with `brew services start caddy`"
1802
+ };
1803
+ return { url: `https://${hostName(hub)}` };
1804
+ }
1805
+ /** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
1806
+ const APP_BUNDLE_ID = "space.carlos.mainflow";
1807
+ /** Close the tabs Safari and Chrome opened on this hub's /open route; each browser only when it is running. */
1808
+ const closeOpenTabs = (hub) => {
1809
+ const marker = `${hostName(hub)}/open/`;
1810
+ return ["Safari", "Google Chrome"].map((app) => `if application "${app}" is running then
1811
+ tell application "${app}"
1812
+ repeat with w in (every window)
1813
+ close (every tab of w whose URL contains "${marker}")
1814
+ end repeat
1815
+ end tell
1816
+ end if`).join("\n");
1817
+ };
1818
+ /**
1819
+ * Show an address of the hub's interface in the macOS app through its mainflow:// scheme (docs/app.md "連結").
1820
+ * A link in a reply reaches here through the default browser, so once the app has the address the browser tab
1821
+ * it left is closed and the app brought back to the front. Returns false when no app handles the scheme; the
1822
+ * caller then leaves the page to the browser. Does nothing and returns false outside macOS.
1823
+ */
1824
+ function openInApp(hub, target, closeAfterMs = 150) {
1825
+ if (process.platform !== "darwin") return false;
1826
+ if (spawnSync("open", [`mainflow://${hub}${target}`]).status !== 0) return false;
1827
+ setTimeout(() => {
1828
+ spawnSync("osascript", ["-e", closeOpenTabs(hub)]);
1829
+ spawn("open", ["-b", APP_BUNDLE_ID], {
1830
+ detached: true,
1831
+ stdio: "ignore"
1832
+ }).on("error", () => {}).unref();
1833
+ }, closeAfterMs);
1834
+ return true;
1835
+ }
1836
+ //#endregion
1837
+ export { MAINLINE_FILE as $, setSessionStatus as A, appendOps as B, updateTask as C, readSession as D, loadSessions as E, sessionBriefing as F, reopenIssue as G, createFinding as H, turnReminder as I, setIssueArchived as J, setFindingArchived as K, decorateReply as L, startSession as M, transcriptPath as N, setSessionArchived as O, updateAction as P, refIndex as Q, endsWithMarker as R, supersedeDecision as S, ensureSession as T, createIssue as U, closeIssue as V, planIssue as W, loadHub as X, HubError as Y, linksInto as Z, setArchived as _, openInApp as a, findHubRoot as at, setMainlineStatus as b, migrateHub as c, addRepo as d, TASK_FILE as et, createDecision as f, initHub as g, dropChecklistItem as h, hostName as i, currentBranch as it, settleSession as j, setSessionInfo as k, acceptDecision as l, createTask as m, DEFAULT_UI_PORT as n, WriteError as nt, siteBlock as o, worktreesOf as ot, createMainline as p, setFindingStatus as q, caddySites as r, DATA_DIR as rt, syncHost as s, APP_BUNDLE_ID as t, ConflictError as tt, addHub as u, setChecklistItem as v, addAction as w, setMainlineTitle as x, setCriterion as y, mayDecorate as z };
package/dist/host.d.ts CHANGED
@@ -21,4 +21,13 @@ export type HostResult = {
21
21
  * different hub of the same name stops here: overwriting it would silently take that hub's host name.
22
22
  */
23
23
  export declare function syncHost(root: string, hub: string, port: number, options?: HostOptions): Promise<HostResult>;
24
+ /** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
25
+ export declare const APP_BUNDLE_ID = "space.carlos.mainflow";
26
+ /**
27
+ * Show an address of the hub's interface in the macOS app through its mainflow:// scheme (docs/app.md "連結").
28
+ * A link in a reply reaches here through the default browser, so once the app has the address the browser tab
29
+ * it left is closed and the app brought back to the front. Returns false when no app handles the scheme; the
30
+ * caller then leaves the page to the browser. Does nothing and returns false outside macOS.
31
+ */
32
+ export declare function openInApp(hub: string, target: string, closeAfterMs?: number): boolean;
24
33
  //#endregion
package/dist/host.js CHANGED
@@ -1,89 +1,2 @@
1
- import { G as WriteError, K as DATA_DIR } from "./dist-DCafRe8c.js";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
4
- import { spawnSync } from "node:child_process";
5
- //#region src/host.ts
6
- const DEFAULT_UI_PORT = 4320;
7
- const hostName = (hub) => `${hub}.mainflow.localhost`;
8
- function siteBlock(hub, port) {
9
- return [
10
- `# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
11
- `${hostName(hub)} {`,
12
- " tls internal",
13
- `\treverse_proxy localhost:${port}`,
14
- "}",
15
- ""
16
- ].join("\n");
17
- }
18
- /** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
19
- function caddySites() {
20
- if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
21
- const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
22
- if (prefix.error || prefix.status !== 0) return null;
23
- return join(prefix.stdout.trim(), "etc/caddy/sites");
24
- }
25
- /** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
26
- function reloadCaddy(sites) {
27
- const caddyfile = join(sites, "..", "..", "Caddyfile");
28
- const reload = spawnSync("caddy", [
29
- "reload",
30
- "--config",
31
- caddyfile
32
- ], { encoding: "utf8" });
33
- if (reload.error) return reload.error.message;
34
- return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
35
- }
36
- /**
37
- * Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
38
- * Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
39
- * refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
40
- * running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
41
- */
42
- async function caddyRunning() {
43
- const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
44
- try {
45
- await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
46
- return true;
47
- } catch {
48
- return false;
49
- }
50
- }
51
- const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
52
- /**
53
- * Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
54
- * port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
55
- * different hub of the same name stops here: overwriting it would silently take that hub's host name.
56
- */
57
- async function syncHost(root, hub, port, options = {}) {
58
- const sites = caddySites();
59
- if (!sites || !existsSync(sites)) return {
60
- url: null,
61
- reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
62
- };
63
- const dir = join(root, DATA_DIR, "local", "caddy");
64
- const file = join(dir, `${hub}.caddy`);
65
- const link = join(sites, `mainflow-${hub}.caddy`);
66
- const linked = lstatSync(link, { throwIfNoEntry: false });
67
- if (linked) {
68
- const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
69
- if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
70
- }
71
- const block = siteBlock(hub, port);
72
- if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
73
- mkdirSync(dir, { recursive: true });
74
- writeFileSync(file, block);
75
- if (linked) rmSync(link);
76
- symlinkSync(file, link);
77
- const failure = (options.reload ?? reloadCaddy)(sites);
78
- if (failure) return {
79
- url: null,
80
- reason: `caddy reload failed: ${failure}`
81
- };
82
- } else if (!await (options.running ?? caddyRunning)()) return {
83
- url: null,
84
- reason: "Caddy is not running; start it with `brew services start caddy`"
85
- };
86
- return { url: `https://${hostName(hub)}` };
87
- }
88
- //#endregion
89
- export { DEFAULT_UI_PORT, caddySites, hostName, siteBlock, syncHost };
1
+ import { a as openInApp, i as hostName, n as DEFAULT_UI_PORT, o as siteBlock, r as caddySites, s as syncHost, t as APP_BUNDLE_ID } from "./host-BFGfpTDq.js";
2
+ export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, siteBlock, syncHost };