@bli-cockpit/cli 0.2.242 → 0.2.244

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.
Files changed (135) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +6 -50
  4. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  5. package/dist/agent-rules.js +8 -0
  6. package/dist/autostart-contract.js +12 -0
  7. package/dist/autostart-darwin-plist.js +2 -2
  8. package/dist/autostart-darwin.js +1 -1
  9. package/dist/autostart-remind.js +245 -0
  10. package/dist/autostart-self-heal.js +17 -1
  11. package/dist/autostart-windows-scripts.js +7 -7
  12. package/dist/autostart.js +16 -15
  13. package/dist/commands/approvals-check-where.js +14 -0
  14. package/dist/commands/approvals-render.js +8 -1
  15. package/dist/commands/approvals.js +3 -2
  16. package/dist/commands/autostart-command.js +10 -0
  17. package/dist/commands/autostart-heal.js +36 -1
  18. package/dist/commands/brain.js +1 -1
  19. package/dist/commands/cal-agenda.js +44 -0
  20. package/dist/commands/cal-availability-share.js +97 -0
  21. package/dist/commands/cal-booking.js +147 -0
  22. package/dist/commands/cal-join.js +62 -0
  23. package/dist/commands/cal-links.js +68 -0
  24. package/dist/commands/cal-people.js +136 -0
  25. package/dist/commands/cal-remind.js +176 -0
  26. package/dist/commands/cal-reminders.js +33 -0
  27. package/dist/commands/cal-suggest.js +89 -0
  28. package/dist/commands/cal.js +41 -1
  29. package/dist/commands/cycle-args.js +127 -0
  30. package/dist/commands/cycles.js +117 -0
  31. package/dist/commands/docs-comments-args.js +85 -0
  32. package/dist/commands/docs-comments.js +128 -0
  33. package/dist/commands/docs-files-args.js +49 -0
  34. package/dist/commands/docs-files.js +179 -0
  35. package/dist/commands/docs-formatting.js +45 -0
  36. package/dist/commands/docs-inbox-args.js +93 -0
  37. package/dist/commands/docs-inbox.js +126 -0
  38. package/dist/commands/docs-notion.js +5 -0
  39. package/dist/commands/docs-read.js +2 -1
  40. package/dist/commands/docs-recovery.js +173 -0
  41. package/dist/commands/docs-refs-args.js +104 -0
  42. package/dist/commands/docs-refs.js +153 -0
  43. package/dist/commands/docs-search-args.js +79 -0
  44. package/dist/commands/docs-search.js +64 -0
  45. package/dist/commands/docs-sync-args.js +82 -0
  46. package/dist/commands/docs-sync.js +197 -0
  47. package/dist/commands/docs-tree.js +136 -0
  48. package/dist/commands/docs.js +85 -51
  49. package/dist/commands/doctor-update.js +4 -1
  50. package/dist/commands/inbox-args.js +81 -0
  51. package/dist/commands/inbox.js +88 -0
  52. package/dist/commands/issue-batch.js +61 -0
  53. package/dist/commands/issue-relations.js +151 -0
  54. package/dist/commands/issue-write.js +38 -5
  55. package/dist/commands/issue.js +32 -1
  56. package/dist/commands/local-args-collector-sources.js +2 -1
  57. package/dist/commands/local-args-tower-approvals.js +16 -4
  58. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  59. package/dist/commands/local-args-tower-cal-links.js +42 -0
  60. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  61. package/dist/commands/local-args-tower-cal-share.js +41 -0
  62. package/dist/commands/local-args-tower-cal.js +81 -8
  63. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  64. package/dist/commands/local-args-tower-msg.js +222 -10
  65. package/dist/commands/local-args-tower-search.js +32 -5
  66. package/dist/commands/local-args-tower-work.js +110 -34
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +6 -4
  69. package/dist/commands/local-help-commands-tower.js +47 -31
  70. package/dist/commands/local-help-commands.js +3 -1
  71. package/dist/commands/local-help-cycles.js +9 -0
  72. package/dist/commands/local-help-docs-files.js +10 -0
  73. package/dist/commands/local-help-docs-formatting.js +7 -0
  74. package/dist/commands/local-help-docs-inbox.js +10 -0
  75. package/dist/commands/local-help-docs-recovery.js +10 -0
  76. package/dist/commands/local-help-docs-refs.js +11 -0
  77. package/dist/commands/local-help-docs-sync.js +9 -0
  78. package/dist/commands/local-help-inbox.js +9 -0
  79. package/dist/commands/local-help-project.js +21 -0
  80. package/dist/commands/local-help-templates.js +9 -0
  81. package/dist/commands/local-help-views.js +10 -0
  82. package/dist/commands/local-help-work-notify.js +6 -0
  83. package/dist/commands/local-help.js +3 -3
  84. package/dist/commands/manager-hints.js +4 -2
  85. package/dist/commands/manager-limits.json +3 -1
  86. package/dist/commands/manager-read-reasons.js +1 -0
  87. package/dist/commands/manager-render-home.js +13 -5
  88. package/dist/commands/manager-render.js +3 -0
  89. package/dist/commands/manager.js +3 -1
  90. package/dist/commands/mcp-stdio-probe.js +1 -0
  91. package/dist/commands/msg-activity.js +134 -0
  92. package/dist/commands/msg-channel-args.js +92 -0
  93. package/dist/commands/msg-channel.js +125 -0
  94. package/dist/commands/msg-context-args.js +67 -0
  95. package/dist/commands/msg-context.js +65 -0
  96. package/dist/commands/msg-dm.js +28 -0
  97. package/dist/commands/msg-file.js +185 -0
  98. package/dist/commands/msg-mark-args.js +78 -0
  99. package/dist/commands/msg-mark.js +148 -0
  100. package/dist/commands/msg-notify-args.js +100 -0
  101. package/dist/commands/msg-notify.js +122 -0
  102. package/dist/commands/msg-schedule-args.js +114 -0
  103. package/dist/commands/msg-schedule.js +226 -0
  104. package/dist/commands/msg-search-args.js +71 -0
  105. package/dist/commands/msg-search.js +48 -0
  106. package/dist/commands/msg-write.js +15 -1
  107. package/dist/commands/msg.js +62 -39
  108. package/dist/commands/project-args.js +82 -0
  109. package/dist/commands/project.js +71 -7
  110. package/dist/commands/public-root.js +1 -1
  111. package/dist/commands/search.js +23 -0
  112. package/dist/commands/session-sync-failures.js +17 -4
  113. package/dist/commands/session-sync-health.js +7 -2
  114. package/dist/commands/session-sync-hold.js +42 -1
  115. package/dist/commands/session-sync-outside-roots.js +72 -14
  116. package/dist/commands/session-sync-upload.js +2 -0
  117. package/dist/commands/session-sync.js +1 -0
  118. package/dist/commands/sources-check.js +63 -2
  119. package/dist/commands/sources-discovery.js +1 -1
  120. package/dist/commands/sources-reconciliation.js +87 -0
  121. package/dist/commands/sync-doctor.js +14 -1
  122. package/dist/commands/sync-report.js +1 -1
  123. package/dist/commands/template-args.js +122 -0
  124. package/dist/commands/templates.js +162 -0
  125. package/dist/commands/view-args.js +131 -0
  126. package/dist/commands/views.js +138 -0
  127. package/dist/commands/work-notify-args.js +67 -0
  128. package/dist/commands/work-notify.js +63 -0
  129. package/dist/delivery-hold-notice.js +34 -0
  130. package/dist/evidence-transcripts-client.js +1 -0
  131. package/dist/log-rotation.js +2 -2
  132. package/dist/notifier.js +83 -0
  133. package/dist/process-runner.js +4 -0
  134. package/dist/repo-identity-git.js +1 -0
  135. package/package.json +4 -4
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Arguments of `cockpit msg schedule|remind|scheduled` (BLI-21802):
3
+ * schedule <channel> --at <instant|"YYYY-MM-DD HH:MM"> [--timezone <zone>] [--thread <id>] [--request-id <uuid>] (body on stdin)
4
+ * remind <message-id> --at <...> [--timezone <zone>] [--note <text>] [--request-id <uuid>]
5
+ * scheduled [--state <state|open|all>] [--before <cursor>] [--limit <n>]
6
+ * scheduled change <id> [--at <...>] [--timezone <zone>] [--revision <n>] (optional new body on stdin)
7
+ * scheduled cancel <id> | scheduled done <id>
8
+ * Split out of `local-args-tower-msg.ts`.
9
+ */
10
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
11
+ export const MSG_SCHEDULE_ACTIONS = ["schedule", "remind", "scheduled"];
12
+ export const MSG_SCHEDULE_VALUE_FLAGS = ["--at", "--note", "--state", "--revision"];
13
+ export const SCHEDULE_STATE_NAMES = ["pending", "sent", "failed", "cancelled", "fired", "done", "open", "all"];
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const NOTE_MAX = 500;
16
+ export function isMsgScheduleAction(action) {
17
+ return MSG_SCHEDULE_ACTIONS.includes(action);
18
+ }
19
+ /** A schedule flag used on another msg verb is named, not ignored. */
20
+ export function rejectMsgScheduleFlags(values) {
21
+ const flag = MSG_SCHEDULE_VALUE_FLAGS.find((name) => values.flags.has(name));
22
+ if (flag)
23
+ throw new Error(`${flag} belongs to \`cockpit msg schedule\`, \`msg remind\` or \`msg scheduled\`.`);
24
+ }
25
+ export function parseMsgScheduleArgs(action, values, rest) {
26
+ const stray = ["--channel", "--members", "--description", "--from", "--expected-revision", "--name", "--out", "--file", "--level", "--message", "--to"]
27
+ .find((flag) => values.flags.has(flag));
28
+ const strayBoolean = ["--private", "--mentions", "--threads", "--unread", "--mark-read", "--remove"].find((flag) => values.booleans.has(flag));
29
+ if (stray || strayBoolean)
30
+ throw new Error(`${stray ?? strayBoolean} does not belong to \`cockpit msg ${action}\`.`);
31
+ const base = {
32
+ kind: "msg",
33
+ action,
34
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
35
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
36
+ isPrivate: false,
37
+ json: values.booleans.has("--json"),
38
+ };
39
+ const at = optionalNonEmpty(values.flags.get("--at"));
40
+ const timezone = optionalNonEmpty(values.flags.get("--timezone"));
41
+ const thread = optionalNonEmpty(values.flags.get("--thread"));
42
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
43
+ const note = values.flags.get("--note");
44
+ const state = optionalNonEmpty(values.flags.get("--state"));
45
+ const before = optionalNonEmpty(values.flags.get("--before"));
46
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
47
+ const revisionText = optionalNonEmpty(values.flags.get("--revision"));
48
+ const revision = revisionText === undefined ? undefined : optionalPositiveInteger(revisionText, "--revision");
49
+ if (requestId && !UUID.test(requestId))
50
+ throw new Error("--request-id takes a UUID.");
51
+ if (thread && (action !== "schedule" || !UUID.test(thread))) {
52
+ throw new Error(action === "schedule" ? "--thread takes a message id (a UUID)." : "--thread belongs to `cockpit msg schedule`.");
53
+ }
54
+ if (note !== undefined && action !== "remind")
55
+ throw new Error("--note belongs to `cockpit msg remind`.");
56
+ if (note !== undefined && note.length > NOTE_MAX)
57
+ throw new Error(`--note is limited to ${NOTE_MAX} characters.`);
58
+ if (requestId && action === "scheduled")
59
+ throw new Error("--request-id belongs to `cockpit msg schedule` and `msg remind`.");
60
+ if (action === "scheduled") {
61
+ const verb = rest[0];
62
+ if (verb === "change" || verb === "cancel" || verb === "done") {
63
+ const targetId = optionalNonEmpty(rest[1]);
64
+ if (!targetId || !UUID.test(targetId))
65
+ throw new Error(`msg scheduled ${verb} needs a schedule id (a UUID) from \`cockpit msg scheduled\`.`);
66
+ if (rest.length > 2)
67
+ throw new Error(`msg scheduled ${verb} takes one schedule id, not ${rest.length - 1}.`);
68
+ if (state || before || limit !== undefined)
69
+ throw new Error("--state, --before and --limit belong to `cockpit msg scheduled` (the list).");
70
+ if (verb !== "change" && (at || timezone || revision !== undefined))
71
+ throw new Error(`--at, --timezone and --revision belong to \`cockpit msg scheduled change\`.`);
72
+ return {
73
+ ...base, scheduleVerb: verb, targetId,
74
+ ...(at ? { scheduleAt: at } : {}), ...(timezone ? { timezone } : {}), ...(revision === undefined ? {} : { scheduleRevision: revision }),
75
+ };
76
+ }
77
+ if (rest.length > 0)
78
+ throw new Error(`msg scheduled does not take "${rest[0]}". Try change, cancel or done, or no word to list.`);
79
+ if (at || timezone || revision !== undefined)
80
+ throw new Error("--at, --timezone and --revision belong to `cockpit msg scheduled change`.");
81
+ if (state && !SCHEDULE_STATE_NAMES.includes(state)) {
82
+ throw new Error(`--state takes one of ${SCHEDULE_STATE_NAMES.join(", ")}.`);
83
+ }
84
+ return {
85
+ ...base, scheduleVerb: "list",
86
+ ...(state ? { scheduleState: state } : {}), ...(before ? { before } : {}), ...(limit === undefined ? {} : { limit }),
87
+ };
88
+ }
89
+ if (state || before || limit !== undefined || revision !== undefined) {
90
+ throw new Error(`--state, --before, --limit and --revision belong to \`cockpit msg scheduled\`, not \`msg ${action}\`.`);
91
+ }
92
+ const target = optionalNonEmpty(rest[0]);
93
+ if (rest.length > 1)
94
+ throw new Error(`msg ${action} takes one ${action === "schedule" ? "channel" : "message id"}, not ${rest.length}.`);
95
+ if (!at) {
96
+ throw new Error(action === "schedule"
97
+ ? 'msg schedule needs --at, e.g. `echo "standup notes" | cockpit msg schedule ops --at "2026-11-02 09:00" --timezone America/Toronto`.'
98
+ : 'msg remind needs --at, e.g. `cockpit msg remind <message-id> --at "2026-11-02 09:00" --timezone America/Toronto`.');
99
+ }
100
+ if (action === "schedule") {
101
+ if (!target)
102
+ throw new Error("msg schedule needs a channel id or name, e.g. `cockpit msg schedule ops --at ...`.");
103
+ return {
104
+ ...base, channelRef: target, scheduleAt: at,
105
+ ...(timezone ? { timezone } : {}), ...(thread ? { threadId: thread } : {}), ...(requestId ? { requestId } : {}),
106
+ };
107
+ }
108
+ if (!target || !UUID.test(target))
109
+ throw new Error("msg remind needs a message id (a UUID), e.g. `cockpit msg remind <message-id> --at ...`.");
110
+ return {
111
+ ...base, targetId: target, scheduleAt: at,
112
+ ...(timezone ? { timezone } : {}), ...(note !== undefined ? { scheduleNote: note } : {}), ...(requestId ? { requestId } : {}),
113
+ };
114
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The schedule verbs of `cockpit msg` (BLI-21802): `schedule`, `remind`, `scheduled` (list), `scheduled change|cancel|done`.
3
+ * Same doors the browser calls (`/api/msg/schedules`, `.../<id>`, `.../<id>/done`); `--json` prints the door's body unchanged so
4
+ * ids, states, instants and reasons match HTTP, the web page, MCP and JARVIS. `--at` is an ISO instant with an offset, or a
5
+ * wall time "YYYY-MM-DD HH:MM" in `--timezone` (this machine's zone when none is given, and the answer names it). A schedule
6
+ * is yours alone. The message body is piped in and never travels on the command line. A refusal keeps the door's reason:
7
+ * invalid_schedule_time, invalid_timezone, schedule_limit_reached, schedule_not_found, schedule_already_sent,
8
+ * schedule_revision_conflict, request_id_reused.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
12
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
13
+ const TAG = "[msg cli]";
14
+ const DEADLINE_MS = 30_000;
15
+ const BODY_MAX_CHARS = 8_000;
16
+ const LOCAL_WALL = /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d$/;
17
+ function systemZone() {
18
+ try {
19
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
20
+ }
21
+ catch {
22
+ return "UTC";
23
+ }
24
+ }
25
+ /** The `at` field of a door body, plus the zone the answer should name when the person gave a wall time without one. */
26
+ function atField(command) {
27
+ const raw = (command.scheduleAt ?? "").trim();
28
+ if (LOCAL_WALL.test(raw)) {
29
+ const assumedZone = !command.timezone;
30
+ const zone = command.timezone ?? systemZone();
31
+ return { at: { local: raw, timezone: zone }, zone, assumedZone };
32
+ }
33
+ return { at: raw, zone: command.timezone ?? null, assumedZone: false };
34
+ }
35
+ function oneLine(text) {
36
+ const line = (text ?? "").split("\n")[0] ?? "";
37
+ return line.length > 100 ? `${line.slice(0, 97)}...` : line;
38
+ }
39
+ function describe(row) {
40
+ return `${row.id} ${row.state} ${row.kind} ${row.due_at} (${row.timezone}) rev ${row.revision}${row.reason ? ` ${row.reason}` : ""}`;
41
+ }
42
+ /** A write whose answer may have been lost keeps its request id: the same input with the same id stores once. */
43
+ function refuseWrite(door, answer, requestId) {
44
+ const mutation = answer.body?.mutation;
45
+ if (answer.httpStatus === undefined || answer.httpStatus >= 500) {
46
+ writeLine(door.io.stderr, `${TAG} the result is unknown. List with \`cockpit msg scheduled\`, or retry unchanged input with --request-id ${requestId}; it stores once.`);
47
+ }
48
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
49
+ }
50
+ async function readBody(command, door, required) {
51
+ if (isInteractiveStdin(door.io)) {
52
+ if (!required)
53
+ return { ok: true, text: "" };
54
+ return { ok: false, code: failAgentDoor(door, TAG, "nothing_piped", 'Pipe the message in, e.g. `echo "hello" | cockpit msg schedule ops --at "2026-11-02 09:00"`. A message never travels on the command line.') };
55
+ }
56
+ try {
57
+ const text = await readPipedText(door.io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A scheduled message is limited to ${BODY_MAX_CHARS} characters.` });
58
+ return { ok: true, text: text.trim() };
59
+ }
60
+ catch (error) {
61
+ void command;
62
+ return { ok: false, code: failAgentDoor(door, TAG, "content_too_long", error instanceof Error ? error.message : String(error)) };
63
+ }
64
+ }
65
+ function sayAssumedZone(door, zone, assumed) {
66
+ if (assumed && zone)
67
+ writeLine(door.io.stderr, `${TAG} no --timezone given; read that wall time in this machine's zone, ${zone}.`);
68
+ }
69
+ async function scheduleVerb(command, door, resolve) {
70
+ const ref = command.channelRef ?? "";
71
+ const resolved = await resolve(ref);
72
+ if (resolved.status === "list_failed")
73
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
74
+ if (resolved.status === "not_found") {
75
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
76
+ }
77
+ const body = await readBody(command, door, true);
78
+ if (!body.ok)
79
+ return body.code;
80
+ if (body.text === "")
81
+ return failAgentDoor(door, TAG, "content_too_long", "Nothing was piped in. A scheduled message is 1 to 8000 characters.");
82
+ const when = atField(command);
83
+ sayAssumedZone(door, when.zone, when.assumedZone);
84
+ const requestId = command.requestId ?? randomUUID();
85
+ const answer = await askAgentDoor(door, {
86
+ path: "/api/msg/schedules",
87
+ method: "POST",
88
+ label: "msg schedule",
89
+ timeoutMs: DEADLINE_MS,
90
+ body: {
91
+ kind: "message", channel_id: resolved.id, body: body.text, at: when.at, request_id: requestId,
92
+ ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}),
93
+ ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
94
+ },
95
+ });
96
+ if (!answer.ok)
97
+ return refuseWrite(door, answer, requestId);
98
+ const stored = answer.body.schedule;
99
+ writeLine(door.io.stderr, `${TAG} scheduled ${JSON.stringify({ schedule_id: stored?.id ?? null, channel_id: resolved.id, request_id: requestId, replayed: answer.body.replayed === true })}`);
100
+ if (door.json)
101
+ return emitAgentDoor(door, { ok: true, ...answer.body });
102
+ writeLine(door.io.stdout, `Scheduled ${stored?.id ?? "a message"} for ${stored?.due_at ?? command.scheduleAt} (${stored?.timezone ?? when.zone ?? "UTC"}) in ${ref}. List: cockpit msg scheduled.`);
103
+ return 0;
104
+ }
105
+ async function remindVerb(command, door) {
106
+ const when = atField(command);
107
+ sayAssumedZone(door, when.zone, when.assumedZone);
108
+ const requestId = command.requestId ?? randomUUID();
109
+ const answer = await askAgentDoor(door, {
110
+ path: "/api/msg/schedules",
111
+ method: "POST",
112
+ label: "msg remind",
113
+ timeoutMs: DEADLINE_MS,
114
+ body: {
115
+ kind: "reminder", message_id: command.targetId, at: when.at, request_id: requestId,
116
+ ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}),
117
+ ...(command.scheduleNote ? { note: command.scheduleNote } : {}),
118
+ },
119
+ });
120
+ if (!answer.ok)
121
+ return refuseWrite(door, answer, requestId);
122
+ const stored = answer.body.schedule;
123
+ writeLine(door.io.stderr, `${TAG} reminder ${JSON.stringify({ schedule_id: stored?.id ?? null, message_id: command.targetId, request_id: requestId })}`);
124
+ if (door.json)
125
+ return emitAgentDoor(door, { ok: true, ...answer.body });
126
+ writeLine(door.io.stdout, `Reminder ${stored?.id ?? ""} set for ${stored?.due_at ?? command.scheduleAt} (${stored?.timezone ?? when.zone ?? "UTC"}). It shows under \`cockpit msg scheduled --state fired\` when due; nothing is posted.`);
127
+ return 0;
128
+ }
129
+ async function listVerb(command, door) {
130
+ const query = new URLSearchParams();
131
+ if (command.scheduleState)
132
+ query.set("state", command.scheduleState);
133
+ if (command.before)
134
+ query.set("before", command.before);
135
+ if (command.limit)
136
+ query.set("limit", String(command.limit));
137
+ const answer = await askAgentDoor(door, {
138
+ path: `/api/msg/schedules${query.size > 0 ? `?${query.toString()}` : ""}`,
139
+ method: "GET",
140
+ label: "msg scheduled",
141
+ timeoutMs: DEADLINE_MS,
142
+ });
143
+ if (!answer.ok)
144
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
145
+ const body = answer.body;
146
+ const items = body.items ?? [];
147
+ writeLine(door.io.stderr, `${TAG} scheduled ${JSON.stringify({ state: command.scheduleState ?? "open", returned: items.length, has_more: body.hasMore === true })}`);
148
+ if (door.json)
149
+ return emitAgentDoor(door, { ok: true, ...answer.body });
150
+ for (const row of items)
151
+ writeLine(door.io.stdout, `${describe(row)} ${oneLine(row.body)}`);
152
+ if (items.length === 0)
153
+ writeLine(door.io.stdout, "Nothing scheduled.");
154
+ writeLine(door.io.stdout, body.hasMore && body.nextCursor ? `more: --before ${body.nextCursor}` : "complete");
155
+ return 0;
156
+ }
157
+ async function changeVerb(command, door) {
158
+ const body = await readBody(command, door, false);
159
+ if (!body.ok)
160
+ return body.code;
161
+ if (!command.scheduleAt && body.text === "") {
162
+ return failAgentDoor(door, TAG, "invalid_body", "Change the time with --at, the text by piping it in, or both.");
163
+ }
164
+ const when = command.scheduleAt ? atField(command) : null;
165
+ if (when)
166
+ sayAssumedZone(door, when.zone, when.assumedZone);
167
+ const answer = await askAgentDoor(door, {
168
+ path: `/api/msg/schedules/${encodeURIComponent(command.targetId ?? "")}`,
169
+ method: "PATCH",
170
+ label: "msg scheduled change",
171
+ timeoutMs: DEADLINE_MS,
172
+ body: {
173
+ ...(when ? { at: when.at, ...(when.zone && typeof when.at === "string" ? { timezone: when.zone } : {}) } : {}),
174
+ ...(body.text !== "" ? { body: body.text } : {}),
175
+ ...(command.scheduleRevision === undefined ? {} : { expected_revision: command.scheduleRevision }),
176
+ },
177
+ });
178
+ if (!answer.ok)
179
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, scheduleExtra(answer.body));
180
+ const result = answer.body;
181
+ writeLine(door.io.stderr, `${TAG} change ${JSON.stringify({ schedule_id: command.targetId, changed: result.changed !== false })}`);
182
+ if (door.json)
183
+ return emitAgentDoor(door, { ok: true, ...answer.body });
184
+ writeLine(door.io.stdout, result.changed === false
185
+ ? `Unchanged: ${command.targetId} already has that time and text.`
186
+ : `Changed ${command.targetId}: now due ${result.schedule?.due_at} (${result.schedule?.timezone}), revision ${result.schedule?.revision}.`);
187
+ return 0;
188
+ }
189
+ function scheduleExtra(body) {
190
+ const mutation = body?.mutation;
191
+ return mutation ? { mutation } : {};
192
+ }
193
+ async function cancelOrDone(command, door) {
194
+ const done = command.scheduleVerb === "done";
195
+ const answer = await askAgentDoor(door, {
196
+ path: `/api/msg/schedules/${encodeURIComponent(command.targetId ?? "")}${done ? "/done" : ""}`,
197
+ method: done ? "POST" : "DELETE",
198
+ label: `msg scheduled ${done ? "done" : "cancel"}`,
199
+ timeoutMs: DEADLINE_MS,
200
+ });
201
+ if (!answer.ok) {
202
+ const sentId = answer.body?.mutation?.message_id;
203
+ if (answer.reason === "schedule_already_sent" && sentId)
204
+ writeLine(door.io.stderr, `${TAG} it already went out as message ${sentId}.`);
205
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, scheduleExtra(answer.body));
206
+ }
207
+ const result = answer.body;
208
+ writeLine(door.io.stderr, `${TAG} ${done ? "done" : "cancel"} ${JSON.stringify({ schedule_id: command.targetId, changed: result.changed !== false })}`);
209
+ if (door.json)
210
+ return emitAgentDoor(door, { ok: true, ...answer.body });
211
+ const past = done ? "Marked done" : "Cancelled";
212
+ writeLine(door.io.stdout, result.changed === false ? `${past} already: ${command.targetId}.` : `${past} ${command.targetId}.`);
213
+ return 0;
214
+ }
215
+ export async function runMsgSchedule(command, door, resolve) {
216
+ if (command.action === "schedule")
217
+ return scheduleVerb(command, door, resolve);
218
+ if (command.action === "remind")
219
+ return remindVerb(command, door);
220
+ switch (command.scheduleVerb ?? "list") {
221
+ case "change": return changeVerb(command, door);
222
+ case "cancel":
223
+ case "done": return cancelOrDone(command, door);
224
+ default: return listVerb(command, door);
225
+ }
226
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The message-search flags shared by `cockpit msg search` and `cockpit search --kind msg` (BLI-21658, slice 8):
3
+ * `--from <email|me>` (repeatable), `--in <channel>`, `--since` / `--until` (`YYYY-MM-DD`, both inclusive local
4
+ * days), `--timezone <IANA>`, `--replies` or `--top-level`, `--has-file`. One reader, one query-string writer, so
5
+ * the two commands cannot mean different things. The door checks the values; this file only checks shape.
6
+ */
7
+ import { optionalNonEmpty } from "./local-arg-values.js";
8
+ export const MSG_SEARCH_VALUE_FLAGS = ["--in", "--since", "--until"];
9
+ export const MSG_SEARCH_BOOLEAN_FLAGS = ["--replies", "--top-level", "--has-file"];
10
+ /** Flags only a message search reads (`--from` and `--timezone` are shared with other verbs, so they are not listed). */
11
+ export const MSG_SEARCH_ONLY_FLAGS = [...MSG_SEARCH_VALUE_FLAGS, ...MSG_SEARCH_BOOLEAN_FLAGS];
12
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
13
+ function machineZone() {
14
+ try {
15
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
16
+ }
17
+ catch {
18
+ return "UTC";
19
+ }
20
+ }
21
+ function day(flag, value) {
22
+ const given = optionalNonEmpty(value);
23
+ if (given === undefined)
24
+ return undefined;
25
+ if (!DAY.test(given))
26
+ throw new Error(`${flag} takes a day written YYYY-MM-DD, not "${given}".`);
27
+ return given;
28
+ }
29
+ /** Reads the filter flags. The machine's IANA zone is sent unless `--timezone` is given. */
30
+ export function readMsgSearchFilters(values) {
31
+ const replies = values.booleans.has("--replies");
32
+ const topLevel = values.booleans.has("--top-level");
33
+ if (replies && topLevel)
34
+ throw new Error("Pick one of --replies or --top-level.");
35
+ const from = (values.flagValues.get("--from") ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "");
36
+ const channel = optionalNonEmpty(values.flags.get("--in"));
37
+ const since = day("--since", values.flags.get("--since"));
38
+ const until = day("--until", values.flags.get("--until"));
39
+ if (since && until && since > until)
40
+ throw new Error(`--since ${since} is after --until ${until}.`);
41
+ return {
42
+ ...(from.length > 0 ? { from } : {}),
43
+ ...(channel ? { in: channel } : {}),
44
+ ...(since ? { since } : {}),
45
+ ...(until ? { until } : {}),
46
+ timezone: optionalNonEmpty(values.flags.get("--timezone")) ?? machineZone(),
47
+ ...(replies ? { thread: "reply" } : topLevel ? { thread: "root" } : {}),
48
+ ...(values.booleans.has("--has-file") ? { hasFile: true } : {}),
49
+ };
50
+ }
51
+ /** True when a flag that narrows the search was given (the zone alone does not narrow). */
52
+ export function hasMsgSearchFilterFlags(filters) {
53
+ return Boolean(filters.from || filters.in || filters.since || filters.until || filters.thread || filters.hasFile);
54
+ }
55
+ /** The structured query parameters `GET /api/msg/search` and `GET /api/search` read. */
56
+ export function appendMsgSearchParams(params, filters) {
57
+ for (const person of filters.from ?? [])
58
+ params.append("from", person);
59
+ if (filters.in)
60
+ params.set("channel", filters.in);
61
+ if (filters.since)
62
+ params.set("since", filters.since);
63
+ if (filters.until)
64
+ params.set("until", filters.until);
65
+ if (filters.timezone)
66
+ params.set("tz", filters.timezone);
67
+ if (filters.thread)
68
+ params.set("thread", filters.thread);
69
+ if (filters.hasFile)
70
+ params.set("has", "file");
71
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cockpit msg search [words] [--from <email|me>]... [--in <channel>] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
3
+ * [--timezone <zone>] [--replies|--top-level] [--has-file] [--before <cursor>] [--limit <n>] [--json]` (BLI-21658).
4
+ *
5
+ * The door the browser's message box presses (`GET /api/msg/search`): newest first, paged by `nextCursor`. `--json`
6
+ * prints the door's body unchanged so ids, filters and paging match HTTP. Split out of `msg.ts` for the file-size ratchet.
7
+ */
8
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
9
+ import { writeLine } from "./cli-io.js";
10
+ import { appendMsgSearchParams } from "./msg-search-args.js";
11
+ const TAG = "[msg cli]";
12
+ const READ_DEADLINE_MS = 30_000;
13
+ const SNIPPET_LENGTH = 140;
14
+ function snippet(content) {
15
+ const flat = (content ?? "").replace(/\s+/g, " ").trim();
16
+ if (flat === "")
17
+ return "(attachment, no text)";
18
+ return flat.length > SNIPPET_LENGTH ? `${flat.slice(0, SNIPPET_LENGTH - 3)}...` : flat;
19
+ }
20
+ export async function runMsgSearch(command, door) {
21
+ const params = new URLSearchParams({ q: command.searchQuery ?? "" });
22
+ appendMsgSearchParams(params, command.searchFilters ?? {});
23
+ if (command.before)
24
+ params.set("before", command.before);
25
+ if (command.limit)
26
+ params.set("limit", String(command.limit));
27
+ const answer = await askAgentDoor(door, {
28
+ path: `/api/msg/search?${params.toString()}`,
29
+ method: "GET",
30
+ label: "msg search",
31
+ timeoutMs: READ_DEADLINE_MS,
32
+ });
33
+ if (!answer.ok)
34
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
35
+ const body = answer.body;
36
+ const hits = body.hits ?? [];
37
+ writeLine(door.io.stderr, `${TAG} search ${JSON.stringify({ returned: hits.length, has_more: body.hasMore === true, coverage: body.coverage ?? null })}`);
38
+ if (door.json)
39
+ return emitAgentDoor(door, { ok: true, ...body });
40
+ if (hits.length === 0)
41
+ writeLine(door.io.stdout, "No messages match.");
42
+ for (const hit of hits) {
43
+ const where = hit.channel ? (hit.channel.is_dm ? "direct message" : `#${hit.channel.name ?? "unnamed"}`) : hit.channel_id;
44
+ writeLine(door.io.stdout, `${hit.id} ${where} ${hit.author_name ?? hit.user_id} ${hit.created_at} ${snippet(hit.content)}`);
45
+ }
46
+ writeLine(door.io.stdout, body.hasMore && body.nextCursor ? `more: --before ${body.nextCursor}` : "complete");
47
+ return 0;
48
+ }
@@ -13,7 +13,21 @@ const CONTENT_MAX_CHARS = 8_000;
13
13
  /** The one sentence a deleted message reads as, on every surface. */
