@bli-cockpit/cli 0.2.212 → 0.2.217

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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * How `cockpit manager` prints clients, items and a decision for a person (BLI-5006).
3
+ *
4
+ * The words are the door's own (a title, an owner, a proposal's fields, a sync's reason); this file
5
+ * only lays them out. Every item ends with a dim `id` line and its `version`, because the verbs take
6
+ * both: an id to name the item, and the version (its `updatedAt`) so a write cannot land on top of a
7
+ * change nobody read. Readable, not exhaustive: `--json` is the whole answer and never comes through here.
8
+ */
9
+ import { dim } from "./cli-io.js";
10
+ const QUEUE_REASON = {
11
+ added: "added by a sync, not looked at yet",
12
+ changed: "changed by a sync since you last saw it",
13
+ proposal: "has a suggested change waiting",
14
+ };
15
+ export function clientLines(clients, color) {
16
+ if (clients.length === 0)
17
+ return ["Tower has no clients yet."];
18
+ return clients.flatMap((client) => [
19
+ `${client.name} [${client.enabled === false ? "disabled" : client.onboardingStatus}]${client.running ? " [reading now]" : ""}`,
20
+ ` ${client.lastSyncedAt ? `last synced ${client.lastSyncedAt}` : "never synced"}${client.pagesWaiting ? `, ${client.pagesWaiting} pages waiting to be read` : ""}`,
21
+ dim(` id ${client.id}`, color),
22
+ "",
23
+ ]).slice(0, -1);
24
+ }
25
+ /** "needs review" when nobody has looked at it; a suggested change waiting is said too. */
26
+ export function reviewLabel(item) {
27
+ const parts = [item.reviewStatus === "unreviewed" ? "needs review" : item.reviewStatus === "rejected" ? "dismissed" : "reviewed"];
28
+ const proposals = item.proposedChanges?.length ?? 0;
29
+ if (proposals > 0)
30
+ parts.push(`${proposals} suggested change${proposals === 1 ? "" : "s"} waiting`);
31
+ if (item.syncChange)
32
+ parts.push("changed by sync");
33
+ if (item.archivedAt)
34
+ parts.push("archived");
35
+ return parts.join(", ");
36
+ }
37
+ /** "owners Pip Moss, Rowan Ash" / "owner Pip Moss", or "no owner" (never a person called "nobody"). */
38
+ function ownersText(item, timeline) {
39
+ const names = item.owners ?? (item.ownerName ? [item.ownerName] : []);
40
+ if (names.length === 0)
41
+ return "no owner";
42
+ return `${timeline || names.length === 1 ? "owner" : "owners"} ${names.join(", ")}`;
43
+ }
44
+ function whenText(item, timeline) {
45
+ if (timeline)
46
+ return item.occurredAt ? item.occurredAt : "no date (it sits in Needs a date)";
47
+ if (!item.dueAt)
48
+ return "no due date";
49
+ return `due ${item.dueAt}${item.overdue ? " (overdue)" : ""}`;
50
+ }
51
+ /** The short row: title, who, when, status, review state, and the id and version the verbs take. */
52
+ export function itemRowLines(item, timeline, color) {
53
+ const review = reviewLabel(item);
54
+ const facts = timeline
55
+ ? [item.kind ?? "item", whenText(item, true), ownersText(item, true)]
56
+ : [ownersText(item, false), whenText(item, false), item.status ?? "open"];
57
+ return [`${item.title} [${review}]`, ` ${facts.join(" | ")}`, dim(` id ${item.id} version ${item.updatedAt}`, color)];
58
+ }
59
+ export function listLines(list, timeline, color) {
60
+ const noun = timeline ? "timeline item" : "action";
61
+ const shown = list.items.length;
62
+ const head = shown < list.total ? `Showing ${shown} of ${list.total} ${noun}s.` : `${list.total} ${noun}${list.total === 1 ? "" : "s"}.`;
63
+ const c = list.counts;
64
+ const cut = list.truncated ? " A list holds at most 500 rows: narrow it with --status, --owner, --kind, --search or --needs-review." : "";
65
+ const lines = [`${head}${cut}`, `The client has ${c.openActions} open ${c.openActions === 1 ? "action" : "actions"} (${c.overdue} overdue) and ${c.toReview} ${c.toReview === 1 ? "item" : "items"} to review.`];
66
+ if (shown === 0)
67
+ lines.push(`No ${noun}s match.`);
68
+ for (const item of list.items)
69
+ lines.push("", ...itemRowLines(item, timeline, color));
70
+ return lines;
71
+ }
72
+ export function queueLines(queue, color) {
73
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
74
+ const lines = [`${count(queue.total.timeline, "timeline item")} and ${count(queue.total.actions, "action")} from syncs after onboarding are waiting for a person.`];
75
+ if (queue.truncated)
76
+ lines.push(`Showing ${queue.timeline.length} and ${queue.actions.length}: the oldest first.`);
77
+ for (const [heading, items, timeline] of [["Timeline items", queue.timeline, true], ["Actions", queue.actions, false]]) {
78
+ if (items.length === 0)
79
+ continue;
80
+ lines.push("", `${heading}:`);
81
+ for (const item of items) {
82
+ const rows = itemRowLines(item, timeline, color);
83
+ lines.push("", rows[0], ` ${QUEUE_REASON[item.queuedFor ?? ""] ?? "waiting for review"}`, ...rows.slice(1));
84
+ }
85
+ }
86
+ // The queue leaves out the onboarding's own items by design, so an empty one is not "all reviewed".
87
+ lines.push("", "Everything not yet reviewed, onboarding included: `cockpit manager list <client> --needs-review --status all` and `list <client> --timeline --needs-review`. `show <id>` lists an item's suggested changes.");
88
+ return lines;
89
+ }
90
+ function valueText(value, label) {
91
+ if (value === null || value === undefined || (Array.isArray(value) && value.length === 0))
92
+ return "(none)";
93
+ const text = Array.isArray(value) ? value.join(", ") : value;
94
+ return label ? `${label} (${text})` : text;
95
+ }
96
+ function sourceLine(source) {
97
+ if (source.addedBy === "human")
98
+ return `from a person: ${[source.url, source.note].filter(Boolean).join(" | ") || "(empty)"}`;
99
+ const page = source.pageTitle ?? source.pageUrl ?? "a page";
100
+ return `${page}${source.quote ? `: "${source.quote}"` : ""}${source.verified === false ? " (not found in the page)" : ""}`;
101
+ }
102
+ /** One item in full: the detail, the sources, every suggested change with its id, and what a sync changed and why. */
103
+ export function itemDetailLines(detail, timeline, color) {
104
+ const item = detail.item;
105
+ const lines = [...itemRowLines(item, timeline, color)];
106
+ if (!timeline && detail.timelineItem)
107
+ lines.push(` From the meeting: ${detail.timelineItem.title}${detail.timelineItem.occurredAt ? ` (${detail.timelineItem.occurredAt})` : ""}`);
108
+ if (timeline && detail.actions && detail.actions.length > 0)
109
+ lines.push(` ${detail.actions.length} action${detail.actions.length === 1 ? "" : "s"} came from it: ${detail.actions.map((action) => action.title).join("; ")}`);
110
+ if (item.detail)
111
+ lines.push("", "Detail:", ...item.detail.split("\n").map((line) => ` ${line}`));
112
+ const unverified = item.unverifiedSourceCount ?? 0;
113
+ const sources = item.sourceCount ?? detail.evidence?.length ?? 0;
114
+ lines.push("", `Sources: ${sources === 0 ? (item.origin === "human" ? "none (a person added it)" : "none") : sources}${unverified > 0 ? ` (${unverified} quote${unverified === 1 ? "" : "s"} not found in a page the AI was given)` : ""}`);
115
+ for (const source of (detail.evidence ?? []))
116
+ lines.push(` ${sourceLine(source)}`);
117
+ const proposals = item.proposedChanges ?? [];
118
+ if (proposals.length > 0) {
119
+ lines.push("", "Suggested changes waiting for you:");
120
+ for (const proposal of proposals) {
121
+ const fields = Object.entries(proposal.fields).map(([name, value]) => `${name}: ${valueText(value, proposal.labels?.[name])}`);
122
+ lines.push(` ${proposal.id} (${proposal.kind}): ${fields.join("; ") || "no field change"}`);
123
+ }
124
+ lines.push(` Decide one with: cockpit manager proposal <id> <proposal id> accept|dismiss|add-new --version <version>${timeline ? " --timeline" : ""}`);
125
+ }
126
+ const change = item.syncChange;
127
+ if (change) {
128
+ lines.push("", `Changed by sync, ${change.at}${change.reason ? `: ${change.reason}` : ""}`);
129
+ for (const [name, field] of Object.entries(change.fields)) {
130
+ lines.push(` ${name}: ${valueText(field.from, field.fromLabel)} -> ${valueText(field.to, field.toLabel)}`);
131
+ }
132
+ }
133
+ return lines;
134
+ }
135
+ /** One client in full: its state, its last read and how many pages wait. */
136
+ export function clientDetailLines(client, color) {
137
+ const lines = [...clientLines([client], color)];
138
+ if (client.notionRootUrl)
139
+ lines.push(` Notion root: ${client.notionRootUrl}`);
140
+ if (client.queued)
141
+ lines.push(" Queued behind another run (Sync all).");
142
+ if (client.storedPages !== undefined)
143
+ lines.push(` ${client.storedPages} stored pages${client.incompletePages ? `, ${client.incompletePages} only partly handed over by Notion` : ""}`);
144
+ const read = client.lastRead;
145
+ if (read) {
146
+ lines.push(` Last read: ${read.kind}, ${read.reason}, started ${read.startedAt}${read.finishedAt ? `, finished ${read.finishedAt}` : ", still running"}`);
147
+ lines.push(` ${read.pagesSeen} pages seen, ${read.pagesChanged} changed; ${read.opsApplied} changes applied, ${read.opsProposed} proposed, ${read.opsRejected} refused`);
148
+ }
149
+ else {
150
+ lines.push(" No read yet.");
151
+ }
152
+ const rebuild = client.lastBriefRebuild;
153
+ if (rebuild)
154
+ lines.push(` Last Rebuild brief: ${rebuild.reason}, started ${rebuild.startedAt}, ${rebuild.pages} pages, ${rebuild.briefWritten ? "brief replaced" : "old brief kept"}`);
155
+ return lines;
156
+ }
157
+ /** One run's receipt: what it read, what it changed, and why it ended. */
158
+ export function runLines(run) {
159
+ return [
160
+ `${run.kind} run: ${run.reason}`,
161
+ ` started ${run.startedAt}${run.finishedAt ? `, finished ${run.finishedAt}` : ", still running"}`,
162
+ ` ${run.pagesSeen} pages seen, ${run.pagesChanged} changed, ${run.pagesFetched} fetched, ${run.pagesArchived} archived; ${run.versionsConsumed} versions read`,
163
+ ` ${run.opsApplied} changes applied, ${run.opsProposed} proposed, ${run.opsRejected} refused${run.batchNo !== null ? ` (batch ${run.batchNo})` : ""}`,
164
+ ...(run.model ? [` model ${run.model}${run.tokensIn !== null ? `, ${run.tokensIn} tokens in, ${run.tokensOut ?? 0} out` : ""}`] : []),
165
+ ...(run.clientId ? [` client ${run.clientId}`] : []),
166
+ ` id ${run.id}`,
167
+ ];
168
+ }
@@ -0,0 +1,100 @@
1
+ const BASE = "/api/manager";
2
+ const enc = encodeURIComponent;
3
+ export function planManagerRequest(command, clientId, newRequestId) {
4
+ const table = command.timeline ? "timeline" : "actions";
5
+ const item = `${BASE}/${table}/${enc(command.id ?? "")}`;
6
+ const seen = { expectedUpdatedAt: command.version };
7
+ const write = (method, path, body) => ({ method, path, body, logFields: itemLog(table) });
8
+ switch (command.action) {
9
+ case "clients":
10
+ return { method: "GET", path: `${BASE}/clients`, logFields: (a) => ({ clients: arrayLength(a, "clients") }) };
11
+ case "client":
12
+ return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}`, logFields: () => ({}) };
13
+ case "run":
14
+ return { method: "GET", path: `${BASE}/runs/${enc(command.runId ?? "")}`, logFields: () => ({}) };
15
+ case "list":
16
+ return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}/${table}${listQuery(command)}`, logFields: (a) => ({ table, returned: arrayLength(a, "items"), total: numberField(a, "total") }) };
17
+ case "queue":
18
+ return { method: "GET", path: `${BASE}/clients/${enc(clientId ?? "")}/review-queue`, logFields: (a) => ({ timeline: arrayLength(a, "timeline"), actions: arrayLength(a, "actions") }) };
19
+ case "show":
20
+ return { method: "GET", path: item, logFields: itemLog(table) };
21
+ case "add": {
22
+ const path = `${BASE}/clients/${enc(clientId ?? "")}/${table}`;
23
+ const requestId = command.requestId ?? newRequestId();
24
+ const common = {
25
+ title: command.title,
26
+ ...(command.detail === undefined ? {} : { detail: command.detail }),
27
+ ...(command.sourceUrl ? { sourceUrl: command.sourceUrl } : {}),
28
+ ...(command.sourceNote ? { sourceNote: command.sourceNote } : {}),
29
+ requestId,
30
+ };
31
+ if (command.timeline) {
32
+ return { ...write("POST", path, { kind: command.itemKind, ...common, ...(command.occurredAt ? { occurredAt: command.occurredAt } : {}), ...(command.ownerName ? { ownerName: command.ownerName } : {}) }), requestId };
33
+ }
34
+ return { ...write("POST", path, { ...common, ...(command.owners ? { owners: command.owners } : {}), ...(command.dueAt ? { dueAt: command.dueAt } : {}), ...(command.timelineItemId ? { timelineItemId: command.timelineItemId } : {}) }), requestId };
35
+ }
36
+ case "edit": {
37
+ const fields = {};
38
+ if (command.title !== undefined)
39
+ fields["title"] = command.title;
40
+ if (command.detail !== undefined)
41
+ fields["detail"] = command.detail;
42
+ if (command.itemKind !== undefined)
43
+ fields["kind"] = command.itemKind;
44
+ if (command.dueAt !== undefined)
45
+ fields["dueAt"] = command.dueAt;
46
+ if (command.occurredAt !== undefined)
47
+ fields["occurredAt"] = command.occurredAt;
48
+ if (command.owners !== undefined)
49
+ fields["owners"] = command.owners;
50
+ if (command.ownerName !== undefined)
51
+ fields["ownerName"] = command.ownerName;
52
+ return write("PATCH", item, { ...seen, ...fields });
53
+ }
54
+ case "done": return write("POST", `${item}/status`, { ...seen, status: "done" });
55
+ case "reopen": return write("POST", `${item}/status`, { ...seen, status: "open" });
56
+ case "link": return write("POST", `${item}/link`, { ...seen, timelineItemId: command.timelineItemId ?? null });
57
+ case "approve": return write("POST", `${item}/review`, { ...seen, decision: "accept" });
58
+ case "dismiss": return write("POST", `${item}/review`, { ...seen, decision: "dismiss" });
59
+ case "proposal": return write("POST", `${item}/proposal`, { ...seen, decision: command.decision, proposalId: command.proposalId });
60
+ case "archive": return write("POST", `${item}/archive`, seen);
61
+ case "unarchive": return write("POST", `${item}/unarchive`, seen);
62
+ }
63
+ }
64
+ function listQuery(command) {
65
+ const query = new URLSearchParams();
66
+ if (command.timeline && command.kindFilter)
67
+ query.set("kind", command.kindFilter);
68
+ if (!command.timeline && command.status)
69
+ query.set("status", command.status);
70
+ if (!command.timeline && command.ownerFilter)
71
+ query.set("owner", command.ownerFilter);
72
+ if (!command.timeline && command.noOwner)
73
+ query.set("noOwner", "true");
74
+ if (!command.timeline && command.overdue)
75
+ query.set("overdue", "true");
76
+ if (command.review)
77
+ query.set("review", command.review);
78
+ if (command.archived)
79
+ query.set("archived", "true");
80
+ if (command.needsReview)
81
+ query.set("needsReview", "true");
82
+ if (command.search)
83
+ query.set("q", command.search);
84
+ const text = query.toString();
85
+ return text ? `?${text}` : "";
86
+ }
87
+ function itemLog(table) {
88
+ return (answer) => {
89
+ const item = answer && typeof answer === "object" ? answer.item : undefined;
90
+ return { table, item_id: item?.id ?? null, review_status: item?.reviewStatus ?? null };
91
+ };
92
+ }
93
+ function arrayLength(answer, key) {
94
+ const value = answer && typeof answer === "object" ? answer[key] : undefined;
95
+ return Array.isArray(value) ? value.length : null;
96
+ }
97
+ function numberField(answer, key) {
98
+ const value = answer && typeof answer === "object" ? answer[key] : undefined;
99
+ return typeof value === "number" ? value : null;
100
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `cockpit manager` (BLI-5006): Tower Manager's client actions and timeline items from the terminal.
3
+ *
4
+ * Plain words: Tower reads a client's Notion pages and keeps their actions and timeline. This lists
5
+ * them, adds and edits them, ticks them off, links an action to the meeting it came from, and
6
+ * decides the AI's suggestions, the same things the manager page does.
7
+ *
8
+ * Sixteen verbs, each one `/api/manager` door on this machine's device token, the same door the MCP
9
+ * twins (`manager_*`) use; the door passes Railway's answer through unchanged. Every write on an
10
+ * existing item carries `--version`, the item's `updatedAt` as `list`/`show` print it: a stale one is
11
+ * refused with the door's own sentence and nothing is written. `approve`, `dismiss` and `proposal`
12
+ * decide as the CALLER (their name is the one recorded). `approve` reads the item first: a dismissed one is brought back
13
+ * only with `--restore`. `--json` carries the door's body, verbatim.
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
17
+ import { colorEnabled, writeLine } from "./cli-io.js";
18
+ import { localCommandHelp } from "./local-help.js";
19
+ import { clientDetailLines, clientLines, itemDetailLines, itemRowLines, runLines, listLines, queueLines, } from "./manager-render.js";
20
+ import { planManagerRequest } from "./manager-requests.js";
21
+ const TAG = "[manager cli]";
22
+ /**
23
+ * Longer than the door waits for the manager server (50 s, `MANAGER_DOOR_TIMEOUT_MS`) and than the door's own
24
+ * platform limit (`maxDuration` 60 s), so a slow write comes back as the door's named 504 rather than this end
25
+ * giving up first while the write may still land.
26
+ */
27
+ export const MANAGER_DEADLINE_MS = 65_000;
28
+ const staleHint = (id, timeline) => `Run \`cockpit manager show ${id}${timeline ? " --timeline" : ""}\` for the current version and try again.`;
29
+ /** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
30
+ /** The verbs that change an existing item. */
31
+ const WRITES = new Set(["edit", "done", "reopen", "link", "approve", "dismiss", "proposal", "archive", "unarchive"]);
32
+ const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
33
+ export async function runManager(command, io) {
34
+ if (command.help) {
35
+ writeLine(io.stderr, `${TAG} help printed ${JSON.stringify({ reason: "no_verb_or_help_asked" })}`);
36
+ writeLine(io.stdout, localCommandHelp("manager"));
37
+ return 0;
38
+ }
39
+ let door;
40
+ try {
41
+ door = await openAgentDoor("manager", command, io);
42
+ }
43
+ catch (error) {
44
+ const detail = error instanceof Error ? error.message : "this machine is not paired with Tower";
45
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "not_paired" })}`);
46
+ if (command.json)
47
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail }));
48
+ else
49
+ writeLine(io.stderr, detail);
50
+ return 1;
51
+ }
52
+ let clientId;
53
+ if (command.client !== undefined) {
54
+ const resolved = await resolveClient(door, command.client);
55
+ if (typeof resolved !== "string")
56
+ return resolved.exit;
57
+ clientId = resolved;
58
+ }
59
+ // Approving a dismissed item brings it back (the web's Restore): say so first, and write nothing unless --restore was given.
60
+ if (command.action === "approve") {
61
+ const stopped = await checkRestore(command, door);
62
+ if (stopped !== undefined)
63
+ return stopped;
64
+ }
65
+ const plan = planManagerRequest(command, clientId, randomUUID);
66
+ return execute(command, door, plan, colorEnabled(io));
67
+ }
68
+ async function execute(command, door, plan, color) {
69
+ const answer = await askAgentDoor(door, { path: plan.path, method: plan.method, label: `manager ${command.action}`, timeoutMs: MANAGER_DEADLINE_MS, ...(plan.body === undefined ? {} : { body: plan.body }) });
70
+ if (!answer.ok)
71
+ return refuse(door, answer, command, plan.requestId);
72
+ writeLine(door.io.stderr, `${TAG} ${command.action} answered ${JSON.stringify({ http_status: answer.httpStatus, ...(plan.requestId ? { request_id: plan.requestId } : {}), ...plan.logFields(answer.body) })}`);
73
+ // An add's JSON is the door's body plus the `requestId` it was sent under, so a retry can name it.
74
+ if (door.json)
75
+ return emitAgentDoor(door, plan.requestId && answer.body && typeof answer.body === "object" ? { ...answer.body, requestId: plan.requestId } : answer.body);
76
+ const out = door.io.stdout;
77
+ const lines = renderAnswer(command, answer.body, color);
78
+ if (plan.requestId)
79
+ lines.push(` request id ${plan.requestId} (to retry this add without making a duplicate, rerun it with --request-id ${plan.requestId})`);
80
+ for (const line of lines)
81
+ writeLine(out, line);
82
+ return 0;
83
+ }
84
+ /** What `approve` needs to know before it writes: is the item dismissed? A number is the exit code of a refusal; undefined means go on and write. */
85
+ async function checkRestore(command, door) {
86
+ const read = { ...command, action: "show" };
87
+ const plan = planManagerRequest(read, undefined, randomUUID);
88
+ const answer = await askAgentDoor(door, { path: plan.path, method: plan.method, label: "manager approve (read)", timeoutMs: MANAGER_DEADLINE_MS });
89
+ if (!answer.ok)
90
+ return refuse(door, answer, read);
91
+ const dismissed = answer.body?.item?.reviewStatus === "rejected";
92
+ writeLine(door.io.stderr, `${TAG} approve read the item ${JSON.stringify({ http_status: answer.httpStatus, dismissed, restore: command.restore === true })}`);
93
+ if (dismissed && command.restore !== true) {
94
+ return failAgentDoor(door, TAG, "restore_needed", "This item is dismissed. Approving it brings it back into the lists, marked reviewed by you. To do that, run the same command with --restore.");
95
+ }
96
+ if (!dismissed && command.restore === true) {
97
+ return failAgentDoor(door, TAG, "not_dismissed", "--restore is only for a dismissed item; this one is not dismissed. Run approve without --restore.");
98
+ }
99
+ return undefined;
100
+ }
101
+ function renderAnswer(command, body, color) {
102
+ const timeline = command.timeline;
103
+ switch (command.action) {
104
+ case "clients": return clientLines(body.clients, color);
105
+ case "client": return clientDetailLines(body, color);
106
+ case "run": return runLines(body);
107
+ case "list": return listLines(body, timeline, color);
108
+ case "queue": return queueLines(body, color);
109
+ case "show": return itemDetailLines(body, timeline, color);
110
+ default: return [...writeSentence(command, body), ...itemRowLines(body.item, timeline, color)];
111
+ }
112
+ }
113
+ /** What a write did, in one sentence a person reads; the item's new version follows on its row. */
114
+ function writeSentence(command, body) {
115
+ const as = "as you";
116
+ // Approving the item or deciding one change leaves the queue only when no other suggested change waits on it.
117
+ const waiting = body.item?.proposedChanges?.length ?? 0;
118
+ const queue = waiting === 0 ? "It leaves the review queue." : `${waiting === 1 ? "Its other suggested change still waits" : `Its ${waiting} other suggested changes still wait`} in the review queue.`;
119
+ switch (command.action) {
120
+ case "add": return [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
121
+ case "edit": return ["Saved your changes."];
122
+ // Railway writes nothing when the action already has that status, so its version comes back unchanged.
123
+ case "done": return [body.item?.updatedAt === command.version ? "Already done; nothing changed." : "Marked done."];
124
+ case "reopen": return [body.item?.updatedAt === command.version ? "Already open; nothing changed." : "Reopened."];
125
+ case "link": return [command.timelineItemId ? `Linked to ${body.timelineItem?.title ?? "that meeting"}.` : "Unlinked from its meeting."];
126
+ case "approve": return [command.restore ? `Restored: ${body.item?.title ?? "that item"}, back in the lists and marked reviewed by you.` : `Approved ${as}. ${queue.replace("other ", "")}`];
127
+ case "dismiss": return [`Dismissed ${as}. It leaves the review queue.`];
128
+ case "proposal": return [
129
+ command.decision === "add_as_new" && body.added ? `Decided ${as}: added as a new item, ${body.added.title} (id ${body.added.id}).` : `Decided ${as}: ${command.decision === "accept" ? "accepted" : command.decision === "dismiss" ? "dismissed" : "added as a new item"}. ${queue}`,
130
+ ];
131
+ case "archive": return ["Archived (deleted from the lists; `unarchive` brings it back)."];
132
+ case "unarchive": return ["Brought back."];
133
+ default: return [];
134
+ }
135
+ }
136
+ /** The client as an id, or its exact name (case-insensitive) looked up in `clients`; anything else is refused by name. */
137
+ async function resolveClient(door, ref) {
138
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref))
139
+ return ref;
140
+ const answer = await askAgentDoor(door, { path: "/api/manager/clients", method: "GET", label: "manager clients", timeoutMs: MANAGER_DEADLINE_MS });
141
+ if (!answer.ok)
142
+ return { exit: refuse(door, answer, undefined) };
143
+ const clients = answer.body.clients;
144
+ const wanted = ref.trim().toLowerCase();
145
+ const matches = clients.filter((client) => client.name.trim().toLowerCase() === wanted);
146
+ if (matches.length === 1) {
147
+ writeLine(door.io.stderr, `${TAG} client resolved ${JSON.stringify({ client_id: matches[0].id, by: "name", clients: clients.length })}`);
148
+ return matches[0].id;
149
+ }
150
+ const names = clients.map((client) => client.name).join(", ");
151
+ const detail = matches.length === 0
152
+ ? `No client is named "${ref}". ${clients.length === 0 ? "Tower has no clients yet." : `Clients: ${names}. (\`cockpit manager clients\` lists them with their ids.)`}`
153
+ : `${matches.length} clients are named "${ref}". Use the id instead: ${matches.map((client) => client.id).join(", ")}.`;
154
+ return { exit: failAgentDoor(door, TAG, matches.length === 0 ? "client_not_found" : "client_name_ambiguous", detail, { clients: clients.length, matches: matches.length }) };
155
+ }
156
+ /**
157
+ * A refusal keeps Railway's own sentence: `message` (the door's own shape), else `error` when `reason`
158
+ * is a string too (Railway's `{ error, reason, refusal? }`). The label is `reason`; `refusal` is the finer
159
+ * label, shown beside it on stderr and in the `--json` object.
160
+ */
161
+ /** What to do next after a refusal that is not "read it again", so every refusal ends with a way forward. */
162
+ function nextStep(reason, refusal, command) {
163
+ const id = command?.id ?? "<id>";
164
+ const flag = command?.timeline ? " --timeline" : "";
165
+ if (reason === "not_found" && command?.id) {
166
+ return command.timeline
167
+ ? " Check the id with `cockpit manager list <client> --timeline`; for an action, drop --timeline."
168
+ : ` Check the id with \`cockpit manager list <client>\`; for a timeline item, add --timeline${command.action === "done" || command.action === "reopen" || command.action === "link" ? " (done, reopen and link are for actions only)" : ""}.`;
169
+ }
170
+ // The door's key check (no such key, or a revoked one): this machine needs signing in again.
171
+ if (reason === "http_401")
172
+ return " Sign this machine in to Tower again with `cockpit login`, then retry.";
173
+ if (reason === "gateway_unreachable")
174
+ return " Check the address (--dashboard-url, or the one `cockpit login` saved) and your network, then try again.";
175
+ if ((reason === "manager_unreachable" || reason === "manager_timeout") && (command === undefined || !WRITES.has(command.action) && command.action !== "add")) {
176
+ return " Try again in a minute; if it keeps failing, Tower's manager server may be down.";
177
+ }
178
+ switch (refusal) {
179
+ // Another person's or another client's add already used this id: a fresh one makes the item.
180
+ case "request_reused": return " Run the add again without --request-id to make a new item.";
181
+ case "proposal_replaced": return ` Run \`cockpit manager show ${id}${flag}\` to see the suggested changes waiting and their ids.`;
182
+ case "item_archived": return ` Bring it back first: \`cockpit manager unarchive ${id}${flag} --version <v>\` (\`list <client>${flag} --archived\` prints its version).`;
183
+ case "item_dismissed": return ` Restore it first: \`cockpit manager approve ${id}${flag} --version <v> --restore\` (\`list <client>${flag} --review dismissed\` prints its version).`;
184
+ case "person_item_not_dismissable": return ` To delete an item a person added, use \`cockpit manager archive ${id}${flag} --version <v>\`.`;
185
+ case "timeline_not_live": return " Link to a live timeline item of the same client: `cockpit manager list <client> --timeline` lists them.";
186
+ case "item_not_archived": return " It is already in the lists; nothing to bring back.";
187
+ default: return "";
188
+ }
189
+ }
190
+ function refuse(door, answer, command, requestId) {
191
+ const body = answer.body && typeof answer.body === "object" ? answer.body : {};
192
+ const text = (value) => (typeof value === "string" && value.trim() !== "" ? value : undefined);
193
+ const sentence = text(body["message"]) ?? (text(body["reason"]) ? text(body["error"]) : undefined) ?? answer.detail;
194
+ const refusal = text(body["refusal"]);
195
+ const reseen = answer.reason === "invalid_state" && (refusal === undefined || RESEEN_REFUSALS.has(refusal));
196
+ const hint = reseen && command?.id
197
+ ? ` ${staleHint(command.id, command.timeline)}`
198
+ : nextStep(answer.reason, refusal, command);
199
+ // An add with no answer from Tower (or a server error) may or may not have been made: the same request id makes the retry safe.
200
+ const unconfirmed = requestId !== undefined && (answer.httpStatus === undefined || answer.httpStatus >= 500);
201
+ const noAnswer = answer.httpStatus === undefined || answer.httpStatus >= 500;
202
+ // A change to an existing item with no answer may or may not have landed: look before trying again.
203
+ const changedUnknown = !unconfirmed && noAnswer && command?.id !== undefined && WRITES.has(command.action);
204
+ const retry = unconfirmed
205
+ ? ` Nothing was confirmed: Tower did not say whether the item was made. Rerun the same command with \`--request-id ${requestId}\` to retry without making a duplicate.`
206
+ : changedUnknown
207
+ ? ` It may or may not have been saved: run \`cockpit manager show ${command.id}${command.timeline ? " --timeline" : ""}\` to see, and use the version it prints if you try again.`
208
+ : "";
209
+ const stop = hint || retry ? (/[.!?]$/.test(sentence) ? "" : ".") : "";
210
+ return failAgentDoor(door, TAG, answer.reason, `${sentence}${stop}${hint}${retry}`, { ...(refusal ? { refusal } : {}), ...(unconfirmed ? { requestId } : {}) });
211
+ }
@@ -19,12 +19,14 @@
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
20
  import { ask, emit, fail, READ_DEADLINE_MS, TAG } from "./notes-door.js";
