summrise-agent 1.2.461 → 1.2.463

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) {
@@ -1375,7 +1457,12 @@ function initTunnel(hostname, regKey) {
1375
1457
  const cfg = path.join(ETC_DIR, "tunnel.yml");
1376
1458
  if (!fs.existsSync(cf)) {
1377
1459
  console.error("tunnel: cloudflared.exe not staged at", cf);
1378
- console.error(" reinstall the package (npm i -g summrise-agent) to stage it.");
1460
+ // NOT "REINSTALL THE PACKAGE". The npm package carries NO boxed components BY DESIGN (that is what
1461
+ // keeps it ~6.7 MB), so a reinstall cannot stage this and the advice sent the operator in a circle.
1462
+ // `setup` is what stages it: resolveComponent fetches it from the RELEASE HOST and the agent's own
1463
+ // sha256 pin checks it again on the path it uses. Its doc records what this costs when nobody does —
1464
+ // "a device with no tunnel is INVISIBLE TO THE CONSOLE while looking perfectly healthy from inside."
1465
+ console.error(" run 'summrise setup' to stage it -- the component comes from the release host, not from the npm package.");
1379
1466
  process.exit(1);
1380
1467
  }
1381
1468
  const host = hostname || "d1.agent.saisi.online";
@@ -1613,7 +1700,13 @@ const commands = {
1613
1700
  // `regOk` is collected here and summarised at the end of setup: best-effort, but the
1614
1701
  // operator should be told once, with the consequence, rather than not at all.
1615
1702
  regOk.push(regWrite("InstallDir", DIR));
1616
- regOk.push(regWrite("DataDir", path.join(process.env.ProgramData || "C:\\ProgramData", "Summrise")));
1703
+ // ...AND ECHO THE RESOLVED ONE, NOT THE DEFAULT. `resolveDataDir()` is registry-first and
1704
+ // `DATA_DIR` is its answer, so a device whose data dir was remapped has that path here. This used to
1705
+ // WRITE THE LITERAL DEFAULT back to the registry — while the tree below was created at `DATA_DIR` —
1706
+ // so the new tree landed at the remapped path and the registry then named the default, which is the
1707
+ // path the AGENT reads. The comment two lines up says "DataDir defaults to %ProgramData%\Summrise",
1708
+ // i.e. exactly what the code did not do: a default applies when nothing is set, not over a remap.
1709
+ regOk.push(regWrite("DataDir", DATA_DIR));
1617
1710
  // Pre-create the data dir tree (sessions/memory/logs — C1 separation).
1618
1711
  const DATA = DATA_DIR;
1619
1712
  for (const sub of ["sessions", "memory", "logs"]) {
@@ -1760,7 +1853,10 @@ const commands = {
1760
1853
  // shims rather than hand-written ones; when npm cannot, say the command.
1761
1854
  {
1762
1855
  const selfVer = String(require("../package.json").version || "");
1763
- const pfx = (0, child_process_1.spawnSync)("npm", ["prefix", "-g"], { encoding: "utf8", shell: true });
1856
+ const pfx = (0, child_process_1.spawnSync)("npm", ["prefix", "-g"], {
1857
+ encoding: "utf8",
1858
+ shell: true,
1859
+ });
1764
1860
  const pre = pfx.status === 0 ? String(pfx.stdout || "").trim() : "";
1765
1861
  if (selfVer && pre) {
1766
1862
  const inst = (0, child_process_1.spawnSync)("npm", [
@@ -1890,10 +1986,10 @@ const commands = {
1890
1986
  console.log("setup: no tunnel configured (local mode). Enable later with `summrise tunnel install <hostname>`.");
1891
1987
  }
1892
1988
  },
1893
- // ── monitor / watch ─────────────────────────────────────────────────────
1989
+ // ── monitor ────────────────────────────────────────────────────────────
1894
1990
  // The device's reachability instrument, in the terminal the operator already
1895
1991
  // 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.
1992
+ // is, needs no browser, and runs its live view until Ctrl+C.
1897
1993
  // async: `wait` blocks on probes (the dispatcher already awaits every command).
1898
1994
  async monitor(args) {
1899
1995
  const sub = String(args[0] || "list").toLowerCase();
@@ -1903,7 +1999,7 @@ const commands = {
1903
1999
  const expect = expectAt >= 0 ? String(args[expectAt + 1] || "") : "";
1904
2000
  const positional = args.filter((a, i) => i > 0 && a !== "--expect" && i !== expectAt + 1);
1905
2001
  if (expectAt >= 0 && !expect) {
1906
- console.error("usage: summrise monitor add <host:port[/path]> --expect \"<text>\"");
2002
+ console.error('usage: summrise monitor add <host:port[/path]> --expect "<text>"');
1907
2003
  process.exit(1);
1908
2004
  }
1909
2005
  if (sub === "add" || sub === "rm" || sub === "remove") {
@@ -1913,7 +2009,12 @@ const commands = {
1913
2009
  process.exit(1);
1914
2010
  }
1915
2011
  if (sub === "add") {
1916
- const r = deviceApi("POST", "/api/monitors/add", { host: t.host, port: t.port, path: t.path, expect });
2012
+ const r = deviceApi("POST", "/api/monitors/add", {
2013
+ host: t.host,
2014
+ port: t.port,
2015
+ path: t.path,
2016
+ expect,
2017
+ });
1917
2018
  if (!r.ok) {
1918
2019
  console.error(`monitor add: ${r.error}`);
1919
2020
  process.exit(1);
@@ -1934,7 +2035,9 @@ const commands = {
1934
2035
  console.error(`monitor rm: ${r.error}`);
1935
2036
  process.exit(1);
1936
2037
  }
1937
- console.log(r.body && r.body.removed ? `stopped watching ${t.id}` : `monitor rm: ${t.id} was not being watched`);
2038
+ console.log(r.body && r.body.removed
2039
+ ? `stopped watching ${t.id}`
2040
+ : `monitor rm: ${t.id} was not being watched`);
1938
2041
  return;
1939
2042
  }
1940
2043
  if (sub === "probe") {
@@ -1964,7 +2067,10 @@ const commands = {
1964
2067
  const probe = payload.result && payload.result.probe;
1965
2068
  // The device sends the CRITERION with the answer (`expect`), so "no match" can name the
1966
2069
  // 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()));
2070
+ console.log(probeLine({
2071
+ id: t.id,
2072
+ expect: (payload.result && payload.result.expect) || null,
2073
+ }, probe, Date.now()));
1968
2074
  return;
1969
2075
  }
1970
2076
  if (sub !== "list") {
@@ -1985,7 +2091,12 @@ const commands = {
1985
2091
  // encoding boundary too. PowerShell decodes a child's output with the console code page
1986
2092
  // (CP936 on d1), so raw UTF-8 through a pipe came back as mojibake while the same text
1987
2093
  // 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));
2094
+ console.log(asciiJson(monitorsJson({
2095
+ device: os.hostname(),
2096
+ askedAtMs: Date.now(),
2097
+ payload: r.body,
2098
+ only: null,
2099
+ }), 2));
1989
2100
  return;
1990
2101
  }
1991
2102
  const targets = (r.body && r.body.targets) || [];
@@ -1999,7 +2110,7 @@ const commands = {
1999
2110
  for (const t of targets)
2000
2111
  console.log(targetLine(t, now));
2001
2112
  },
2002
- // Live view: redraw in place every few seconds until Ctrl+C. `summrise watch
2113
+ // Live view: redraw in place every few seconds until Ctrl+C. `summrise monitor
2003
2114
  // <host:port[/path]>` narrows it to one target and adds its outage log.
2004
2115
  // `summrise desktop` = put the window back, or say it is already there.
2005
2116
  //
@@ -2109,7 +2220,15 @@ const commands = {
2109
2220
  }
2110
2221
  },
2111
2222
  start() {
2112
- svc("Run");
2223
+ // CHECK IT, LIKE `stop` DOES. The comment on `svc` above says the return value exists because
2224
+ // discarding it made `start`/`restart` "silent either way" — and two of the four call sites still
2225
+ // discarded it, including this one, which is step 2 of every documented journey. An operator whose
2226
+ // agent never came up got exit 0 and no sentence at all.
2227
+ const r = svc("Run");
2228
+ if (r && r.status !== 0) {
2229
+ console.error(`summrise start: schtasks /Run failed (status ${r.status}) -- the agent is NOT running`);
2230
+ process.exit(1);
2231
+ }
2113
2232
  },
2114
2233
  stop() {
2115
2234
  // Say WHY before the process goes: see markDeliberateStop.
@@ -2123,9 +2242,18 @@ const commands = {
2123
2242
  },
2124
2243
  restart() {
2125
2244
  markDeliberateStop();
2126
- svc("End");
2245
+ // THE STOP IS REPORTED BUT NOT FATAL: a task that was not running has nothing to end, so a non-zero
2246
+ // `/End` is the ordinary case for "restart a stopped agent", and the operator asked for the START.
2247
+ const end = svc("End");
2248
+ if (end && end.status !== 0) {
2249
+ console.error(`summrise restart: schtasks /End failed (status ${end.status}) -- the old process may still be running`);
2250
+ }
2127
2251
  sh("timeout /t 2 >nul");
2128
- svc("Run");
2252
+ const run = svc("Run");
2253
+ if (run && run.status !== 0) {
2254
+ console.error(`summrise restart: schtasks /Run failed (status ${run.status}) -- the agent is NOT running`);
2255
+ process.exit(1);
2256
+ }
2129
2257
  },
2130
2258
  // Boot switch: `summrise autostart on|off|status` (default status). Flips the
2131
2259
  // ENABLED flag on BOTH boot tasks (service + desktop shell) — this is the
@@ -2225,6 +2353,28 @@ const commands = {
2225
2353
  "\n summrise update");
2226
2354
  process.exit(1);
2227
2355
  }
2356
+ // AND THE SAME REFUSAL WITHOUT THE NETWORK. The guard above cannot fire when the CDN is
2357
+ // unreadable, and the defect it exists for is exactly what happens then: an update that stamps the
2358
+ // install with a version it already has, swaps in the same build, and reports success while
2359
+ // `status` keeps saying the device is behind.
2360
+ let deviceNow = "";
2361
+ try {
2362
+ deviceNow = fs
2363
+ .readFileSync(path.join(ETC_DIR, ".summrise-release"), "utf8")
2364
+ .trim();
2365
+ }
2366
+ catch {
2367
+ // No marker is not a no-op; the update below writes one.
2368
+ }
2369
+ if (updateWouldNotMove(deviceNow, selfVersion)) {
2370
+ console.error(`update: this CLI is ${selfVersion} and the device is ALREADY on ${deviceNow}.` +
2371
+ "\n Updating from here would stamp the device with the version it already has and change nothing," +
2372
+ "\n because the device reads <install>/.summrise-release (written by this package) as its version." +
2373
+ "\n If the device is meant to be newer, this CLI is too old to deliver it: install the new one first." +
2374
+ "\n npm i -g summrise-agent" +
2375
+ "\n summrise update");
2376
+ process.exit(1);
2377
+ }
2228
2378
  // npm audit #10: no mutual exclusion — two updates (or setup racing a
2229
2379
  // swap) interleave Copy-Item on *.new, leaving a half-written exe "ok".
2230
2380
  // setup REMOVES the marker; update now CREATES it (refuse if <10 min
@@ -2410,7 +2560,7 @@ const commands = {
2410
2560
  // BEFORE touching anything (fail-closed — a config-path argument boots
2411
2561
  // old AND new agents alike, so aborting here leaves the old version
2412
2562
  // 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 }`,
2563
+ `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
2564
  `"[$(Get-Date -Format o)] task repointed at etc\\config.yaml" | ${log}`,
2415
2565
  // Layout-v2 migration (ADR 0008): move pre-v2 root paths into their
2416
2566
  // v2 homes. Best-effort per item; the gate below is fail-closed.
@@ -2418,7 +2568,7 @@ const commands = {
2418
2568
  // Fail-closed gate: the new agent reads ONLY the v2 homes. A missing
2419
2569
  // config/hostname here means migration failed — do NOT swap (the old
2420
2570
  // 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 }`,
2571
+ `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
2572
  // A running exe cannot be overwritten on Windows — stop the service
2423
2573
  // first (task end + process kill), THEN swap with retry.
2424
2574
  "try { Stop-ScheduledTask SummriseAgent -ErrorAction Stop } catch {}",
@@ -2442,7 +2592,7 @@ const commands = {
2442
2592
  // back up (it will run the old exe until the next update).
2443
2593
  `try { Start-ScheduledTask SummriseAgent -ErrorAction Stop } catch { schtasks /Run /TN SummriseAgent }`,
2444
2594
  `"[$(Get-Date -Format o)] task restarted" | ${log}`,
2445
- `try { Remove-Item -Force (Join-Path $env:ProgramData 'SummriseAgent\\update-busy') } catch {}`,
2595
+ `try { Remove-Item -Force (${busyMarkerPs()}) } catch {}`,
2446
2596
  // Custom-port installs: the firewall rule must track the configured
2447
2597
  // bind port (baked at update time from the live config.yaml — the
2448
2598
  // swap itself runs from a static file and cannot read it).
@@ -2605,7 +2755,14 @@ const commands = {
2605
2755
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
2606
2756
  now: () => Date.now(),
2607
2757
  });
2608
- const verdict = releaseMarkerVerdict({ ...check, want: toVersion });
2758
+ const verdict = releaseMarkerVerdict({
2759
+ ...check,
2760
+ want: toVersion,
2761
+ // THE VERB MATTERS: this is an INSTALL that failed, so the advice names the previous version as
2762
+ // the thing to pin — not the one that just failed to install.
2763
+ verb: "update",
2764
+ from: fromVersion,
2765
+ });
2609
2766
  if (verdict.writePin) {
2610
2767
  console.log(`update: ${fromVersion || "?"} -> ${toVersion} COMPLETE (the device reported the new release)`);
2611
2768
  }
@@ -3023,11 +3180,22 @@ const commands = {
3023
3180
  // WITHOUT tripping the usage print + process.exit at module load.
3024
3181
  if (require.main === module) {
3025
3182
  const [cmd, ...rest] = process.argv.slice(2);
3183
+ // `--version` ANSWERS THE CHECK THE INSTALLER'S OWN CHECKLIST MAKES, and it used to fail it. It is not
3184
+ // a verb, so it fell through to the usage branch below, printed the verb list and exited 1 — while
3185
+ // step 4 of `deploy/README-installer.md` BEGINS with "`summrise --version` / the panel opens". A fresh
3186
+ // install that worked therefore reported a failure in the one place the operator is told to look,
3187
+ // which is the same defect as a comment promising more than the code does.
3188
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
3189
+ console.log(String(require("../package.json").version || ""));
3190
+ process.exit(0);
3191
+ }
3026
3192
  if (!cmd || !commands[cmd]) {
3027
3193
  // The list is DERIVED from `commands`, so the help cannot promise a verb that was pruned
3028
3194
  // (`report` and `watch` were still advertised after their round-25 removal — a usage line is a
3029
3195
  // contract, and one that lies is worse than none).
3030
- console.log("summrise <" + Object.keys(commands).join("|") + "> -- Summrise Agent control");
3196
+ console.log("summrise <" +
3197
+ Object.keys(commands).join("|") +
3198
+ "> -- Summrise Agent control");
3031
3199
  Object.keys(commands).forEach((k) => console.log(" ", k));
3032
3200
  process.exit(cmd ? 1 : 0);
3033
3201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summrise-agent",
3
- "version": "1.2.461",
3
+ "version": "1.2.463",
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
@@ -70,6 +70,13 @@ function agentToken() {
70
70
  let tok = null;
71
71
  try {
72
72
  const raw = fs.readFileSync(path.join(INSTALL_ROOT, "etc", "config.yaml"), "utf8");
73
+ // THIS PATTERN IS NARROWER THAN THE TWO OTHER PARSERS OF THE SAME LINE: the CLI's
74
+ // (bin/summrise.js, `[A-Za-z0-9._-]+`) and the agent's Rust recovery both accept more than
75
+ // lowercase hex. Measured against a live device on 2026-09-24 — the token is 64 hex characters
76
+ // with no space before the colon, so all three agree TODAY, and hex is what the worker issues.
77
+ // The failure mode if that ever stops being true is SILENT: no credential, a 401, a title that
78
+ // stays "Summrise" and vitals that stay blank — the state the comment above claims to have
79
+ // fixed. Widen this line before debugging that.
73
80
  const m = /device_token:\s*"?([0-9a-f]{16,})"?/.exec(raw);
74
81
  if (m)
75
82
  tok = m[1];
@@ -705,6 +712,21 @@ async function autoLaunchTaskSet(enabled) {
705
712
  // interactive user is not resolvable from the service session. A bare
706
713
  // STRING invocation happens to work (shell default), but spawn arrays
707
714
  // need an explicit account: use Administrator (the d1 console user).
715
+ //
716
+ // THIS PATH CANNOT REPRODUCE WHAT `summrise` REGISTERS, and the difference matters. The CLI
717
+ // gives SummriseDesktop TWO triggers — AtLogOn plus a guarded 5-minute pulse (bin/summrise.js:
718
+ // `New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -RepetitionInterval 5min`, run
719
+ // through desktop-pulse.vbs → ensure-desktop.ps1, which exits when electron is already up).
720
+ // `schtasks /create` has no way to express a repetition interval at all, so what this writes is
721
+ // a LOGON-ONLY task: toggling auto-launch OFF deletes the hardened task, and toggling it ON
722
+ // again replaces it with one that cannot recover a wedged shell. `summrise update` heals it and
723
+ // says so in its log — "desk: SummriseDesktop hardened (guarded 5-min pulse)" — which is how
724
+ // this collision was found in the first place.
725
+ //
726
+ // Doing it properly means New-ScheduledTaskTrigger through PowerShell (the CLI's shape,
727
+ // duplicated in JS) or delegating to the CLI; neither is a one-liner, and the update-time repair
728
+ // bounds the damage to "until the next update". Recorded where the downgrade happens, so the
729
+ // next reader sees the trap instead of rediscovering it from a shell that stopped recovering.
708
730
  const r = await runSchtasks([
709
731
  "/create", "/tn", AUTOSTART_TASK,
710
732
  // schtasks re-parses the /tr VALUE as a command line — it needs its