hanoman 0.1.58 → 0.1.59
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/dist/build-info.json +3 -3
- package/dist/cli.js +31 -0
- package/dist/server.js +295 -24
- package/docs/agent-integration.md +5 -0
- package/package.json +1 -1
- package/web/assets/{index-qxiC8dAD.js → index-Co8NkEQJ.js} +1547 -1547
- package/web/index.html +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli.js
CHANGED
|
@@ -6109,6 +6109,8 @@ var init_api = __esm({
|
|
|
6109
6109
|
sessionTranscript: (id) => `${API}/terminal/history/${encodeURIComponent(id)}/transcript`,
|
|
6110
6110
|
eventsWs: `${API}/events/ws`,
|
|
6111
6111
|
// SPEC-199 · WebSocket siar dashboard (global, bukan per-sesi)
|
|
6112
|
+
// SPEC-919 · ADR-0147 · muat awal + fallback halaman Klien. Selama WS sehat tak ada yang men-poll-nya.
|
|
6113
|
+
presence: `${API}/presence`,
|
|
6112
6114
|
wsTickets: `${API}/ws-tickets`,
|
|
6113
6115
|
vps: `${API}/vps`,
|
|
6114
6116
|
vpsOne: (id) => `${API}/vps/${id}`,
|
|
@@ -7751,6 +7753,34 @@ var init_session_ask = __esm({
|
|
|
7751
7753
|
}
|
|
7752
7754
|
});
|
|
7753
7755
|
|
|
7756
|
+
// ../shared/src/presence.ts
|
|
7757
|
+
var PRESENCE_PROTOCOL, MAX_PRESENCE_SESSIONS, PRESENCE_MAX_FRAME_BYTES, zPresenceSession, zPresenceFrame;
|
|
7758
|
+
var init_presence = __esm({
|
|
7759
|
+
"../shared/src/presence.ts"() {
|
|
7760
|
+
"use strict";
|
|
7761
|
+
init_zod();
|
|
7762
|
+
PRESENCE_PROTOCOL = 1;
|
|
7763
|
+
MAX_PRESENCE_SESSIONS = 100;
|
|
7764
|
+
PRESENCE_MAX_FRAME_BYTES = 32 * 1024;
|
|
7765
|
+
zPresenceSession = external_exports.object({
|
|
7766
|
+
sessionId: external_exports.string().min(1).max(200),
|
|
7767
|
+
projectId: external_exports.string().min(1).max(200),
|
|
7768
|
+
specId: external_exports.string().max(200).optional(),
|
|
7769
|
+
flow: external_exports.string().max(40).optional(),
|
|
7770
|
+
/** Fase `active` dari `readPhases()`. Absen = sesi tanpa berkas fase (mis. konsol VPS). */
|
|
7771
|
+
phase: external_exports.string().max(80).optional(),
|
|
7772
|
+
agent: external_exports.enum(["claude", "codex"]),
|
|
7773
|
+
status: external_exports.enum(["working", "waiting", "exited"]),
|
|
7774
|
+
startedAt: external_exports.string().max(40)
|
|
7775
|
+
}).strict();
|
|
7776
|
+
zPresenceFrame = external_exports.object({
|
|
7777
|
+
t: external_exports.literal("presence"),
|
|
7778
|
+
v: external_exports.literal(PRESENCE_PROTOCOL),
|
|
7779
|
+
sessions: external_exports.array(zPresenceSession).max(MAX_PRESENCE_SESSIONS)
|
|
7780
|
+
}).strict();
|
|
7781
|
+
}
|
|
7782
|
+
});
|
|
7783
|
+
|
|
7754
7784
|
// ../shared/src/index.ts
|
|
7755
7785
|
var init_src = __esm({
|
|
7756
7786
|
"../shared/src/index.ts"() {
|
|
@@ -7790,6 +7820,7 @@ var init_src = __esm({
|
|
|
7790
7820
|
init_terminal_io();
|
|
7791
7821
|
init_session_dialog();
|
|
7792
7822
|
init_session_ask();
|
|
7823
|
+
init_presence();
|
|
7793
7824
|
}
|
|
7794
7825
|
});
|
|
7795
7826
|
|
package/dist/server.js
CHANGED
|
@@ -6563,6 +6563,8 @@ var init_api = __esm({
|
|
|
6563
6563
|
sessionTranscript: (id) => `${API}/terminal/history/${encodeURIComponent(id)}/transcript`,
|
|
6564
6564
|
eventsWs: `${API}/events/ws`,
|
|
6565
6565
|
// SPEC-199 · WebSocket siar dashboard (global, bukan per-sesi)
|
|
6566
|
+
// SPEC-919 · ADR-0147 · muat awal + fallback halaman Klien. Selama WS sehat tak ada yang men-poll-nya.
|
|
6567
|
+
presence: `${API}/presence`,
|
|
6566
6568
|
wsTickets: `${API}/ws-tickets`,
|
|
6567
6569
|
vps: `${API}/vps`,
|
|
6568
6570
|
vpsOne: (id) => `${API}/vps/${id}`,
|
|
@@ -8399,6 +8401,61 @@ var init_session_ask = __esm({
|
|
|
8399
8401
|
}
|
|
8400
8402
|
});
|
|
8401
8403
|
|
|
8404
|
+
// ../shared/src/presence.ts
|
|
8405
|
+
function trimPresenceToBudget(sessions, maxBytes = PRESENCE_MAX_FRAME_BYTES) {
|
|
8406
|
+
let out4 = sessions.slice(0, MAX_PRESENCE_SESSIONS);
|
|
8407
|
+
while (out4.length > 0 && Buffer.byteLength(presenceFrameJson(out4)) > maxBytes) {
|
|
8408
|
+
out4 = out4.slice(0, Math.max(0, Math.floor(out4.length / 2)));
|
|
8409
|
+
}
|
|
8410
|
+
return out4;
|
|
8411
|
+
}
|
|
8412
|
+
function presenceSignature(sessions) {
|
|
8413
|
+
return JSON.stringify(
|
|
8414
|
+
[...sessions].sort((a, b) => a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0).map((s2) => [
|
|
8415
|
+
s2.sessionId,
|
|
8416
|
+
s2.projectId,
|
|
8417
|
+
s2.specId ?? "",
|
|
8418
|
+
s2.flow ?? "",
|
|
8419
|
+
s2.phase ?? "",
|
|
8420
|
+
s2.agent,
|
|
8421
|
+
s2.status,
|
|
8422
|
+
s2.startedAt
|
|
8423
|
+
])
|
|
8424
|
+
);
|
|
8425
|
+
}
|
|
8426
|
+
var PRESENCE_PROTOCOL, MAX_PRESENCE_SESSIONS, PRESENCE_MAX_FRAME_BYTES, PRESENCE_TICK_MS, PRESENCE_HEARTBEAT_MS, PRESENCE_OFFLINE_MS, PRESENCE_MAX_FRAMES_PER_MIN, LOCAL_DEVICE_ID, zPresenceSession, zPresenceFrame, presenceFrameJson;
|
|
8427
|
+
var init_presence = __esm({
|
|
8428
|
+
"../shared/src/presence.ts"() {
|
|
8429
|
+
"use strict";
|
|
8430
|
+
init_zod();
|
|
8431
|
+
PRESENCE_PROTOCOL = 1;
|
|
8432
|
+
MAX_PRESENCE_SESSIONS = 100;
|
|
8433
|
+
PRESENCE_MAX_FRAME_BYTES = 32 * 1024;
|
|
8434
|
+
PRESENCE_TICK_MS = 3e3;
|
|
8435
|
+
PRESENCE_HEARTBEAT_MS = 3e4;
|
|
8436
|
+
PRESENCE_OFFLINE_MS = 9e4;
|
|
8437
|
+
PRESENCE_MAX_FRAMES_PER_MIN = 60;
|
|
8438
|
+
LOCAL_DEVICE_ID = "local";
|
|
8439
|
+
zPresenceSession = external_exports.object({
|
|
8440
|
+
sessionId: external_exports.string().min(1).max(200),
|
|
8441
|
+
projectId: external_exports.string().min(1).max(200),
|
|
8442
|
+
specId: external_exports.string().max(200).optional(),
|
|
8443
|
+
flow: external_exports.string().max(40).optional(),
|
|
8444
|
+
/** Fase `active` dari `readPhases()`. Absen = sesi tanpa berkas fase (mis. konsol VPS). */
|
|
8445
|
+
phase: external_exports.string().max(80).optional(),
|
|
8446
|
+
agent: external_exports.enum(["claude", "codex"]),
|
|
8447
|
+
status: external_exports.enum(["working", "waiting", "exited"]),
|
|
8448
|
+
startedAt: external_exports.string().max(40)
|
|
8449
|
+
}).strict();
|
|
8450
|
+
zPresenceFrame = external_exports.object({
|
|
8451
|
+
t: external_exports.literal("presence"),
|
|
8452
|
+
v: external_exports.literal(PRESENCE_PROTOCOL),
|
|
8453
|
+
sessions: external_exports.array(zPresenceSession).max(MAX_PRESENCE_SESSIONS)
|
|
8454
|
+
}).strict();
|
|
8455
|
+
presenceFrameJson = (sessions) => JSON.stringify({ t: "presence", v: PRESENCE_PROTOCOL, sessions });
|
|
8456
|
+
}
|
|
8457
|
+
});
|
|
8458
|
+
|
|
8402
8459
|
// ../shared/src/index.ts
|
|
8403
8460
|
var init_src = __esm({
|
|
8404
8461
|
"../shared/src/index.ts"() {
|
|
@@ -8438,6 +8495,7 @@ var init_src = __esm({
|
|
|
8438
8495
|
init_terminal_io();
|
|
8439
8496
|
init_session_dialog();
|
|
8440
8497
|
init_session_ask();
|
|
8498
|
+
init_presence();
|
|
8441
8499
|
}
|
|
8442
8500
|
});
|
|
8443
8501
|
|
|
@@ -11113,7 +11171,8 @@ function parsePanes(out4) {
|
|
|
11113
11171
|
agent,
|
|
11114
11172
|
alternate,
|
|
11115
11173
|
activity,
|
|
11116
|
-
eventHook
|
|
11174
|
+
eventHook,
|
|
11175
|
+
created
|
|
11117
11176
|
] = line.split(" ");
|
|
11118
11177
|
if (!n2?.startsWith(PREFIX)) return [];
|
|
11119
11178
|
const exited = dead === "1";
|
|
@@ -11140,6 +11199,8 @@ function parsePanes(out4) {
|
|
|
11140
11199
|
agent: agent === "codex" ? "codex" : "claude",
|
|
11141
11200
|
altScreen: alternate === "1",
|
|
11142
11201
|
activityAt,
|
|
11202
|
+
// SPEC-919 · sesi yang lahir di tmux yang tak menjawab field ini → 0 (epoch).
|
|
11203
|
+
startedAt: Number(created) || 0,
|
|
11143
11204
|
// SPEC-909 · ADR-0146 · sesi yang lahir sebelum pembaruan tak punya opsi ini → false.
|
|
11144
11205
|
eventHook: eventHook === "1"
|
|
11145
11206
|
}];
|
|
@@ -11709,8 +11770,11 @@ exit 1
|
|
|
11709
11770
|
"#{@hanoman_branch}",
|
|
11710
11771
|
"#{@hanoman_agent}",
|
|
11711
11772
|
"#{alternate_on}",
|
|
11773
|
+
// SPEC-919 · field baru ditambahkan di UJUNG: posisi kolom lama tak bergeser, dan
|
|
11774
|
+
// `pty-parse.test.ts` mengunci panjang FMT terhadap destructuring `parsePanes`.
|
|
11712
11775
|
"#{window_activity}",
|
|
11713
|
-
"#{@hanoman_event_hook}"
|
|
11776
|
+
"#{@hanoman_event_hook}",
|
|
11777
|
+
"#{session_created}"
|
|
11714
11778
|
].join(" ");
|
|
11715
11779
|
toSessionInfo = ({
|
|
11716
11780
|
id,
|
|
@@ -14735,6 +14799,39 @@ var init_bootstrap = __esm({
|
|
|
14735
14799
|
}
|
|
14736
14800
|
});
|
|
14737
14801
|
|
|
14802
|
+
// src/services/presence/snapshot.ts
|
|
14803
|
+
function paneToPresence(p, phase) {
|
|
14804
|
+
return {
|
|
14805
|
+
sessionId: p.id,
|
|
14806
|
+
projectId: p.projectId,
|
|
14807
|
+
...p.specId ? { specId: p.specId } : {},
|
|
14808
|
+
...p.flow ? { flow: p.flow } : {},
|
|
14809
|
+
...phase ? { phase } : {},
|
|
14810
|
+
agent: p.agent,
|
|
14811
|
+
// Presedensi: pane mati sudah berakhir apa pun isi markernya.
|
|
14812
|
+
status: p.exited ? "exited" : p.decision ? "waiting" : "working",
|
|
14813
|
+
startedAt: isoFromEpochSeconds(p.startedAt)
|
|
14814
|
+
};
|
|
14815
|
+
}
|
|
14816
|
+
function activePhase(p) {
|
|
14817
|
+
if (!p.flow || !p.phaseFile) return void 0;
|
|
14818
|
+
return readPhases(p.phaseFile, p.flow).find((f) => f.state === "active")?.name;
|
|
14819
|
+
}
|
|
14820
|
+
async function buildLocalPresence() {
|
|
14821
|
+
const panes = await listPanesAsync();
|
|
14822
|
+
return panes.slice(0, MAX_PRESENCE_SESSIONS).map((p) => paneToPresence(p, activePhase(p)));
|
|
14823
|
+
}
|
|
14824
|
+
var isoFromEpochSeconds;
|
|
14825
|
+
var init_snapshot = __esm({
|
|
14826
|
+
"src/services/presence/snapshot.ts"() {
|
|
14827
|
+
"use strict";
|
|
14828
|
+
init_src();
|
|
14829
|
+
init_pty();
|
|
14830
|
+
init_session_phases();
|
|
14831
|
+
isoFromEpochSeconds = (s2) => new Date((s2 || 0) * 1e3).toISOString();
|
|
14832
|
+
}
|
|
14833
|
+
});
|
|
14834
|
+
|
|
14738
14835
|
// src/services/conflicts.ts
|
|
14739
14836
|
function stamp(d) {
|
|
14740
14837
|
const v = d.updatedAt;
|
|
@@ -14805,6 +14902,54 @@ var init_conflicts = __esm({
|
|
|
14805
14902
|
}
|
|
14806
14903
|
});
|
|
14807
14904
|
|
|
14905
|
+
// src/services/presence/sender.ts
|
|
14906
|
+
function createPresenceSender(o) {
|
|
14907
|
+
const heartbeatMs = o.heartbeatMs ?? PRESENCE_HEARTBEAT_MS;
|
|
14908
|
+
let lastSignature = null;
|
|
14909
|
+
let lastSentAt = 0;
|
|
14910
|
+
return {
|
|
14911
|
+
async tick(now) {
|
|
14912
|
+
let sessions;
|
|
14913
|
+
try {
|
|
14914
|
+
sessions = trimPresenceToBudget(await o.build());
|
|
14915
|
+
} catch {
|
|
14916
|
+
return;
|
|
14917
|
+
}
|
|
14918
|
+
const signature = presenceSignature(sessions);
|
|
14919
|
+
const due = lastSignature === null || signature !== lastSignature || now - lastSentAt >= heartbeatMs;
|
|
14920
|
+
if (!due) return;
|
|
14921
|
+
lastSignature = signature;
|
|
14922
|
+
lastSentAt = now;
|
|
14923
|
+
try {
|
|
14924
|
+
o.send(presenceFrameJson(sessions));
|
|
14925
|
+
} catch {
|
|
14926
|
+
}
|
|
14927
|
+
}
|
|
14928
|
+
};
|
|
14929
|
+
}
|
|
14930
|
+
function startPresenceSender(o) {
|
|
14931
|
+
const sender = createPresenceSender({
|
|
14932
|
+
send: o.send,
|
|
14933
|
+
build: o.build ?? buildLocalPresence,
|
|
14934
|
+
heartbeatMs: o.heartbeatMs
|
|
14935
|
+
});
|
|
14936
|
+
void sender.tick(Date.now());
|
|
14937
|
+
const timer9 = setInterval(() => {
|
|
14938
|
+
void sender.tick(Date.now());
|
|
14939
|
+
}, o.tickMs ?? PRESENCE_TICK_MS);
|
|
14940
|
+
timer9.unref?.();
|
|
14941
|
+
return { stop() {
|
|
14942
|
+
clearInterval(timer9);
|
|
14943
|
+
} };
|
|
14944
|
+
}
|
|
14945
|
+
var init_sender = __esm({
|
|
14946
|
+
"src/services/presence/sender.ts"() {
|
|
14947
|
+
"use strict";
|
|
14948
|
+
init_src();
|
|
14949
|
+
init_snapshot();
|
|
14950
|
+
}
|
|
14951
|
+
});
|
|
14952
|
+
|
|
14808
14953
|
// ../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js
|
|
14809
14954
|
var require_constants = __commonJS({
|
|
14810
14955
|
"../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js"(exports, module) {
|
|
@@ -18853,11 +18998,14 @@ async function startSyncClient(base2, token, tickMs2) {
|
|
|
18853
18998
|
const connectWs = async () => {
|
|
18854
18999
|
const { WebSocket: WebSocket2 } = await Promise.resolve().then(() => (init_wrapper(), wrapper_exports));
|
|
18855
19000
|
const wsUrl = base2.replace(/^http/, "ws").replace(/\/$/, "") + "/api/sync/ws";
|
|
18856
|
-
|
|
18857
|
-
ws
|
|
19001
|
+
const sock = new WebSocket2(wsUrl, { headers: { authorization: `Bearer ${token}` } });
|
|
19002
|
+
ws = sock;
|
|
19003
|
+
sock.on("open", () => {
|
|
19004
|
+
reconnectDelay = 0;
|
|
18858
19005
|
void tick6();
|
|
19006
|
+
presence = startPresenceSender({ send: (json) => sock.send(json) });
|
|
18859
19007
|
});
|
|
18860
|
-
|
|
19008
|
+
sock.on("message", async (raw) => {
|
|
18861
19009
|
try {
|
|
18862
19010
|
const msg = JSON.parse(raw.toString());
|
|
18863
19011
|
if (msg.t !== "sync") return;
|
|
@@ -18865,15 +19013,19 @@ async function startSyncClient(base2, token, tickMs2) {
|
|
|
18865
19013
|
} catch {
|
|
18866
19014
|
}
|
|
18867
19015
|
});
|
|
18868
|
-
|
|
18869
|
-
|
|
19016
|
+
sock.on("close", () => {
|
|
19017
|
+
presence?.stop();
|
|
19018
|
+
presence = void 0;
|
|
19019
|
+
if (!started) return;
|
|
19020
|
+
reconnectDelay = nextBackoff(reconnectDelay);
|
|
19021
|
+
reconnectTimer = setTimeout(() => {
|
|
18870
19022
|
void connectWs();
|
|
18871
|
-
},
|
|
18872
|
-
|
|
18873
|
-
|
|
18874
|
-
|
|
19023
|
+
}, withJitter(reconnectDelay));
|
|
19024
|
+
reconnectTimer.unref?.();
|
|
19025
|
+
});
|
|
19026
|
+
sock.on("error", () => {
|
|
18875
19027
|
try {
|
|
18876
|
-
|
|
19028
|
+
sock.close();
|
|
18877
19029
|
} catch {
|
|
18878
19030
|
}
|
|
18879
19031
|
});
|
|
@@ -18893,6 +19045,13 @@ function stopSyncClient() {
|
|
|
18893
19045
|
clearInterval(timer4);
|
|
18894
19046
|
timer4 = void 0;
|
|
18895
19047
|
}
|
|
19048
|
+
if (reconnectTimer) {
|
|
19049
|
+
clearTimeout(reconnectTimer);
|
|
19050
|
+
reconnectTimer = void 0;
|
|
19051
|
+
}
|
|
19052
|
+
reconnectDelay = 0;
|
|
19053
|
+
presence?.stop();
|
|
19054
|
+
presence = void 0;
|
|
18896
19055
|
try {
|
|
18897
19056
|
ws?.close();
|
|
18898
19057
|
} catch {
|
|
@@ -18905,7 +19064,7 @@ async function applySyncConfig() {
|
|
|
18905
19064
|
const token = effectiveStr("SYNC_DEVICE_TOKEN");
|
|
18906
19065
|
if (base2 && token) await startSyncClient(base2, token, effectiveInt("SYNC_TICK_MS"));
|
|
18907
19066
|
}
|
|
18908
|
-
var MAX_SYNC_RECORD_BYTES, feedHole, MAX_DRAIN_PAGES, timer4, ws, started, pullSehat;
|
|
19067
|
+
var MAX_SYNC_RECORD_BYTES, feedHole, MAX_DRAIN_PAGES, timer4, ws, started, RECONNECT_MIN_MS, RECONNECT_MAX_MS, nextBackoff, withJitter, reconnectTimer, reconnectDelay, presence, pullSehat;
|
|
18909
19068
|
var init_sync_client = __esm({
|
|
18910
19069
|
"src/services/sync-client.ts"() {
|
|
18911
19070
|
"use strict";
|
|
@@ -18918,10 +19077,16 @@ var init_sync_client = __esm({
|
|
|
18918
19077
|
init_rename_project();
|
|
18919
19078
|
init_config2();
|
|
18920
19079
|
init_safe_outbound_request();
|
|
19080
|
+
init_sender();
|
|
18921
19081
|
MAX_SYNC_RECORD_BYTES = 1024 * 1024;
|
|
18922
19082
|
feedHole = false;
|
|
18923
19083
|
MAX_DRAIN_PAGES = 500;
|
|
18924
19084
|
started = false;
|
|
19085
|
+
RECONNECT_MIN_MS = 1e3;
|
|
19086
|
+
RECONNECT_MAX_MS = 3e4;
|
|
19087
|
+
nextBackoff = (prev) => prev <= 0 ? RECONNECT_MIN_MS : Math.min(RECONNECT_MAX_MS, prev * 2);
|
|
19088
|
+
withJitter = (ms, rnd = Math.random) => Math.round(ms * (0.8 + rnd() * 0.4));
|
|
19089
|
+
reconnectDelay = 0;
|
|
18925
19090
|
pullSehat = true;
|
|
18926
19091
|
}
|
|
18927
19092
|
});
|
|
@@ -22575,14 +22740,14 @@ async function loadDeviceIndex() {
|
|
|
22575
22740
|
const rows = await prisma.deviceToken.findMany({ select: { id: true, name: true, revokedAt: true } });
|
|
22576
22741
|
return new Map(rows.map((d) => [d.id, { name: d.name, revoked: d.revokedAt !== null }]));
|
|
22577
22742
|
}
|
|
22578
|
-
function handledByView(raw,
|
|
22743
|
+
function handledByView(raw, devices2) {
|
|
22579
22744
|
return handledByOf(raw).map((e) => {
|
|
22580
|
-
const d =
|
|
22745
|
+
const d = devices2.get(e.deviceId);
|
|
22581
22746
|
return { deviceId: e.deviceId, name: d?.name ?? e.name, revoked: d?.revoked ?? false };
|
|
22582
22747
|
});
|
|
22583
22748
|
}
|
|
22584
|
-
async function toProjectView(p, sessions,
|
|
22585
|
-
const deviceIndex =
|
|
22749
|
+
async function toProjectView(p, sessions, devices2) {
|
|
22750
|
+
const deviceIndex = devices2 ?? await loadDeviceIndex();
|
|
22586
22751
|
const specs = await prisma.spec.findMany({ where: { projectId: p.id } });
|
|
22587
22752
|
const { coverage } = await scanRepoDocs(await resolveRepoDir(p.id));
|
|
22588
22753
|
const binding = await getBinding(p.id);
|
|
@@ -22753,8 +22918,8 @@ async function projects_default(app2) {
|
|
|
22753
22918
|
const { q, handledBy, page, limit } = req.query;
|
|
22754
22919
|
const ps = await prisma.project.findMany({ orderBy: { createdAt: "desc" } });
|
|
22755
22920
|
const sessions = listSessions();
|
|
22756
|
-
const
|
|
22757
|
-
const views = await Promise.all(ps.map((p) => toProjectView(p, sessions,
|
|
22921
|
+
const devices2 = await loadDeviceIndex();
|
|
22922
|
+
const views = await Promise.all(ps.map((p) => toProjectView(p, sessions, devices2)));
|
|
22758
22923
|
const needle = (q ?? "").trim().toLowerCase();
|
|
22759
22924
|
const filtered = needle ? views.filter((v) => `${v.name} ${v.desc} ${v.stack}`.toLowerCase().includes(needle)) : views;
|
|
22760
22925
|
const device = (handledBy ?? "").trim();
|
|
@@ -36175,6 +36340,82 @@ async function update(app2) {
|
|
|
36175
36340
|
// src/services/events.ts
|
|
36176
36341
|
init_pty();
|
|
36177
36342
|
init_notifications();
|
|
36343
|
+
|
|
36344
|
+
// src/services/presence/view.ts
|
|
36345
|
+
init_src();
|
|
36346
|
+
init_db();
|
|
36347
|
+
init_snapshot();
|
|
36348
|
+
import { hostname } from "node:os";
|
|
36349
|
+
|
|
36350
|
+
// src/services/presence/registry.ts
|
|
36351
|
+
init_src();
|
|
36352
|
+
var devices = /* @__PURE__ */ new Map();
|
|
36353
|
+
function recordPresence(deviceId, sessions, now = Date.now()) {
|
|
36354
|
+
const prev = devices.get(deviceId)?.sessions;
|
|
36355
|
+
const next = /* @__PURE__ */ new Map();
|
|
36356
|
+
for (const session of sessions) {
|
|
36357
|
+
const before2 = prev?.get(session.sessionId);
|
|
36358
|
+
next.set(session.sessionId, {
|
|
36359
|
+
session,
|
|
36360
|
+
statusAt: before2 && before2.session.status === session.status ? before2.statusAt : now
|
|
36361
|
+
});
|
|
36362
|
+
}
|
|
36363
|
+
devices.set(deviceId, { sessions: next, lastFrameAt: now });
|
|
36364
|
+
}
|
|
36365
|
+
function dropPresence(deviceId) {
|
|
36366
|
+
devices.delete(deviceId);
|
|
36367
|
+
}
|
|
36368
|
+
function presenceEntries(now = Date.now()) {
|
|
36369
|
+
const out4 = [];
|
|
36370
|
+
for (const [deviceId, d] of devices) {
|
|
36371
|
+
if (now - d.lastFrameAt >= PRESENCE_OFFLINE_MS) {
|
|
36372
|
+
devices.delete(deviceId);
|
|
36373
|
+
continue;
|
|
36374
|
+
}
|
|
36375
|
+
out4.push({
|
|
36376
|
+
deviceId,
|
|
36377
|
+
sessions: [...d.sessions.values()].map((t) => ({
|
|
36378
|
+
...t.session,
|
|
36379
|
+
statusAt: new Date(t.statusAt).toISOString()
|
|
36380
|
+
}))
|
|
36381
|
+
});
|
|
36382
|
+
}
|
|
36383
|
+
return out4;
|
|
36384
|
+
}
|
|
36385
|
+
|
|
36386
|
+
// src/services/presence/view.ts
|
|
36387
|
+
async function presenceView(o = {}) {
|
|
36388
|
+
const now = o.now ?? Date.now();
|
|
36389
|
+
const local = o.local ?? buildLocalPresence;
|
|
36390
|
+
recordPresence(LOCAL_DEVICE_ID, await local().catch(() => []), now);
|
|
36391
|
+
const live = new Map(presenceEntries(now).map((e) => [e.deviceId, e.sessions]));
|
|
36392
|
+
const rows = await prisma.deviceToken.findMany({
|
|
36393
|
+
where: { revokedAt: null },
|
|
36394
|
+
orderBy: { createdAt: "asc" }
|
|
36395
|
+
});
|
|
36396
|
+
const devices2 = [{
|
|
36397
|
+
deviceId: LOCAL_DEVICE_ID,
|
|
36398
|
+
name: hostname(),
|
|
36399
|
+
local: true,
|
|
36400
|
+
online: true,
|
|
36401
|
+
lastSeenAt: new Date(now).toISOString(),
|
|
36402
|
+
sessions: live.get(LOCAL_DEVICE_ID) ?? []
|
|
36403
|
+
}];
|
|
36404
|
+
for (const r of rows) {
|
|
36405
|
+
const sessions = live.get(r.id);
|
|
36406
|
+
devices2.push({
|
|
36407
|
+
deviceId: r.id,
|
|
36408
|
+
name: r.name,
|
|
36409
|
+
local: false,
|
|
36410
|
+
online: !!sessions,
|
|
36411
|
+
lastSeenAt: r.lastSeenAt?.toISOString() ?? null,
|
|
36412
|
+
sessions: sessions ?? []
|
|
36413
|
+
});
|
|
36414
|
+
}
|
|
36415
|
+
return { enabled: rows.length > 0, devices: devices2 };
|
|
36416
|
+
}
|
|
36417
|
+
|
|
36418
|
+
// src/services/events.ts
|
|
36178
36419
|
init_db();
|
|
36179
36420
|
init_config2();
|
|
36180
36421
|
init_src();
|
|
@@ -36964,6 +37205,7 @@ function parseParams(topic, params) {
|
|
|
36964
37205
|
|
|
36965
37206
|
// src/services/events.ts
|
|
36966
37207
|
var clients = /* @__PURE__ */ new Set();
|
|
37208
|
+
var cookieClients = /* @__PURE__ */ new WeakSet();
|
|
36967
37209
|
var tickMs = () => effectiveInt("HANOMAN_EVENTS_TICK_MS") ?? 1e3;
|
|
36968
37210
|
var GROUPS = [
|
|
36969
37211
|
// SPEC-409 · ADR-0091 · AC-3 · `deciding` menandai sesi yang sedang DISUSUN keputusannya oleh
|
|
@@ -36997,11 +37239,19 @@ var GROUPS = [
|
|
|
36997
37239
|
{ everyTicks: 30, last: "", build: async () => ({ t: "codexLimits", limits: await getCodexLimits() }) },
|
|
36998
37240
|
// SPEC-214 · deteksi update jarang berubah; recompute tiap 300 dtk, dedup signature → siar hanya
|
|
36999
37241
|
// saat status berubah. getUpdateStatus cache 15s + fetch ter-gate (server.ts) → attach tak menahan.
|
|
37000
|
-
{ everyTicks: 300, last: "", build: async () => ({ t: "update", update: await getUpdateStatus() }) }
|
|
37242
|
+
{ everyTicks: 300, last: "", build: async () => ({ t: "update", update: await getUpdateStatus() }) },
|
|
37243
|
+
// SPEC-919 · ADR-0147 · sesi hidup lintas device (grup GLOBAL ke-10 — `leadAsks` SPEC-909 ikut
|
|
37244
|
+
// dihitung). 3 dtk: presence berdenyut 30 dtk, jadi kadens lebih rapat hanya menambah build tanpa
|
|
37245
|
+
// menambah informasi. `presenceView` menyegarkan sesi mesin ini sendiri di dalamnya — satu
|
|
37246
|
+
// `tmux list-panes` asinkron, tak menahan event loop.
|
|
37247
|
+
{ everyTicks: 3, cookieOnly: true, last: "", build: async () => ({ t: "presence", ...await presenceView() }) }
|
|
37001
37248
|
];
|
|
37002
|
-
function broadcast2(msg) {
|
|
37249
|
+
function broadcast2(msg, cookieOnly = false) {
|
|
37003
37250
|
const s2 = JSON.stringify(msg);
|
|
37004
|
-
for (const c of clients)
|
|
37251
|
+
for (const c of clients) {
|
|
37252
|
+
if (cookieOnly && !cookieClients.has(c)) continue;
|
|
37253
|
+
sendTo(c, s2);
|
|
37254
|
+
}
|
|
37005
37255
|
}
|
|
37006
37256
|
var entries2 = /* @__PURE__ */ new Map();
|
|
37007
37257
|
function sendTo(c, s2) {
|
|
@@ -37107,7 +37357,7 @@ async function __tick() {
|
|
|
37107
37357
|
const sig = JSON.stringify(msg);
|
|
37108
37358
|
if (sig === g.last) continue;
|
|
37109
37359
|
g.last = sig;
|
|
37110
|
-
broadcast2(msg);
|
|
37360
|
+
broadcast2(msg, g.cookieOnly);
|
|
37111
37361
|
}
|
|
37112
37362
|
for (const e of entries2.values()) {
|
|
37113
37363
|
e.tick++;
|
|
@@ -37135,6 +37385,7 @@ function stopLoop() {
|
|
|
37135
37385
|
}
|
|
37136
37386
|
async function attach2(c, o = {}) {
|
|
37137
37387
|
clients.add(c);
|
|
37388
|
+
if (o.maySubscribe !== false) cookieClients.add(c);
|
|
37138
37389
|
startLoop();
|
|
37139
37390
|
const topics = o.maySubscribe === false ? [] : TOPIC_NAMES;
|
|
37140
37391
|
try {
|
|
@@ -37143,6 +37394,7 @@ async function attach2(c, o = {}) {
|
|
|
37143
37394
|
return;
|
|
37144
37395
|
}
|
|
37145
37396
|
for (const g of GROUPS) {
|
|
37397
|
+
if (g.cookieOnly && !cookieClients.has(c)) continue;
|
|
37146
37398
|
let msg;
|
|
37147
37399
|
try {
|
|
37148
37400
|
msg = await g.build();
|
|
@@ -37158,6 +37410,7 @@ async function attach2(c, o = {}) {
|
|
|
37158
37410
|
}
|
|
37159
37411
|
function detach2(c) {
|
|
37160
37412
|
clients.delete(c);
|
|
37413
|
+
cookieClients.delete(c);
|
|
37161
37414
|
dropClientSubs(c);
|
|
37162
37415
|
if (clients.size === 0) stopLoop();
|
|
37163
37416
|
}
|
|
@@ -37284,6 +37537,7 @@ async function bindings_default(app2) {
|
|
|
37284
37537
|
|
|
37285
37538
|
// src/routes/sync.ts
|
|
37286
37539
|
init_zod();
|
|
37540
|
+
init_src();
|
|
37287
37541
|
init_db();
|
|
37288
37542
|
import { gzipSync } from "node:zlib";
|
|
37289
37543
|
|
|
@@ -37433,6 +37687,16 @@ async function sync_default(app2) {
|
|
|
37433
37687
|
}
|
|
37434
37688
|
const client = { send: (m) => socket2.send(m), close: () => socket2.close() };
|
|
37435
37689
|
attachSync(client);
|
|
37690
|
+
const guard = new WsMessageGuard({ perWindow: PRESENCE_MAX_FRAMES_PER_MIN });
|
|
37691
|
+
socket2.on("message", (raw) => {
|
|
37692
|
+
try {
|
|
37693
|
+
if (!guard.accept(raw).ok) return;
|
|
37694
|
+
const parsed = zPresenceFrame.safeParse(JSON.parse(raw.toString("utf8")));
|
|
37695
|
+
if (!parsed.success) return;
|
|
37696
|
+
recordPresence(principal.id, parsed.data.sessions);
|
|
37697
|
+
} catch {
|
|
37698
|
+
}
|
|
37699
|
+
});
|
|
37436
37700
|
const revalidate = setInterval(() => {
|
|
37437
37701
|
void revalidateWsPrincipal(req, principal).then((ok2) => {
|
|
37438
37702
|
if (!ok2) socket2.close(1008, "token revoked");
|
|
@@ -37443,10 +37707,16 @@ async function sync_default(app2) {
|
|
|
37443
37707
|
clearInterval(revalidate);
|
|
37444
37708
|
release();
|
|
37445
37709
|
detachSync(client);
|
|
37710
|
+
dropPresence(principal.id);
|
|
37446
37711
|
});
|
|
37447
37712
|
});
|
|
37448
37713
|
}
|
|
37449
37714
|
|
|
37715
|
+
// src/routes/presence.ts
|
|
37716
|
+
async function presence_default(app2) {
|
|
37717
|
+
app2.get("/presence", async () => presenceView());
|
|
37718
|
+
}
|
|
37719
|
+
|
|
37450
37720
|
// src/routes/session-results.ts
|
|
37451
37721
|
init_db();
|
|
37452
37722
|
var view4 = (r) => ({
|
|
@@ -41540,7 +41810,7 @@ function capabilityForRoute(method, path) {
|
|
|
41540
41810
|
const rw = (d) => `${d}:${read2 ? "read" : "write"}`;
|
|
41541
41811
|
const seg = path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
41542
41812
|
const top = seg[0] ?? "";
|
|
41543
|
-
if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "portal" || top === "client-accounts" || top === "session-events") return "COOKIE_ONLY";
|
|
41813
|
+
if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "presence" || top === "portal" || top === "client-accounts" || top === "session-events") return "COOKIE_ONLY";
|
|
41544
41814
|
if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
|
|
41545
41815
|
return read2 ? "GLOBAL_READ" : "COOKIE_ONLY";
|
|
41546
41816
|
if (top === "scheduler") {
|
|
@@ -41812,6 +42082,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
|
|
|
41812
42082
|
await api.register(agent_tokens_default);
|
|
41813
42083
|
await api.register(bindings_default);
|
|
41814
42084
|
await api.register(sync_default);
|
|
42085
|
+
await api.register(presence_default);
|
|
41815
42086
|
await api.register(session_results_default);
|
|
41816
42087
|
await api.register(session_history_default);
|
|
41817
42088
|
await api.register(session_events_default);
|
|
@@ -154,6 +154,11 @@ token selalu **403**, apa pun capability-nya, dan tak ada capability yang bisa m
|
|
|
154
154
|
bisa memalsukan "sesi X bertanya Y" bisa menggerakkan lead atas nama sesi mana pun — itu peniruan
|
|
155
155
|
identitas, bukan capability. Agent token di sini menerima **401** (token sesi tak cocok), bukan
|
|
156
156
|
403: gate cookie mem-bypass path-nya dan route menegakkan tokennya sendiri
|
|
157
|
+
- `/api/presence` — peta pekerjaan yang sedang berjalan di **seluruh mesin** operator: sesi apa
|
|
158
|
+
hidup di device mana (SPEC-919/ADR-0147). Tak ada capability yang bisa berarti sesuatu untuk
|
|
159
|
+
agregat lintas-mesin, jadi ia cookie-only apa pun method-nya. Frame siar `presence` di
|
|
160
|
+
`/api/events/ws` mengikuti gerbang yang sama — grup itu **tidak** dikirim ke principal non-cookie,
|
|
161
|
+
walau kanalnya sendiri `GLOBAL_READ` bagi agent token
|
|
157
162
|
- `POST /api/update/apply` dan tulis lain di bawah prefix status (`/api/limits`, `/api/update`,
|
|
158
163
|
`/api/events`, `/api/fs`, `/api/health`) — **baca**-nya terbuka untuk token mana pun, **tulis**-nya
|
|
159
164
|
cookie-only
|