@bli-cockpit/cli 0.2.242 → 0.2.243

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 (121) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +22 -11
  3. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  4. package/dist/agent-rules.js +8 -0
  5. package/dist/autostart-contract.js +12 -0
  6. package/dist/autostart-darwin-plist.js +2 -2
  7. package/dist/autostart-darwin.js +1 -1
  8. package/dist/autostart-remind.js +245 -0
  9. package/dist/autostart-self-heal.js +17 -1
  10. package/dist/autostart-windows-scripts.js +7 -7
  11. package/dist/autostart.js +16 -15
  12. package/dist/commands/approvals-check-where.js +14 -0
  13. package/dist/commands/approvals-render.js +8 -1
  14. package/dist/commands/approvals.js +3 -2
  15. package/dist/commands/autostart-command.js +10 -0
  16. package/dist/commands/autostart-heal.js +36 -1
  17. package/dist/commands/brain.js +1 -1
  18. package/dist/commands/cal-agenda.js +44 -0
  19. package/dist/commands/cal-availability-share.js +97 -0
  20. package/dist/commands/cal-booking.js +147 -0
  21. package/dist/commands/cal-join.js +62 -0
  22. package/dist/commands/cal-links.js +68 -0
  23. package/dist/commands/cal-people.js +136 -0
  24. package/dist/commands/cal-remind.js +176 -0
  25. package/dist/commands/cal-reminders.js +33 -0
  26. package/dist/commands/cal-suggest.js +89 -0
  27. package/dist/commands/cal.js +41 -1
  28. package/dist/commands/cycle-args.js +127 -0
  29. package/dist/commands/cycles.js +117 -0
  30. package/dist/commands/docs-comments-args.js +85 -0
  31. package/dist/commands/docs-comments.js +128 -0
  32. package/dist/commands/docs-files-args.js +49 -0
  33. package/dist/commands/docs-files.js +179 -0
  34. package/dist/commands/docs-formatting.js +45 -0
  35. package/dist/commands/docs-inbox-args.js +93 -0
  36. package/dist/commands/docs-inbox.js +126 -0
  37. package/dist/commands/docs-notion.js +5 -0
  38. package/dist/commands/docs-read.js +2 -1
  39. package/dist/commands/docs-recovery.js +173 -0
  40. package/dist/commands/docs-refs-args.js +104 -0
  41. package/dist/commands/docs-refs.js +153 -0
  42. package/dist/commands/docs-search-args.js +79 -0
  43. package/dist/commands/docs-search.js +64 -0
  44. package/dist/commands/docs-sync-args.js +82 -0
  45. package/dist/commands/docs-sync.js +197 -0
  46. package/dist/commands/docs-tree.js +136 -0
  47. package/dist/commands/docs.js +85 -51
  48. package/dist/commands/doctor-update.js +4 -1
  49. package/dist/commands/inbox-args.js +81 -0
  50. package/dist/commands/inbox.js +88 -0
  51. package/dist/commands/issue-batch.js +61 -0
  52. package/dist/commands/issue-relations.js +151 -0
  53. package/dist/commands/issue-write.js +38 -5
  54. package/dist/commands/issue.js +32 -1
  55. package/dist/commands/local-args-tower-approvals.js +16 -4
  56. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  57. package/dist/commands/local-args-tower-cal-links.js +42 -0
  58. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  59. package/dist/commands/local-args-tower-cal-share.js +41 -0
  60. package/dist/commands/local-args-tower-cal.js +81 -8
  61. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  62. package/dist/commands/local-args-tower-msg.js +222 -10
  63. package/dist/commands/local-args-tower-search.js +32 -5
  64. package/dist/commands/local-args-tower-work.js +110 -34
  65. package/dist/commands/local-auth.js +1 -1
  66. package/dist/commands/local-help-commands-tower-ops.js +2 -0
  67. package/dist/commands/local-help-commands-tower.js +47 -31
  68. package/dist/commands/local-help-cycles.js +9 -0
  69. package/dist/commands/local-help-docs-files.js +10 -0
  70. package/dist/commands/local-help-docs-formatting.js +7 -0
  71. package/dist/commands/local-help-docs-inbox.js +10 -0
  72. package/dist/commands/local-help-docs-recovery.js +10 -0
  73. package/dist/commands/local-help-docs-refs.js +11 -0
  74. package/dist/commands/local-help-docs-sync.js +9 -0
  75. package/dist/commands/local-help-inbox.js +9 -0
  76. package/dist/commands/local-help-project.js +21 -0
  77. package/dist/commands/local-help-templates.js +9 -0
  78. package/dist/commands/local-help-views.js +10 -0
  79. package/dist/commands/local-help-work-notify.js +6 -0
  80. package/dist/commands/local-help.js +2 -2
  81. package/dist/commands/mcp-stdio-probe.js +1 -0
  82. package/dist/commands/msg-activity.js +134 -0
  83. package/dist/commands/msg-channel-args.js +92 -0
  84. package/dist/commands/msg-channel.js +125 -0
  85. package/dist/commands/msg-context-args.js +67 -0
  86. package/dist/commands/msg-context.js +65 -0
  87. package/dist/commands/msg-dm.js +28 -0
  88. package/dist/commands/msg-file.js +185 -0
  89. package/dist/commands/msg-mark-args.js +78 -0
  90. package/dist/commands/msg-mark.js +148 -0
  91. package/dist/commands/msg-notify-args.js +100 -0
  92. package/dist/commands/msg-notify.js +122 -0
  93. package/dist/commands/msg-schedule-args.js +114 -0
  94. package/dist/commands/msg-schedule.js +226 -0
  95. package/dist/commands/msg-search-args.js +71 -0
  96. package/dist/commands/msg-search.js +48 -0
  97. package/dist/commands/msg-write.js +15 -1
  98. package/dist/commands/msg.js +62 -39
  99. package/dist/commands/project-args.js +82 -0
  100. package/dist/commands/project.js +71 -7
  101. package/dist/commands/public-root.js +1 -1
  102. package/dist/commands/search.js +23 -0
  103. package/dist/commands/session-sync-failures.js +17 -4
  104. package/dist/commands/session-sync-health.js +7 -2
  105. package/dist/commands/session-sync-hold.js +42 -1
  106. package/dist/commands/session-sync-upload.js +2 -0
  107. package/dist/commands/session-sync.js +1 -0
  108. package/dist/commands/sync-doctor.js +14 -1
  109. package/dist/commands/sync-report.js +1 -1
  110. package/dist/commands/template-args.js +122 -0
  111. package/dist/commands/templates.js +162 -0
  112. package/dist/commands/view-args.js +131 -0
  113. package/dist/commands/views.js +138 -0
  114. package/dist/commands/work-notify-args.js +67 -0
  115. package/dist/commands/work-notify.js +63 -0
  116. package/dist/delivery-hold-notice.js +34 -0
  117. package/dist/log-rotation.js +2 -2
  118. package/dist/notifier.js +83 -0
  119. package/dist/process-runner.js +4 -0
  120. package/dist/repo-identity-git.js +1 -0
  121. package/package.json +4 -4
