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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.58",
3
- "sha": "36f46443",
4
- "builtAt": "2026-08-23T16:44:16.513Z"
2
+ "version": "0.1.59",
3
+ "sha": "efcde67d",
4
+ "builtAt": "2026-08-24T03:26:58.817Z"
5
5
  }
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
- ws = new WebSocket2(wsUrl, { headers: { authorization: `Bearer ${token}` } });
18857
- ws.on("open", () => {
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
- ws.on("message", async (raw) => {
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
- const reconnect = () => {
18869
- setTimeout(() => {
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
- }, 3e3);
18872
- };
18873
- ws.on("close", reconnect);
18874
- ws.on("error", () => {
19023
+ }, withJitter(reconnectDelay));
19024
+ reconnectTimer.unref?.();
19025
+ });
19026
+ sock.on("error", () => {
18875
19027
  try {
18876
- ws?.close();
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, devices) {
22743
+ function handledByView(raw, devices2) {
22579
22744
  return handledByOf(raw).map((e) => {
22580
- const d = devices.get(e.deviceId);
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, devices) {
22585
- const deviceIndex = devices ?? await loadDeviceIndex();
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 devices = await loadDeviceIndex();
22757
- const views = await Promise.all(ps.map((p) => toProjectView(p, sessions, devices)));
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) sendTo(c, s2);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {