kanbo-cli 0.3.0 → 0.4.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-main.cjs CHANGED
@@ -357,6 +357,7 @@ const BOARD_ERROR_TEXTS = {
357
357
  text: `No card "${named(details.issueId)}" here.`,
358
358
  next: "kanbo card list"
359
359
  }),
360
+ issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
360
361
  issue_parent_not_found: (details) => ({
361
362
  text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
362
363
  next: "kanbo card list"
@@ -3560,7 +3561,7 @@ new Command();
3560
3561
  * `package.version`. A build carries it as a literal; running from source
3561
3562
  * (tests, `tsx`) reads the package's own `package.json` instead.
3562
3563
  */
3563
- const KANBO_PACKAGE_VERSION = "0.3.0";
3564
+ const KANBO_PACKAGE_VERSION = "0.4.0";
3564
3565
  //#endregion
3565
3566
  //#region src/cli/agent-shell.ts
3566
3567
  /**
@@ -3595,22 +3596,26 @@ const AGENT_SHELL_MARKERS = [
3595
3596
  {
3596
3597
  variable: "CLAUDECODE",
3597
3598
  matches: (value) => value === "1",
3598
- label: "CLAUDECODE=1"
3599
+ label: "CLAUDECODE=1",
3600
+ tool: "claude"
3599
3601
  },
3600
3602
  {
3601
3603
  variable: "GEMINI_CLI",
3602
3604
  matches: (value) => value === "1",
3603
- label: "GEMINI_CLI=1"
3605
+ label: "GEMINI_CLI=1",
3606
+ tool: "gemini"
3604
3607
  },
3605
3608
  {
3606
3609
  variable: "CURSOR_AGENT",
3607
3610
  matches: (value) => value !== "",
3608
- label: "CURSOR_AGENT"
3611
+ label: "CURSOR_AGENT",
3612
+ tool: "cursor"
3609
3613
  },
3610
3614
  {
3611
3615
  variable: "CODEX_THREAD_ID",
3612
3616
  matches: (value) => value !== "",
3613
- label: "CODEX_THREAD_ID"
3617
+ label: "CODEX_THREAD_ID",
3618
+ tool: "codex"
3614
3619
  }
3615
3620
  ];
3616
3621
  /**
@@ -3627,6 +3632,15 @@ function readAgentShellMarker(env = process.env) {
3627
3632
  if (kind === "person") return null;
3628
3633
  return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
3629
3634
  }
3635
+ /**
3636
+ * The name of the agent tool this shell belongs to (`claude`, `gemini`,
3637
+ * `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
3638
+ * `null` for a person's shell. Same precedence as `readAgentShellMarker`.
3639
+ */
3640
+ function readAgentTool(env = process.env) {
3641
+ if (readAgentShellMarker(env) === null) return null;
3642
+ return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
3643
+ }
3630
3644
  //#endregion
3631
3645
  //#region src/cli/actor.ts
3632
3646
  /**
@@ -3708,11 +3722,15 @@ const HUMAN_ONLY_MESSAGES = {
3708
3722
  "run clear-session": describeRunSessionRefRefusal,
3709
3723
  "run attach-session --replace": describeRunSessionRefRefusal
3710
3724
  };
3711
- /** The actor every board write from this tool is filed under. */
3725
+ /**
3726
+ * The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
3727
+ * else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
3728
+ * operating system's user.
3729
+ */
3712
3730
  function createCliActor() {
3713
3731
  return {
3714
3732
  kind: "external",
3715
- id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
3733
+ id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
3716
3734
  name: (0, node_os.hostname)()
3717
3735
  };
3718
3736
  }
@@ -3750,6 +3768,14 @@ function createPlacementActor() {
3750
3768
  placesForPerson: true
3751
3769
  };
3752
3770
  }
3771
+ /** Who the board page writes as: the person at this machine — `KANBO_ACTOR_ID` in a person's shell, else the OS user; never an agent tool, since the page is read and typed in by a person. */
3772
+ function createServeWriter() {
3773
+ return {
3774
+ kind: "external",
3775
+ id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
3776
+ name: (0, node_os.hostname)()
3777
+ };
3778
+ }
3753
3779
  /**
3754
3780
  * The person who owns this shell, as the board files a person. Only for a
3755
3781
  * caller that has already asked `isAgentShell` — `requireHumanActor` for a
@@ -11994,12 +12020,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
11994
12020
  issueId,
11995
12021
  parentIssueId
11996
12022
  });
11997
- if (!await store.issues.findInWorkspace(workspaceId, parentIssueId)) throw new BoardError("issue_parent_not_found", {
12023
+ const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
12024
+ if (!parent) throw new BoardError("issue_parent_not_found", {
11998
12025
  workspaceId,
11999
12026
  parentIssueId
12000
12027
  });
12028
+ if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
12029
+ issueId,
12030
+ parentIssueId
12031
+ });
12001
12032
  return parentIssueId;
12002
12033
  }
12034
+ /**
12035
+ * Is the card somewhere under `ancestorId` — its parent, its parent's parent,
12036
+ * and so on up? A card put under one of its own sub-cards would make a loop no
12037
+ * reader of the board can walk to the top of. The cards already seen end the
12038
+ * walk, so a loop the data holds from before cannot hang it.
12039
+ */
12040
+ async function isUnder(store, card, ancestorId) {
12041
+ const seen = /* @__PURE__ */ new Set();
12042
+ let id = card.parentIssueId;
12043
+ while (id !== null && !seen.has(id)) {
12044
+ if (id === ancestorId) return true;
12045
+ seen.add(id);
12046
+ id = (await store.issues.findById(id))?.parentIssueId ?? null;
12047
+ }
12048
+ return false;
12049
+ }
12003
12050
  /** Is the card's own number already taken by another card in the target workspace? */
12004
12051
  async function numberTakenByAnother(store, workspaceId, card) {
12005
12052
  const holder = await store.issues.findByNumber(workspaceId, card.number);
@@ -23776,6 +23823,111 @@ async function openWithPostgresJs(url) {
23776
23823
  };
23777
23824
  }
23778
23825
  //#endregion
23826
+ //#region src/cli/project-registry.ts
23827
+ /**
23828
+ * Which project folders kanbo has opened a board in.
23829
+ *
23830
+ * Every project keeps its board in its own folder, so nothing else knows which
23831
+ * other projects exist. This list, in the user's home, is what lets one board
23832
+ * page offer them all. It holds a folder and a time — never a connection
23833
+ * string, never board data — and deleting it is safe: it fills again as the
23834
+ * projects are used.
23835
+ */
23836
+ /** The file's name inside kanbo's per-user folder. */
23837
+ const REGISTRY_FILE_NAME = "projects.json";
23838
+ /** The file is private to the user, as a binding is. */
23839
+ const REGISTRY_FILE_MODE = 384;
23840
+ /** The folder holding it is private to the user too. */
23841
+ const REGISTRY_DIRECTORY_MODE = 448;
23842
+ /**
23843
+ * A project noted within this many seconds is not noted again, so an ordinary
23844
+ * command does not rewrite the file: a day is fresh enough for "recently used".
23845
+ */
23846
+ const RECENTLY_NOTED_SECONDS = 86400;
23847
+ const RegisteredProjectSchema = object$1({
23848
+ path: string().min(1),
23849
+ lastSeenAt: number().int().nonnegative()
23850
+ });
23851
+ const RegistrySchema = object$1({
23852
+ schemaVersion: literal(1),
23853
+ projects: array(RegisteredProjectSchema)
23854
+ });
23855
+ /** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
23856
+ function projectRegistryPath(env = process.env) {
23857
+ const home = env.KANBO_HOME?.trim();
23858
+ return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
23859
+ }
23860
+ /** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
23861
+ function readProjectRegistry(path = projectRegistryPath()) {
23862
+ return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
23863
+ }
23864
+ /**
23865
+ * Note that `projectDir` was just used. Writes only when the folder is new or
23866
+ * was last noted more than a day ago, so ordinary commands do not rewrite the file.
23867
+ * Drops entries whose `.kanbo/binding.json` is gone.
23868
+ */
23869
+ function rememberProject(projectDir, options = {}) {
23870
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
23871
+ const path = options.path ?? projectRegistryPath();
23872
+ const canonical = canonicalPath$1(projectDir);
23873
+ const entries = readEntries(path);
23874
+ const known = entries.find((entry) => entry.path === canonical);
23875
+ if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
23876
+ const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
23877
+ kept.push({
23878
+ path: canonical,
23879
+ lastSeenAt: now
23880
+ });
23881
+ writeEntries(path, kept);
23882
+ }
23883
+ /**
23884
+ * `rememberProject`, for the places that must not fail because of it: a home
23885
+ * directory that cannot be written is not a reason for `kanbo card list` to fail.
23886
+ * With `KANBO_DEBUG=1` the reason is printed on stderr.
23887
+ */
23888
+ function rememberProjectQuietly(projectDir) {
23889
+ const path = projectRegistryPath();
23890
+ try {
23891
+ rememberProject(projectDir, { path });
23892
+ } catch (error) {
23893
+ if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
23894
+ }
23895
+ }
23896
+ function canonicalPath$1(projectDir) {
23897
+ const absolute = (0, node_path.resolve)(projectDir);
23898
+ try {
23899
+ return (0, node_fs.realpathSync)(absolute);
23900
+ } catch {
23901
+ return absolute;
23902
+ }
23903
+ }
23904
+ function readEntries(path) {
23905
+ try {
23906
+ const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
23907
+ return parsed.success ? parsed.data.projects : [];
23908
+ } catch {
23909
+ return [];
23910
+ }
23911
+ }
23912
+ function writeEntries(path, projects) {
23913
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
23914
+ recursive: true,
23915
+ mode: REGISTRY_DIRECTORY_MODE
23916
+ });
23917
+ const temporaryPath = `${path}.${process.pid}.tmp`;
23918
+ try {
23919
+ (0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
23920
+ schemaVersion: 1,
23921
+ projects
23922
+ }), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
23923
+ (0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
23924
+ renameWithRetry(temporaryPath, path);
23925
+ } catch (error) {
23926
+ (0, node_fs.rmSync)(temporaryPath, { force: true });
23927
+ throw error;
23928
+ }
23929
+ }
23930
+ //#endregion
23779
23931
  //#region src/postgres/open-database.ts
