cawdev-cli 1.2.1 → 1.2.2

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.
@@ -4,14 +4,48 @@
4
4
  // for the server's analysis task. One place, so the two cannot drift (R584).
5
5
  // Zero dependencies, like all of tools/.
6
6
 
7
- /** A GET against the SonarQube server a scan just reported to, authorised with the scanner's own token. */
8
- export function sonarAsker(serverUrl, token) {
9
- return async (path) => {
10
- const response = await fetch(`${serverUrl}${path}`, { headers: { authorization: `Bearer ${token}` } });
11
- if (!response.ok) {
12
- throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
7
+ export const SONAR_REQUEST_TIMEOUT_MS = 30_000;
8
+
9
+ const isAbort = (error) =>
10
+ ['TimeoutError', 'AbortError'].includes(error?.name) || ['TimeoutError', 'AbortError'].includes(error?.cause?.name);
11
+
12
+ /**
13
+ * A GET against the SonarQube server a scan just reported to, authorised with the
14
+ * scanner's own token. Every request is bounded (R622): by `requestTimeoutMs`, by
15
+ * the asker's `deadline`, and by a per-call `timeoutMs`, whichever is smallest.
16
+ * A cut-off request throws an error with `code === 'SONAR_TIMEOUT'`, and
17
+ * `byDeadline` true when the deadline or the per-call limit set the limit.
18
+ */
19
+ export function sonarAsker(serverUrl, token, { requestTimeoutMs = SONAR_REQUEST_TIMEOUT_MS, deadline = Infinity } = {}) {
20
+ return async (path, { timeoutMs } = {}) => {
21
+ const pathOnly = path.split('?')[0];
22
+ const left = Math.min(deadline - Date.now(), timeoutMs ?? Infinity);
23
+ const byDeadline = left < requestTimeoutMs;
24
+ const limit = Math.max(1, Math.min(requestTimeoutMs, left));
25
+ try {
26
+ const response = await fetch(`${serverUrl}${path}`, {
27
+ headers: { authorization: `Bearer ${token}` },
28
+ signal: AbortSignal.timeout(limit),
29
+ });
30
+ if (!response.ok) {
31
+ await response.body?.cancel().catch(() => {});
32
+ throw new Error(`SonarQube answered ${response.status} to ${pathOnly}`);
33
+ }
34
+ return await response.json();
35
+ } catch (error) {
36
+ if (!isAbort(error)) {
37
+ throw error;
38
+ }
39
+ const timeout = new Error(
40
+ byDeadline
41
+ ? `SonarQube did not answer ${pathOnly} before the reading's time ran out`
42
+ : `SonarQube did not answer ${pathOnly} within ${Math.ceil(limit / 1000)}s`,
43
+ );
44
+ timeout.code = 'SONAR_TIMEOUT';
45
+ timeout.limitMs = limit;
46
+ timeout.byDeadline = byDeadline;
47
+ throw timeout;
13
48
  }
14
- return response.json();
15
49
  };
16
50
  }
17
51
 
@@ -19,16 +53,28 @@ export function sonarAsker(serverUrl, token) {
19
53
  * Waits for a queued analysis to settle — shared by every scan path, BRANCH,
20
54
  * MAIN and PROJECT alike. Throws rather than reports: every caller runs
21
55
  * inside `scanQuality`'s own try/catch, which turns it into an error reading.
56
+ * Each poll is limited to the time left (R622), so one unanswered request
57
+ * cannot outlive the deadline.
22
58
  */
23
59
  export async function waitForAnalysis(ask, ceTaskId, deadline, timeoutSeconds) {
24
60
  let settled = null;
25
61
  while (Date.now() < deadline) {
26
- const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`);
62
+ let current;
63
+ try {
64
+ ({ task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(ceTaskId)}`, {
65
+ timeoutMs: deadline - Date.now(),
66
+ }));
67
+ } catch (error) {
68
+ if (error?.code === 'SONAR_TIMEOUT' && error.byDeadline) {
69
+ break;
70
+ }
71
+ throw error;
72
+ }
27
73
  if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
28
74
  settled = current;
29
75
  break;
30
76
  }
31
- await new Promise((wake) => setTimeout(wake, 3000));
77
+ await new Promise((wake) => setTimeout(wake, Math.min(3000, Math.max(0, deadline - Date.now()))));
32
78
  }
33
79
  if (!settled) {
34
80
  throw new Error(`SonarQube had not finished analysing after ${timeoutSeconds}s.`);
package/mcp/server.mjs CHANGED
@@ -301,10 +301,14 @@ function renderRounds(where) {
301
301
  * by counting is an agent one skipped question away from acting on the wrong
302
302
  * one.
303
303
  */
304
+ /** R612: the platform closes a question with no person behind it; it reaches us as this. */
305
+ const THE_PLATFORM = '(the platform)';
306
+ const bySomeone = (email) => Boolean(email) && email !== THE_PLATFORM;
307
+
304
308
  function renderRound(asked, answered) {
305
309
  const title = asked?.title ?? 'The round';
306
310
  const lines = answered.map((question, at) => {
307
- const said = question.answeredByEmail
311
+ const said = bySomeone(question.answeredByEmail)
308
312
  ? `${question.answeredByEmail}: ${question.answer}`
309
313
  : question.answer;
310
314
  const opinions = (question.opinions ?? [])
@@ -329,7 +333,10 @@ function renderAnswer(answered) {
329
333
  .map((opinion) => ` ${opinion.authorEmail}: ${opinion.body}`)
330
334
  .join('\n');
331
335
 
332
- const answer = `${answered.answeredByEmail} answered:\n\n${answered.answer}`;
336
+ // R612: no answeredByEmail means the platform closed it, not a person.
337
+ const answer = bySomeone(answered.answeredByEmail)
338
+ ? `${answered.answeredByEmail} answered:\n\n${answered.answer}`
339
+ : `Nobody answered — the question was closed:\n\n${answered.answer}`;
333
340
  return said ? `${answer}\n\nWhat people said before deciding:\n${said}` : answer;
334
341
  }
335
342
 
@@ -1693,7 +1700,7 @@ const TOOLS = [
1693
1700
  if (answered) {
1694
1701
  return renderRound(null, answered);
1695
1702
  }
1696
- return `Still unfinished. Call await_group again with group_id ${args.group_id}.`;
1703
+ return `Still unfinished. Call await_group again with group_id ${args.group_id} to keep waiting. If you carry on instead, the round stays in their inbox — and the run reads as waiting on them — until this stage ends; then it is closed, and an answer given after that is refused.`;
1697
1704
  },
1698
1705
  },
1699
1706
 
@@ -1774,7 +1781,7 @@ const TOOLS = [
1774
1781
  if (answered) {
1775
1782
  return renderAnswer(answered);
1776
1783
  }
1777
- return `Still nothing. Call await_answer again with question_id ${args.question_id}.`;
1784
+ return `Still nothing. Call await_answer again with question_id ${args.question_id} to keep waiting. If you carry on instead, the question stays in their inbox — and the run reads as waiting on them — until this stage ends; then it is closed, and an answer given after that is refused.`;
1778
1785
  },
1779
1786
  },
1780
1787
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
package/runner/README.md CHANGED
@@ -358,6 +358,33 @@ having none.
358
358
 
359
359
  ### Advanced mode: one card per session
360
360
 
361
+ **Advanced mode is the default terminal (R624).** `cawdev` opens in it wherever
362
+ the terminal can draw it — a TTY, not `TERM=dumb`, at least 80×24. Below that,
363
+ or through a pipe (`--watch-only | …`), it is the scrollback, with one line
364
+ saying why. `cawdev --simple` and `/simple` are the scrollback terminal;
365
+ `/advanced-mode` is the way back; the choice is remembered per platform in
366
+ `prefs.mjs` (`mode: simple|advanced`) beside `/agent` and `/matrix`. This does
367
+ not reopen R81 — it weighs it again: with several sessions and a roadmap on one
368
+ machine, R81's reason (a full-screen repaint eats the scrollback) no longer
369
+ wins, and the scrollback is one key away. `/export` (or `/export 3`,
370
+ `/export R29`) lays a session's log into the normal screen so the terminal's own
371
+ search and copy reach it; quitting and `/simple` lay in what the focused
372
+ session said.
373
+
374
+ **The motion is a spinner and a robot, not rain.** The rain is **off by
375
+ default** (`/matrix on` brings it back exactly as it was, remembered). A
376
+ running card's status mark, and the mark on the current stage of its walk,
377
+ turn: `◴ ◵ ◶ ◷` for a stage that writes (plan, code, merge, release, roadmap,
378
+ interview), `◜ ◝ ◞ ◟` for one that only reads (test, ask, audit, scope), a
379
+ frame every two ticks of the 120 ms clock. A waiting card holds `◆`, a queued
380
+ one rests on the spinner's first frame. A status that changes is drawn
381
+ reversed for four ticks. The robots (a finished plan, a finished development
382
+ docked at `▶ review`, nothing built, a landing at `▶ main`, a merge in flight)
383
+ move whether the rain is on or off. `n`/`N` move to the next/previous card
384
+ that needs you (`n` still refuses when the focused card holds a permission
385
+ request); `/close 3`, `/close all` and `w` close ended cards. A running card is
386
+ refused with `that session is still running · x x cancels it`.
387
+
361
388
  `/advanced-mode` gives **one card per session** this machine is running, or
362
389
  has run since you attached. The cards sit above **the prompt, which stays at
363
390
  the bottom** the way a shell's does. A session's log scrolling past in a corner was neither readable
@@ -1116,7 +1143,13 @@ files at all. R47 removes the need for this by giving each run a workspace.
1116
1143
  and leaves the run exactly as the platform has it; if it is QUEUED, the next
1117
1144
  poll offers it again and it is claimed properly. Only 400, 404, 409 and 410
1118
1145
  count as a refusal; any other status (401, 403, 408, 429), a network error or
1119
- a 5xx still fails the run.
1146
+ a 5xx still fails the run. For a MERGE run (R621), the merge the daemon
1147
+ prepared is undone before it lets go, on a refusal and on a failure alike
1148
+ (`git merge --abort`, or a `reset --keep` past its own merge commit), so the
1149
+ checkout is offered again as it was; the FAILED summary says so. A checkout
1150
+ found mid-merge that the daemon did not start is refused with a sentence
1151
+ naming `git commit` and `git merge --abort`, and is not touched; a MERGE run
1152
+ carrying on its own branch is the exception.
1120
1153
  3. **Spawns the agent** in that directory, in its own process group, with the
1121
1154
  run token in its environment and an `.mcp.json` pointing at cawdev's MCP
1122
1155
  server. **Your own token never reaches the child.**