@triflux/core 10.43.0 → 10.45.0

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.
Files changed (41) hide show
  1. package/hooks/codex-session-hook.mjs +67 -1
  2. package/hooks/cto-north-star-brief.mjs +3 -16
  3. package/hooks/error-context.mjs +3 -6
  4. package/hooks/hook-manager.mjs +1 -1
  5. package/hooks/hook-registry.json +0 -11
  6. package/hooks/keyword-rules.json +147 -23
  7. package/hooks/permission-safe-allow.mjs +36 -2
  8. package/hooks/session-start-fast.mjs +50 -1
  9. package/hooks/session-start-lake.mjs +3 -16
  10. package/hub/bridge.mjs +111 -5
  11. package/hub/cli-adapter-base.mjs +11 -2
  12. package/hub/codex-adapter.mjs +3 -0
  13. package/hub/gemini-adapter.mjs +29 -11
  14. package/hub/intent.mjs +24 -32
  15. package/hub/lib/memory-store.mjs +66 -30
  16. package/hub/lib/tfx-route-args.mjs +41 -4
  17. package/hub/reflexion.mjs +0 -57
  18. package/hub/router.mjs +474 -24
  19. package/hub/team/claude-daemon-control.mjs +2 -0
  20. package/hub/team/intervention.mjs +1 -1
  21. package/hub/team/retry-state-machine.mjs +41 -13
  22. package/hub/team/synapse-http.mjs +149 -4
  23. package/hud/cli-policy.mjs +69 -0
  24. package/hud/constants.mjs +20 -17
  25. package/hud/context-monitor.mjs +31 -21
  26. package/hud/hud-qos-status.mjs +81 -91
  27. package/hud/providers/claude.mjs +150 -84
  28. package/hud/providers/codex.mjs +10 -1
  29. package/hud/renderers.mjs +21 -59
  30. package/hud/utils.mjs +1 -15
  31. package/package.json +1 -1
  32. package/scripts/lib/agent-route-policy.mjs +367 -0
  33. package/scripts/lib/cli-agy.mjs +24 -4
  34. package/scripts/lib/cli-codex.mjs +22 -160
  35. package/scripts/lib/codex-profile-config.mjs +48 -4
  36. package/scripts/lib/gemini-profiles.mjs +113 -29
  37. package/scripts/lib/keyword-rules.mjs +24 -0
  38. package/scripts/lib/logger.mjs +60 -43
  39. package/scripts/lib/machine-profile.mjs +293 -0
  40. package/scripts/lib/mcp-health.mjs +44 -18
  41. package/hub/log-retention.mjs +0 -726