14
14
  export const DELETED_SENTENCE = "This message was deleted";
15
15
  export function messageText(message) {
16
- return message.deleted_at ? `(${DELETED_SENTENCE})` : (message.content ?? "");
16
+ if (message.deleted_at)
17
+ return `(${DELETED_SENTENCE})`;
18
+ const names = message.files?.map((file) => file.name) ?? [];
19
+ if (message.content)
20
+ return names.length > 0 ? `${message.content} [files: ${names.join(", ")}]` : message.content;
21
+ // BLI-21521: an attachment-only post has NULL content and is not deleted.
22
+ const blockCount = Array.isArray(message.blocks)
23
+ ? Number(message.blocks.find((block) => block?.type === "files")?.count ?? 0)
24
+ : 0;
25
+ const count = names.length || blockCount;
26
+ if (count === 1 && names[0])
27
+ return `Shared a file: ${names[0]}`;
28
+ if (count === 1)
29
+ return "Shared a file";
30
+ return count > 1 ? `Shared ${count} files` : "";
17
31
  }
18
32
  export async function readContent(io) {
19
33
  if (isInteractiveStdin(io)) {
@@ -17,21 +17,24 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
20
- import { writeLine } from "./cli-io.js";
20
+ import { isInteractiveStdin, writeLine } from "./cli-io.js";
21
21
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
22
22
  import { markReadThrough } from "./msg-read-line.js";
23
+ import { runMsgActivityVerb } from "./msg-activity.js";
23
24
  import { runMsgDraft } from "./msg-draft.js";
25
+ import { runMsgSearch } from "./msg-search.js";
26
+ import { openDm } from "./msg-dm.js";
27
+ import { runMsgMark } from "./msg-mark.js";
28
+ import { runMsgChannel } from "./msg-channel.js";
29
+ import { runMsgFile } from "./msg-file.js";
24
30
  import { deleteMessage, editMessage, messageOutcome, messageText, readContent, refuseMsgWrite } from "./msg-write.js";
25
31
  const TAG = "[msg cli]";
26
- const READ_DEADLINE_MS = 30_000;
27
- const WRITE_DEADLINE_MS = 30_000;
32
+ const [READ_DEADLINE_MS, WRITE_DEADLINE_MS] = [30_000, 30_000];
28
33
  export async function runMsg(command, io) {
29
34
  const door = await openAgentDoor("msg", command, io);
30
35
  switch (command.action) {
31
- case "show":
32
- return showMessage(command, door);
33
- case "channels":
34
- return listChannels(door);
36
+ case "show": return showMessage(command, door);
37
+ case "channels": return listChannels(door);
35
38
  case "read":
36
39
  return readChannel(command, door);
37
40
  case "unread":
@@ -46,12 +49,35 @@ export async function runMsg(command, io) {
46
49
  return messageOutcome(command, door);
47
50
  case "draft":
48
51
  return draftVerb(command, door);
52
+ case "file":
53
+ return fileVerb(command, door);
49
54
  case "thread":
50
55
  return readThread(command, door);
51
- case "create":
52
- return createChannel(command, door);
53
- case "dm":
54
- return openDm(command, door);
56
+ case "create": return createChannel(command, door);
57
+ case "dm": return openDm(command, door);
58
+ case "search":
59
+ return runMsgSearch(command, door);
60
+ case "channel":
61
+ case "members":
62
+ case "hide":
63
+ case "unhide":
64
+ return runMsgChannel(command, door, (ref) => resolveChannelId(door, ref));
65
+ case "save":
66
+ case "saved":
67
+ case "pin":
68
+ case "pins":
69
+ case "forward":
70
+ case "schedule":
71
+ case "remind":
72
+ case "scheduled":
73
+ case "context":
74
+ return runMsgMark(command, door, (ref) => resolveChannelId(door, ref));
75
+ case "activity":
76
+ case "follow":
77
+ case "unfollow":
78
+ case "react":
79
+ case "notify":
80
+ return runMsgActivityVerb(command, door, (ref) => resolveChannelId(door, ref));
55
81
  }
56
82
  }
57
83
  /** `msg draft`: `list` needs no channel; every other verb resolves the channel the same way read and send do. */
@@ -66,6 +92,18 @@ async function draftVerb(command, door) {
66
92
  return channelNotFound(door, ref);
67
93
  return runMsgDraft(command, door, resolved.id);
68
94
  }
95
+ /** `msg file`: `upload` and `list` name a channel; the other verbs name a file. */
96
+ async function fileVerb(command, door) {
97
+ if (command.fileVerb !== "upload" && command.fileVerb !== "list")
98
+ return runMsgFile(command, door, null);
99
+ const ref = command.channelRef ?? "";
100
+ const resolved = await resolveChannelId(door, ref);
101
+ if (resolved.status === "list_failed")
102
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
103
+ if (resolved.status === "not_found")
104
+ return channelNotFound(door, ref);
105
+ return runMsgFile(command, door, resolved.id);
106
+ }
69
107
  async function fetchChannels(door) {
70
108
  const answer = await readThroughCache({
71
109
  io: door.io,
@@ -276,10 +314,16 @@ async function sendMessage(command, door) {
276
314
  return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
277
315
  if (resolved.status === "not_found")
278
316
  return channelNotFound(door, ref);
279
- const content = await readContent(door.io);
280
- if (!content.ok)
281
- return failAgentDoor(door, TAG, content.reason, content.detail);
282
- if (content.text === "")
317
+ // BLI-21521: with --file the text is optional; stdin is read only when something is piped in.
318
+ const fileIds = command.fileIds ?? [];
319
+ let text = "";
320
+ if (fileIds.length === 0 || !isInteractiveStdin(door.io)) {
321
+ const content = await readContent(door.io);
322
+ if (!content.ok)
323
+ return failAgentDoor(door, TAG, content.reason, content.detail);
324
+ text = content.text;
325
+ }
326
+ if (text === "" && fileIds.length === 0)
283
327
  return failAgentDoor(door, TAG, "invalid_body", "There was nothing to send. Nothing was sent.");
284
328
  // BLI-21314: every send carries a request id, so a lost response can be checked and a retry posts once.
285
329
  const requestId = command.requestId ?? randomUUID();
@@ -289,7 +333,8 @@ async function sendMessage(command, door) {
289
333
  label: "msg send",
290
334
  timeoutMs: WRITE_DEADLINE_MS,
291
335
  body: {
292
- content: content.text,
336
+ ...(text === "" ? {} : { content: text }),
337
+ ...(fileIds.length > 0 ? { file_ids: fileIds } : {}),
293
338
  request_id: requestId,
294
339
  ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
295
340
  },
@@ -297,7 +342,7 @@ async function sendMessage(command, door) {
297
342
  if (!answer.ok)
298
343
  return refuseMsgWrite(door, answer, requestId, "send");
299
344
  const { message = null, mutation = null } = answer.body;
300
- writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, request_id: requestId, chars: content.text.length })}`);
345
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, request_id: requestId, chars: text.length, files: fileIds.length })}`);
301
346
  if (door.json)
302
347
  return emitAgentDoor(door, { ok: true, message, mutation });
303
348
  writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
@@ -360,28 +405,6 @@ function reportChannelCreationToHuman(door, channel, name, requestedMembers, mem
360
405
  writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
361
406
  }
362
407
  }
363
- async function openDm(command, door) {
364
- const email = command.dmEmail ?? "";
365
- if (email === "")
366
- return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
367
- const answer = await askAgentDoor(door, {
368
- path: "/api/msg/channels",
369
- method: "POST",
370
- label: "msg dm",
371
- timeoutMs: WRITE_DEADLINE_MS,
372
- body: { dm_participant_emails: [email] },
373
- });
374
- if (!answer.ok)
375
- return failAgentDoor(door, TAG, answer.reason, answer.detail);
376
- const channel = answer.body.channel ?? null;
377
- writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
378
- if (door.json)
379
- return emitAgentDoor(door, { ok: true, channel });
380
- // Idempotent by contract (`get_or_create_dm`), so the wording says what is
381
- // true either way rather than claiming a creation that may not have happened.
382
- writeLine(door.io.stdout, `Direct message with ${email}: ${channel?.id ?? "?"}`);
383
- return 0;
384
- }
385
408
  async function showMessage(command, door) {
386
409
  const answer = await askAgentDoor(door, { path: `/api/msg/messages/${encodeURIComponent(command.threadId ?? "")}`, method: "GET", label: "msg show", timeoutMs: READ_DEADLINE_MS });
387
410
  if (!answer.ok)