21
21
  import { printCoverage, readCoverage } from "./notes-recordings.js";
22
- import { signInToWisprflow } from "./notes-wisprflow-login.js";
22
+ import { connectWisprflowInBrowser } from "./notes-wisprflow-login.js";
23
+ // BLI-17974: the sync loop lives in `notes-sync-loop.ts`; re-exported so `notes.ts` is unchanged.
24
+ export { syncNotetaker } from "./notes-sync-loop.js";
23
25
  /** A pass can take a while; a read never should. */
24
26
  const WRITE_DEADLINE_MS = 120_000;
25
27
  /** An API key is long; anything past this is not one, and is refused by size. */
26
28
  const KEY_MAX_CHARS = 500;
27
- /** BLI-12926: a Wispr Flow sign-in bundle holds an OAuth refresh token, which can run longer. */
29
+ /** BLI-12926: a pre-built Wispr Flow sign-in bundle (`--key-stdin`) holds an OAuth refresh token, which can run longer. */
28
30
  const SIGN_IN_MAX_CHARS = 4000;
29
31
  /**
30
32
  * Granola, read through the one door that answers for it
@@ -102,7 +104,7 @@ export async function listNotesAccounts(command, door) {
102
104
  writeLine(door.io.stdout, "");
103
105
  writeLine(door.io.stdout, 'Connect one: printf "%s" "<your fellow api key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
104
106
  writeLine(door.io.stdout, ' or: printf "%s" "<your circleback api key>" | cockpit notes connect --provider circleback --key-stdin');
105
- writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (signs in through your browser; no key)");
107
+ writeLine(door.io.stdout, " or: cockpit notes connect --provider wisprflow (opens Wispr's sign-in in your browser and waits; no key)");
106
108
  if (coverage)
107
109
  printCoverage(door, coverage);
108
110
  return 0;
@@ -126,37 +128,51 @@ export async function listNotesAccounts(command, door) {
126
128
  void command;
127
129
  return 0;
128
130
  }
129
- export async function connectNotetaker(command, door) {
130
- let apiKey;
131
+ /**
132
+ * BLI-17973. Wispr Flow has no API key: this calls the same start door the
133
+ * Settings button, the MCP tool and JARVIS call, opens the address it returns
134
+ * and polls the same status door. Nothing is read from stdin and no credential
135
+ * passes through here; the server holds the sign-in's state and seals the result.
136
+ */
137
+ async function connectWisprflowViaBrowser(command, door, options) {
138
+ if (command.name) {
139
+ writeLine(door.io.stderr, `${TAG} --name ignored for wisprflow ${JSON.stringify({ reason: "web_sign_in_names_the_account" })}`);
140
+ }
141
+ const connected = await connectWisprflowInBrowser(door, options);
142
+ if (!connected.ok)
143
+ return fail(door, connected.reason, connected.detail);
144
+ if (door.json)
145
+ return emit(door, { ok: true, account: connected.account, already_connected: connected.already });
146
+ writeLine(door.io.stdout, connected.already
147
+ ? `Wispr Flow is already connected (${connected.account.id}).`
148
+ : `Connected wisprflow (${connected.account.id}).`);
149
+ writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit notes sync <id>`, or wait for the half-hourly cron.");
150
+ return 0;
151
+ }
152
+ export async function connectNotetaker(command, door, wisprflow = {}) {
131
153
  if (command.provider === "wisprflow" && !command.keyStdin) {
132
- // BLI-12926. Wispr Flow has no API key: the person signs in through their
133
- // browser and what gets sealed is the sign-in. Nothing is read from stdin.
134
- const signedIn = await signInToWisprflow({ io: door.io });
135
- if (!signedIn.ok)
136
- return fail(door, signedIn.reason, signedIn.detail);
137
- apiKey = signedIn.bundle;
154
+ return connectWisprflowViaBrowser(command, door, wisprflow);
138
155
  }
139
- else {
140
- // The key comes from stdin and nowhere else. An interactive terminal with
141
- // nothing piped in is told how, rather than left waiting on a prompt that
142
- // would echo the secret into the scrollback.
143
- if (isInteractiveStdin(door.io)) {
144
- return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
145
- }
146
- try {
147
- apiKey = (await readPipedText(door.io.stdin, {
148
- // A Wispr Flow sign-in bundle carries an OAuth refresh token, which
149
- // can be far longer than any API key.
150
- maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
151
- overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
152
- })).trim();
153
- }
154
- catch (error) {
155
- return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
156
- }
157
- if (apiKey === "") {
158
- return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
159
- }
156
+ // The key comes from stdin and nowhere else. An interactive terminal with
157
+ // nothing piped in is told how, rather than left waiting on a prompt that
158
+ // would echo the secret into the scrollback.
159
+ if (isInteractiveStdin(door.io)) {
160
+ return fail(door, "key_required_on_stdin", 'The API key is read from stdin, never from a flag. Try: printf "%s" "<your key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
161
+ }
162
+ let apiKey;
163
+ try {
164
+ apiKey = (await readPipedText(door.io.stdin, {
165
+ // A pre-built Wispr Flow sign-in bundle carries an OAuth refresh token,
166
+ // which can be far longer than any API key.
167
+ maxChars: command.provider === "wisprflow" ? SIGN_IN_MAX_CHARS : KEY_MAX_CHARS,
168
+ overflowMessage: "That is longer than any notetaker API key; nothing was sent.",
169
+ })).trim();
170
+ }
171
+ catch (error) {
172
+ return fail(door, "key_unreadable", error instanceof Error ? error.message : String(error));
173
+ }
174
+ if (apiKey === "") {
175
+ return fail(door, "key_required_on_stdin", "Nothing arrived on stdin, so nothing was connected.");
160
176
  }
161
177
  const answer = await ask(door, {
162
178
  path: "/api/notes/accounts",
@@ -281,34 +297,6 @@ export async function detachNotetaker(command, door) {
281
297
  writeLine(door.io.stdout, "The meetings it already filed stay where they are.");
282
298
  return 0;
283
299
  }
284
- export async function syncNotetaker(command, door) {
285
- const accountId = command.accountId ?? "";
286
- if (accountId === "") {
287
- // One account per call on purpose: the sweep over ALL of them is the cron,
288
- // and a terminal asking for "everything" would hide which one failed.
289
- return fail(door, "account_required", "Name the notetaker to sync: `cockpit notes sync <account-id>`. `cockpit notes accounts` lists the ids.");
290
- }
291
- const answer = await ask(door, {
292
- path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
293
- method: "POST",
294
- label: "notes sync",
295
- timeoutMs: WRITE_DEADLINE_MS,
296
- });
297
- if (!answer.ok)
298
- return fail(door, answer.reason, answer.detail);
299
- const run = answer.body.run;
300
- if (door.json)
301
- return emit(door, { ok: true, run });
302
- if (!run) {
303
- writeLine(door.io.stdout, "Tower answered without a run.");
304
- return 0;
305
- }
306
- // The ledger row's own words, relayed rather than rephrased.
307
- writeLine(door.io.stdout, `${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped.`);
308
- if (run.detail)
309
- writeLine(door.io.stdout, run.detail);
310
- return run.ok ? 0 : 1;
311
- }
312
300
  /**
313
301
  * `sync-status [<account id>]` → `GET /api/notes/sync-status`. Ticket 10730:
314
302
  * the read the integrations audit went to `notes_sync_runs` directly for.