package/hub/bridge.mjs CHANGED
@@ -400,6 +400,16 @@ export function parseArgs(argv) {
400
400
  command: { type: "string" },
401
401
  role: { type: "string" },
402
402
  "session-id": { type: "string" },
403
+ cwd: { type: "string" },
404
+ "worktree-path": { type: "string" },
405
+ branch: { type: "string" },
406
+ host: { type: "string" },
407
+ "session-kind": { type: "string" },
408
+ "is-remote": { type: "string" },
409
+ "tmux-session": { type: "string" },
410
+ "tmux-pane": { type: "string" },
411
+ "omx-session-id": { type: "string" },
412
+ "codex-session-id": { type: "string" },
403
413
  reason: { type: "string" },
404
414
  type: { type: "string" },
405
415
  from: { type: "string" },
@@ -606,6 +616,71 @@ function unavailableResult() {
606
616
  return { ok: false, reason: "hub_unavailable" };
607
617
  }
608
618
 
619
+ function nonEmptyString(value, fallback = "") {
620
+ const normalized = String(value ?? "").trim();
621
+ return normalized || fallback;
622
+ }
623
+
624
+ function readBoolean(value) {
625
+ return ["1", "true", "yes"].includes(nonEmptyString(value).toLowerCase());
626
+ }
627
+
628
+ function sessionAgentId(sessionId) {
629
+ return `codex-session-${sessionId.replace(/[^A-Za-z0-9._-]/g, "_")}`;
630
+ }
631
+
632
+ export function buildInteractiveSessionRegistrationPayload(args = {}) {
633
+ const sessionId = nonEmptyString(args["session-id"]);
634
+ if (!sessionId) return null;
635
+
636
+ const cwd = nonEmptyString(args.cwd, process.cwd());
637
+ const worktreePath = nonEmptyString(args["worktree-path"], cwd);
638
+ const host = nonEmptyString(args.host, "local");
639
+ const sessionKind = nonEmptyString(args["session-kind"], "interactive");
640
+ const agentId = nonEmptyString(args.agent, sessionAgentId(sessionId));
641
+ const metadata = {
642
+ pid: process.ppid,
643
+ registered_at: Date.now(),
644
+ cwd,
645
+ worktree_path: worktreePath,
646
+ branch: nonEmptyString(args.branch),
647
+ host,
648
+ session_kind: sessionKind,
649
+ is_remote: readBoolean(args["is-remote"]),
650
+ ...(nonEmptyString(args["tmux-session"])
651
+ ? { tmux_session: nonEmptyString(args["tmux-session"]) }
652
+ : {}),
653
+ ...(nonEmptyString(args["tmux-pane"])
654
+ ? { tmux_pane: nonEmptyString(args["tmux-pane"]) }
655
+ : {}),
656
+ ...(nonEmptyString(args["omx-session-id"])
657
+ ? { omx_session_id: nonEmptyString(args["omx-session-id"]) }
658
+ : {}),
659
+ ...(nonEmptyString(args["codex-session-id"])
660
+ ? { codex_session_id: nonEmptyString(args["codex-session-id"]) }
661
+ : {}),
662
+ };
663
+
664
+ return {
665
+ sessionId,
666
+ cwd,
667
+ worktreePath,
668
+ branch: nonEmptyString(args.branch),
669
+ host,
670
+ sessionKind,
671
+ isRemote: readBoolean(args["is-remote"]),
672
+ agent_id: agentId,
673
+ cli: nonEmptyString(args.cli, "codex"),
674
+ capabilities: args.capabilities
675
+ ? String(args.capabilities).split(",")
676
+ : ["code"],
677
+ topics: args.topics ? String(args.topics).split(",") : [],
678
+ heartbeat_ttl_ms: 300000,
679
+ session_id: sessionId,
680
+ metadata,
681
+ };
682
+ }
683
+
609
684
  function emitJson(payload) {
610
685
  if (payload !== undefined) {
611
686
  console.log(JSON.stringify(payload));
@@ -613,17 +688,33 @@ function emitJson(payload) {
613
688
  return payload?.ok !== false;
614
689
  }
615
690
 
691
+ const HUB_STORE_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
692
+ function normalizeCliForStore(cli) {
693
+ return HUB_STORE_CLI_VALUES.has(cli) ? cli : "other";
694
+ }
695
+
616
696
  async function cmdRegister(args) {
697
+ const interactiveSession = buildInteractiveSessionRegistrationPayload(args);
698
+ if (interactiveSession) {
699
+ const result = await requestJson("/synapse/register", {
700
+ body: interactiveSession,
701
+ timeoutMs: 3000,
702
+ });
703
+ return emitJson(result || unavailableResult());
704
+ }
705
+
617
706
  const agentId = args.agent;
618
707
  const timeoutSec = parseInt(args.timeout || "600", 10);
708
+ const rawCli = args.cli || "other";
619
709
  const outcome = await requestHub(HUB_OPERATIONS.register, {
620
710
  agent_id: agentId,
621
- cli: args.cli || "other",
711
+ cli: normalizeCliForStore(rawCli),
622
712
  timeout_sec: timeoutSec,
623
713
  heartbeat_ttl_ms: (timeoutSec + 120) * 1000,
624
714
  topics: args.topics ? args.topics.split(",") : [],
625
715
  capabilities: args.capabilities ? args.capabilities.split(",") : ["code"],
626
716
  metadata: {
717
+ cli: rawCli,
627
718
  pid: process.ppid,
628
719
  registered_at: Date.now(),
629
720
  },
@@ -634,8 +725,12 @@ async function cmdRegister(args) {
634
725
  return emitJson({
635
726
  ok: true,
636
727
  agent_id: agentId,
728
+ lease_id: result.data?.lease_id,
729
+ presence_generation: result.data?.presence_generation,
637
730
  lease_expires_ms: result.data?.lease_expires_ms,
638
731
  pipe_path: result.data?.pipe_path || getHubPipePath(),
732
+ hub_instance_id: result.data?.hub_instance_id,
733
+ store_fingerprint: result.data?.store_fingerprint,
639
734
  });
640
735
  }
641
736
 
@@ -646,6 +741,10 @@ async function cmdHeartbeat(args) {
646
741
  const outcome = await requestHub(HUB_OPERATIONS.heartbeat, {
647
742
  agent_id: args.agent,
648
743
  ttl_ms: args["ttl-ms"] != null ? Number(args["ttl-ms"]) : undefined,
744
+ presence_generation:
745
+ args.generation != null ? Number(args.generation) : undefined,
746
+ hub_instance_id: args["hub-instance-id"],
747
+ store_fingerprint: args["store-fingerprint"],
649
748
  });
650
749
  return emitJson(outcome?.result || unavailableResult());
651
750
  }
@@ -731,6 +830,8 @@ async function cmdTakeoverRole(args) {
731
830
  agent_id: agentId,
732
831
  reason: args.reason || "manual",
733
832
  requested_by: args["requested-by"] || "bridge",
833
+ hub_instance_id: args["hub-instance-id"],
834
+ store_fingerprint: args["store-fingerprint"],
734
835
  });
735
836
  const result = outcome?.result;
736
837
  return emitJson(result || unavailableResult());
@@ -748,6 +849,8 @@ async function cmdSendInput(args) {
748
849
  async function cmdContext(args) {
749
850
  const outcome = await requestHub(HUB_OPERATIONS.context, {
750
851
  agent_id: args.agent,
852
+ presence_generation:
853
+ args.generation != null ? Number(args.generation) : undefined,
751
854
  topics: args.topics ? args.topics.split(",") : undefined,
752
855
  max_messages: parseInt(args.max || "10", 10),
753
856
  auto_ack: true,
@@ -793,13 +896,13 @@ async function cmdContext(args) {
793
896
  async function cmdDeregister(args) {
794
897
  const outcome = await requestHub(HUB_OPERATIONS.deregister, {
795
898
  agent_id: args.agent,
899
+ presence_generation:
900
+ args.generation != null ? Number(args.generation) : undefined,
901
+ hub_instance_id: args["hub-instance-id"],
902
+ store_fingerprint: args["store-fingerprint"],
796
903
  });
797
904
  const result = outcome?.result;
798
905
 
799
- if (result?.ok) {
800
- return emitJson({ ok: true, agent_id: args.agent, status: "offline" });
801
- }
802
-
803
906
  return emitJson(result || unavailableResult());
804
907
  }
805
908
 
@@ -1291,6 +1394,7 @@ async function cmdDaemonProbe(args) {
1291
1394
  short: payload.short,
1292
1395
  sessionId: payload.sessionId,
1293
1396
  timeoutMs: numericOption(payload.timeoutMs, 6000),
1397
+ tmpRoot: payload.tmpRoot,
1294
1398
  });
1295
1399
  return emitJson(probe);
1296
1400
  } catch (error) {
@@ -1314,6 +1418,7 @@ async function cmdDaemonAttach(args) {
1314
1418
  short: payload.short,
1315
1419
  sessionId: payload.sessionId,
1316
1420
  timeoutMs: numericOption(payload.timeoutMs, 6000),
1421
+ tmpRoot: payload.tmpRoot,
1317
1422
  });
1318
1423
  if (!probe.ok) return emitJson(daemonAttachProbeFailureResult(probe));
1319
1424
  const short = payload.short ?? probe.target?.short;
@@ -1382,6 +1487,7 @@ async function cmdDaemonInterrupt(args) {
1382
1487
  short: payload.short,
1383
1488
  sessionId: payload.sessionId,
1384
1489
  timeoutMs: numericOption(payload.timeoutMs, 6000),
1490
+ tmpRoot: payload.tmpRoot,
1385
1491
  });
1386
1492
  if (!probe.ok) return emitJson(daemonInterruptProbeFailureResult(probe));
1387
1493
  const short = payload.short ?? probe.target?.short;
@@ -167,7 +167,7 @@ export const CODEX_MCP_EXECUTION_EXIT_CODE = 1;
167
167
  *
168
168
  * @param {string} prompt
169
169
  * @param {string|null} resultFile — null이면 --output-last-message 생략
170
- * @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
170
+ * @param {{ profile?: string, codexHome?: string, disallowUltra?: boolean, enforceCanonicalProfile?: boolean, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
171
171
  * @returns {string} 실행할 셸 커맨드
172
172
  */
173
173
  export function buildExecCommand(prompt, resultFile = null, opts = {}) {
@@ -177,6 +177,9 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
177
177
  sandboxBypass = true,
178
178
  mcpServers,
179
179
  stdinPrompt,
180
+ codexHome,
181
+ disallowUltra,
182
+ enforceCanonicalProfile,
180
183
  } = opts;
181
184
 
182
185
  const parts = ["codex"];
@@ -185,7 +188,13 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
185
188
  // still contains an inline [profiles.X] table (and codex re-injects such
186
189
  // tables when it rewrites config.toml), so the `-c model=.. -c
187
190
  // model_reasoning_effort=..` form is immune and mutates no config.
188
- const profileOverrides = profile ? codexProfileConfigOverrides(profile) : [];
191
+ const profileOverrides = profile
192
+ ? codexProfileConfigOverrides(profile, {
193
+ codexHome,
194
+ disallowUltra,
195
+ enforceCanonicalProfile,
196
+ })
197
+ : [];
189
198
 
190
199
  if (FEATURES.execSubcommand) {
191
200
  parts.push("exec");
@@ -118,6 +118,9 @@ export function buildExecArgs(opts = {}) {
118
118
  const prompt = typeof opts.prompt === "string" ? opts.prompt : "";
119
119
  const command = buildExecCommand(prompt, opts.resultFile || null, {
120
120
  profile: opts.profile,
121
+ codexHome: opts.codexHome,
122
+ disallowUltra: opts.disallowUltra,
123
+ enforceCanonicalProfile: opts.enforceCanonicalProfile,
121
124
  skipGitRepoCheck: true,
122
125
  sandboxBypass: true,
123
126
  cwd: opts.cwd,
@@ -4,6 +4,10 @@
4
4
  import { mkdirSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
+ import {
8
+ resolveGeminiModel,
9
+ resolveGeminiProfileForPurpose,
10
+ } from "../scripts/lib/gemini-profiles.mjs";
7
11
  import {
8
12
  executeWithCircuitBroker,
9
13
  normalizePathForShell,
@@ -61,15 +65,13 @@ async function runPreflight(opts = {}) {
61
65
  // ── Command building ────────────────────────────────────────────
62
66
 
63
67
  function buildGeminiCommand(prompt, resultFile, opts = {}) {
64
- const parts = [
65
- "printf",
66
- "%s",
67
- shellSingleQuote(prompt),
68
- "|",
69
- "agy",
70
- "--print",
71
- "--dangerously-skip-permissions",
72
- ];
68
+ // agy 의 --print 는 값(프롬프트)을 받는 플래그다. 1.1.27 부터 stdin 프롬프트는
69
+ // "empty prompt" 로 거부되므로 프롬프트를 --print 의 값으로, 맨 끝에 넘긴다.
70
+ const parts = ["agy", "--dangerously-skip-permissions"];
71
+ if (typeof opts.model === "string" && opts.model.trim()) {
72
+ parts.push("--model", shellSingleQuote(opts.model.trim()));
73
+ }
74
+ parts.push("--print", shellSingleQuote(prompt));
73
75
 
74
76
  if (resultFile) {
75
77
  return `${parts.join(" ")} > ${shellSingleQuote(normalizePathForShell(resultFile))} 2>${shellSingleQuote(normalizePathForShell(resultFile + ".err"))}`;
@@ -78,11 +80,27 @@ function buildGeminiCommand(prompt, resultFile, opts = {}) {
78
80
  return parts.join(" ");
79
81
  }
80
82
 
83
+ function resolveAntigravityModel(opts = {}) {
84
+ // effort 는 역할별 SSOT(scripts/lib/gemini-profiles.mjs). 명시 model 이 우선한다.
85
+ const explicit = typeof opts.model === "string" ? opts.model.trim() : "";
86
+ if (explicit)
87
+ return resolveGeminiModel(explicit, {
88
+ profilesPath: opts.geminiProfilesPath,
89
+ });
90
+ if (!opts.role && !opts.agent) return "";
91
+ return resolveGeminiModel(
92
+ resolveGeminiProfileForPurpose(opts.role || opts.agent),
93
+ {
94
+ profilesPath: opts.geminiProfilesPath,
95
+ },
96
+ );
97
+ }
98
+
81
99
  function buildAttempts(opts, preflight) {
82
100
  const timeout = Number.isFinite(opts.timeout) ? opts.timeout : 900_000;
83
101
  const base = {
84
102
  timeout,
85
- model: opts.model,
103
+ model: resolveAntigravityModel(opts),
86
104
  allowedMcpServers: Array.isArray(opts.mcpServers)
87
105
  ? [...opts.mcpServers]
88
106
  : [],
@@ -98,7 +116,7 @@ function buildAttempts(opts, preflight) {
98
116
  export function buildExecArgs(opts = {}) {
99
117
  const prompt = typeof opts.prompt === "string" ? opts.prompt : "";
100
118
  return buildGeminiCommand(prompt, opts.resultFile || null, {
101
- model: opts.model,
119
+ model: resolveAntigravityModel(opts),
102
120
  allowedMcpServers: opts.mcpServers,
103
121
  });
104
122
  }
package/hub/intent.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
5
  import crypto from "node:crypto";
6
+ import { resolveCodexAgentPolicy } from "../scripts/lib/agent-route-policy.mjs";
6
7
  import { whichCommand } from "./platform.mjs";
7
8
 
8
9
  /** 캐시 엔트리: { category, confidence, ts } */
@@ -61,31 +62,27 @@ function _tryCodexClassify(prompt) {
61
62
  * triflux 특화 의도 카테고리 (10개)
62
63
  */
63
64
  export const INTENT_CATEGORIES = {
64
- // 모델 정책 (2026-04-25):
65
- // - GPT-5.6 family = 기존 semantic tier를 Sol/Terra/Luna lane으로 매핑
66
- // - gpt-5.4-mini = 자잘/부가/가성비 (fast tier 가능)
67
- // - gpt-5.3-codex = escalation 가성비 중간 (fast 미지원)
68
- // - 기존 effort는 유지하고 모델만 GPT-5.6 family로 교체
69
- implement: {
70
- agent: "executor",
71
- mcp: "implement",
72
- effort: "gpt56_terra_high",
73
- },
74
- debug: { agent: "debugger", mcp: "implement", effort: "gpt56_sol_xhigh" },
75
- analyze: { agent: "analyst", mcp: "analyze", effort: "gpt56_sol_xhigh" },
76
- design: { agent: "architect", mcp: "analyze", effort: "gpt56_sol_xhigh" },
77
- review: { agent: "code-reviewer", mcp: "review", effort: "gpt56_terra_high" },
78
- document: { agent: "writer", mcp: "docs", effort: "pro" },
79
- research: { agent: "scientist", mcp: "analyze", effort: "gpt56_terra_high" },
80
- "quick-fix": {
81
- agent: "build-fixer",
82
- mcp: "implement",
83
- effort: "gpt56_luna_low",
84
- },
85
- explain: { agent: "writer", mcp: "docs", effort: "flash" },
86
- test: { agent: "test-engineer", mcp: null, effort: null },
65
+ implement: { agent: "executor", mcp: "implement" },
66
+ debug: { agent: "debugger", mcp: "implement" },
67
+ analyze: { agent: "analyst", mcp: "analyze" },
68
+ design: { agent: "architect", mcp: "analyze" },
69
+ review: { agent: "code-reviewer", mcp: "review" },
70
+ document: { agent: "writer", mcp: "docs" },
71
+ research: { agent: "scientist", mcp: "analyze" },
72
+ "quick-fix": { agent: "build-fixer", mcp: "implement" },
73
+ explain: { agent: "writer", mcp: "docs" },
74
+ test: { agent: "test-engineer", mcp: null },
87
75
  };
88
76
 
77
+ function routingFor(category) {
78
+ const route = INTENT_CATEGORIES[category] || INTENT_CATEGORIES.implement;
79
+ return {
80
+ agent: route.agent,
81
+ mcp: route.mcp,
82
+ effort: resolveCodexAgentPolicy(route.agent).profile,
83
+ };
84
+ }
85
+
89
86
  /** @internal 키워드 → 카테고리 매핑 패턴 */
90
87
  const KEYWORD_PATTERNS = [
91
88
  {
@@ -267,17 +264,12 @@ export function classifyIntent(prompt) {
267
264
  // 캐시 확인
268
265
  const cached = _getCached(hash);
269
266
  if (cached) {
270
- const routing =
271
- INTENT_CATEGORIES[cached.category] || INTENT_CATEGORIES.implement;
267
+ const routing = routingFor(cached.category);
272
268
  return {
273
269
  category: cached.category,
274
270
  confidence: cached.confidence,
275
271
  reasoning: `cache-hit: ${cached.category} (${cached.confidence.toFixed(2)})`,
276
- routing: {
277
- agent: routing.agent,
278
- mcp: routing.mcp,
279
- effort: routing.effort,
280
- },
272
+ routing,
281
273
  };
282
274
  }
283
275
 
@@ -308,12 +300,12 @@ export function classifyIntent(prompt) {
308
300
  // 캐시 저장
309
301
  _intentCache.set(hash, { category, confidence, ts: Date.now() });
310
302
 
311
- const routing = INTENT_CATEGORIES[category] || INTENT_CATEGORIES.implement;
303
+ const routing = routingFor(category);
312
304
  return {
313
305
  category,
314
306
  confidence,
315
307
  reasoning,
316
- routing: { agent: routing.agent, mcp: routing.mcp, effort: routing.effort },
308
+ routing,
317
309
  };
318
310
  }
319
311
 
@@ -142,6 +142,7 @@ export function createMemoryStore() {
142
142
  const store = {
143
143
  type: "memory",
144
144
  db: null,
145
+ fingerprint: "memory:ephemeral",
145
146
  uuidv7,
146
147
 
147
148
  close() {},
@@ -166,6 +167,7 @@ export function createMemoryStore() {
166
167
  topics: clone(topics),
167
168
  last_seen_ms: now,
168
169
  lease_expires_ms: now + heartbeat_ttl_ms,
170
+ presence_generation: Number(current.presence_generation || 0) + 1,
169
171
  status: "online",
170
172
  metadata: clone(metadata),
171
173
  };
@@ -173,6 +175,7 @@ export function createMemoryStore() {
173
175
  return {
174
176
  agent_id,
175
177
  lease_id: uuidv7(),
178
+ presence_generation: next.presence_generation,
176
179
  lease_expires_ms: next.lease_expires_ms,
177
180
  server_time_ms: now,
178
181
  effective: {
@@ -183,6 +186,7 @@ export function createMemoryStore() {
183
186
  lease_expires_ms: next.lease_expires_ms,
184
187
  store_type: "memory",
185
188
  db_path: null,
189
+ store_fingerprint: "memory:ephemeral",
186
190
  },
187
191
  };
188
192
  },
@@ -191,14 +195,23 @@ export function createMemoryStore() {
191
195
  return clone(agents.get(id) || null);
192
196
  },
193
197
 
194
- refreshLease(agentId, ttlMs = 30000) {
198
+ refreshLease(agentId, ttlMs = 30000, presenceGeneration) {
195
199
  const current = agents.get(agentId);
196
- if (!current)
200
+ if (
201
+ !current ||
202
+ (presenceGeneration != null &&
203
+ current.presence_generation !== presenceGeneration)
204
+ )
197
205
  return {
198
206
  agent_id: agentId,
199
207
  lease_expires_ms: Date.now() + ttlMs,
200
208
  server_time_ms: Date.now(),
201
- effective: { updated: false, store_type: "memory", db_path: null },
209
+ effective: {
210
+ updated: false,
211
+ store_type: "memory",
212
+ db_path: null,
213
+ store_fingerprint: "memory:ephemeral",
214
+ },
202
215
  };
203
216
  const now = Date.now();
204
217
  current.last_seen_ms = now;
@@ -208,7 +221,12 @@ export function createMemoryStore() {
208
221
  agent_id: agentId,
209
222
  lease_expires_ms: current.lease_expires_ms,
210
223
  server_time_ms: now,
211
- effective: { updated: true, store_type: "memory", db_path: null },
224
+ effective: {
225
+ updated: true,
226
+ store_type: "memory",
227
+ db_path: null,
228
+ store_fingerprint: "memory:ephemeral",
229
+ },
212
230
  };
213
231
  },
214
232
 
@@ -243,9 +261,19 @@ export function createMemoryStore() {
243
261
 
244
262
  sweepStaleAgents() {
245
263
  const now = Date.now();
264
+ const candidates = Array.from(agents.values(), (agent) => ({
265
+ agent_id: agent.agent_id,
266
+ presence_generation: agent.presence_generation,
267
+ }));
246
268
  let stale = 0;
247
269
  let offline = 0;
248
- for (const agent of agents.values()) {
270
+ for (const candidate of candidates) {
271
+ const agent = agents.get(candidate.agent_id);
272
+ if (
273
+ !agent ||
274
+ agent.presence_generation !== candidate.presence_generation
275
+ )
276
+ continue;
249
277
  if (agent.status === "online" && agent.lease_expires_ms < now) {
250
278
  agent.status = "stale";
251
279
  stale += 1;
@@ -260,9 +288,14 @@ export function createMemoryStore() {
260
288
  return { stale, offline };
261
289
  },
262
290
 
263
- updateAgentStatus(agentId, status) {
291
+ updateAgentStatus(agentId, status, presenceGeneration) {
264
292
  const current = agents.get(agentId);
265
- if (!current) return false;
293
+ if (
294
+ !current ||
295
+ (presenceGeneration != null &&
296
+ current.presence_generation !== presenceGeneration)
297
+ )
298
+ return false;
266
299
  current.status = status;
267
300
  return true;
268
301
  },
@@ -421,21 +454,11 @@ export function createMemoryStore() {
421
454
  .map((request) => clone(request));
422
455
  },
423
456
 
424
- expireHumanRequests() {
425
- let changed = 0;
426
- const now = Date.now();
427
- for (const request of humanRequests.values()) {
428
- if (request.state === "pending" && request.deadline_ms < now) {
429
- request.state = "timed_out";
430
- changed += 1;
431
- }
432
- }
433
- return changed;
434
- },
435
-
436
457
  moveToDeadLetter(messageId, reason, lastError = null) {
437
458
  const current = messages.get(messageId);
438
- if (current) current.status = "dead_letter";
459
+ if (!current || current.status === "dead_letter") return false;
460
+ current.status = "dead_letter";
461
+ if (deadLetters.has(messageId)) return true;
439
462
  deadLetters.set(messageId, {
440
463
  message_id: messageId,
441
464
  reason,
@@ -685,25 +708,38 @@ export function createMemoryStore() {
685
708
  });
686
709
  },
687
710
 
688
- sweepExpired() {
689
- const now = Date.now();
690
- let expiredMessages = 0;
711
+ sweepExpired({ now_ms = Date.now() } = {}) {
712
+ const transitions = [];
691
713
  for (const message of messages.values()) {
692
- if (message.status === "queued" && message.expires_at_ms < now) {
693
- message.status = "dead_letter";
714
+ if (
715
+ !["queued", "delivered"].includes(message.status) ||
716
+ message.expires_at_ms > now_ms
717
+ )
718
+ continue;
719
+ const snapshot = clone(message);
720
+ const previousStatus = message.status;
721
+ message.status = "dead_letter";
722
+ const dlqInserted = !deadLetters.has(message.id);
723
+ if (dlqInserted) {
694
724
  deadLetters.set(message.id, {
695
725
  message_id: message.id,
696
726
  reason: "ttl_expired",
697
- failed_at_ms: now,
727
+ failed_at_ms: now_ms,
698
728
  last_error: null,
699
729
  });
700
- expiredMessages += 1;
701
730
  }
731
+ transitions.push({
732
+ message: snapshot,
733
+ previous_status: previousStatus,
734
+ reason: "ttl_expired",
735
+ failed_at_ms: now_ms,
736
+ dlq_inserted: dlqInserted,
737
+ });
702
738
  }
703
- const humanRequestsExpired = store.expireHumanRequests();
704
739
  return {
705
- messages: expiredMessages,
706
- human_requests: humanRequestsExpired,
740
+ now_ms,
741
+ messages: transitions.length,
742
+ transitions,
707
743
  };
708
744
  },
709
745