23780
23932
  /** `information_schema.tables`, the first of the two views the guard reads. */
23781
23933
  const informationSchemaTables = pgSchema("information_schema").table("tables", {
@@ -24087,15 +24239,17 @@ function withBoardOptions(command) {
24087
24239
  * as a client is talking to it. A failure on the way out closes the board first
24088
24240
  * — a caller that never received a session has nothing to close.
24089
24241
  */
24090
- async function openBoardSession(options, mode) {
24091
- const cwd = process.cwd();
24242
+ async function openBoardSession(options, mode, cwd = process.cwd()) {
24092
24243
  const target = resolveDbTarget({
24093
24244
  explicitPath: options.db,
24094
24245
  explicitUrl: options.databaseUrl,
24095
24246
  asAgent: options.asAgent,
24096
24247
  cwd
24097
24248
  });
24098
- return target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
24249
+ const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
24250
+ const found = findBinding(cwd);
24251
+ if (found) rememberProjectQuietly(found.projectDir);
24252
+ return session;
24099
24253
  }
24100
24254
  async function openFileSession(target, options, mode, cwd) {
24101
24255
  const board = await openBoardDatabase(target.path);
@@ -24705,7 +24859,7 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
24705
24859
  kanbo_card_get: { kanbo: "kanbo card get <id>" },
24706
24860
  kanbo_card_list: { kanbo: "kanbo card list" },
24707
24861
  kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
24708
- kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
24862
+ kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
24709
24863
  kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
24710
24864
  kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
24711
24865
  kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
@@ -24903,6 +25057,81 @@ const cardCommentTool = defineKanboTool({
24903
25057
  },
24904
25058
  run: async (transport, input) => await transport.cardComment(input)
24905
25059
  });
25060
+ /** The `detail` and `fields` arguments the list tools share. */
25061
+ const cardListViewArguments = {
25062
+ detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
25063
+ fields: array(_enum([
25064
+ "number",
25065
+ "title",
25066
+ "description",
25067
+ "column",
25068
+ "columnSlug",
25069
+ "statusLine",
25070
+ "waitingFor",
25071
+ "priority",
25072
+ "labels",
25073
+ "executionMode",
25074
+ "parentIssueId",
25075
+ "attemptCount",
25076
+ "activeRun",
25077
+ "updatedAt"
25078
+ ])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
25079
+ };
25080
+ function compactKanboCard(card) {
25081
+ return {
25082
+ id: card.id,
25083
+ title: cardDisplayTitle(card),
25084
+ column: card.columnSlug,
25085
+ ...card.statusLine ? { statusLine: card.statusLine } : {},
25086
+ ...card.waitingFor ? { waitingFor: card.waitingFor } : {},
25087
+ ...card.parentIssueId ? { parentId: card.parentIssueId } : {},
25088
+ ...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
25089
+ ...card.activeRun ? { activeRun: {
25090
+ agentName: card.activeRun.agentName,
25091
+ startedAt: card.activeRun.startedAt
25092
+ } } : {},
25093
+ updatedAt: card.updatedAt,
25094
+ ...card.returned ? { returned: card.returned } : {},
25095
+ ...card.lastComment ? { lastComment: card.lastComment } : {}
25096
+ };
25097
+ }
25098
+ /** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
25099
+ const writtenCardDetailArgument = _enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
25100
+ /** What each tool that writes a card says about its answer, in its description. */
25101
+ const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
25102
+ /**
25103
+ * The card a write hands back, as the tool answers with it: compact and on one
25104
+ * line, as a list prints it. An agent writes its card at every step — a status
25105
+ * line, a move, an update — and reads each answer again on every later turn,
25106
+ * so a whole card, description included, would cost it again and again.
25107
+ * `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
25108
+ */
25109
+ function formatWrittenKanboCard(card, detail) {
25110
+ return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
25111
+ }
25112
+ function viewCard(card, view) {
25113
+ if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
25114
+ return view.detail === "full" ? card : compactKanboCard(card);
25115
+ }
25116
+ /**
25117
+ * The page as the tool's answer: how many cards the question picks, where the
25118
+ * next page starts when there is one, and one card per line.
25119
+ */
25120
+ function formatKanboCardPage(page, view, returned) {
25121
+ const shownUntil = page.offset + page.cards.length;
25122
+ const more = Math.max(0, page.total - shownUntil);
25123
+ const head = {
25124
+ total: page.total,
25125
+ ...page.offset > 0 ? { offset: page.offset } : {},
25126
+ ...more > 0 ? {
25127
+ more,
25128
+ nextOffset: shownUntil
25129
+ } : {}
25130
+ };
25131
+ const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
25132
+ const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
25133
+ return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
25134
+ }
24906
25135
  //#endregion
24907
25136
  //#region src/mcp/tools/card-create.ts
24908
25137
  /** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
@@ -24911,19 +25140,20 @@ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pas
24911
25140
  const cardCreateTool = defineKanboTool({
24912
25141
  name: "kanbo_card_create",
24913
25142
  title: "Create a card",
24914
- description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
25143
+ description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")} Answers the card compact, as kanbo_card_list prints it; detail: "full" for every field.`,
24915
25144
  inputSchema: {
24916
25145
  title: string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
24917
25146
  description: string().optional().describe("What the card is about, spelled out for whoever picks it up."),
24918
25147
  column: string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
24919
25148
  parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
24920
- executionMode: executionModeArgument.optional()
25149
+ executionMode: executionModeArgument.optional(),
25150
+ detail: writtenCardDetailArgument
24921
25151
  },
24922
25152
  example: {
24923
25153
  description: "Add slugify(text) with a test",
24924
25154
  parent: "TST-1"
24925
25155
  },
24926
- run: async (transport, input) => await transport.cardCreate(input)
25156
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
24927
25157
  });
24928
25158
  //#endregion
24929
25159
  //#region src/mcp/transport.ts
@@ -25054,9 +25284,14 @@ function projectKanboColumn(column) {
25054
25284
  }
25055
25285
  /** A comment as a card read lists it, from the row either transport holds. */
25056
25286
  function projectKanboCardComment(comment) {
25287
+ const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
25288
+ const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
25289
+ const named = author !== "system" || decision;
25290
+ const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
25057
25291
  return {
25058
25292
  id: comment.id,
25059
- author: normalizeCommentAuthorKind(comment.authorKind),
25293
+ author,
25294
+ by: named || outside ? comment.authorId ?? author : "kanbo",
25060
25295
  content: comment.content,
25061
25296
  createdAt: comment.createdAt
25062
25297
  };
@@ -25104,67 +25339,6 @@ const cardLinkPrTool = defineKanboTool({
25104
25339
  },
25105
25340
  run: async (transport, input) => await transport.cardLinkPullRequest(input)
25106
25341
  });
25107
- /** The `detail` and `fields` arguments the list tools share. */
25108
- const cardListViewArguments = {
25109
- detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
25110
- fields: array(_enum([
25111
- "number",
25112
- "title",
25113
- "description",
25114
- "column",
25115
- "columnSlug",
25116
- "statusLine",
25117
- "waitingFor",
25118
- "priority",
25119
- "labels",
25120
- "executionMode",
25121
- "parentIssueId",
25122
- "attemptCount",
25123
- "activeRun",
25124
- "updatedAt"
25125
- ])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
25126
- };
25127
- function compactKanboCard(card) {
25128
- return {
25129
- id: card.id,
25130
- title: cardDisplayTitle(card),
25131
- column: card.columnSlug,
25132
- ...card.statusLine ? { statusLine: card.statusLine } : {},
25133
- ...card.waitingFor ? { waitingFor: card.waitingFor } : {},
25134
- ...card.parentIssueId ? { parentId: card.parentIssueId } : {},
25135
- ...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
25136
- ...card.activeRun ? { activeRun: {
25137
- agentName: card.activeRun.agentName,
25138
- startedAt: card.activeRun.startedAt
25139
- } } : {},
25140
- updatedAt: card.updatedAt,
25141
- ...card.returned ? { returned: card.returned } : {},
25142
- ...card.lastComment ? { lastComment: card.lastComment } : {}
25143
- };
25144
- }
25145
- function viewCard(card, view) {
25146
- if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
25147
- return view.detail === "full" ? card : compactKanboCard(card);
25148
- }
25149
- /**
25150
- * The page as the tool's answer: how many cards the question picks, where the
25151
- * next page starts when there is one, and one card per line.
25152
- */
25153
- function formatKanboCardPage(page, view, returned) {
25154
- const shownUntil = page.offset + page.cards.length;
25155
- const more = Math.max(0, page.total - shownUntil);
25156
- const head = {
25157
- total: page.total,
25158
- ...page.offset > 0 ? { offset: page.offset } : {},
25159
- ...more > 0 ? {
25160
- more,
25161
- nextOffset: shownUntil
25162
- } : {}
25163
- };
25164
- const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
25165
- const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
25166
- return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
25167
- }
25168
25342
  /** The cards on the board a question picks, one page of them. */
25169
25343
  const cardListTool = defineKanboTool({
25170
25344
  name: "kanbo_card_list",
@@ -25219,10 +25393,11 @@ function readMoment(value) {
25219
25393
  const cardMoveTool = defineKanboTool({
25220
25394
  name: "kanbo_card_move",
25221
25395
  title: "Move a card",
25222
- description: "Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing.",
25396
+ description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
25223
25397
  inputSchema: {
25224
25398
  card: cardArgument,
25225
- column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
25399
+ column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
25400
+ detail: writtenCardDetailArgument
25226
25401
  },
25227
25402
  aliases: { column: ["to"] },
25228
25403
  needs: { column: "the column slug, e.g. in_progress" },
@@ -25230,7 +25405,7 @@ const cardMoveTool = defineKanboTool({
25230
25405
  card: "TST-5",
25231
25406
  column: "in_progress"
25232
25407
  },
25233
- run: async (transport, input) => await transport.cardMove(input)
25408
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
25234
25409
  });
25235
25410
  //#endregion
25236
25411
  //#region src/mcp/tools/card-pull-requests.ts
@@ -25245,11 +25420,11 @@ const cardPullRequestsTool = defineKanboTool({
25245
25420
  });
25246
25421
  //#endregion
25247
25422
  //#region src/mcp/tools/card-update.ts
25248
- /** What a card says: its title, what it is about, how urgent it is, how it is labelled. */
25423
+ /** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
25249
25424
  const cardUpdateTool = defineKanboTool({
25250
25425
  name: "kanbo_card_update",
25251
25426
  title: "Update a card",
25252
- description: "Change what a card says: its title, its description, its priority, its labels or where its work happens.",
25427
+ description: `Change what a card says: its title, its description, its priority, its labels or where its work happens. A card at the wrong level moves with \`parent\`: another card's id, or "none" for the top level. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
25253
25428
  inputSchema: {
25254
25429
  card: cardArgument,
25255
25430
  title: string().min(1).optional().describe("What the card is called."),
@@ -25262,18 +25437,26 @@ const cardUpdateTool = defineKanboTool({
25262
25437
  "urgent"
25263
25438
  ]).optional(),
25264
25439
  labels: array(string()).optional().describe("The labels the card carries, replacing the ones it has."),
25265
- executionMode: executionModeArgument.optional()
25440
+ executionMode: executionModeArgument.optional(),
25441
+ parent: cardArgument.optional().describe("The card to put this one under, by its id or key, e.g. MAN-012; \"none\" makes it a top-level card."),
25442
+ detail: writtenCardDetailArgument
25266
25443
  },
25267
25444
  example: {
25268
25445
  card: "TST-5",
25269
25446
  title: "Add slugify"
25270
25447
  },
25271
- run: async (transport, { card, ...patch }) => {
25272
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update. Pass title, description, priority, labels or executionMode.");
25273
- return await transport.cardUpdate({
25448
+ prepare: (input) => {
25449
+ const { parent, ...rest } = input;
25450
+ return parent === null ? rest : input;
25451
+ },
25452
+ run: async (transport, { card, detail, parent, ...patch }) => {
25453
+ if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
25454
+ const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
25455
+ return formatWrittenKanboCard(await transport.cardUpdate({
25274
25456
  card,
25275
- ...patch
25276
- });
25457
+ ...patch,
25458
+ ...placement
25459
+ }), detail);
25277
25460
  }
25278
25461
  });
25279
25462
  //#endregion
@@ -25410,10 +25593,11 @@ const KANBO_TOOLS = [
25410
25593
  defineKanboTool({
25411
25594
  name: "kanbo_status_line",
25412
25595
  title: "Set the status line",
25413
- description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running.",
25596
+ description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running. " + WRITTEN_CARD_ANSWER_SENTENCE,
25414
25597
  inputSchema: {
25415
25598
  card: cardArgument,
25416
- text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
25599
+ text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
25600
+ detail: writtenCardDetailArgument
25417
25601
  },
25418
25602
  aliases: { text: ["content"] },
25419
25603
  needs: { text: "the status line, one sentence" },
@@ -25421,22 +25605,23 @@ const KANBO_TOOLS = [
25421
25605
  card: "TST-5",
25422
25606
  text: "Running the tests"
25423
25607
  },
25424
- run: async (transport, input) => await transport.statusLine(input)
25608
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
25425
25609
  }),
25426
25610
  defineKanboTool({
25427
25611
  name: "kanbo_wait_approval",
25428
25612
  title: "Hand the card to a person",
25429
- description: "Mark the card as waiting for a person and end your turn: `card`, and `text` for the status line the person reads. A person approves or returns it; you will be told the answer.",
25613
+ description: `Mark the card as waiting for a person and end your turn: \`card\`, and \`text\` for the status line the person reads. A person approves or returns it; you will be told the answer. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
25430
25614
  inputSchema: {
25431
25615
  card: cardArgument,
25432
- text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
25616
+ text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
25617
+ detail: writtenCardDetailArgument
25433
25618
  },
25434
25619
  aliases: { text: ["content"] },
25435
25620
  example: {
25436
25621
  card: "TST-5",
25437
25622
  text: "Ready for review: slugify added with a test"
25438
25623
  },
25439
- run: async (transport, input) => await transport.waitApproval(input)
25624
+ run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
25440
25625
  }),
25441
25626
  runStartTool,
25442
25627
  runFinishTool
@@ -25981,7 +26166,7 @@ function describeCardExtras(extras) {
25981
26166
  })))].join("\n"));
25982
26167
  if (extras.comments && extras.comments.length > 0) {
25983
26168
  const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
25984
- sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
26169
+ sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
25985
26170
  }
25986
26171
  if (extras.runs && extras.runs.length > 0) sections.push(["Runs:", ...extras.runs.map((run) => `#${run.attempt} ${run.state} ${sanitizeTerminalText(run.agentName)} ${formatMoment(run.startedAt)} ${run.id}`)].join("\n"));
25987
26172
  if (extras.history && extras.history.length > 0) sections.push(["History:", ...extras.history.map((change) => `${formatMoment(change.createdAt)} ${change.actor} ${change.field}: ${sanitizeTerminalText(change.from ?? "—")} → ${sanitizeTerminalText(change.to ?? "—")}`)].join("\n"));
@@ -26079,10 +26264,11 @@ function registerCardCommands(program) {
26079
26264
  }, createPlacementActor()));
26080
26265
  });
26081
26266
  });
26082
- withBoardOptions(card.command("update").description("Change a card's title, description, priority or labels").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode)).action(async (reference, options) => {
26267
+ withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
26083
26268
  await runBoardCommand(options, "write", async (session) => {
26084
26269
  const input = readUpdateInput(options);
26085
26270
  const existing = await requireCard(session, reference);
26271
+ if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
26086
26272
  return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
26087
26273
  });
26088
26274
  });
@@ -26223,9 +26409,10 @@ async function presentCard(session, card) {
26223
26409
  };
26224
26410
  }
26225
26411
  /**
26226
- * The fields `card update` was asked to change. A command that names none of
26227
- * them is refused rather than obeyed: an empty update still bumps the board
26228
- * version, and every reader would be told the board changed when it did not.
26412
+ * The fields `card update` was asked to change — all but the parent, a card
26413
+ * of the board, looked up there once the card itself is. A command that names
26414
+ * none of them is refused rather than obeyed: an empty update still bumps the
26415
+ * board version, and every reader would be told the board changed when it did not.
26229
26416
  */
26230
26417
  function readUpdateInput(options) {
26231
26418
  const input = {};
@@ -26234,7 +26421,7 @@ function readUpdateInput(options) {
26234
26421
  if (options.priority !== void 0) input.priority = options.priority;
26235
26422
  if (options.labels !== void 0) input.labels = options.labels;
26236
26423
  if (options.executionMode !== void 0) input.executionMode = options.executionMode;
26237
- if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels or --execution-mode.");
26424
+ if (Object.keys(input).length === 0 && options.parent === void 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels, --execution-mode or --parent.");
26238
26425
  return input;
26239
26426
  }
26240
26427
  function parsePriority(value) {
@@ -42838,6 +43025,7 @@ const COMMAND_SHEET = [
42838
43025
  "- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
42839
43026
  "- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
42840
43027
  "- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
43028
+ "- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
42841
43029
  "- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
42842
43030
  "- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
42843
43031
  "- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
@@ -43965,7 +44153,7 @@ function startUpdateCheck(deps = {}) {
43965
44153
  setTimeout: globalThis.setTimeout,
43966
44154
  clearTimeout: globalThis.clearTimeout
43967
44155
  };
43968
- const current = deps.currentVersion ?? "0.3.0";
44156
+ const current = deps.currentVersion ?? "0.4.0";
43969
44157
  const controller = new AbortController();
43970
44158
  let found = null;
43971
44159
  const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
@@ -45287,6 +45475,7 @@ async function applyInitPlan(plan) {
45287
45475
  }
45288
45476
  if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
45289
45477
  const bindingPath = writeBinding(plan.projectDir, plan.binding);
45478
+ rememberProjectQuietly(plan.projectDir);
45290
45479
  const files = plan.fileChanges.map(({ kind, change }) => ({
45291
45480
  kind,
45292
45481
  ...applyFileChange(change)
@@ -46034,7 +46223,7 @@ async function planAgents(projectDir, agents, options) {
46034
46223
  }
46035
46224
  /** The board this project is bound to, and the workspace on it. */
46036
46225
  async function resolveBoard(projectDir, options) {
46037
- if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
46226
+ if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
46038
46227
  const target = resolveInitTarget(projectDir, options);
46039
46228
  if (target.kind === "postgres") return {
46040
46229
  target,
@@ -46053,7 +46242,7 @@ async function resolveBoard(projectDir, options) {
46053
46242
  * does — a person on a machine with nothing else installed gets a board, not a
46054
46243
  * refusal that names a flag.
46055
46244
  */
46056
- function namesNoBoard(projectDir, options) {
46245
+ function namesNoBoard$1(projectDir, options) {
46057
46246
  return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
46058
46247
  }
46059
46248
  /** The workspace a host database — or the board file this project is already bound to — says this project is. */
@@ -46486,6 +46675,7 @@ const KANBO_MCP_INSTRUCTIONS = [
46486
46675
  `- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
46487
46676
  `- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
46488
46677
  `- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
46678
+ `- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
46489
46679
  `- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
46490
46680
  `- ${PERSON_ONLY_RULE}`,
46491
46681
  `- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
@@ -48193,10 +48383,14 @@ async function createDbTransport(input) {
48193
48383
  executionMode
48194
48384
  }, actor));
48195
48385
  },
48196
- cardUpdate: async ({ card, ...patch }) => {
48386
+ cardUpdate: async ({ card, parent, ...patch }) => {
48197
48387
  await session.assertWritable();
48198
48388
  const existing = await resolveCard(card);
48199
- return await project(await ops.updateCard(existing.id, patch, actor));
48389
+ const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
48390
+ return await project(await ops.updateCard(existing.id, {
48391
+ ...patch,
48392
+ ...placement
48393
+ }, actor));
48200
48394
  },
48201
48395
  cardMove: async ({ card, column }) => {
48202
48396
  await session.assertWritable();
@@ -48350,13 +48544,40 @@ function withUpdateNote(board, peek) {
48350
48544
  }
48351
48545
  //#endregion
48352
48546
  //#region src/cli/commands/mcp.ts
48547
+ /**
48548
+ * The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
48549
+ * else the agent tool, else `agent` until the client says its name (see
48550
+ * `nameActorAfterClient`).
48551
+ */
48552
+ function createMcpActor() {
48553
+ const tool = readAgentTool();
48554
+ const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
48555
+ const actor = createCliActor();
48556
+ if (!namedByShell) actor.id = "agent";
48557
+ return {
48558
+ actor,
48559
+ namedByShell
48560
+ };
48561
+ }
48562
+ /**
48563
+ * Files the session's writes under the client's own name (`Test Client` ->
48564
+ * `test-client`) once it has initialized. `actor` is the object the board
48565
+ * holds, so later writes see the new id.
48566
+ */
48567
+ function nameActorAfterClient(server, actor) {
48568
+ server.server.oninitialized = () => {
48569
+ const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
48570
+ if (id) actor.id = id;
48571
+ };
48572
+ }
48353
48573
  function registerMcpCommand(program) {
48354
48574
  program.command("mcp").description("Run the board's MCP server for an agent (it starts this itself)").option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").action(async (options) => {
48575
+ const { actor, namedByShell } = createMcpActor();
48355
48576
  const board = await openMcpBoard({
48356
48577
  dbPath: options.db,
48357
48578
  databaseUrl: options.databaseUrl,
48358
48579
  workspaceId: options.workspace,
48359
- actor: createCliActor()
48580
+ actor
48360
48581
  });
48361
48582
  let closing;
48362
48583
  const close = () => closing ??= board.close().catch((error) => {
@@ -48365,6 +48586,7 @@ function registerMcpCommand(program) {
48365
48586
  const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
48366
48587
  const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
48367
48588
  server.server.onclose = () => void close();
48589
+ if (!namedByShell) nameActorAfterClient(server, actor);
48368
48590
  try {
48369
48591
  await server.connect(new StdioServerTransport());
48370
48592
  } catch (error) {
@@ -49009,6 +49231,10 @@ const BOARD_ERROR_RESPONSES = {
49009
49231
  status: 404,
49010
49232
  message: "Issue not found"
49011
49233
  },
49234
+ issue_parent_cycle: {
49235
+ status: 400,
49236
+ message: "Issue cannot be put under one of its own sub-issues"
49237
+ },
49012
49238
  issue_parent_not_found: {
49013
49239
  status: 404,
49014
49240
  message: "Parent issue not found"
@@ -49390,10 +49616,20 @@ function toStatusView(status) {
49390
49616
  entryRules: readEntryRules(status)
49391
49617
  };
49392
49618
  }
49393
- function toCommentView(comment) {
49619
+ function toCommentView(comment, personWriterId = null) {
49620
+ if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
49621
+ ...comment,
49622
+ author: {
49623
+ kind: "user",
49624
+ id: comment.authorId ?? "__self__",
49625
+ displayName: "You",
49626
+ avatarUrl: null,
49627
+ label: null
49628
+ }
49629
+ };
49394
49630
  return {
49395
49631
  ...comment,
49396
- author: resolveActor(commentAuthorReference(comment))
49632
+ author: resolveActor(commentAuthorReference(comment), personWriterId)
49397
49633
  };
49398
49634
  }
49399
49635
  /**
@@ -49401,23 +49637,34 @@ function toCommentView(comment) {
49401
49637
  *
49402
49638
  * For the kinds that need nobody else's tables: an outside writer is shown
49403
49639
  * under the name it gave, the board writing for itself is `kanbo`, and a person
49404
- * is `You`. An agent and a provider target are rows of whatever app launched
49640
+ * is `You`. So is an outside writer that is the person who started this
49641
+ * server (`personWriterId`): their own writes carry the name their shell files
49642
+ * them under. An agent and a provider target are rows of whatever app launched
49405
49643
  * them, which this server cannot read, so they are shown by the id the board
49406
49644
  * holds for them.
49407
49645
  */
49408
- function resolveActor(actor) {
49646
+ function resolveActor(actor, personWriterId) {
49409
49647
  if (actor.kind === "system") {
49410
49648
  const provenance = {
49411
49649
  authorId: actor.id,
49412
49650
  sourceChatSessionId: actor.sourceChatSessionId
49413
49651
  };
49414
- if (isOutsideWriter(provenance)) return {
49415
- kind: "system",
49416
- id: provenance.authorId,
49417
- displayName: provenance.authorId,
49418
- avatarUrl: null,
49419
- label: "External"
49420
- };
49652
+ if (isOutsideWriter(provenance)) {
49653
+ if (personWriterId !== null && provenance.authorId === personWriterId) return {
49654
+ kind: "user",
49655
+ id: provenance.authorId,
49656
+ displayName: "You",
49657
+ avatarUrl: null,
49658
+ label: null
49659
+ };
49660
+ return {
49661
+ kind: "system",
49662
+ id: provenance.authorId,
49663
+ displayName: provenance.authorId,
49664
+ avatarUrl: null,
49665
+ label: "External"
49666
+ };
49667
+ }
49421
49668
  return {
49422
49669
  kind: "system",
49423
49670
  id: null,
@@ -49453,13 +49700,13 @@ function resolveActor(actor) {
49453
49700
  * serves, named by its identity; actors as `resolveActor` reads them; and an
49454
49701
  * agent assignee by its id.
49455
49702
  */
49456
- function createActivityResolver(workspace) {
49703
+ function createActivityResolver(workspace, personWriterId = null) {
49457
49704
  return {
49458
49705
  workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
49459
49706
  id: workspace.id,
49460
49707
  name: workspace.name
49461
49708
  }]]),
49462
- resolveActor: async (actor) => resolveActor(actor),
49709
+ resolveActor: async (actor) => resolveActor(actor, personWriterId),
49463
49710
  resolveAgentName: async (agentId) => agentId
49464
49711
  };
49465
49712
  }
@@ -49480,10 +49727,15 @@ function createActivityResolver(workspace) {
49480
49727
  const OK = { ok: true };
49481
49728
  /** How many cards a search answers with when the caller names no limit. */
49482
49729
  const DEFAULT_SEARCH_LIMIT = 20;
49483
- function createIssueRoutes(session) {
49730
+ /**
49731
+ * `personWriterId` is the id the person who started the server is filed under;
49732
+ * their writes read as `You`, everyone else's by name.
49733
+ */
49734
+ function createIssueRoutes(session, options = {}) {
49735
+ const personWriterId = options.personWriterId ?? null;
49484
49736
  const { ops, store, workspace } = session;
49485
49737
  const workspaceId = workspace.id;
49486
- const activityResolver = createActivityResolver(workspace);
49738
+ const activityResolver = createActivityResolver(workspace, personWriterId);
49487
49739
  /** The served workspace, when that is the one the caller named — or named none. */
49488
49740
  function requireWorkspace(named) {
49489
49741
  if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
@@ -49768,6 +50020,15 @@ function createIssueRoutes(session) {
49768
50020
  return updated;
49769
50021
  }
49770
50022
  }),
50023
+ route({
50024
+ method: "GET",
50025
+ path: "/issues/workspace",
50026
+ handle: async () => ({
50027
+ id: workspace.id,
50028
+ identifier: workspace.identifier,
50029
+ name: workspace.name
50030
+ })
50031
+ }),
49771
50032
  route({
49772
50033
  method: "GET",
49773
50034
  path: "/issues/:id",
@@ -49921,7 +50182,7 @@ function createIssueRoutes(session) {
49921
50182
  path: "/issues/:id/comments",
49922
50183
  handle: async ({ params }) => {
49923
50184
  await requireCard(params.id);
49924
- return (await ops.listComments(params.id)).map(toCommentView);
50185
+ return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
49925
50186
  }
49926
50187
  }),
49927
50188
  route({
@@ -49934,7 +50195,7 @@ function createIssueRoutes(session) {
49934
50195
  return toCommentView(await ops.addComment({
49935
50196
  issueId: params.id,
49936
50197
  content: body.content
49937
- }, actors.writer));
50198
+ }, actors.person), personWriterId);
49938
50199
  }
49939
50200
  }),
49940
50201
  route({
@@ -50063,7 +50324,7 @@ function assertServeBinding(host, token) {
50063
50324
  }
50064
50325
  async function startKanboServer(options) {
50065
50326
  assertServeBinding(options.host, options.token);
50066
- const routes = createIssueRoutes(options.session);
50327
+ const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
50067
50328
  const listenHost = stripBrackets(options.host);
50068
50329
  let boundPort = 0;
50069
50330
  const handle = createRequestHandler(routes, options, () => boundPort);
@@ -50109,6 +50370,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
50109
50370
  "HEAD",
50110
50371
  "OPTIONS"
50111
50372
  ]);
50373
+ /** What the board page tells the server about itself. */
50374
+ const PRESENCE_BODY = object$1({
50375
+ tabId: string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
50376
+ state: _enum(["open", "closed"])
50377
+ });
50112
50378
  function createRequestHandler(routes, options, boundPort) {
50113
50379
  const token = options.token || null;
50114
50380
  const corsOrigins = new Set(options.corsOrigins ?? []);
@@ -50169,17 +50435,41 @@ function createRequestHandler(routes, options, boundPort) {
50169
50435
  throw new ServeError(401, "unauthorized", "A valid bearer token is required");
50170
50436
  }
50171
50437
  if (!SAFE_METHODS.has(request.method ?? "GET") && !isJsonContentType(request.headers["content-type"])) throw new ServeError(415, "unsupported_media_type", "Send the request as content-type: application/json");
50172
- const matched = matchRoute(routes, request.method ?? "GET", url.pathname);
50173
- if (!matched) throw new ServeError(404, "not_found", "Not Found");
50438
+ if (url.pathname === "/page/presence" && request.method === "POST") {
50439
+ if (!options.presence) {
50440
+ response.writeHead(204).end();
50441
+ return;
50442
+ }
50443
+ const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
50444
+ if (!parsed.success) throw validationError("body", parsed.error);
50445
+ if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
50446
+ else options.presence.closed(parsed.data.tabId);
50447
+ sendJson(response, 200, { ok: true });
50448
+ return;
50449
+ }
50450
+ const projectPath = readProjectPath(url.pathname);
50451
+ if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
50452
+ sendJson(response, 200, options.projects.list());
50453
+ return;
50454
+ }
50455
+ const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
50456
+ if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
50457
+ if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
50174
50458
  const body = await readJsonBody(request);
50175
50459
  const actors = resolveRequestActors(options.actors, {
50176
50460
  actorHeader: request.headers[ACTOR_HEADER],
50177
50461
  authenticated: token !== null
50178
50462
  });
50179
50463
  const result = queue.then(async () => {
50180
- if (matched.route.write) await options.session.assertWritable();
50181
- return await matched.route.run({
50182
- params: matched.params,
50464
+ const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
50465
+ session: options.session,
50466
+ routes
50467
+ };
50468
+ const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
50469
+ if (!found) throw new ServeError(404, "not_found", "Not Found");
50470
+ if (found.route.write) await target.session.assertWritable();
50471
+ return await found.route.run({
50472
+ params: found.params,
50183
50473
  query: readQuery(url.searchParams),
50184
50474
  body,
50185
50475
  actors
@@ -50205,6 +50495,21 @@ function createRequestHandler(routes, options, boundPort) {
50205
50495
  }
50206
50496
  };
50207
50497
  }
50498
+ /** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
50499
+ function readProjectPath(pathname) {
50500
+ const segments = pathname.split("/").filter(Boolean);
50501
+ if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
50502
+ let projectId;
50503
+ try {
50504
+ projectId = decodeURIComponent(segments[1]);
50505
+ } catch {
50506
+ return null;
50507
+ }
50508
+ return {
50509
+ projectId,
50510
+ rest: `/${segments.slice(2).join("/")}`
50511
+ };
50512
+ }
50208
50513
  function isJsonContentType(value) {
50209
50514
  return value?.split(";")[0].trim().toLowerCase() === "application/json";
50210
50515
  }
@@ -50348,6 +50653,146 @@ function readTextOrNull(path) {
50348
50653
  }
50349
50654
  }
50350
50655
  //#endregion
50656
+ //#region src/serve/page-presence.ts
50657
+ function createPagePresence(options) {
50658
+ const closedGraceMs = options.closedGraceMs ?? 5e3;
50659
+ const staleMs = options.staleMs ?? 12e4;
50660
+ const tickMs = options.tickMs ?? 1e4;
50661
+ const now = options.now ?? Date.now;
50662
+ const tabs = /* @__PURE__ */ new Map();
50663
+ let finished = false;
50664
+ let graceTimer = null;
50665
+ let lastTick = now();
50666
+ function stop() {
50667
+ finished = true;
50668
+ clearInterval(ticker);
50669
+ if (graceTimer) {
50670
+ clearTimeout(graceTimer);
50671
+ graceTimer = null;
50672
+ }
50673
+ }
50674
+ function idle() {
50675
+ if (finished) return;
50676
+ stop();
50677
+ options.onIdle();
50678
+ }
50679
+ const ticker = setInterval(() => {
50680
+ const current = now();
50681
+ const asleep = current - lastTick > 3 * tickMs;
50682
+ lastTick = current;
50683
+ if (asleep) {
50684
+ for (const id of tabs.keys()) tabs.set(id, current);
50685
+ return;
50686
+ }
50687
+ if (tabs.size === 0) return;
50688
+ for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
50689
+ if (tabs.size === 0) idle();
50690
+ }, tickMs);
50691
+ ticker.unref();
50692
+ return {
50693
+ seen: (tabId) => {
50694
+ if (!finished) tabs.set(tabId, now());
50695
+ },
50696
+ closed: (tabId) => {
50697
+ if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
50698
+ if (graceTimer) clearTimeout(graceTimer);
50699
+ graceTimer = setTimeout(() => {
50700
+ graceTimer = null;
50701
+ if (tabs.size === 0) idle();
50702
+ }, closedGraceMs);
50703
+ graceTimer.unref();
50704
+ },
50705
+ stop
50706
+ };
50707
+ }
50708
+ //#endregion
50709
+ //#region src/serve/projects.ts
50710
+ function createServeProjects(input) {
50711
+ const routesOptions = { personWriterId: input.personWriterId ?? null };
50712
+ const opened = /* @__PURE__ */ new Map();
50713
+ let defaultOpened = null;
50714
+ /** Every project that can be offered right now, default first, with the folder each lives in. */
50715
+ function entries() {
50716
+ const defaultDir = input.defaultProject?.dir ?? null;
50717
+ const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
50718
+ const found = [];
50719
+ for (const dir of dirs) {
50720
+ const binding = input.readBinding(dir);
50721
+ if (binding === null && dir !== defaultDir) continue;
50722
+ found.push({
50723
+ view: viewOf(dir, binding, dir === defaultDir),
50724
+ dir
50725
+ });
50726
+ }
50727
+ return found;
50728
+ }
50729
+ return {
50730
+ list: () => entries().map((entry) => entry.view),
50731
+ resolve: async (projectId) => {
50732
+ const entry = entries().find((candidate) => candidate.view.id === projectId);
50733
+ if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
50734
+ if (entry.view.isDefault && input.defaultProject) {
50735
+ defaultOpened ??= {
50736
+ session: input.defaultProject.session,
50737
+ routes: createIssueRoutes(input.defaultProject.session, routesOptions)
50738
+ };
50739
+ return defaultOpened;
50740
+ }
50741
+ const cached = opened.get(projectId);
50742
+ if (cached) return cached;
50743
+ let session;
50744
+ try {
50745
+ session = await input.openSession(entry.dir);
50746
+ } catch (error) {
50747
+ throw new ServeError(503, "project_unavailable", describeFailure(error).message);
50748
+ }
50749
+ const project = {
50750
+ session,
50751
+ routes: createIssueRoutes(session, routesOptions)
50752
+ };
50753
+ opened.set(projectId, project);
50754
+ return project;
50755
+ },
50756
+ close: async () => {
50757
+ const sessions = [...opened.values()].map((project) => project.session);
50758
+ opened.clear();
50759
+ await Promise.allSettled(sessions.map(async (session) => await session.close()));
50760
+ }
50761
+ };
50762
+ }
50763
+ function projectId(dir) {
50764
+ return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
50765
+ }
50766
+ function viewOf(dir, binding, isDefault) {
50767
+ return {
50768
+ id: projectId(dir),
50769
+ name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
50770
+ folder: tildify(dir),
50771
+ storage: storageOf(dir, binding),
50772
+ isDefault
50773
+ };
50774
+ }
50775
+ /** Where the board lives. The connection string is read here to name its host, and goes no further. */
50776
+ function storageOf(dir, binding) {
50777
+ if (binding?.databaseUrl) return {
50778
+ kind: "postgres",
50779
+ where: hostOf(maskDatabaseUrl(binding.databaseUrl))
50780
+ };
50781
+ return {
50782
+ kind: "file",
50783
+ path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
50784
+ };
50785
+ }
50786
+ /** The host of an already masked connection string; a string that is not one names no host. */
50787
+ function hostOf(maskedUrl) {
50788
+ try {
50789
+ const { host } = new URL(maskedUrl);
50790
+ return host && !host.includes("*") ? host : "a Postgres database";
50791
+ } catch {
50792
+ return "a Postgres database";
50793
+ }
50794
+ }
50795
+ //#endregion
50351
50796
  //#region src/cli/commands/serve.ts
50352
50797
  function registerServeCommand(program) {
50353
50798
  program.command("serve").description("Open the board in your browser").option("--port <port>", "port to listen on", String(DEFAULT_SERVE_PORT)).option("--host <host>", "address to listen on; anything but this computer needs a token", DEFAULT_SERVE_HOST).option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").option("--token <token>", "a password every request must carry (or set KANBO_SERVE_TOKEN); made up for you when left out").option("--cors-origin <origin>", "let pages from this web address call the board; repeat for more", collect, []).option("--no-open", "print the board page link without opening it in the browser").action(async (options) => {
@@ -50355,7 +50800,7 @@ function registerServeCommand(program) {
50355
50800
  status: (line) => console.error(line),
50356
50801
  page: (line) => console.log(line)
50357
50802
  });
50358
- await waitForInterrupt();
50803
+ if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
50359
50804
  try {
50360
50805
  await running.close();
50361
50806
  process.exit(0);
@@ -50367,26 +50812,46 @@ function registerServeCommand(program) {
50367
50812
  }
50368
50813
  /**
50369
50814
  * Open the board and serve it — everything `kanbo serve` does but wait. The
50370
- * command waits for Ctrl-C and ends the process; the home menu waits for
50371
- * Ctrl-C and comes back to its menu.
50815
+ * command waits for Ctrl-C (or the last board tab closing) and ends the
50816
+ * process; the home menu waits the same and comes back to its menu.
50372
50817
  */
50373
50818
  async function startServe(options, output) {
50374
50819
  const port = parsePort(options.port);
50375
50820
  const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
50821
+ const here = findBinding(process.cwd());
50822
+ const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
50823
+ const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
50376
50824
  const session = await openBoardSession({
50377
50825
  db: options.db,
50378
50826
  databaseUrl: options.databaseUrl,
50379
50827
  workspace: options.workspace
50380
- }, "read");
50828
+ }, "read", mostRecent?.path);
50381
50829
  const agentShell = isAgentShell();
50382
50830
  const actors = {
50383
- writer: createCliActor(),
50831
+ writer: createServeWriter(),
50384
50832
  person: agentShell ? null : createPersonActor()
50385
50833
  };
50834
+ const projects = createServeProjects({
50835
+ defaultProject: defaultDir === null ? null : {
50836
+ dir: defaultDir,
50837
+ session
50838
+ },
50839
+ readRegistry: () => readProjectRegistry(),
50840
+ readBinding: (dir) => readProjectBinding(dir),
50841
+ openSession: async (dir) => await openBoardSession({}, "read", dir),
50842
+ personWriterId: actors.writer.id
50843
+ });
50844
+ const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
50845
+ let presence;
50846
+ const idle = new Promise((resolve) => {
50847
+ presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
50848
+ });
50386
50849
  let server;
50387
50850
  try {
50388
50851
  server = await startKanboServer({
50389
50852
  session,
50853
+ projects,
50854
+ presence,
50390
50855
  actors,
50391
50856
  host: options.host,
50392
50857
  port,
@@ -50394,10 +50859,12 @@ async function startServe(options, output) {
50394
50859
  corsOrigins: options.corsOrigin
50395
50860
  });
50396
50861
  } catch (error) {
50862
+ presence?.stop();
50397
50863
  await session.close();
50398
50864
  throw error;
50399
50865
  }
50400
- output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
50866
+ const projectCount = projects.list().length;
50867
+ output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
50401
50868
  output.status(`kanbo serve: ${describePersonRights(actors)}`);
50402
50869
  presentServePage({
50403
50870
  url: server.url,
@@ -50411,15 +50878,25 @@ async function startServe(options, output) {
50411
50878
  });
50412
50879
  return {
50413
50880
  link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
50881
+ idle,
50414
50882
  close: async () => {
50883
+ presence?.stop();
50415
50884
  try {
50416
50885
  await server.close();
50417
50886
  } finally {
50418
- await session.close();
50887
+ try {
50888
+ await projects.close();
50889
+ } finally {
50890
+ await session.close();
50891
+ }
50419
50892
  }
50420
50893
  }
50421
50894
  };
50422
50895
  }
50896
+ /** Whether neither a flag nor the environment says which board to open. */
50897
+ function namesNoBoard(options) {
50898
+ return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
50899
+ }
50423
50900
  /** What `kanbo serve` with no options is told — the home menu serves the board this way. */
50424
50901
  const DEFAULT_SERVE_OPTIONS = {
50425
50902
  port: String(DEFAULT_SERVE_PORT),
@@ -50427,16 +50904,21 @@ const DEFAULT_SERVE_OPTIONS = {
50427
50904
  corsOrigin: [],
50428
50905
  open: true
50429
50906
  };
50430
- /** Settles on the first Ctrl-C (SIGINT) or SIGTERM, and stops listening for both. */
50431
- function waitForInterrupt() {
50907
+ /**
50908
+ * Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
50909
+ * stops listening for both signals whichever it was.
50910
+ */
50911
+ function waitForStop(idle) {
50432
50912
  return new Promise((resolve) => {
50433
- const stop = () => {
50434
- process.off("SIGINT", stop);
50435
- process.off("SIGTERM", stop);
50436
- resolve();
50913
+ const finish = (why) => {
50914
+ process.off("SIGINT", onSignal);
50915
+ process.off("SIGTERM", onSignal);
50916
+ resolve(why);
50437
50917
  };
50438
- process.on("SIGINT", stop);
50439
- process.on("SIGTERM", stop);
50918
+ const onSignal = () => finish("interrupt");
50919
+ process.on("SIGINT", onSignal);
50920
+ process.on("SIGTERM", onSignal);
50921
+ idle.then(() => finish("idle"));
50440
50922
  });
50441
50923
  }
50442
50924
  function collect(value, previous) {
@@ -50720,7 +51202,7 @@ const SERVE_FROM_MENU = {
50720
51202
  status: (line) => console.error(line),
50721
51203
  page: () => {}
50722
51204
  }),
50723
- waitForStop: waitForInterrupt
51205
+ waitForStop: (running) => waitForStop(running.idle)
50724
51206
  };
50725
51207
  /** The `--check` instruction cells that mean a kanbo section is there and readable. */
50726
51208
  const INSTRUCTION_BLOCK_PRESENT = /* @__PURE__ */ new Set([
@@ -50882,7 +51364,7 @@ async function serveUntilStopped(context) {
50882
51364
  const running = await start();
50883
51365
  try {
50884
51366
  say(context.ui, `Board open at ${running.link} — press Ctrl-C to stop and return to the menu`);
50885
- await waitForStop();
51367
+ await waitForStop(running);
50886
51368
  } finally {
50887
51369
  await running.close();
50888
51370
  }