@@ -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)
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `cockpit project` argument parsing (BLI-3716 list, BLI-21659 show/create/update/archive/restore).
3
+ *
4
+ * A description never travels on argv by default: `--file <path>` reads it from a local file (safest on Windows);
5
+ * `--description <text>` is the short form and `--description none` clears it. Write-safety flags
6
+ * (`--request-id`, `--expected-updated-at`) belong to the four write verbs.
7
+ */
8
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
9
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ const ACTIONS = ["list", "show", "create", "update", "archive", "restore"];
11
+ const WRITES = ["create", "update", "archive", "restore"];
12
+ export function parseProjectArgs(args) {
13
+ const values = parseNamedArgs(args, {
14
+ allowedFlags: [
15
+ "--home", "--dashboard-url", "--archived", "--cursor", "--limit", "--json",
16
+ "--name", "--description", "--file", "--color", "--request-id", "--expected-updated-at",
17
+ ],
18
+ valueFlags: [
19
+ "--home", "--dashboard-url", "--cursor", "--limit",
20
+ "--name", "--description", "--file", "--color", "--request-id", "--expected-updated-at",
21
+ ],
22
+ });
23
+ const first = values.positionals[0];
24
+ const action = (first === undefined ? "list" : first);
25
+ if (!ACTIONS.includes(action)) {
26
+ throw new Error(`Unknown project command: ${first}. Try list, show, create, update, archive or restore.`);
27
+ }
28
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
29
+ const needsRef = action !== "list" && action !== "create";
30
+ if (needsRef && rest.length !== 1)
31
+ throw new Error(`project ${action} takes exactly one project id or name.`);
32
+ if (!needsRef && rest.length > 0)
33
+ throw new Error(`project ${action} does not take "${rest[0]}".`);
34
+ const given = (flag) => values.flags.has(flag);
35
+ const isWrite = WRITES.includes(action);
36
+ if (!isWrite && (given("--request-id") || given("--expected-updated-at"))) {
37
+ throw new Error("--request-id and --expected-updated-at belong to create, update, archive and restore.");
38
+ }
39
+ if (action !== "create" && action !== "update" && (given("--name") || given("--description") || given("--file"))) {
40
+ throw new Error("--name, --description and --file belong to project create and update.");
41
+ }
42
+ if (action !== "create" && given("--color"))
43
+ throw new Error("--color belongs to project create.");
44
+ if (action !== "list" && (given("--cursor") || given("--limit") || values.booleans.has("--archived"))) {
45
+ throw new Error("--archived, --cursor and --limit belong to project list.");
46
+ }
47
+ if (given("--description") && given("--file"))
48
+ throw new Error("Give --description or --file, not both.");
49
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
50
+ if (requestId && !UUID_PATTERN.test(requestId))
51
+ throw new Error("--request-id must be a UUID.");
52
+ const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
53
+ if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
54
+ throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
55
+ }
56
+ const name = optionalNonEmpty(values.flags.get("--name"));
57
+ if (action === "create" && !name)
58
+ throw new Error('project create needs --name "<name>".');
59
+ const rawDescription = values.flags.get("--description");
60
+ const description = rawDescription === undefined ? undefined : rawDescription.trim().toLowerCase() === "none" ? null : rawDescription;
61
+ const filePath = optionalNonEmpty(values.flags.get("--file"));
62
+ if (action === "update" && name === undefined && description === undefined && filePath === undefined) {
63
+ throw new Error("project update needs --name, --description (or none) or --file.");
64
+ }
65
+ return {
66
+ kind: "project",
67
+ action,
68
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
69
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
70
+ includeArchived: values.booleans.has("--archived"),
71
+ ...(optionalNonEmpty(values.flags.get("--cursor")) ? { cursor: optionalNonEmpty(values.flags.get("--cursor")) } : {}),
72
+ ...(values.flags.has("--limit") ? { limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit") } : {}),
73
+ ...(needsRef ? { ref: rest[0] } : {}),
74
+ ...(name === undefined ? {} : { name }),
75
+ ...(description === undefined ? {} : { description }),
76
+ ...(filePath === undefined ? {} : { filePath }),
77
+ ...(optionalNonEmpty(values.flags.get("--color")) ? { color: optionalNonEmpty(values.flags.get("--color")) } : {}),
78
+ ...(requestId ? { requestId } : {}),
79
+ ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
80
+ json: values.booleans.has("--json"),
81
+ };
82
+ }
@@ -1,20 +1,21 @@
1
1
  /**
2
- * `cockpit project list` — the projects issues are filed under (BLI-3716).
2
+ * `cockpit project list|show|create|update|archive|restore` — the projects issues are filed under
3
+ * (BLI-3716 list; BLI-21659 the rest).
3
4
  *
4
- * One verb, because `GET /api/work/projects` is the whole of the Work
5
- * surface's project door today (BLI-3703 scoped projects to list-only; there
6
- * is no create/update/delete route to put a terminal in front of). Its own
7
- * command rather than `cockpit issue projects` because a person says
8
- * "projects", and `cockpit issue list --project <name>` resolves a name
9
- * against exactly this list.
5
+ * Its own command rather than `cockpit issue projects` because a person says "projects", and
6
+ * `cockpit issue list --project <name>` resolves a name against exactly this list. Writes go through
7
+ * `/api/work/projects` (receipt, `tower_only` delivery: nothing reaches Linear); no verb deletes a project.
10
8
  */
11
9
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
12
10
  import { writeLine } from "./cli-io.js";
13
11
  import { pageFieldsOf, pageFooter } from "./issue-list-page.js";
12
+ import { readBody } from "./issue-contracts.js";
14
13
  const TAG = "[project cli]";
15
14
  const READ_DEADLINE_MS = 30_000;
16
15
  export async function runProject(command, io) {
17
16
  const door = await openAgentDoor("project", command, io);
17
+ if (command.action !== "list")
18
+ return await runProjectVerb(command, door);
18
19
  const params = new URLSearchParams();
19
20
  if (command.includeArchived)
20
21
  params.set("include_archived", "true");
@@ -47,4 +48,67 @@ export async function runProject(command, io) {
47
48
  if (footer)
48
49
  writeLine(door.io.stdout, footer);
49
50
  return 0;
51
+ }
52
+ async function runProjectVerb(command, door) {
53
+ const base = "/api/work/projects";
54
+ const ref = command.ref ? `${base}/${encodeURIComponent(command.ref)}` : base;
55
+ if (command.action === "show") {
56
+ const answer = await askAgentDoor(door, { path: ref, method: "GET", label: "project show", timeoutMs: READ_DEADLINE_MS });
57
+ if (!answer.ok)
58
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
59
+ const body = answer.body;
60
+ if (door.json)
61
+ return emitAgentDoor(door, { ok: true, ...body, ...pageFieldsOf(body) });
62
+ const project = body.project;
63
+ const out = door.io.stdout;
64
+ writeLine(out, `${project?.name ?? command.ref} (${project?.archived_at ? "archived" : "active"}, ${project?.source ?? "native"})`);
65
+ writeLine(out, `id ${project?.id ?? ""} updated ${project?.updated_at ?? ""}`);
66
+ if (project?.description)
67
+ writeLine(out, project.description);
68
+ for (const issue of body.issues ?? [])
69
+ writeLine(out, `${issue.identifier ?? ""} ${issue.state ?? ""} ${issue.title ?? ""}`);
70
+ const footer = pageFooter(body, "tickets");
71
+ if (footer)
72
+ writeLine(out, footer);
73
+ if (body.href)
74
+ writeLine(out, `Filtered list: ${body.href}`);
75
+ return 0;
76
+ }
77
+ const payload = {
78
+ ...(command.requestId ? { request_id: command.requestId } : {}),
79
+ ...(command.expectedUpdatedAt ? { expected_updated_at: command.expectedUpdatedAt } : {}),
80
+ };
81
+ if (command.action === "create" || command.action === "update") {
82
+ if (command.name !== undefined)
83
+ payload["name"] = command.name;
84
+ if (command.color !== undefined)
85
+ payload["color"] = command.color;
86
+ if (command.filePath) {
87
+ const read = await readBody({ filePath: command.filePath }, door.io);
88
+ if (!read.ok)
89
+ return failAgentDoor(door, TAG, read.reason, read.detail);
90
+ payload["description"] = read.text;
91
+ }
92
+ else if (command.description !== undefined) {
93
+ payload["description"] = command.description;
94
+ }
95
+ }
96
+ const path = command.action === "create" ? base : command.action === "update" ? ref : `${ref}/${command.action}`;
97
+ const answer = await askAgentDoor(door, {
98
+ path,
99
+ method: command.action === "update" ? "PATCH" : "POST",
100
+ label: `project ${command.action}`,
101
+ timeoutMs: READ_DEADLINE_MS,
102
+ body: payload,
103
+ });
104
+ if (!answer.ok) {
105
+ const mutation = answer.body?.mutation;
106
+ return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
107
+ }
108
+ const body = answer.body;
109
+ console.error(`${TAG} ${command.action} committed`, JSON.stringify({ project_id: body.project?.id, delivery: body.mutation?.delivery?.status }));
110
+ if (door.json)
111
+ return emitAgentDoor(door, { ok: true, project: body.project, mutation: body.mutation });
112
+ writeLine(door.io.stdout, `${command.action} committed: ${body.project?.name ?? ""} (${body.project?.id ?? ""}), delivery ${body.mutation?.delivery?.status ?? "tower_only"}.`);
113
+ return 0;
50
114
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.242");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.243");
25
25
  return 0;
26
26
  }
27
27
 
@@ -26,6 +26,8 @@
26
26
  */
27
27
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
28
28
  import { writeLine } from "./cli-io.js";
29
+ import { appendDocSearchParams } from "./docs-search-args.js";
30
+ import { appendMsgSearchParams } from "./msg-search-args.js";
29
31
  const TAG = "[search cli]";
30
32
  const READ_DEADLINE_MS = 30_000;
31
33
  /** Group headings, in the order a person reads them. Mirrors the overlay. */
@@ -56,6 +58,12 @@ export async function runSearch(command, io) {
56
58
  params.set("kinds", command.kinds.join(","));
57
59
  if (command.limit !== undefined)
58
60
  params.set("limit", String(command.limit));
61
+ // BLI-21658: message filters ride the same door, which answers them only for kinds=msg.
62
+ if (command.msgFilters)
63
+ appendMsgSearchParams(params, command.msgFilters);
64
+ // BLI-21697: document filters ride the same door, which answers them only for kinds=doc.
65
+ if (command.docFilters)
66
+ appendDocSearchParams(params, command.docFilters);
59
67
  const answer = await askAgentDoor(door, {
60
68
  path: `/api/search?${params.toString()}`,
61
69
  method: "GET",
@@ -139,6 +147,21 @@ function render(door, body) {
139
147
  writeLine(door.io.stdout, `${KIND_LABELS[kind] ?? kind.toUpperCase()} did not answer (${reason}). Nothing from there is in this list.`);
140
148
  }
141
149
  }
150
+ // Named, so a filtered list is not read as the ranked, semantic one (BLI-21658).
151
+ if (body.degraded?.msg === "filtered_lexical_only") {
152
+ writeLine(door.io.stdout, "");
153
+ writeLine(door.io.stdout, "MESSAGES were filtered: keyword matching only (filtered_lexical_only), newest first, not ranked.");
154
+ if (body.msgPaging?.hasMore && body.msgPaging.nextCursor) {
155
+ writeLine(door.io.stdout, `More messages exist: \`cockpit msg search ... --before ${body.msgPaging.nextCursor}\` continues with the same filters.`);
156
+ }
157
+ }
158
+ if (body.degraded?.doc === "filtered_lexical_only") {
159
+ writeLine(door.io.stdout, "");
160
+ writeLine(door.io.stdout, "DOCUMENTS were filtered: keyword matching only (filtered_lexical_only), not ranked by meaning.");
161
+ if (body.docCoverage && body.docCoverage.coverage !== "exhausted") {
162
+ writeLine(door.io.stdout, `Documents coverage: ${body.docCoverage.coverage}${body.docCoverage.reason ? ` (${body.docCoverage.reason})` : ""}; ${body.docCoverage.matched} matched.`);
163
+ }
164
+ }
142
165
  writeLine(door.io.stdout, "");
143
166
  writeLine(door.io.stdout, `${hits.length} result(s) in ${body.elapsedMs ?? 0} ms.`);
144
167
  return 0;
@@ -13,7 +13,7 @@
13
13
  * without saying why.
14
14
  */
15
15
  import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
16
- import { isDeliveryHoldReason } from "./session-sync-hold.js";
16
+ import { budgetStarved, isDeliveryHoldReason } from "./session-sync-hold.js";
17
17
  import { sourceScanFailureReason } from "./session-sync-scan.js";
18
18
  /**
19
19
  * The label used when a sync fails and nothing on the way there said why.
@@ -55,8 +55,15 @@ export function createSyncFailureLedger() {
55
55
  * hold in `session-sync-hold.ts` and rides the same receipt — and a tick that
56
56
  * ALSO failed for a real reason still fails on that reason. Nothing is
57
57
  * silenced; only the verdict changed.
58
+ *
59
+ * Ticket 21952 does the same for the per-tick size budget. Overflow defers to the next tick by design, so a tick that
60
+ * sent something and held the rest reports a named hold (`held_budget:` in `session-sync-hold.ts`) instead of failing:
61
+ * one machine was RED on every tick from 2026-10-15 while it uploaded 2 GiB each time. Only a STARVED tick, one that
62
+ * deferred evidence and let nothing through, still fails `deferred_byte_budget` / `deferred_object_budget`. Without
63
+ * `budgetUse` (an older caller) the old rule stands: any deferral fails.
58
64
  */
59
- export function recordWorktreeDeliveryFailures(ledger, outcomes) {
65
+ export function recordWorktreeDeliveryFailures(ledger, outcomes, budgetUse) {
66
+ const budgetHolds = budgetUse !== undefined && !budgetStarved(budgetUse);
60
67
  for (const outcome of outcomes) {
61
68
  recordWorktreeSyncFailure(ledger, outcome);
62
69
  const { sync } = outcome;
@@ -71,15 +78,21 @@ export function recordWorktreeDeliveryFailures(ledger, outcomes) {
71
78
  for (const reason of sync.raw_evidence_retry_reasons ?? []) {
72
79
  if (isDeliveryHoldReason(reason))
73
80
  continue;
81
+ if (budgetHolds && isBudgetDeferralReason(reason))
82
+ continue;
74
83
  ledger.add({
75
84
  label: "raw_evidence_retry_required",
76
85
  rendered: `raw_evidence_retry:${reason}`,
77
86
  });
78
87
  }
79
- ledger.fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
80
- ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
88
+ ledger.fail(!budgetHolds && sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
89
+ ledger.fail(!budgetHolds && sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
81
90
  }
82
91
  }
92
+ /** The two reasons the per-tick budget writes when it defers a file. Exact labels, never a pattern. */
93
+ function isBudgetDeferralReason(reason) {
94
+ return reason === "deferred_byte_budget" || reason === "deferred_object_budget";
95
+ }
83
96
  function recordWorktreeSyncFailure(ledger, { worktree, sync }) {
84
97
  if (sync.status === "uploaded")
85
98
  return;
@@ -13,7 +13,7 @@ import { collectionRootLabel } from "./heartbeat.js";
13
13
  import { workDirectories } from "../root-work.js";
14
14
  import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
15
15
  import { countSessionsNewThisTick, countSessionsPendingUpload, } from "./session-sync-counters.js";
16
- import { logDeliveryHold, summarizeDeliveryHold } from "./session-sync-hold.js";
16
+ import { logBudgetHold, logDeliveryHold, summarizeBudgetHold, summarizeDeliveryHold } from "./session-sync-hold.js";
17
17
  /**
18
18
  * Decide whether this tick failed, and record every condition that decided it.
19
19
  *
@@ -23,7 +23,7 @@ import { logDeliveryHold, summarizeDeliveryHold } from "./session-sync-hold.js";
23
23
  export function decideSyncHealth(options) {
24
24
  const { outcomes } = options.worktreePass;
25
25
  const ledger = createSyncFailureLedger();
26
- recordWorktreeDeliveryFailures(ledger, outcomes);
26
+ recordWorktreeDeliveryFailures(ledger, outcomes, options.worktreePass.budgetUse);
27
27
  recordSourceScanFailures(ledger, options.scan);
28
28
  const sessionsOutsideRoot = options.sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
29
29
  const nothingInRoot = nothingInRootCount({
@@ -54,6 +54,10 @@ export function decideSyncHealth(options) {
54
54
  const hold = summarizeDeliveryHold(outcomes);
55
55
  if (hold && ok)
56
56
  logDeliveryHold(hold);
57
+ // Ticket 21952: the size-budget hold, said on every tick that has one, ok or not.
58
+ const budgetHold = summarizeBudgetHold(options.worktreePass.budgetUse);
59
+ if (budgetHold)
60
+ logBudgetHold(budgetHold);
57
61
  const records = ledger.sortedRecords();
58
62
  return {
59
63
  ok,
@@ -61,6 +65,7 @@ export function decideSyncHealth(options) {
61
65
  failure_records: records,
62
66
  notice,
63
67
  hold,
68
+ budgetHold,
64
69
  sessions_outside_root: sessionsOutsideRoot,
65
70
  outside_root_directories: workDirectories([...(options.scan.codexAttribution.results ?? []), ...(options.scan.claudeAttribution.results ?? [])].filter(result => OUTSIDE_APPROVED_ROOT_REASONS.has(result.reason)))
66
71
  .map(entry => ({ ...collectionRootLabel(entry.directory), count: entry.count }))
@@ -11,7 +11,7 @@
11
11
  * reads every `session-sync*.ts` sibling and demands that only the failure
12
12
  * ledger writes one, and this file is not it.
13
13
  */
14
- import { formatDeliveryHoldNotice, } from "../delivery-hold-notice.js";
14
+ import { formatBudgetHoldNotice, formatDeliveryHoldNotice, } from "../delivery-hold-notice.js";
15
15
  import { DELIVERY_BACKOFF_HOLDING_REASON } from "../raw-evidence-staging.js";
16
16
  /**
17
17
  * Is this reason a delivery hold rather than a delivery failure?
@@ -87,4 +87,45 @@ export function logDeliveryHold(hold) {
87
87
  last_reason: hold.lastReason,
88
88
  next_action: "nothing until the next attempt is due; run `cockpit doctor` if it is already past",
89
89
  }));
90
+ }
91
+ /** The token's leading word, owned with its grammar by `delivery-hold-notice.ts` so the fleet board parses what this writes. */
92
+ export { BUDGET_HOLD_NOTICE_PREFIX } from "../delivery-hold-notice.js";
93
+ /** Reads the shared budget's counters, or null when the pass carried none. */
94
+ export function budgetUseOf(budget) {
95
+ if (!budget)
96
+ return undefined;
97
+ return {
98
+ admittedBytes: budget.admittedBytes ?? 0,
99
+ admittedObjects: budget.admittedObjects ?? 0,
100
+ deferredBytes: budget.deferredBytes ?? 0,
101
+ deferredObjects: budget.deferredObjects ?? 0,
102
+ };
103
+ }
104
+ /**
105
+ * Did the budget hold anything back while still letting something through?
106
+ *
107
+ * The collector's design is that overflow defers and converges: the next tick starts with a fresh budget and picks up
108
+ * what this one held. A tick that admitted at least one object made progress, so what it deferred is a hold. A tick
109
+ * that deferred and admitted NOTHING made no progress at all, which stays a failure (`budgetStarved`).
110
+ */
111
+ export function summarizeBudgetHold(use) {
112
+ if (!use || use.deferredObjects <= 0 || budgetStarved(use))
113
+ return null;
114
+ const notice = formatBudgetHoldNotice(use);
115
+ return notice ? { ...use, notice } : null;
116
+ }
117
+ /** True when the budget deferred evidence and let nothing through this tick. */
118
+ export function budgetStarved(use) {
119
+ return Boolean(use && use.deferredObjects > 0 && use.admittedObjects === 0);
120
+ }
121
+ /** The run's line for a size-budget hold: bytes sent, bytes held, and that the next tick takes the rest. */
122
+ export function logBudgetHold(hold) {
123
+ console.error("[session-sync] raw evidence held back by the per-tick size budget, the next tick takes the rest", JSON.stringify({
124
+ reason: hold.notice,
125
+ sent_bytes: hold.admittedBytes,
126
+ sent_objects: hold.admittedObjects,
127
+ held_bytes: hold.deferredBytes,
128
+ held_objects: hold.deferredObjects,
129
+ next_action: "none; the next tick starts with a fresh budget",
130
+ }));
90
131
  }
@@ -13,6 +13,7 @@ import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, }
13
13
  import { isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
14
14
  import { giveOutsideRootsTheirOwnAllowance, outsideRootsLast, } from "./session-sync-outside-roots.js";
15
15
  import { runYieldingAtBoundaries } from "./sync-yield.js";
16
+ import { budgetUseOf } from "./session-sync-hold.js";
16
17
  const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
17
18
  const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
18
19
  /**
@@ -54,6 +55,7 @@ export async function syncEveryTargetWorktree(options) {
54
55
  claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
55
56
  growthDampedSessionCount: dampedClaudePointers.size,
56
57
  yieldedWorktreesLeft: loop.left,
58
+ budgetUse: budgetUseOf(rawEvidenceBudget),
57
59
  };
58
60
  }
59
61
  function syncTargetWithAllowance(options, worktree, syncWorktrees, rawEvidenceBudget, discoveredWorktreeKeys, dampedClaudePointers) {
@@ -190,6 +190,7 @@ async function finishAttributedSync(options, context) {
190
190
  failure_records: health.failure_records,
191
191
  notice: health.notice,
192
192
  hold: health.hold,
193
+ budgetHold: health.budgetHold ?? null,
193
194
  yielded_worktrees_left: worktreePass.yieldedWorktreesLeft,
194
195
  sessions_observed: sessions.length,
195
196
  sessions_outside_root: health.sessions_outside_root,
@@ -14,6 +14,7 @@ import { doctorNeedsPerson, sanitizeEventCode } from "./doctor-report.js";
14
14
  import { runSelfUpdate } from "./install-update.js";
15
15
  import { reportInstallEventsBestEffort } from "./install-receipts.js";
16
16
  import { clearDoctorRequest, requestedDoctorId } from "./sync-doctor-request.js";
17
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
17
18
  export async function runDoctorAfterSync(command, io, dashboardUrl, overrides = {}, facts) {
18
19
  if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
19
20
  console.error("[sync-doctor] checkout does not repair the fleet machine", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
@@ -69,11 +70,23 @@ export async function runDoctorAfterSync(command, io, dashboardUrl, overrides =
69
70
  await lock.handle.release();
70
71
  }
71
72
  }
73
+ /**
74
+ * Ticket 21952. launchd hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin`, where neither `node` nor `npm` lives, so
75
+ * the tick doctor's `npm view` and `npm install` could not start on any Mac: every macOS receipt read
76
+ * `latest_version_unknown -> fail:npm_install_failed` while already on the newest CLI, and Windows (whose scheduler
77
+ * keeps the user's PATH) read `already_latest`. The scheduled self-update already runs npm with the running node's bin
78
+ * dir on PATH (`envWithNodeRuntimeOnPath`); the tick doctor now does the same. A caller's explicit env still wins.
79
+ */
80
+ export function withNodeRuntimeOnPath(io) {
81
+ const env = envWithNodeRuntimeOnPath(io.env ?? process.env);
82
+ const wrap = (runner) => runner ? (cmd, args, options) => runner(cmd, args, { ...options, env: options?.env ?? env }) : undefined;
83
+ return { exec: wrap(io.exec), interactiveExec: wrap(io.interactiveExec) };
84
+ }
72
85
  async function collectDoctorReceipt(command, io, dashboardUrl, overrides, facts, paths, requestId) {
73
86
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
74
87
  let receipt = null;
75
88
  const quiet = new Writable({ write(_chunk, _encoding, done) { done(); } });
76
- const doctorIo = { ...io, stdout: quiet };
89
+ const doctorIo = { ...io, stdout: quiet, ...withNodeRuntimeOnPath(io) };
77
90
  await runDoctor({ kind: "doctor", alias: "doctor", automaticTick: true, homeDir: command.homeDir, dashboardUrl, json: true, dryRun: false }, doctorIo, {
78
91
  selfUpdate: (updateIo, options) => runSelfUpdate({ ...updateIo, stdout: quiet }, options),
79
92
  runLogin: async () => { throw new Error("automatic_doctor_cannot_prompt"); },
@@ -152,7 +152,7 @@ function syncResult(run) {
152
152
  notice: run.notice,
153
153
  // BLI-4022: unlike the reasons, the hold survives a successful run — it is
154
154
  // what the tick withheld, not why it failed.
155
- holdNotice: run.hold?.notice ?? null,
155
+ holdNotice: run.hold?.notice ?? run.budgetHold?.notice ?? null,
156
156
  sessionsObserved: run.sessions_observed,
157
157
  sessionsOutsideRoot: run.sessions_outside_root,
158
158
  outsideRootDirectories: run.outside_root_directories,