summrise-agent 1.2.462 → 1.2.464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/summrise.js CHANGED
@@ -70,10 +70,12 @@ exports.sha256File = sha256File;
70
70
  exports.resolveComponent = resolveComponent;
71
71
  exports.ensureElectron = ensureElectron;
72
72
  exports.statusReport = statusReport;
73
+ exports.updateWouldNotMove = updateWouldNotMove;
73
74
  exports.isBehind = isBehind;
74
75
  exports.behindBy = behindBy;
75
76
  exports.updateReceiptPs = updateReceiptPs;
76
77
  exports.updateBusyPath = updateBusyPath;
78
+ exports.busyMarkerPs = busyMarkerPs;
77
79
  exports.awaitReleaseMarker = awaitReleaseMarker;
78
80
  exports.releaseMarkerVerdict = releaseMarkerVerdict;
79
81
  exports.boxedVersions = boxedVersions;
@@ -353,8 +355,8 @@ function desktopStartPs(installQ) {
353
355
  "if (Get-Process electron -ErrorAction SilentlyContinue) { Write-Output 'already-running'; exit 0 }",
354
356
  "# DO NOT RE-REGISTER THE TASK TO START IT. Register-ScheduledTask needs the interactive",
355
357
  "# user's principal as DOMAIN\\user, and a shell running as a service account has no such",
356
- "# mapping -- the device answered \"No mapping between account names and security IDs was",
357
- "# done ... UserId\" from a PTY running as systemprofile, while the task itself was Ready.",
358
+ '# mapping -- the device answered "No mapping between account names and security IDs was',
359
+ '# done ... UserId" from a PTY running as systemprofile, while the task itself was Ready.',
358
360
  "# The task already carries the right principal -- `summrise setup` creates it -- so starting",
359
361
  "# it is all that is needed.",
360
362
  "$t = Get-ScheduledTask -TaskName SummriseDesktop -ErrorAction SilentlyContinue",
@@ -421,7 +423,12 @@ function parseTargetArg(arg) {
421
423
  const port = Number(m[2]);
422
424
  if (!Number.isInteger(port) || port < 1 || port > 65535)
423
425
  return null;
424
- return { host: m[1], port, path: m[3] || "", id: `${m[1]}:${port}${m[3] || ""}` };
426
+ return {
427
+ host: m[1],
428
+ port,
429
+ path: m[3] || "",
430
+ id: `${m[1]}:${port}${m[3] || ""}`,
431
+ };
425
432
  }
426
433
  /**
427
434
  * JSON that survives the command line.
@@ -443,16 +450,31 @@ function deviceApi(method, pathname, body) {
443
450
  const dir = ETC_DIR;
444
451
  const token = deviceToken(dir);
445
452
  if (!token)
446
- return { ok: false, error: "no device token in " + path.join(dir, "config.yaml") };
453
+ return {
454
+ ok: false,
455
+ error: "no device token in " + path.join(dir, "config.yaml"),
456
+ };
447
457
  const port = agentPort(dir);
448
- const args = ["-sS", "-m", "15", "-X", method, "-H", "Authorization: Bearer " + token];
458
+ const args = [
459
+ "-sS",
460
+ "-m",
461
+ "15",
462
+ "-X",
463
+ method,
464
+ "-H",
465
+ "Authorization: Bearer " + token,
466
+ ];
449
467
  if (body !== undefined) {
450
468
  args.push("-H", "content-type: application/json", "-d", asciiJson(body));
451
469
  }
452
470
  args.push(`http://127.0.0.1:${port}${pathname}`);
453
471
  const r = (0, child_process_1.spawnSync)("curl", args, { encoding: "utf8", timeout: 20000 });
454
472
  if (r.error || r.status !== 0) {
455
- return { ok: false, error: `device unreachable on 127.0.0.1:${port}` + (r.error ? ` (${r.error.message})` : "") };
473
+ return {
474
+ ok: false,
475
+ error: `device unreachable on 127.0.0.1:${port}` +
476
+ (r.error ? ` (${r.error.message})` : ""),
477
+ };
456
478
  }
457
479
  try {
458
480
  return { ok: true, body: JSON.parse(String(r.stdout || "").trim()) };
@@ -461,21 +483,15 @@ function deviceApi(method, pathname, body) {
461
483
  return { ok: false, error: "device sent something that is not JSON" };
462
484
  }
463
485
  }
464
- // `4m`, `1h 04m`, `2d 4h` — the shapes the rest of the panel uses.
465
- // `summrise report` — ONE block an operator can paste into a ticket, a chat or a
466
- // handover note, assembled from data the device already serves.
467
- //
468
- // WHY IT IS A COMMAND AND NOT A SENTENCE IN THE DOCS. Everything here exists on the
469
- // device: the release it runs, how long the agent has been up, what the previous boot
470
- // looked like, CPU/memory, the sessions open, and every watched target with its
471
- // outages. What was missing is a shape a person can hand to somebody else — the
472
- // numbers are the same, the ASSEMBLY is the feature.
473
- //
474
- // THE RENDER IS PURE (`reportText`) and the fetching is three lines of `deviceApi`, so
475
- // the format is testable without a device. Two rules it keeps: a server that did not
476
- // answer is printed as NOT READ, never as an invented value; and a target that is down
477
- // brings its outage log with it, because "it is down" without "since when, and how
478
- // often" is the half of the answer that starts an argument.
486
+ /**
487
+ * A duration in the shapes the rest of the panel uses: `45s`, `4m`, `1h 04m`, `2d 4h`.
488
+ *
489
+ * IT USED TO CARRY FOURTEEN LINES ABOUT `summrise report`, a verb pruned in round 25 — including "THE
490
+ * RENDER IS PURE (`reportText`)", naming a function that occurs nowhere else in this file. The doc
491
+ * described a command an operator cannot run, on a function whose actual job went unsaid. Two rules from
492
+ * that block are worth keeping, and this is not the place they belong: a server that did not answer is
493
+ * printed as NOT READ, never as an invented value, and a target that is down brings its outage log.
494
+ */
479
495
  function fmtDuration(ms) {
480
496
  const s = Math.max(0, Math.floor(Number(ms || 0) / 1000));
481
497
  if (s < 60)
@@ -483,7 +499,9 @@ function fmtDuration(ms) {
483
499
  if (s < 3600)
484
500
  return `${Math.floor(s / 60)}m`;
485
501
  const h = Math.floor(s / 3600);
486
- return h >= 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
502
+ return h >= 24
503
+ ? `${Math.floor(h / 24)}d ${h % 24}h`
504
+ : `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
487
505
  }
488
506
  function targetLine(t, nowMs, width = 30) {
489
507
  const s = t.summary || {};
@@ -491,19 +509,27 @@ function targetLine(t, nowMs, width = 30) {
491
509
  const state = up ? "up" : s.up_now === false ? "down" : "no readings";
492
510
  const id = String(t.id || "");
493
511
  const since = s.since_ms ? ` ${fmtDuration(nowMs - s.since_ms)}` : "";
494
- const status = s.last_status === null || s.last_status === undefined ? "" : ` HTTP ${s.last_status}`;
512
+ const status = s.last_status === null || s.last_status === undefined
513
+ ? ""
514
+ : ` HTTP ${s.last_status}`;
495
515
  // A content check that did not find its text is the case a status code cannot express, so it
496
516
  // is printed as its own word next to the code.
497
- const match = s.last_expect_ok === false ? " no match" : s.last_expect_ok === true ? " matches" : "";
517
+ const match = s.last_expect_ok === false
518
+ ? " no match"
519
+ : s.last_expect_ok === true
520
+ ? " matches"
521
+ : "";
498
522
  const lat = s.latency ? ` ${s.latency.avg}ms avg` : "";
499
523
  const pct = s.up_pct === null || s.up_pct === undefined ? "" : ` ${s.up_pct}% up`;
500
- const drops = s.drops ? ` ${s.drops} ${s.drops === 1 ? "drop" : "drops"}` : "";
524
+ const drops = s.drops
525
+ ? ` ${s.drops} ${s.drops === 1 ? "drop" : "drops"}`
526
+ : "";
501
527
  // The operator's own words belong on the line they explain, not in a separate view.
502
528
  const note = t.note && t.note.text ? ` — ${t.note.text}` : "";
503
529
  return `${up ? "UP " : s.up_now === false ? "DOWN" : "? "} ${id.padEnd(width)} ${state}${since}${status}${match}${lat}${pct}${drops}${note}`;
504
530
  }
505
531
  // ── MACHINE-READABLE OUTPUT ────────────────────────────────────────────────
506
- // `summrise monitor list --json` and `summrise watch --once --json` print the DEVICE'S OWN ANSWER,
532
+ // `summrise monitor list --json` prints the DEVICE'S OWN ANSWER,
507
533
  // projected rather than re-derived: the summaries, the transitions and the samples are the ones
508
534
  // `/api/monitors` returns, so a script and the panel (and the AI) can never disagree about whether
509
535
  // a target is up. The CLI adds only what the device cannot know — which device answered, and when
@@ -528,13 +554,23 @@ function monitorsJson({ device, askedAtMs, payload, only }) {
528
554
  // The device's numbers, verbatim. `up: null` means "not read yet" and is NOT false.
529
555
  up: t.summary && t.summary.up_now !== undefined ? t.summary.up_now : null,
530
556
  up_pct: t.summary && t.summary.up_pct !== undefined ? t.summary.up_pct : null,
531
- since_ms: t.summary && t.summary.since_ms !== undefined ? t.summary.since_ms : null,
557
+ since_ms: t.summary && t.summary.since_ms !== undefined
558
+ ? t.summary.since_ms
559
+ : null,
532
560
  drops: t.summary && t.summary.drops !== undefined ? t.summary.drops : null,
533
561
  latency_ms: t.summary && t.summary.latency ? t.summary.latency.avg : null,
534
- last_status: t.summary && t.summary.last_status !== undefined ? t.summary.last_status : null,
535
- last_expect_ok: t.summary && t.summary.last_expect_ok !== undefined ? t.summary.last_expect_ok : null,
562
+ last_status: t.summary && t.summary.last_status !== undefined
563
+ ? t.summary.last_status
564
+ : null,
565
+ last_expect_ok: t.summary && t.summary.last_expect_ok !== undefined
566
+ ? t.summary.last_expect_ok
567
+ : null,
536
568
  probes: t.summary && t.summary.probes !== undefined ? t.summary.probes : null,
537
- transitions: (t.transitions || []).map((x) => ({ at_ms: x.at_ms, up: x.up, lasted_ms: x.lasted_ms })),
569
+ transitions: (t.transitions || []).map((x) => ({
570
+ at_ms: x.at_ms,
571
+ up: x.up,
572
+ lasted_ms: x.lasted_ms,
573
+ })),
538
574
  }));
539
575
  return {
540
576
  device: device || "",
@@ -919,6 +955,13 @@ function sha256File(p) {
919
955
  *
920
956
  * `null` when neither source works: every component here is optional to the
921
957
  * AGENT, and a failed fetch must not fail the install.
958
+ *
959
+ * THE FIRST ARM IS A SEAM, NOT A LIVE PATH — measured, because a reader deserves to know which: no
960
+ * component is in `package.json`'s `files[]` or in `required-in-tgz.txt`, and `tar tzf` on the published
961
+ * tarball confirms none of the three is there. So today the release host is the ONLY source, and the
962
+ * `existsSync` above exists so that a future release which DOES box one gets it from the package without
963
+ * touching this function. The test beside it pins the fact, so boxing one is a deliberate act that also
964
+ * updates this sentence.
922
965
  */
923
966
  function resolveComponent(name, pkgPath) {
924
967
  if (fs.existsSync(pkgPath))
@@ -1069,9 +1112,22 @@ function statusReport(f) {
1069
1112
  * sentence's truthiness — where "0 releases" and "-1 releases" are both TRUTHY, so it would have refused every update,
1070
1113
  * including the correct one. Caught by asking the artefact before shipping it. */
1071
1114
  function versionTriple(v) {
1072
- const t = String(v || "").split(".").map(Number);
1115
+ const t = String(v || "")
1116
+ .split(".")
1117
+ .map(Number);
1073
1118
  return t.length === 3 && t.every((n) => Number.isFinite(n)) ? t : null;
1074
1119
  }
1120
+ /** WOULD THIS UPDATE MOVE THE DEVICE AT ALL? The parity fact, and it needs NO NETWORK: the CLI stamps
1121
+ * `<install>/.summrise-release` with ITS OWN version, and the device's `agent_update` reads that file as
1122
+ * the local version — so when the device is already on the version this CLI carries, the stamp equals
1123
+ * what is there, the swap installs the same build, and nothing moves. That is round 201's measured
1124
+ * defect ("update requested 1.2.438 -> 1.2.438", "copy ok=True", release unchanged), which is why the
1125
+ * guard below exists at all. THE GUARD THAT EXISTS CANNOT SEE IT: it compares the CLI against the
1126
+ * RELEASE CHANNEL, and when the CDN is unreadable `latest` is empty and the whole check is skipped, so a
1127
+ * network blip re-opens the defect. This one compares two facts already on the machine. */
1128
+ function updateWouldNotMove(fromVersion, selfVersion) {
1129
+ return Boolean(fromVersion) && fromVersion === selfVersion;
1130
+ }
1075
1131
  /** Is `latest` ahead of `device` on the SAME release line? */
1076
1132
  function isBehind(device, latest) {
1077
1133
  const a = versionTriple(device);
@@ -1131,6 +1187,21 @@ function updateReceiptPs(dataDirQ, fromVersion, toVersion) {
1131
1187
  function updateBusyPath() {
1132
1188
  return path.join(process.env.ProgramData || "C:\\ProgramData", "SummriseAgent", "update-busy");
1133
1189
  }
1190
+ /**
1191
+ * The SAME marker as the PowerShell text the swap script embeds, DERIVED from `updateBusyPath()` rather
1192
+ * than written out by hand — which is what this file did, three times, inside the generated script. The
1193
+ * agent fixed exactly this on its own side and says why (tools.rs, above BUSY_MARKER_REL): "It used to be
1194
+ * spelled out TWICE: the Rust acquirer built <ProgramData>\SummriseAgent\update-busy from PathBuf joins
1195
+ * while the generated PowerShell swap script carried the same location as two hand-written string
1196
+ * literals. A drift between the two is invisible until an update actually runs — and then the swap
1197
+ * releases a file the agent never created, the marker survives, and every later update is refused for up
1198
+ * to an hour." The CLI's own doc above claims ONE owner for this path; this is what makes that true.
1199
+ */
1200
+ function busyMarkerPs() {
1201
+ const root = process.env.ProgramData || "C:\\ProgramData";
1202
+ const rel = path.relative(root, updateBusyPath()).split(path.sep).join("\\");
1203
+ return `(Join-Path $env:ProgramData '${rel}')`;
1204
+ }
1134
1205
  /**
1135
1206
  * Poll `etc\.summrise-release` until it shows `want`, or the budget expires.
1136
1207
  *
@@ -1167,11 +1238,17 @@ async function awaitReleaseMarker(o) {
1167
1238
  * misreporting its own version and refusing the update that would fix it.
1168
1239
  */
1169
1240
  function releaseMarkerVerdict(c) {
1241
+ // WHOSE FAILURE IS THIS? The message is printed by TWO callers — `rollback`, which staged a release in
1242
+ // order to PIN it, and `update`, which staged one to INSTALL it — and it said "rollback:" with
1243
+ // "re-run `summrise rollback <want>`" for both. On the update path that advice is worse than useless:
1244
+ // it names the version that just failed to install, so following it asks the device to pin a release
1245
+ // it is not running. The verb decides the sentence now.
1246
+ const verb = c.verb ?? "rollback";
1170
1247
  if (c.ok) {
1171
1248
  return {
1172
1249
  writePin: true,
1173
1250
  exitCode: 0,
1174
- message: `rollback: pinned to ${c.want} -- auto-upgrade refused until 'summrise rollback --clear' or a forced agent_update`,
1251
+ message: `${verb}: pinned to ${c.want} -- auto-upgrade refused until 'summrise rollback --clear' or a forced agent_update`,
1175
1252
  };
1176
1253
  }
1177
1254
  return {
@@ -1181,12 +1258,17 @@ function releaseMarkerVerdict(c) {
1181
1258
  // failed, or it is still running (it kills and restarts the agent), or the marker
1182
1259
  // could not be read at all — and `saw === null` is the weakest of the three. The old
1183
1260
  // wording asserted "the swap did NOT take" for all of them.
1184
- message: `rollback: no release marker showing ${c.want} within ${Number.isFinite(c.waitedMs)
1261
+ message: `${verb}: no release marker showing ${c.want} within ${Number.isFinite(c.waitedMs)
1185
1262
  ? Math.round(c.waitedMs / 1000) + "s"
1186
1263
  : "the read-back window"} ` +
1187
1264
  `(last read: ${c.saw ?? "empty or unreadable"}). ` +
1188
1265
  `NOT pinned (a pin would claim a version this device may not be running) and no release ` +
1189
- `marker written. Check the update log and \`summrise status\`, then re-run 'summrise rollback ${c.want}'.`,
1266
+ `marker written. ` +
1267
+ (verb === "update"
1268
+ ? `Check the update log and \`summrise status\`, then re-run \`summrise update\`. If it fails ` +
1269
+ `the same way, \`summrise rollback ${c.from ?? "<the version it was on>"}\` pins the release ` +
1270
+ `this device is ACTUALLY running — which is not the one that failed to install.`
1271
+ : `Check the update log and \`summrise status\`, then re-run 'summrise rollback ${c.want}'.`),
1190
1272
  };
1191
1273
  }
1192
1274
  function boxedVersions(installDir, pkgDir) {
@@ -1367,6 +1449,28 @@ function svc(action) {
1367
1449
  // silent either way. `autostart` already checks; this is the same pattern.
1368
1450
  return sh(`schtasks /${action} /TN ${TASK}`, { stdio: "inherit" });
1369
1451
  }
1452
+ /**
1453
+ * The scheduled task's State (`Running`, `Ready`, `Disabled`, …) or **null when it could not be read**.
1454
+ *
1455
+ * THREE STATES, NOT ONE. `autostart` states the rule and names the shape — "a failed READ reported as
1456
+ * evidence of ABSENCE" — which it calls the THIRD instance, after `statusReport` and `rollback status`.
1457
+ * Here `null` means "I could not ask", and callers report that as itself instead of as a verdict.
1458
+ *
1459
+ * `LOCALE` is why this asks `Get-ScheduledTask` rather than parsing `schtasks /Query`: the latter's headers
1460
+ * are localized, which is the reason `autostart` uses this call too.
1461
+ */
1462
+ function taskState(name) {
1463
+ const r = (0, child_process_1.spawnSync)("powershell", [
1464
+ "-NoProfile",
1465
+ "-Command",
1466
+ `(Get-ScheduledTask -TaskName '${name}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty State)`,
1467
+ ], { encoding: "utf8" });
1468
+ const err = r && r.error;
1469
+ if (!r || err || r.status !== 0)
1470
+ return null; // spawn failed, or the read itself failed
1471
+ const s = String(r.stdout || "").trim();
1472
+ return s === "" ? null : s; // empty = the task is not there, which is also "no answer to Running?"
1473
+ }
1370
1474
  // Shared tunnel bootstrap: login (token or interactive) → create tunnel →
1371
1475
  // DNS route → write tunnel.yml. Used by `summrise setup --tunnel` and
1372
1476
  // `summrise tunnel install`.
@@ -1375,7 +1479,12 @@ function initTunnel(hostname, regKey) {
1375
1479
  const cfg = path.join(ETC_DIR, "tunnel.yml");
1376
1480
  if (!fs.existsSync(cf)) {
1377
1481
  console.error("tunnel: cloudflared.exe not staged at", cf);
1378
- console.error(" reinstall the package (npm i -g summrise-agent) to stage it.");
1482
+ // NOT "REINSTALL THE PACKAGE". The npm package carries NO boxed components BY DESIGN (that is what
1483
+ // keeps it ~6.7 MB), so a reinstall cannot stage this and the advice sent the operator in a circle.
1484
+ // `setup` is what stages it: resolveComponent fetches it from the RELEASE HOST and the agent's own
1485
+ // sha256 pin checks it again on the path it uses. Its doc records what this costs when nobody does —
1486
+ // "a device with no tunnel is INVISIBLE TO THE CONSOLE while looking perfectly healthy from inside."
1487
+ console.error(" run 'summrise setup' to stage it -- the component comes from the release host, not from the npm package.");
1379
1488
  process.exit(1);
1380
1489
  }
1381
1490
  const host = hostname || "d1.agent.saisi.online";
@@ -1613,7 +1722,13 @@ const commands = {
1613
1722
  // `regOk` is collected here and summarised at the end of setup: best-effort, but the
1614
1723
  // operator should be told once, with the consequence, rather than not at all.
1615
1724
  regOk.push(regWrite("InstallDir", DIR));
1616
- regOk.push(regWrite("DataDir", path.join(process.env.ProgramData || "C:\\ProgramData", "Summrise")));
1725
+ // ...AND ECHO THE RESOLVED ONE, NOT THE DEFAULT. `resolveDataDir()` is registry-first and
1726
+ // `DATA_DIR` is its answer, so a device whose data dir was remapped has that path here. This used to
1727
+ // WRITE THE LITERAL DEFAULT back to the registry — while the tree below was created at `DATA_DIR` —
1728
+ // so the new tree landed at the remapped path and the registry then named the default, which is the
1729
+ // path the AGENT reads. The comment two lines up says "DataDir defaults to %ProgramData%\Summrise",
1730
+ // i.e. exactly what the code did not do: a default applies when nothing is set, not over a remap.
1731
+ regOk.push(regWrite("DataDir", DATA_DIR));
1617
1732
  // Pre-create the data dir tree (sessions/memory/logs — C1 separation).
1618
1733
  const DATA = DATA_DIR;
1619
1734
  for (const sub of ["sessions", "memory", "logs"]) {
@@ -1760,7 +1875,10 @@ const commands = {
1760
1875
  // shims rather than hand-written ones; when npm cannot, say the command.
1761
1876
  {
1762
1877
  const selfVer = String(require("../package.json").version || "");
1763
- const pfx = (0, child_process_1.spawnSync)("npm", ["prefix", "-g"], { encoding: "utf8", shell: true });
1878
+ const pfx = (0, child_process_1.spawnSync)("npm", ["prefix", "-g"], {
1879
+ encoding: "utf8",
1880
+ shell: true,
1881
+ });
1764
1882
  const pre = pfx.status === 0 ? String(pfx.stdout || "").trim() : "";
1765
1883
  if (selfVer && pre) {
1766
1884
  const inst = (0, child_process_1.spawnSync)("npm", [
@@ -1793,12 +1911,25 @@ const commands = {
1793
1911
  try {
1794
1912
  const reg = path.join(SCRIPTS_DIR, "register-desktop-task.ps1");
1795
1913
  fs.writeFileSync(reg, desktopTaskPs(DIR).join("\r\n") + "\r\n");
1796
- sh(
1797
- // DOUBLE quotes — a cmd-layer argument. The single-quoted version this shipped with
1798
- // reached PowerShell with the quotes included and died on the device with "unsupported
1799
- // path format" for a path that was perfectly valid.
1800
- `powershell -NoProfile -ExecutionPolicy Bypass -File "${reg}"`);
1801
- console.log("setup: SummriseDesktop registered (logon + a 5-minute watchdog) and started");
1914
+ // THE STATUS WAS DISCARDED AND THE SUCCESS LINE WAS UNCONDITIONAL, which is the defect the AGENT
1915
+ // task's check ten lines below names in its own comment: "audit #7: used to claim success
1916
+ // regardless". Worse, the `catch` below could not fire for this — `sh()` RETURNS, it does not
1917
+ // throw — so the only thing it ever caught was `writeFileSync`.
1918
+ //
1919
+ // ONE CALL, and its status is READ. DOUBLE quotes in the command: a cmd-layer argument, because the
1920
+ // single-quoted version this shipped with reached PowerShell with the quotes included and died on
1921
+ // the device with "unsupported path format" for a path that was perfectly valid.
1922
+ //
1923
+ // A WARNING, NOT FATAL, unlike the agent task: headless installs have no SummriseDesktop, and the
1924
+ // registration script itself exits quietly when electron is already alive.
1925
+ const reg302 = sh(`powershell -NoProfile -ExecutionPolicy Bypass -File "${reg}"`, { stdio: "pipe" });
1926
+ if (reg302 && reg302.status === 0) {
1927
+ console.log("setup: SummriseDesktop registered (logon + a 5-minute watchdog) and started");
1928
+ }
1929
+ else {
1930
+ console.log(`setup: WARNING -- registering SummriseDesktop failed (status ${reg302 ? reg302.status : "spawn error"}); ` +
1931
+ `run: powershell -File "${SCRIPTS_DIR}\\register-desktop-task.ps1"`);
1932
+ }
1802
1933
  }
1803
1934
  catch {
1804
1935
  console.log(`setup: WARNING -- could not register SummriseDesktop; run: powershell -File "${SCRIPTS_DIR}\\register-desktop-task.ps1"`);
@@ -1890,10 +2021,10 @@ const commands = {
1890
2021
  console.log("setup: no tunnel configured (local mode). Enable later with `summrise tunnel install <hostname>`.");
1891
2022
  }
1892
2023
  },
1893
- // ── monitor / watch ─────────────────────────────────────────────────────
2024
+ // ── monitor ────────────────────────────────────────────────────────────
1894
2025
  // The device's reachability instrument, in the terminal the operator already
1895
2026
  // works in. The panel draws the same numbers; this runs where the ssh session
1896
- // is, needs no browser, and `summrise watch` keeps it live on screen.
2027
+ // is, needs no browser, and runs its live view until Ctrl+C.
1897
2028
  // async: `wait` blocks on probes (the dispatcher already awaits every command).
1898
2029
  async monitor(args) {
1899
2030
  const sub = String(args[0] || "list").toLowerCase();
@@ -1903,7 +2034,7 @@ const commands = {
1903
2034
  const expect = expectAt >= 0 ? String(args[expectAt + 1] || "") : "";
1904
2035
  const positional = args.filter((a, i) => i > 0 && a !== "--expect" && i !== expectAt + 1);
1905
2036
  if (expectAt >= 0 && !expect) {
1906
- console.error("usage: summrise monitor add <host:port[/path]> --expect \"<text>\"");
2037
+ console.error('usage: summrise monitor add <host:port[/path]> --expect "<text>"');
1907
2038
  process.exit(1);
1908
2039
  }
1909
2040
  if (sub === "add" || sub === "rm" || sub === "remove") {
@@ -1913,7 +2044,12 @@ const commands = {
1913
2044
  process.exit(1);
1914
2045
  }
1915
2046
  if (sub === "add") {
1916
- const r = deviceApi("POST", "/api/monitors/add", { host: t.host, port: t.port, path: t.path, expect });
2047
+ const r = deviceApi("POST", "/api/monitors/add", {
2048
+ host: t.host,
2049
+ port: t.port,
2050
+ path: t.path,
2051
+ expect,
2052
+ });
1917
2053
  if (!r.ok) {
1918
2054
  console.error(`monitor add: ${r.error}`);
1919
2055
  process.exit(1);
@@ -1934,7 +2070,9 @@ const commands = {
1934
2070
  console.error(`monitor rm: ${r.error}`);
1935
2071
  process.exit(1);
1936
2072
  }
1937
- console.log(r.body && r.body.removed ? `stopped watching ${t.id}` : `monitor rm: ${t.id} was not being watched`);
2073
+ console.log(r.body && r.body.removed
2074
+ ? `stopped watching ${t.id}`
2075
+ : `monitor rm: ${t.id} was not being watched`);
1938
2076
  return;
1939
2077
  }
1940
2078
  if (sub === "probe") {
@@ -1964,7 +2102,10 @@ const commands = {
1964
2102
  const probe = payload.result && payload.result.probe;
1965
2103
  // The device sends the CRITERION with the answer (`expect`), so "no match" can name the
1966
2104
  // text it looked for — an unreadable verdict is a verdict nobody can act on.
1967
- console.log(probeLine({ id: t.id, expect: (payload.result && payload.result.expect) || null }, probe, Date.now()));
2105
+ console.log(probeLine({
2106
+ id: t.id,
2107
+ expect: (payload.result && payload.result.expect) || null,
2108
+ }, probe, Date.now()));
1968
2109
  return;
1969
2110
  }
1970
2111
  if (sub !== "list") {
@@ -1985,7 +2126,12 @@ const commands = {
1985
2126
  // encoding boundary too. PowerShell decodes a child's output with the console code page
1986
2127
  // (CP936 on d1), so raw UTF-8 through a pipe came back as mojibake while the same text
1987
2128
  // printed directly read fine — the second face of this bug.
1988
- console.log(asciiJson(monitorsJson({ device: os.hostname(), askedAtMs: Date.now(), payload: r.body, only: null }), 2));
2129
+ console.log(asciiJson(monitorsJson({
2130
+ device: os.hostname(),
2131
+ askedAtMs: Date.now(),
2132
+ payload: r.body,
2133
+ only: null,
2134
+ }), 2));
1989
2135
  return;
1990
2136
  }
1991
2137
  const targets = (r.body && r.body.targets) || [];
@@ -1999,7 +2145,7 @@ const commands = {
1999
2145
  for (const t of targets)
2000
2146
  console.log(targetLine(t, now));
2001
2147
  },
2002
- // Live view: redraw in place every few seconds until Ctrl+C. `summrise watch
2148
+ // Live view: redraw in place every few seconds until Ctrl+C. `summrise monitor
2003
2149
  // <host:port[/path]>` narrows it to one target and adds its outage log.
2004
2150
  // `summrise desktop` = put the window back, or say it is already there.
2005
2151
  //
@@ -2045,6 +2191,17 @@ const commands = {
2045
2191
  process.exit(1);
2046
2192
  },
2047
2193
  status() {
2194
+ // THE EXIT CODE IS 0 EVEN WHEN THE REPORT SAYS "UNKNOWN", AND THAT IS THE CONVENTION.
2195
+ //
2196
+ // `status` is asked for a REPORT; it produces one, and the verdict lives in the text. `tunnel`
2197
+ // exits 1 on the same unreadable process list (`:3655-3662`) because the thing IT was asked for —
2198
+ // a running tunnel — did not happen. The two are not inconsistent; they answer different
2199
+ // questions, and the difference is what the caller asked for rather than how bad the news is.
2200
+ //
2201
+ // A script that needs a VERDICT must not read this exit code. `monitor list --json` states its own
2202
+ // contract at `:2424-2426`: "A device that could not be reached is a NON-ZERO EXIT with the reason
2203
+ // on stderr, never a JSON body pretending everything is fine."
2204
+ //
2048
2205
  // NOT via shell: `shell: true` concatenates argv into one cmd.exe string,
2049
2206
  // so the unquoted filter "IMAGENAME eq …" was split at its spaces, tasklist
2050
2207
  // rejected it, and this ALWAYS printed STOPPED even with the agent running.
@@ -2109,23 +2266,59 @@ const commands = {
2109
2266
  }
2110
2267
  },
2111
2268
  start() {
2112
- svc("Run");
2269
+ // CHECK IT, LIKE `stop` DOES. The comment on `svc` above says the return value exists because
2270
+ // discarding it made `start`/`restart` "silent either way" — and two of the four call sites still
2271
+ // discarded it, including this one, which is step 2 of every documented journey. An operator whose
2272
+ // agent never came up got exit 0 and no sentence at all.
2273
+ const r = svc("Run");
2274
+ if (r && r.status !== 0) {
2275
+ console.error(`summrise start: schtasks /Run failed (status ${r.status}) -- the agent is NOT running`);
2276
+ process.exit(1);
2277
+ }
2113
2278
  },
2114
2279
  stop() {
2115
2280
  // Say WHY before the process goes: see markDeliberateStop.
2116
2281
  markDeliberateStop();
2117
2282
  const r = svc("End");
2118
2283
  if (r && r.status !== 0) {
2119
- console.error(`summrise stop: schtasks /End failed (status ${r.status}) -- the agent may still be running`);
2120
- process.exit(1);
2284
+ // A NON-ZERO `/End` IS NOT EVIDENCE THE AGENT IS STILL RUNNING, and the mirror of this mistake is
2285
+ // already documented twice in this file. `restart` says it about the identical condition: "a task
2286
+ // that was not running has nothing to end, so a non-zero /End is the ordinary case". `svc`'s own
2287
+ // comment records the ORIGINAL bug, which was the opposite — "`stop` printed 'stopped' and exited 0
2288
+ // for a missing task or an access-denied". So the exit code answers neither question.
2289
+ //
2290
+ // ASK INSTEAD. `autostart` reads `Get-ScheduledTask ... -ExpandProperty State` and its comment names
2291
+ // this exact shape — "a failed READ reported as evidence of ABSENCE" — which it calls the THIRD
2292
+ // instance. This is the fourth, from the other side: an ordinary read taken as evidence of PRESENCE,
2293
+ // with "the agent may still be running" printed for a task that had already stopped.
2294
+ const st = taskState(TASK);
2295
+ if (st === null) {
2296
+ // Could not read it — say that, rather than guessing either way (the three-state rule).
2297
+ console.error(`summrise stop: schtasks /End failed (status ${r.status}) and the task's state could not be read`);
2298
+ process.exit(1);
2299
+ }
2300
+ if (st === "Running") {
2301
+ console.error(`summrise stop: schtasks /End failed (status ${r.status}) and the task is still Running`);
2302
+ process.exit(1);
2303
+ }
2304
+ console.error(`summrise stop: schtasks /End returned ${r.status}, but the task is not Running -- nothing to stop`);
2121
2305
  }
2122
2306
  console.log("stopped -- revives via 'summrise start' or the 5-min watchdog ('summrise autostart off' opts out of autostart)");
2123
2307
  },
2124
2308
  restart() {
2125
2309
  markDeliberateStop();
2126
- svc("End");
2310
+ // THE STOP IS REPORTED BUT NOT FATAL: a task that was not running has nothing to end, so a non-zero
2311
+ // `/End` is the ordinary case for "restart a stopped agent", and the operator asked for the START.
2312
+ const end = svc("End");
2313
+ if (end && end.status !== 0) {
2314
+ console.error(`summrise restart: schtasks /End failed (status ${end.status}) -- the old process may still be running`);
2315
+ }
2127
2316
  sh("timeout /t 2 >nul");
2128
- svc("Run");
2317
+ const run = svc("Run");
2318
+ if (run && run.status !== 0) {
2319
+ console.error(`summrise restart: schtasks /Run failed (status ${run.status}) -- the agent is NOT running`);
2320
+ process.exit(1);
2321
+ }
2129
2322
  },
2130
2323
  // Boot switch: `summrise autostart on|off|status` (default status). Flips the
2131
2324
  // ENABLED flag on BOTH boot tasks (service + desktop shell) — this is the
@@ -2225,6 +2418,28 @@ const commands = {
2225
2418
  "\n summrise update");
2226
2419
  process.exit(1);
2227
2420
  }
2421
+ // AND THE SAME REFUSAL WITHOUT THE NETWORK. The guard above cannot fire when the CDN is
2422
+ // unreadable, and the defect it exists for is exactly what happens then: an update that stamps the
2423
+ // install with a version it already has, swaps in the same build, and reports success while
2424
+ // `status` keeps saying the device is behind.
2425
+ let deviceNow = "";
2426
+ try {
2427
+ deviceNow = fs
2428
+ .readFileSync(path.join(ETC_DIR, ".summrise-release"), "utf8")
2429
+ .trim();
2430
+ }
2431
+ catch {
2432
+ // No marker is not a no-op; the update below writes one.
2433
+ }
2434
+ if (updateWouldNotMove(deviceNow, selfVersion)) {
2435
+ console.error(`update: this CLI is ${selfVersion} and the device is ALREADY on ${deviceNow}.` +
2436
+ "\n Updating from here would stamp the device with the version it already has and change nothing," +
2437
+ "\n because the device reads <install>/.summrise-release (written by this package) as its version." +
2438
+ "\n If the device is meant to be newer, this CLI is too old to deliver it: install the new one first." +
2439
+ "\n npm i -g summrise-agent" +
2440
+ "\n summrise update");
2441
+ process.exit(1);
2442
+ }
2228
2443
  // npm audit #10: no mutual exclusion — two updates (or setup racing a
2229
2444
  // swap) interleave Copy-Item on *.new, leaving a half-written exe "ok".
2230
2445
  // setup REMOVES the marker; update now CREATES it (refuse if <10 min
@@ -2410,7 +2625,7 @@ const commands = {
2410
2625
  // BEFORE touching anything (fail-closed — a config-path argument boots
2411
2626
  // old AND new agents alike, so aborting here leaves the old version
2412
2627
  // running untouched). Without this the moved config strands the boot.
2413
- `try { ${bootTaskPs(`${q}\\summrise-agent.exe`, `${q}\\etc\\config.yaml`, false).join("; ")} } catch { "[$(Get-Date -Format o)] task repoint FAILED: $($_.Exception.Message)" | ${log}; try { Remove-Item -Force (Join-Path $env:ProgramData 'SummriseAgent\\update-busy') } catch {}; exit 1 }`,
2628
+ `try { ${bootTaskPs(`${q}\\summrise-agent.exe`, `${q}\\etc\\config.yaml`, false).join("; ")} } catch { "[$(Get-Date -Format o)] task repoint FAILED: $($_.Exception.Message)" | ${log}; try { Remove-Item -Force (${busyMarkerPs()}) } catch {}; exit 1 }`,
2414
2629
  `"[$(Get-Date -Format o)] task repointed at etc\\config.yaml" | ${log}`,
2415
2630
  // Layout-v2 migration (ADR 0008): move pre-v2 root paths into their
2416
2631
  // v2 homes. Best-effort per item; the gate below is fail-closed.
@@ -2418,7 +2633,7 @@ const commands = {
2418
2633
  // Fail-closed gate: the new agent reads ONLY the v2 homes. A missing
2419
2634
  // config/hostname here means migration failed — do NOT swap (the old
2420
2635
  // exe keeps running the old layout until the next update).
2421
- `if ((-not (Test-Path '${q}\\etc\\config.yaml')) -or (-not (Test-Path '${q}\\etc\\summrise-agent.hostname'))) { "[$(Get-Date -Format o)] migration gate FAILED (etc\\config.yaml/hostname missing) -- aborting, old version keeps running" | ${log}; try { Remove-Item -Force (Join-Path $env:ProgramData 'SummriseAgent\\update-busy') } catch {}; exit 1 }`,
2636
+ `if ((-not (Test-Path '${q}\\etc\\config.yaml')) -or (-not (Test-Path '${q}\\etc\\summrise-agent.hostname'))) { "[$(Get-Date -Format o)] migration gate FAILED (etc\\config.yaml/hostname missing) -- aborting, old version keeps running" | ${log}; try { Remove-Item -Force (${busyMarkerPs()}) } catch {}; exit 1 }`,
2422
2637
  // A running exe cannot be overwritten on Windows — stop the service
2423
2638
  // first (task end + process kill), THEN swap with retry.
2424
2639
  "try { Stop-ScheduledTask SummriseAgent -ErrorAction Stop } catch {}",
@@ -2442,7 +2657,7 @@ const commands = {
2442
2657
  // back up (it will run the old exe until the next update).
2443
2658
  `try { Start-ScheduledTask SummriseAgent -ErrorAction Stop } catch { schtasks /Run /TN SummriseAgent }`,
2444
2659
  `"[$(Get-Date -Format o)] task restarted" | ${log}`,
2445
- `try { Remove-Item -Force (Join-Path $env:ProgramData 'SummriseAgent\\update-busy') } catch {}`,
2660
+ `try { Remove-Item -Force (${busyMarkerPs()}) } catch {}`,
2446
2661
  // Custom-port installs: the firewall rule must track the configured
2447
2662
  // bind port (baked at update time from the live config.yaml — the
2448
2663
  // swap itself runs from a static file and cannot read it).
@@ -2605,7 +2820,14 @@ const commands = {
2605
2820
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
2606
2821
  now: () => Date.now(),
2607
2822
  });
2608
- const verdict = releaseMarkerVerdict({ ...check, want: toVersion });
2823
+ const verdict = releaseMarkerVerdict({
2824
+ ...check,
2825
+ want: toVersion,
2826
+ // THE VERB MATTERS: this is an INSTALL that failed, so the advice names the previous version as
2827
+ // the thing to pin — not the one that just failed to install.
2828
+ verb: "update",
2829
+ from: fromVersion,
2830
+ });
2609
2831
  if (verdict.writePin) {
2610
2832
  console.log(`update: ${fromVersion || "?"} -> ${toVersion} COMPLETE (the device reported the new release)`);
2611
2833
  }
@@ -2878,8 +3100,17 @@ const commands = {
2878
3100
  if (regLeft.status === 0)
2879
3101
  survivors.push("registry key HKLM\\SOFTWARE\\Summrise\\Agent");
2880
3102
  if (survivors.length) {
2881
- console.log("uninstall: WARNING -- still present after removal:", survivors.join(", "));
2882
- console.log("uninstall: a locked file or a permission problem; re-run after stopping the agent.");
3103
+ // ONE FAILURE CLASS, ONE TREATMENT. The data-dir twin forty lines below exits 1 for exactly this
3104
+ // ("FAILED to purge the data dir -- … is still present"), and this branch warned and exited 0 —
3105
+ // while the comment directly above states the rule it was breaking: `sh()` discards its result
3106
+ // "at every one of its call sites", so a locked file, an AV hold or a denied HKLM write "produced
3107
+ // 'removed' with the thing still there, AND EXIT 0".
3108
+ //
3109
+ // The program dir and the registry key are what `uninstall` was asked to remove. A warning that
3110
+ // exits 0 is the same false verdict the data-dir branch already refuses.
3111
+ console.error("uninstall: FAILED -- still present after removal:", survivors.join(", "));
3112
+ console.error("uninstall: a locked file or a permission problem; re-run after stopping the agent.");
3113
+ process.exitCode = 1;
2883
3114
  }
2884
3115
  else {
2885
3116
  console.log("uninstall: program dir + registry removed");
@@ -3023,11 +3254,22 @@ const commands = {
3023
3254
  // WITHOUT tripping the usage print + process.exit at module load.
3024
3255
  if (require.main === module) {
3025
3256
  const [cmd, ...rest] = process.argv.slice(2);
3257
+ // `--version` ANSWERS THE CHECK THE INSTALLER'S OWN CHECKLIST MAKES, and it used to fail it. It is not
3258
+ // a verb, so it fell through to the usage branch below, printed the verb list and exited 1 — while
3259
+ // step 4 of `deploy/README-installer.md` BEGINS with "`summrise --version` / the panel opens". A fresh
3260
+ // install that worked therefore reported a failure in the one place the operator is told to look,
3261
+ // which is the same defect as a comment promising more than the code does.
3262
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
3263
+ console.log(String(require("../package.json").version || ""));
3264
+ process.exit(0);
3265
+ }
3026
3266
  if (!cmd || !commands[cmd]) {
3027
3267
  // The list is DERIVED from `commands`, so the help cannot promise a verb that was pruned
3028
3268
  // (`report` and `watch` were still advertised after their round-25 removal — a usage line is a
3029
3269
  // contract, and one that lies is worse than none).
3030
- console.log("summrise <" + Object.keys(commands).join("|") + "> -- Summrise Agent control");
3270
+ console.log("summrise <" +
3271
+ Object.keys(commands).join("|") +
3272
+ "> -- Summrise Agent control");
3031
3273
  Object.keys(commands).forEach((k) => console.log(" ", k));
3032
3274
  process.exit(cmd ? 1 : 0);
3033
3275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.462",
3
+ "version": "1.2.464",
4
4
  "description": "Summrise Agent — Windows device MCP server (terminal/SSH/serial) + panel. Self-hosted npm distribution.",
5
5
  "//build": "唯一构建路径:在本目录执行 npm run build。它是自包含的:经 npm exec 按需拉取 typescript@5,无需预装任何 node_modules(干净检出可跑;旧脚本引用 ../summrise-desktop-electron/node_modules/.bin/tsc,干净检出必 ENOENT)。必须用 npm exec --package … -- tsc 的形式:npx 会吃掉传给 tsc 的 -p/--outDir 等参数(round-435:npx 写法从未真正跑通过)。先编 electron TS(--noCheck 纯 emit,与 release.yml 一致)同步两处 src/*.js,再编 src/summrise.ts 到 bin/summrise.js 并校验 round-298 标记(弱 oracle,只证某次构建发生,新鲜性由 publish-release.sh 的 tsc 重编 + cmp 门保证)。",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "license": "MIT",
26
26
  "scripts": {
27
- "build": "npm exec --yes --package typescript@5 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/preload.js ../summrise-desktop-electron/src/preload.js && cp /tmp/summrise-electron-fresh/url-policy.js ../summrise-desktop-electron/src/url-policy.js && cp ../summrise-desktop-electron/src/main.js ../summrise-desktop-electron/src/preload.js ../summrise-desktop-electron/src/url-policy.js ./summrise-desktop-electron/src/ && npm exec --yes --package typescript@5 -- tsc -p tsconfig.json --outDir /tmp/summrise-fresh-bin && cp /tmp/summrise-fresh-bin/summrise.js bin/summrise.js && grep -q \"summrise-release\" bin/summrise.js && rm -rf /tmp/summrise-electron-fresh /tmp/summrise-fresh-bin",
27
+ "build": "npm exec --yes --package typescript@5.9.3 -- tsc -p ../summrise-desktop-electron/tsconfig.json --outDir /tmp/summrise-electron-fresh --noCheck && cp /tmp/summrise-electron-fresh/main.js ../summrise-desktop-electron/src/main.js && cp /tmp/summrise-electron-fresh/preload.js ../summrise-desktop-electron/src/preload.js && cp /tmp/summrise-electron-fresh/url-policy.js ../summrise-desktop-electron/src/url-policy.js && cp ../summrise-desktop-electron/src/main.js ../summrise-desktop-electron/src/preload.js ../summrise-desktop-electron/src/url-policy.js ./summrise-desktop-electron/src/ && npm exec --yes --package typescript@5.9.3 -- tsc -p tsconfig.json --outDir /tmp/summrise-fresh-bin && cp /tmp/summrise-fresh-bin/summrise.js bin/summrise.js && grep -q \"summrise-release\" bin/summrise.js && rm -rf /tmp/summrise-electron-fresh /tmp/summrise-fresh-bin",
28
28
  "test": "node --test"
29
29
  }
30
30
  }
Binary file