ccdeck 3.37.1 → 3.38.1
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/README.md +6 -3
- package/dist/web/assets/{BrowserWatchModal-njN0zg93.js → BrowserWatchModal-D2iFbRdJ.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DYSZZZEz.js → UsageHistoryModal-DaH3RkMw.js} +1 -1
- package/dist/web/assets/index-D-HX6Mea.css +1 -0
- package/dist/web/assets/index-DnnkOgia.js +307 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/claude-jobs.mjs +152 -0
- package/src/server/deck-prefs.mjs +5 -2
- package/src/server/lan-engine.mjs +40 -34
- package/src/server/lan-reach.mjs +21 -0
- package/src/server/lan-routes.mjs +4 -1
- package/src/server/lan-sync.mjs +6 -5
- package/src/server/ring-bounds.mjs +9 -1
- package/src/server/session-activity.mjs +176 -0
- package/src/server/session-enrichment.mjs +69 -4
- package/src/server/session-tracking.mjs +22 -3
- package/src/server/thermal-metrics.mjs +113 -4
- package/src/server/thermal-sampler.mjs +10 -4
- package/dist/web/assets/index-B0SOa-XB.css +0 -1
- package/dist/web/assets/index-DDxnWaTa.js +0 -306
package/dist/web/index.html
CHANGED
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
65
65
|
})();
|
|
66
66
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
67
|
+
<script type="module" crossorigin src="/assets/index-DnnkOgia.js"></script>
|
|
68
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D-HX6Mea.css">
|
|
69
69
|
</head>
|
|
70
70
|
<body>
|
|
71
71
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.38.1",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Claude Code's own account of a background session, read from the file its
|
|
2
|
+
// agent view draws from.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT IS. A session started with `claude --bg`, or dispatched from
|
|
5
|
+
// `claude agents`, is a background job, and Claude Code keeps one folder per job
|
|
6
|
+
// at `<config dir>/jobs/<short id>/`. Its `state.json` is what the agent view
|
|
7
|
+
// prints: `state` (working, blocked, done, failed, stopped), a one-line `detail`,
|
|
8
|
+
// the question it is stuck on (`needs`), the reply Claude Code would suggest
|
|
9
|
+
// (`suggestedReply`) and the headline of a finished job (`output.result`).
|
|
10
|
+
//
|
|
11
|
+
// Those lines are written by Claude Code's classifier: a pattern match on the
|
|
12
|
+
// session's last words ("result:", "needs input:", "failed:"), and a small model
|
|
13
|
+
// when the pattern finds nothing — at every turn end, and inside a long turn
|
|
14
|
+
// once a minute or so. Read off the 2.1.289 bundle and this machine's job
|
|
15
|
+
// folders on 2026-10-05.
|
|
16
|
+
//
|
|
17
|
+
// WHY THE DECK READS IT. That classifier has already been paid for, by Claude
|
|
18
|
+
// Code, and it answers the question the deck's waiting queue cannot: not that a
|
|
19
|
+
// session stopped, but what it is asking and what it got done. For a background
|
|
20
|
+
// session the deck would otherwise show nothing at all between hooks.
|
|
21
|
+
//
|
|
22
|
+
// WHY EVERY FIELD IS CHECKED. Claude Code's documentation says in as many words
|
|
23
|
+
// that these files "are not a stable interface" (code.claude.com/docs/en/
|
|
24
|
+
// agent-view), and `claude agents --json`, the supported reader, carries the
|
|
25
|
+
// state but none of the words. So the shape is treated as untrusted: an unknown
|
|
26
|
+
// state is no job, a field of the wrong type is absent, a file that stops
|
|
27
|
+
// parsing is a session with no line. Nothing here may throw, and nothing here
|
|
28
|
+
// can make the deck worse than it was before it read the file.
|
|
29
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
32
|
+
|
|
33
|
+
/** The states the agent view groups by. `stopped` is a job somebody ended,
|
|
34
|
+
* or one the supervisor found dead; it says nothing worth a line. */
|
|
35
|
+
const JOB_STATES = new Set(["working", "blocked", "done", "failed", "stopped"]);
|
|
36
|
+
|
|
37
|
+
/** Claude Code caps its own `detail` at 800 characters. A line on a card is
|
|
38
|
+
* far shorter than that, but cutting is the renderer's job; this only bounds
|
|
39
|
+
* what a malformed file could put on the wire. */
|
|
40
|
+
const JOB_TEXT_MAX_CHARS = 800;
|
|
41
|
+
const REPLY_MAX_CHARS = 200;
|
|
42
|
+
|
|
43
|
+
/** How many job folders one pass will look at. The folders are removed when a
|
|
44
|
+
* job is deleted, so this is tens on a working machine; the bound is for the
|
|
45
|
+
* one that never deletes anything. */
|
|
46
|
+
const MAX_JOB_DIRS = 500;
|
|
47
|
+
|
|
48
|
+
function text(raw, max) {
|
|
49
|
+
if (typeof raw !== "string") return "";
|
|
50
|
+
const t = raw.replace(/\s+/g, " ").trim();
|
|
51
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + "…" : t;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One `state.json`, as the deck can use it, or null.
|
|
56
|
+
*
|
|
57
|
+
* `updatedAt` is the file's own clock: the client compares it against the
|
|
58
|
+
* session's recap to show whichever is newer, and the deck may be reading a
|
|
59
|
+
* file written while it was down. A job with no session id cannot be matched to
|
|
60
|
+
* anything the deck draws, so it is not a job here.
|
|
61
|
+
*/
|
|
62
|
+
export function jobStatusOf(raw, jobId) {
|
|
63
|
+
if (!raw || typeof raw !== "object") return null;
|
|
64
|
+
const state = raw.state;
|
|
65
|
+
if (!JOB_STATES.has(state)) return null;
|
|
66
|
+
const sessionId = typeof raw.sessionId === "string" && raw.sessionId ? raw.sessionId : null;
|
|
67
|
+
if (!sessionId) return null;
|
|
68
|
+
const updatedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : NaN;
|
|
69
|
+
const out = {
|
|
70
|
+
id: typeof jobId === "string" ? jobId : "",
|
|
71
|
+
state,
|
|
72
|
+
detail: text(raw.detail, JOB_TEXT_MAX_CHARS),
|
|
73
|
+
updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
|
|
74
|
+
};
|
|
75
|
+
const needs = text(raw.needs, JOB_TEXT_MAX_CHARS);
|
|
76
|
+
if (needs && state === "blocked") out.needs = needs;
|
|
77
|
+
const reply = text(raw.suggestedReply, REPLY_MAX_CHARS);
|
|
78
|
+
if (reply && state === "blocked") out.suggestedReply = reply;
|
|
79
|
+
const result = text(raw.output?.result, JOB_TEXT_MAX_CHARS);
|
|
80
|
+
if (result) out.result = result;
|
|
81
|
+
return { sessionId, resumeSessionId: typeof raw.resumeSessionId === "string" ? raw.resumeSessionId : null, job: out };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The watch over `<config dir>/jobs`.
|
|
86
|
+
*
|
|
87
|
+
* Shaped like the output watch: `io` is injectable so the suite can drive a
|
|
88
|
+
* folder that grows, changes and loses files without arranging any of it for
|
|
89
|
+
* real. A file is re-read only when its size or mtime moved, so a quiet machine
|
|
90
|
+
* costs one `readdir` and one `stat` per job per pass.
|
|
91
|
+
*/
|
|
92
|
+
export function createJobWatch(io = {}) {
|
|
93
|
+
const jobsDir = io.dir ?? (() => join(claudeConfigDir(), "jobs"));
|
|
94
|
+
const listDir = io.readdir ?? (p => readdir(p, { withFileTypes: true }));
|
|
95
|
+
const statFile = io.stat ?? (p => stat(p));
|
|
96
|
+
const readText = io.readFile ?? (p => readFile(p, "utf8"));
|
|
97
|
+
/** jobId -> { size, mtimeMs, parsed } — the last read of each file. */
|
|
98
|
+
const files = new Map();
|
|
99
|
+
let polling = false;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One pass. Answers `Map<sessionId, job>` for every job folder that holds a
|
|
103
|
+
* readable state, or null when a pass is already running — an overlapping
|
|
104
|
+
* caller has nothing new to say, and answering with the same map would only
|
|
105
|
+
* re-run the caller's change gate for nothing.
|
|
106
|
+
*
|
|
107
|
+
* Two folders naming one session is a job that was resumed or re-dispatched;
|
|
108
|
+
* the one written most recently is the one the agent view is showing.
|
|
109
|
+
*/
|
|
110
|
+
async function poll() {
|
|
111
|
+
if (polling) return null;
|
|
112
|
+
polling = true;
|
|
113
|
+
try {
|
|
114
|
+
const out = new Map();
|
|
115
|
+
let entries;
|
|
116
|
+
try { entries = await listDir(jobsDir()); } catch { files.clear(); return out; }
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const e of entries.slice(0, MAX_JOB_DIRS)) {
|
|
119
|
+
if (!e?.isDirectory?.()) continue;
|
|
120
|
+
const id = e.name;
|
|
121
|
+
seen.add(id);
|
|
122
|
+
const path = join(jobsDir(), id, "state.json");
|
|
123
|
+
let st;
|
|
124
|
+
try { st = await statFile(path); } catch { files.delete(id); continue; }
|
|
125
|
+
const size = Number(st?.size ?? 0);
|
|
126
|
+
const mtimeMs = Number(st?.mtimeMs ?? 0);
|
|
127
|
+
let entry = files.get(id);
|
|
128
|
+
if (!entry || entry.size !== size || entry.mtimeMs !== mtimeMs) {
|
|
129
|
+
let parsed = null;
|
|
130
|
+
// A file caught mid-write fails the parse; the next pass reads it whole.
|
|
131
|
+
try { parsed = jobStatusOf(JSON.parse(await readText(path)), id); } catch { parsed = null; }
|
|
132
|
+
entry = { size, mtimeMs, parsed };
|
|
133
|
+
files.set(id, entry);
|
|
134
|
+
}
|
|
135
|
+
const p = entry.parsed;
|
|
136
|
+
if (!p) continue;
|
|
137
|
+
for (const sid of new Set([p.sessionId, p.resumeSessionId].filter(Boolean))) {
|
|
138
|
+
const prev = out.get(sid);
|
|
139
|
+
if (!prev || p.job.updatedAt >= prev.updatedAt) out.set(sid, p.job);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const id of files.keys()) if (!seen.has(id)) files.delete(id);
|
|
143
|
+
return out;
|
|
144
|
+
} finally {
|
|
145
|
+
polling = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clear() { files.clear(); }
|
|
150
|
+
|
|
151
|
+
return { poll, clear };
|
|
152
|
+
}
|
|
@@ -133,8 +133,11 @@ export const DEFAULTS = Object.freeze({
|
|
|
133
133
|
// WHAT MAKES IT SURVIVABLE IS THE GATE THAT NEVER CHANGED: `shared` is
|
|
134
134
|
// empty, so a deck that pairs is offered NOTHING until somebody ticks a
|
|
135
135
|
// login here. Pairing is a name in a list; a login is the thing worth
|
|
136
|
-
// having, and it still takes a deliberate tick on this machine.
|
|
137
|
-
//
|
|
136
|
+
// having, and it still takes a deliberate tick on this machine. The other
|
|
137
|
+
// direction is not gated by a tick here, since 3.38.1: a deck the switch
|
|
138
|
+
// pairs adds and repairs the logins a person ticked on it, as any paired
|
|
139
|
+
// deck does (see roundWith), and never replaces one that works. So a deck
|
|
140
|
+
// on a network it does not own turns this off in the dialog, in one press.
|
|
138
141
|
autoAsk: true,
|
|
139
142
|
autoAccept: true,
|
|
140
143
|
pairingMode: "automatic",
|
|
@@ -397,7 +397,8 @@ export function createEngine({
|
|
|
397
397
|
* is new.
|
|
398
398
|
*
|
|
399
399
|
* Or one the accept switch chose for them, which `auto` says and the pin
|
|
400
|
-
* keeps — see addTrusted, and
|
|
400
|
+
* keeps — see addTrusted, and sharedWith in lan-sync.mjs for what such a
|
|
401
|
+
* deck is not handed.
|
|
401
402
|
*/
|
|
402
403
|
const pin = ({ fp, pub, name }, { auto = false } = {}) => {
|
|
403
404
|
const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now(), ...(auto ? { auto: true } : {}) });
|
|
@@ -727,11 +728,11 @@ export function createEngine({
|
|
|
727
728
|
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
728
729
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
729
730
|
const list = keepManifest(conn.key, theirs, conn.peerFp);
|
|
730
|
-
// FROM
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
731
|
+
// FROM ANY PAIRED DECK, NEITHER AN ADD NOR A HEAL NEEDS MY TICK. Adding
|
|
732
|
+
// is the case the owner asked for by name: an account that appears among
|
|
733
|
+
// the decks I paired with appears on all of them, which is the whole of
|
|
734
|
+
// "I do not want to paste blobs any more". What can reach this is what a
|
|
735
|
+
// paired deck chose to offer.
|
|
735
736
|
//
|
|
736
737
|
// A heal used to need the tick, on the reasoning that it replaces a slot
|
|
737
738
|
// I already have. It replaces only one claude-swap has quarantined, with
|
|
@@ -746,13 +747,28 @@ export function createEngine({
|
|
|
746
747
|
// so this is the ordinary case between one person's machines, not an
|
|
747
748
|
// edge.
|
|
748
749
|
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
750
|
+
// A DECK THE SWITCH PAIRED INCLUDED, which is what 3.33.0 got wrong. It
|
|
751
|
+
// held such a deck to the tick — what it offered came in only for an
|
|
752
|
+
// account ticked here — on the reasoning that nobody here chose it. On
|
|
753
|
+
// the shipped defaults that was a deadlock rather than a gate. The
|
|
754
|
+
// accept switch ships on, so two of one person's machines pair by it;
|
|
755
|
+
// the setup dialog lists only this deck's own accounts, so a login this
|
|
756
|
+
// deck lacks cannot be ticked here; so an add from such a deck could
|
|
757
|
+
// never pass. A Mac ticked eight working logins, the deck it had paired
|
|
758
|
+
// with by the switch received none of them, ever, and its round line
|
|
759
|
+
// read "all logins fine". The owner chose fully automatic (2026-10-06):
|
|
760
|
+
// whoever made the pairing, a round takes everything plan() returns.
|
|
761
|
+
//
|
|
762
|
+
// What stands between a deck nobody here chose and this store does not
|
|
763
|
+
// rest on the tick, and none of it moved. plan() never replaces a login
|
|
764
|
+
// that works here (syncAction never asks for a forced import). A giving
|
|
765
|
+
// deck hands a deck its switch paired only what a PERSON ticked on it,
|
|
766
|
+
// not what an arrival ticked onward (sharedWith in lan-sync.mjs). And
|
|
767
|
+
// nothing that arrives from one is ticked onward here (ticksOnArrival).
|
|
768
|
+
// What it costs is the switch's own: on a network the owner does not
|
|
769
|
+
// control, a deck the switch pairs can add the logins ticked on it — so
|
|
770
|
+
// the dialog and the README say to turn the switch off there.
|
|
771
|
+
//
|
|
756
772
|
// OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
|
|
757
773
|
// list through above (see lan-manifest.mjs), slices to 50 and type-
|
|
758
774
|
// filters `key` and `email`; this line read `theirs.accounts` and got
|
|
@@ -763,15 +779,7 @@ export function createEngine({
|
|
|
763
779
|
// the store lock, while the panel drew 50 and every other paired deck
|
|
764
780
|
// waited behind it. `step.key` also reached transferChallenge and
|
|
765
781
|
// importAccount untyped, which `offered`'s filter would have caught.
|
|
766
|
-
const
|
|
767
|
-
const takes = step => chosen() || cfg.shared.includes(step.key);
|
|
768
|
-
const wanted = plan(mine, list).filter(takes);
|
|
769
|
-
// TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
|
|
770
|
-
// LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
|
|
771
|
-
// round. From a deck the switch paired, an account unticked mid-round
|
|
772
|
-
// ends only that step: the next one may still be ticked, and the skipped
|
|
773
|
-
// row says why rather than vanishing.
|
|
774
|
-
const stillWanted = takes;
|
|
782
|
+
const wanted = plan(mine, list);
|
|
775
783
|
/** One login, asked for and opened: a `want` carrying its own proof that
|
|
776
784
|
* names the account (see transferChallenge), and the `have` opened under
|
|
777
785
|
* the additional data it was sealed with. The login, or why there is
|
|
@@ -790,15 +798,16 @@ export function createEngine({
|
|
|
790
798
|
return blob ? { blob } : { why: "could not open" };
|
|
791
799
|
};
|
|
792
800
|
let cut = false;
|
|
801
|
+
// Losing the session — LAN switched off, the peer unpaired — ends the
|
|
802
|
+
// round, checked before each question and again before each import. A
|
|
803
|
+
// tick changed here mid-round ends nothing: no step reads one.
|
|
793
804
|
for (const step of wanted) {
|
|
794
805
|
if (!stillPaired()) { cut = true; break; }
|
|
795
|
-
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
796
806
|
const { blob, why } = await wantLogin(step);
|
|
797
807
|
if (!blob) { done.push({ ...step, ok: false, why }); continue; }
|
|
798
|
-
// Unpairing
|
|
799
|
-
//
|
|
808
|
+
// Unpairing or disabling LAN while export was in progress takes effect
|
|
809
|
+
// before the received credential touches disk.
|
|
800
810
|
if (!stillPaired()) { cut = true; break; }
|
|
801
|
-
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
802
811
|
// A verdict rather than a boolean, because "refused" and "kept the
|
|
803
812
|
// slot it already has" are different things to tell somebody and the
|
|
804
813
|
// second one used to be reported as success. A bare `true` is still
|
|
@@ -825,11 +834,11 @@ export function createEngine({
|
|
|
825
834
|
// be asleep or on another network. Nothing new is exposed — the login
|
|
826
835
|
// came FROM the group, so the group has it.
|
|
827
836
|
//
|
|
828
|
-
// ONLY AN ADD,
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
837
|
+
// ONLY AN ADD, only from the local network, and only from a deck
|
|
838
|
+
// somebody here chose — see ticksOnArrival for why each. A healed slot
|
|
839
|
+
// was here already and its tick is somebody's decision either way; a
|
|
840
|
+
// tailnet reaches further than the person's own machines; and what a
|
|
841
|
+
// deck the switch paired brings is not passed on in this deck's name.
|
|
833
842
|
// The store can finish an import after the owner disabled LAN or
|
|
834
843
|
// revoked this peer. Keep the imported slot, but do not turn it into
|
|
835
844
|
// a newly shared credential on behalf of an obsolete transfer.
|
|
@@ -1026,9 +1035,6 @@ export function createEngine({
|
|
|
1026
1035
|
const card = id => ({
|
|
1027
1036
|
...heardOf(id),
|
|
1028
1037
|
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1029
|
-
// And whether a switch said that yes rather than a person, which is
|
|
1030
|
-
// what the dialog needs to say which logins will not arrive from it.
|
|
1031
|
-
...(trustedPeer(cfg.trusted, id)?.auto ? { autoPaired: true } : {}),
|
|
1032
1038
|
});
|
|
1033
1039
|
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
1034
1040
|
if (!stillListed(p, now())) continue;
|
package/src/server/lan-reach.mjs
CHANGED
|
@@ -536,6 +536,27 @@ export function reachability({ platform, probe, aliases = [], exePath = "", inbo
|
|
|
536
536
|
return windowsReach({ probe, aliases, exePath, inbound });
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
/**
|
|
540
|
+
* The verdict as the status route serves it: `said` is the last one taken, and
|
|
541
|
+
* `inboundAt` is when a connection from another machine last got in, as the
|
|
542
|
+
* engine knows it NOW.
|
|
543
|
+
*
|
|
544
|
+
* A VERDICT IS TAKEN AT MOST EVERY FIVE MINUTES (see refreshReach in
|
|
545
|
+
* lan-deck.mjs), and a complaint in it was true only of the moment it was
|
|
546
|
+
* taken. Reported from the Omarchy box this module's Linux block describes:
|
|
547
|
+
* its owner ran the two `ufw allow` lines, decks connected straight away —
|
|
548
|
+
* ufw applies a rule the moment it is added — and the panel went on saying
|
|
549
|
+
* "ufw is running here…" until the cached verdict expired, because it was
|
|
550
|
+
* taken before anything had connected. The connection that got in is the
|
|
551
|
+
* same measurement every platform here already puts above whatever it read,
|
|
552
|
+
* so it outranks a verdict taken before it, too. The answer is the one the
|
|
553
|
+
* next probe will give; every other answer is served as it was.
|
|
554
|
+
*/
|
|
555
|
+
export function servedReach(said, inboundAt) {
|
|
556
|
+
if (!said?.blocked || !inboundAt) return said ?? null;
|
|
557
|
+
return { blocked: false, why: "inbound seen", category: said.category ?? "", alias: said.alias ?? "" };
|
|
558
|
+
}
|
|
559
|
+
|
|
539
560
|
// ── the machine nothing can be asked about ──────────────────────────────────
|
|
540
561
|
//
|
|
541
562
|
// Every block above depends on the operating system answering a question.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { cleanAlias, isAliasKey, lanEnabled, withAlias, withManualEntry } from "./deck-prefs.mjs";
|
|
11
11
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
12
12
|
import { lanEngine, lastReach, refreshReach, tailnet } from "./lan-deck.mjs";
|
|
13
|
+
import { servedReach } from "./lan-reach.mjs";
|
|
13
14
|
import { IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
|
|
14
15
|
import { readBody, send } from "./http-io.mjs";
|
|
15
16
|
|
|
@@ -21,7 +22,9 @@ export function handleLanStatus(req, res) {
|
|
|
21
22
|
// Behind the answer, like the reach probe: the dialog's poll is what finds a
|
|
22
23
|
// Tailscale somebody installed while the deck was running.
|
|
23
24
|
if (lanEnabled(heldPrefs.current())) void tailnet.freshen(TAILNET_IDLE_MS);
|
|
24
|
-
|
|
25
|
+
// Read against what has got in since it was taken — see servedReach.
|
|
26
|
+
const status = lanEngine.status();
|
|
27
|
+
return send(res, 200, { ok: true, ...status, reach: servedReach(lastReach(), status.inboundAt) });
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -464,11 +464,12 @@ export function trustedPeer(trusted, fp) {
|
|
|
464
464
|
* told nothing changed.
|
|
465
465
|
*
|
|
466
466
|
* `auto` SAYS A SWITCH MADE THE PIN, not a person — the accept switch pressing
|
|
467
|
-
* accept for the owner. A pin that says so
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
467
|
+
* accept for the owner. A pin that says so is handed only what a person ticked
|
|
468
|
+
* here (see sharedWith), and what it brings is not ticked onward here (see
|
|
469
|
+
* ticksOnArrival in lan-engine.mjs); it places logins here like any paired
|
|
470
|
+
* deck, since 3.38.1 — see roundWith. A person's pin of the same key takes the
|
|
471
|
+
* mark away, because pressing accept, or pairing by invite, is the choice the
|
|
472
|
+
* switch only stood in for; a switch never puts it back on a pin a person made.
|
|
472
473
|
*/
|
|
473
474
|
export function addTrusted(trusted, entry) {
|
|
474
475
|
const list = Array.isArray(trusted) ? trusted : [];
|
|
@@ -73,8 +73,16 @@ export const MAX_BUFFER = 2000; // recent HOOK events kept for late SSE subs
|
|
|
73
73
|
* `OutputObserved` is deliberately NOT here. It says something landed at a
|
|
74
74
|
* given moment, which is history — two of them are two events, not one value
|
|
75
75
|
* twice.
|
|
76
|
+
*
|
|
77
|
+
* `ActivityObserved` and `JobObserved` are: each says what the session is doing
|
|
78
|
+
* or what its background job says NOW, and the next one replaces it outright.
|
|
79
|
+
* The activity line moves about once per reply the model writes, which is the
|
|
80
|
+
* same cadence as the tool calls the budget is there to protect.
|
|
76
81
|
*/
|
|
77
|
-
const LAST_VALUE_WINS = new Set([
|
|
82
|
+
const LAST_VALUE_WINS = new Set([
|
|
83
|
+
"ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed",
|
|
84
|
+
"ActivityObserved", "JobObserved",
|
|
85
|
+
]);
|
|
78
86
|
|
|
79
87
|
/**
|
|
80
88
|
* Whether this payload is enrichment MAX_BUFFER does not count — see
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// What a session is doing right now, in one line, read off its transcript.
|
|
2
|
+
//
|
|
3
|
+
// WHERE THE RULE COMES FROM. Claude Code's agent view (`claude agents`) prints
|
|
4
|
+
// one line beside every background session saying what it is doing, and while
|
|
5
|
+
// a turn is running that line is not a summary at all. It is the newest
|
|
6
|
+
// assistant message, read for the first thing a person can read: a text block
|
|
7
|
+
// of more than eight characters, and failing that the `description` the model
|
|
8
|
+
// wrote on its tool call — "Run the API tests" — and failing that what the call
|
|
9
|
+
// is touching. Read off the 2.1.289 bundle on 2026-10-05; the model-written
|
|
10
|
+
// summary that replaces it a minute into a long turn is the half that costs a
|
|
11
|
+
// request, and the deck takes that half from the job's own file when there is
|
|
12
|
+
// one (see claude-jobs.mjs). This half costs nothing, needs only the transcript
|
|
13
|
+
// the output watch is already reading, and is as true of an interactive
|
|
14
|
+
// session as of a background one.
|
|
15
|
+
//
|
|
16
|
+
// WHY THE MESSAGE AND NOT THE LINE. Claude Code writes one transcript line per
|
|
17
|
+
// content block, and the blocks of one reply share `message.id`: "Now I'll read
|
|
18
|
+
// the reducer" lands a moment before the Read it introduces. Folded by message,
|
|
19
|
+
// the sentence outranks the call it explains, as it does in the agent view;
|
|
20
|
+
// folded by line, the call would replace the sentence a second after it
|
|
21
|
+
// appeared, and the line would read like a tool log.
|
|
22
|
+
//
|
|
23
|
+
// WHEN A MESSAGE SAYS NOTHING. A reply holding only thinking, or a call this
|
|
24
|
+
// cannot name, leaves the line where it was. Thinking is the model working on
|
|
25
|
+
// what the line already says, and a blank line would claim it had stopped.
|
|
26
|
+
//
|
|
27
|
+
// What it does NOT decide is whether the line is still true. A line describes
|
|
28
|
+
// the turn that wrote it, and the next prompt makes it history; the client
|
|
29
|
+
// holds that rule (session-status.ts), because a prompt reaches the deck
|
|
30
|
+
// through its hook before the model has written a word.
|
|
31
|
+
|
|
32
|
+
/** The cheap test every line gets before anything is parsed. */
|
|
33
|
+
const ASSISTANT_MARK = '"type":"assistant"';
|
|
34
|
+
|
|
35
|
+
/** Claude Code's own floor for a text block worth showing: "ok", "Done." and
|
|
36
|
+
* "Let me" say nothing about what is happening, and its line skips them. */
|
|
37
|
+
const MIN_SAID_CHARS = 8;
|
|
38
|
+
|
|
39
|
+
/** A row is one line of a 300px sidebar, three at most. Past this the text is
|
|
40
|
+
* cut at a word and marked, so a model that opens with a paragraph does not
|
|
41
|
+
* push the row's own figures off the panel. */
|
|
42
|
+
const ACTIVITY_MAX_CHARS = 160;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The first line of what the model wrote, as plain words.
|
|
46
|
+
*
|
|
47
|
+
* Markdown is the model's formatting for a terminal and reads as noise in a
|
|
48
|
+
* one-line row: `**bold**`, backticks, a heading's hashes, a bullet's dash. Only
|
|
49
|
+
* the first non-empty line is taken — the rest of a long reply is the answer,
|
|
50
|
+
* not the activity.
|
|
51
|
+
*/
|
|
52
|
+
export function cleanSaid(raw) {
|
|
53
|
+
if (typeof raw !== "string") return "";
|
|
54
|
+
const first = raw.split("\n").map(l => l.trim()).find(Boolean) ?? "";
|
|
55
|
+
let text = first
|
|
56
|
+
.replace(/^#{1,6}\s+/, "")
|
|
57
|
+
.replace(/^(?:[-*+]|\d+[.)])\s+/, "")
|
|
58
|
+
.replace(/\*\*|__|`/g, "")
|
|
59
|
+
.replace(/\s+/g, " ")
|
|
60
|
+
.trim();
|
|
61
|
+
if (text.length > ACTIVITY_MAX_CHARS) {
|
|
62
|
+
const cut = text.slice(0, ACTIVITY_MAX_CHARS - 1);
|
|
63
|
+
const space = cut.lastIndexOf(" ");
|
|
64
|
+
text = (space > ACTIVITY_MAX_CHARS * 0.6 ? cut.slice(0, space) : cut).trimEnd() + "…";
|
|
65
|
+
}
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The last path segment, for a call whose subject is a file. */
|
|
70
|
+
function baseName(path) {
|
|
71
|
+
if (typeof path !== "string" || !path) return "";
|
|
72
|
+
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
73
|
+
return parts[parts.length - 1] ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A host, for a fetch: the whole address is a query string as often as not. */
|
|
77
|
+
function hostOf(url) {
|
|
78
|
+
if (typeof url !== "string") return "";
|
|
79
|
+
try { return new URL(url).host; } catch { return ""; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What one tool call is doing, in words, or "" when it says nothing a person
|
|
84
|
+
* could read.
|
|
85
|
+
*
|
|
86
|
+
* The model's own `description` first: it is a sentence written for exactly
|
|
87
|
+
* this reader, and Bash, Task and Agent carry one on nearly every call. The
|
|
88
|
+
* rest are the calls that carry none but name their subject plainly — a file,
|
|
89
|
+
* a pattern, a host. Anything else is left unnamed rather than printed as a
|
|
90
|
+
* tool id: "Using mcp__github__list_pulls" is a log line, not an activity.
|
|
91
|
+
*/
|
|
92
|
+
export function toolActivity(name, input) {
|
|
93
|
+
const desc = typeof input?.description === "string" ? input.description : "";
|
|
94
|
+
if (desc.trim()) return cleanSaid(desc);
|
|
95
|
+
const say = (verb, subject) => (subject ? cleanSaid(`${verb} ${subject}`) : "");
|
|
96
|
+
switch (name) {
|
|
97
|
+
case "Read": return say("Reading", baseName(input?.file_path));
|
|
98
|
+
case "Edit":
|
|
99
|
+
case "MultiEdit": return say("Editing", baseName(input?.file_path));
|
|
100
|
+
case "Write": return say("Writing", baseName(input?.file_path));
|
|
101
|
+
case "NotebookEdit": return say("Editing", baseName(input?.notebook_path));
|
|
102
|
+
case "Grep": return say("Searching for", typeof input?.pattern === "string" ? input.pattern : "");
|
|
103
|
+
case "Glob": return say("Finding", typeof input?.pattern === "string" ? input.pattern : "");
|
|
104
|
+
case "WebFetch": return say("Fetching", hostOf(input?.url));
|
|
105
|
+
case "WebSearch": return say("Searching the web for", typeof input?.query === "string" ? input.query : "");
|
|
106
|
+
default: return "";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What one transcript line adds to the line, or null for a line that is not
|
|
112
|
+
* the main chain's model writing.
|
|
113
|
+
*
|
|
114
|
+
* `said` is the newest readable text block on the line, `did` the newest
|
|
115
|
+
* nameable call; either may be "" when the line carries neither. A subagent's
|
|
116
|
+
* sidechain is not the session's own reply — current Claude Code writes those
|
|
117
|
+
* to their own files anyway, and the parent's Task call already says what the
|
|
118
|
+
* subagent was sent to do.
|
|
119
|
+
*/
|
|
120
|
+
export function activityOfLine(line) {
|
|
121
|
+
if (!line || !line.includes(ASSISTANT_MARK)) return null;
|
|
122
|
+
let rec;
|
|
123
|
+
try { rec = JSON.parse(line); } catch { return null; }
|
|
124
|
+
if (!rec || rec.type !== "assistant" || rec.isSidechain === true) return null;
|
|
125
|
+
const content = rec.message?.content;
|
|
126
|
+
if (!Array.isArray(content)) return null;
|
|
127
|
+
let said = "";
|
|
128
|
+
let did = "";
|
|
129
|
+
for (const block of content) {
|
|
130
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
131
|
+
const text = cleanSaid(block.text);
|
|
132
|
+
if (text.length > MIN_SAID_CHARS) said = text;
|
|
133
|
+
} else if (block?.type === "tool_use") {
|
|
134
|
+
const text = toolActivity(block.name, block.input);
|
|
135
|
+
if (text) did = text;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const at = typeof rec.timestamp === "string" ? Date.parse(rec.timestamp) : NaN;
|
|
139
|
+
return {
|
|
140
|
+
messageId: typeof rec.message?.id === "string" ? rec.message.id : null,
|
|
141
|
+
said,
|
|
142
|
+
did,
|
|
143
|
+
at: Number.isFinite(at) ? at : null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A session's fold, before it has read anything. */
|
|
148
|
+
export function newActivityState() {
|
|
149
|
+
return { messageId: null, said: "", did: "", shown: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fold one transcript line into `st.shown` — `{ text, source, at }`, or null
|
|
154
|
+
* until something readable has been written.
|
|
155
|
+
*
|
|
156
|
+
* Pure apart from `st`, and shaped like foldRecapLine beside it so the output
|
|
157
|
+
* watch's tap can run both over the same tail. A new `message.id` starts a new
|
|
158
|
+
* reply; within one reply what was said outranks what was done, whichever line
|
|
159
|
+
* came last. A line with no message id is its own reply, which is what an
|
|
160
|
+
* older transcript or a hand-written fixture looks like.
|
|
161
|
+
*/
|
|
162
|
+
export function foldActivityLine(st, line) {
|
|
163
|
+
const a = activityOfLine(line);
|
|
164
|
+
if (!a) return;
|
|
165
|
+
if (!a.messageId || a.messageId !== st.messageId) {
|
|
166
|
+
st.messageId = a.messageId;
|
|
167
|
+
st.said = "";
|
|
168
|
+
st.did = "";
|
|
169
|
+
}
|
|
170
|
+
if (a.said) st.said = a.said;
|
|
171
|
+
if (a.did) st.did = a.did;
|
|
172
|
+
const text = st.said || st.did;
|
|
173
|
+
if (!text) return;
|
|
174
|
+
if (st.shown && st.shown.text === text) return;
|
|
175
|
+
st.shown = { text, source: st.said ? "said" : "tool", at: a.at ?? Date.now() };
|
|
176
|
+
}
|