sentinelayer-cli 0.30.2 → 0.30.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.30.2",
3
+ "version": "0.30.4",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -32,6 +32,7 @@ import {
32
32
  registerAgent,
33
33
  unregisterAgent,
34
34
  } from "../session/agent-registry.js";
35
+ import { inferSessionAgentIdentity } from "../session/agent-identity.js";
35
36
  import { startSenti, stopSenti } from "../session/daemon.js";
36
37
  import {
37
38
  getDaemonStatus,
@@ -75,6 +76,8 @@ import { appendToStream, readStream, tailStream } from "../session/stream.js";
75
76
  import {
76
77
  addSessionEventIdentityKeys,
77
78
  dedupeSessionEvents,
79
+ sessionEventIdentityKeys,
80
+ sessionEventUpgradesExisting,
78
81
  sessionEventHasKnownIdentity,
79
82
  } from "../session/event-identity.js";
80
83
  import { readSessionPreview } from "../session/preview.js";
@@ -1607,20 +1610,30 @@ async function publishListenerPresenceEvent({
1607
1610
  agentId,
1608
1611
  agentModel = "cli",
1609
1612
  displayName = "",
1613
+ provider = "",
1614
+ clientKind = "cli",
1610
1615
  listenerId,
1611
1616
  lifecycle = {},
1612
1617
  } = {}) {
1613
1618
  const normalizedType = normalizeString(lifecycle.type) || "heartbeat";
1614
1619
  const eventName = listenerLifecycleEventName(normalizedType);
1620
+ const identity = inferSessionAgentIdentity({
1621
+ agentId,
1622
+ model: agentModel,
1623
+ displayName,
1624
+ provider,
1625
+ clientKind,
1626
+ });
1615
1627
  const event = createAgentEvent({
1616
1628
  event: eventName,
1617
1629
  sessionId,
1618
1630
  agent: {
1619
1631
  id: agentId,
1620
- model: normalizeString(agentModel) || "cli",
1632
+ model: normalizeString(identity.model) || "cli",
1621
1633
  role: "listener",
1622
- displayName: normalizeString(displayName) || agentId,
1623
- clientKind: "cli",
1634
+ displayName: normalizeString(identity.displayName) || agentId,
1635
+ provider: normalizeString(identity.provider) || undefined,
1636
+ clientKind: normalizeString(identity.clientKind) || "cli",
1624
1637
  },
1625
1638
  eventId: `session-listener-${listenerId}-${normalizedType}-${lifecycle.pollCount ?? 0}`,
1626
1639
  idempotencyToken: `session-listener:${listenerId}:${normalizedType}:${lifecycle.pollCount ?? 0}`,
@@ -1676,20 +1689,30 @@ export function buildSessionCoachingEvent({
1676
1689
  agentId,
1677
1690
  agentModel = "cli",
1678
1691
  displayName = "",
1692
+ provider = "",
1693
+ clientKind = "cli",
1679
1694
  listenerId = "",
1680
1695
  tick = 0,
1681
1696
  tips = SESSION_LIVE_SUCCESS_TIPS,
1682
1697
  } = {}) {
1683
1698
  const tipList = Array.isArray(tips) && tips.length ? tips : SESSION_LIVE_SUCCESS_TIPS;
1699
+ const identity = inferSessionAgentIdentity({
1700
+ agentId,
1701
+ model: agentModel,
1702
+ displayName,
1703
+ provider,
1704
+ clientKind,
1705
+ });
1684
1706
  return createAgentEvent({
1685
1707
  event: "session_coaching",
1686
1708
  sessionId,
1687
1709
  agent: {
1688
1710
  id: agentId,
1689
- model: normalizeString(agentModel) || "cli",
1711
+ model: normalizeString(identity.model) || "cli",
1690
1712
  role: "listener",
1691
- displayName: normalizeString(displayName) || agentId,
1692
- clientKind: "cli",
1713
+ displayName: normalizeString(identity.displayName) || agentId,
1714
+ provider: normalizeString(identity.provider) || undefined,
1715
+ clientKind: normalizeString(identity.clientKind) || "cli",
1693
1716
  },
1694
1717
  eventId: `session-coaching-${listenerId || agentId}-${tick}`,
1695
1718
  idempotencyToken: `session-coaching:${listenerId || agentId}:${tick}`,
@@ -1707,20 +1730,30 @@ function buildListenerCatchupEvent({
1707
1730
  agentId,
1708
1731
  agentModel = "cli",
1709
1732
  displayName = "",
1733
+ provider = "",
1734
+ clientKind = "cli",
1710
1735
  listenerId,
1711
1736
  catchup = {},
1712
1737
  } = {}) {
1713
1738
  const message = formatListenerCatchupNotice(catchup);
1714
1739
  const pollCount = Number(catchup.pollCount || 0);
1740
+ const identity = inferSessionAgentIdentity({
1741
+ agentId,
1742
+ model: agentModel,
1743
+ displayName,
1744
+ provider,
1745
+ clientKind,
1746
+ });
1715
1747
  return createAgentEvent({
1716
1748
  event: "session_listen_catchup",
1717
1749
  sessionId,
1718
1750
  agent: {
1719
1751
  id: agentId,
1720
- model: normalizeString(agentModel) || "cli",
1752
+ model: normalizeString(identity.model) || "cli",
1721
1753
  role: "listener",
1722
- displayName: normalizeString(displayName) || agentId,
1723
- clientKind: "cli",
1754
+ displayName: normalizeString(identity.displayName) || agentId,
1755
+ provider: normalizeString(identity.provider) || undefined,
1756
+ clientKind: normalizeString(identity.clientKind) || "cli",
1724
1757
  },
1725
1758
  eventId: `session-listener-${listenerId}-catchup-${pollCount}`,
1726
1759
  idempotencyToken: `session-listener:${listenerId}:catchup:${pollCount}`,
@@ -1959,6 +1992,9 @@ async function ensureSessionSayAgentRegistered(
1959
1992
  const registered = await rememberAgentIdentity(sessionId, {
1960
1993
  agentId,
1961
1994
  model: normalizeString(agent.model) || "cli",
1995
+ displayName: normalizeString(agent.displayName),
1996
+ provider: normalizeString(agent.provider),
1997
+ clientKind: normalizeString(agent.clientKind) || "cli",
1962
1998
  role: sessionSayRegistryRole(agent.role),
1963
1999
  targetPath,
1964
2000
  });
@@ -1995,12 +2031,24 @@ async function resolveSessionAgentEnvelope(
1995
2031
  const resolvedRole = normalizeString(role) || normalizeString(registeredAgent?.role);
1996
2032
  const resolvedDisplayName =
1997
2033
  normalizeString(displayName) || normalizeString(registeredAgent?.displayName);
2034
+ const identity = inferSessionAgentIdentity({
2035
+ agentId: normalizedAgentId,
2036
+ model: resolvedModel,
2037
+ displayName: resolvedDisplayName,
2038
+ provider: registeredAgent?.provider,
2039
+ clientKind: normalizeString(clientKind) || normalizeString(registeredAgent?.clientKind) || "cli",
2040
+ });
1998
2041
  const envelope = {
1999
2042
  id: normalizedAgentId,
2000
- model: resolvedModel || undefined,
2043
+ model: normalizeString(identity.model) || resolvedModel || undefined,
2001
2044
  role: resolvedRole || undefined,
2002
- displayName: resolvedDisplayName || undefined,
2003
- clientKind: normalizeString(clientKind) || undefined,
2045
+ displayName: normalizeString(identity.displayName) || resolvedDisplayName || undefined,
2046
+ provider: normalizeString(identity.provider) || undefined,
2047
+ clientKind:
2048
+ normalizeString(identity.clientKind) ||
2049
+ normalizeString(clientKind) ||
2050
+ normalizeString(registeredAgent?.clientKind) ||
2051
+ undefined,
2004
2052
  };
2005
2053
  return Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined));
2006
2054
  }
@@ -2242,6 +2290,7 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2242
2290
  reason: "",
2243
2291
  remoteEvents: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
2244
2292
  appended: appended.appended,
2293
+ upgraded: appended.upgraded,
2245
2294
  skipped: appended.skipped,
2246
2295
  failed: appended.failed,
2247
2296
  cursor: remoteTail.cursor || null,
@@ -2259,6 +2308,34 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2259
2308
  }
2260
2309
  }
2261
2310
 
2311
+ function remoteSessionEventUpgradesLocal(localEvent = {}, remoteEvent = {}) {
2312
+ return sessionEventUpgradesExisting(localEvent, remoteEvent);
2313
+ }
2314
+
2315
+ function indexSessionEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
2316
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
2317
+ for (const key of sessionEventIdentityKeys(event)) {
2318
+ const existingIndex = indexByKey.get(key);
2319
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
2320
+ ? eventList[existingIndex]
2321
+ : null;
2322
+ if (!existingEvent || remoteSessionEventUpgradesLocal(existingEvent, event)) {
2323
+ indexByKey.set(key, index);
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ function findSessionEventIdentityIndex(indexByKey, event = {}) {
2329
+ if (!indexByKey) return -1;
2330
+ for (const key of sessionEventIdentityKeys(event)) {
2331
+ const index = indexByKey.get(key);
2332
+ if (Number.isInteger(index) && index >= 0) {
2333
+ return index;
2334
+ }
2335
+ }
2336
+ return -1;
2337
+ }
2338
+
2262
2339
  async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
2263
2340
  const events = Array.isArray(remoteEvents) ? remoteEvents : [];
2264
2341
  if (events.length === 0) {
@@ -2269,18 +2346,47 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2269
2346
  };
2270
2347
  }
2271
2348
  const knownKeys = new Set();
2349
+ const localIndexByKey = new Map();
2272
2350
  const localEvents = await readStream(sessionId, {
2273
2351
  targetPath,
2274
2352
  tail: 0,
2275
2353
  });
2276
- for (const event of localEvents) {
2354
+ for (const [index, event] of localEvents.entries()) {
2277
2355
  addSessionEventIdentityKeys(knownKeys, event);
2356
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, event, index);
2278
2357
  }
2279
2358
 
2280
2359
  let appended = 0;
2360
+ let upgraded = 0;
2281
2361
  let skipped = 0;
2282
2362
  let failed = 0;
2283
2363
  for (const event of events) {
2364
+ const existingIndex = findSessionEventIdentityIndex(localIndexByKey, event);
2365
+ if (existingIndex >= 0) {
2366
+ const existing = localEvents[existingIndex];
2367
+ if (!remoteSessionEventUpgradesLocal(existing, event)) {
2368
+ skipped += 1;
2369
+ continue;
2370
+ }
2371
+ // Keep the richer canonical API row in local history when it
2372
+ // upgrades an optimistic/local post that had no durable cursor yet.
2373
+ // Future reads can then page by sequence instead of rediscovering the
2374
+ // upgrade every time.
2375
+ try {
2376
+ const persisted = await appendToStream(sessionId, event, {
2377
+ targetPath,
2378
+ syncRemote: false,
2379
+ });
2380
+ localEvents.push(persisted);
2381
+ addSessionEventIdentityKeys(knownKeys, persisted);
2382
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, persisted, localEvents.length - 1);
2383
+ upgraded += 1;
2384
+ } catch {
2385
+ addSessionEventIdentityKeys(knownKeys, event);
2386
+ failed += 1;
2387
+ }
2388
+ continue;
2389
+ }
2284
2390
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
2285
2391
  skipped += 1;
2286
2392
  continue;
@@ -2299,6 +2405,7 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2299
2405
  }
2300
2406
  return {
2301
2407
  appended,
2408
+ upgraded,
2302
2409
  skipped,
2303
2410
  failed,
2304
2411
  };
@@ -3021,6 +3128,8 @@ export function registerSessionCommand(program) {
3021
3128
  targetPath,
3022
3129
  agentId: resolvedAgentId,
3023
3130
  model,
3131
+ displayName: normalizeString(acceptedOnboarding?.displayName),
3132
+ clientKind: "cli",
3024
3133
  role,
3025
3134
  trackProcessExit: false,
3026
3135
  awaitRemoteSync: hasConcreteAgentIdentity,
@@ -3059,6 +3168,9 @@ export function registerSessionCommand(program) {
3059
3168
  agentId: joined.agentId,
3060
3169
  role: joined.role,
3061
3170
  model: joined.model,
3171
+ displayName: joined.displayName || null,
3172
+ provider: joined.provider || null,
3173
+ clientKind: joined.clientKind || null,
3062
3174
  status: joined.status,
3063
3175
  joinedAt: joined.joinedAt,
3064
3176
  materializedLocalSession: localSession.materialized,
@@ -3305,12 +3417,19 @@ export function registerSessionCommand(program) {
3305
3417
  if (to) {
3306
3418
  eventPayload.to = to;
3307
3419
  }
3420
+ const agentIdentity = inferSessionAgentIdentity({
3421
+ agentId,
3422
+ model: options.model,
3423
+ displayName: options.displayName,
3424
+ clientKind: "cli",
3425
+ });
3308
3426
  const agent = {
3309
3427
  id: agentId,
3310
- model: normalizeString(options.model) || "cli",
3311
- displayName: normalizeString(options.displayName) || undefined,
3428
+ model: normalizeString(agentIdentity.model) || "cli",
3429
+ displayName: normalizeString(agentIdentity.displayName) || undefined,
3430
+ provider: normalizeString(agentIdentity.provider) || undefined,
3312
3431
  role: normalizeString(options.role) || "coder",
3313
- clientKind: "cli",
3432
+ clientKind: normalizeString(agentIdentity.clientKind) || "cli",
3314
3433
  };
3315
3434
  const event = createAgentEvent({
3316
3435
  event: "session_message",
@@ -3992,8 +4111,16 @@ export function registerSessionCommand(program) {
3992
4111
  "presence-interval",
3993
4112
  60,
3994
4113
  );
3995
- const agentModel = normalizeString(options.model) || "cli";
3996
- const displayName = normalizeString(options.displayName) || agentId;
4114
+ const listenerIdentity = inferSessionAgentIdentity({
4115
+ agentId,
4116
+ model: options.model,
4117
+ displayName: options.displayName,
4118
+ clientKind: "cli",
4119
+ });
4120
+ const agentModel = normalizeString(listenerIdentity.model) || "cli";
4121
+ const displayName = normalizeString(listenerIdentity.displayName) || agentId;
4122
+ const provider = normalizeString(listenerIdentity.provider);
4123
+ const clientKind = normalizeString(listenerIdentity.clientKind) || "cli";
3997
4124
  const limit = parsePositiveInteger(options.limit, "limit", 200);
3998
4125
  const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
3999
4126
  if (!["ndjson", "text"].includes(emitFormat)) {
@@ -4085,6 +4212,8 @@ export function registerSessionCommand(program) {
4085
4212
  agentId,
4086
4213
  agentModel,
4087
4214
  displayName,
4215
+ provider,
4216
+ clientKind,
4088
4217
  listenerId,
4089
4218
  tick: coachingTick++,
4090
4219
  }),
@@ -4155,6 +4284,8 @@ export function registerSessionCommand(program) {
4155
4284
  agentId,
4156
4285
  agentModel,
4157
4286
  displayName,
4287
+ provider,
4288
+ clientKind,
4158
4289
  listenerId,
4159
4290
  catchup,
4160
4291
  }),
@@ -4257,6 +4388,8 @@ export function registerSessionCommand(program) {
4257
4388
  agentId,
4258
4389
  agentModel,
4259
4390
  displayName,
4391
+ provider,
4392
+ clientKind,
4260
4393
  listenerId,
4261
4394
  lifecycle,
4262
4395
  });
@@ -4882,12 +5015,26 @@ export function registerSessionCommand(program) {
4882
5015
  const displayEvents = [...allEvents];
4883
5016
  let remoteTailAppended = 0;
4884
5017
  let remoteTailDisplayedOnly = 0;
5018
+ let remoteTailUpgraded = 0;
4885
5019
  if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
4886
5020
  const knownKeys = new Set();
4887
- for (const event of allEvents) {
5021
+ const displayIndexByKey = new Map();
5022
+ for (const [index, event] of displayEvents.entries()) {
4888
5023
  addSessionEventIdentityKeys(knownKeys, event);
5024
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, index);
4889
5025
  }
4890
5026
  for (const event of remoteTail.events) {
5027
+ const existingIndex = findSessionEventIdentityIndex(displayIndexByKey, event);
5028
+ if (existingIndex >= 0) {
5029
+ const existing = displayEvents[existingIndex];
5030
+ if (remoteSessionEventUpgradesLocal(existing, event)) {
5031
+ displayEvents[existingIndex] = event;
5032
+ addSessionEventIdentityKeys(knownKeys, event);
5033
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, existingIndex);
5034
+ remoteTailUpgraded += 1;
5035
+ }
5036
+ continue;
5037
+ }
4891
5038
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
4892
5039
  continue;
4893
5040
  }
@@ -4898,10 +5045,12 @@ export function registerSessionCommand(program) {
4898
5045
  });
4899
5046
  displayEvents.push(appended);
4900
5047
  addSessionEventIdentityKeys(knownKeys, appended);
5048
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, appended, displayEvents.length - 1);
4901
5049
  remoteTailAppended += 1;
4902
5050
  } catch {
4903
5051
  displayEvents.push(event);
4904
5052
  addSessionEventIdentityKeys(knownKeys, event);
5053
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, displayEvents.length - 1);
4905
5054
  remoteTailDisplayedOnly += 1;
4906
5055
  }
4907
5056
  }
@@ -4915,10 +5064,11 @@ export function registerSessionCommand(program) {
4915
5064
  const recentHumanActivity = remoteActions?.ok
4916
5065
  ? readProjectionRecentHumanActivity(remoteActions)
4917
5066
  : [];
5067
+ const dedupedDisplayEvents = dedupeSessionEvents(displayEvents);
4918
5068
  const transcriptEvents = includeControlEvents
4919
- ? displayEvents
4920
- : displayEvents.filter((event) => !isSessionControlEvent(event));
4921
- const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
5069
+ ? dedupedDisplayEvents
5070
+ : dedupedDisplayEvents.filter((event) => !isSessionControlEvent(event));
5071
+ const hiddenControlEventCount = dedupedDisplayEvents.length - transcriptEvents.length;
4922
5072
  const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
4923
5073
  const autoView = recordSessionReadViews(normalizedSessionId, events, {
4924
5074
  targetPath,
@@ -4968,6 +5118,7 @@ export function registerSessionCommand(program) {
4968
5118
  verified: Boolean(remoteTail.ok),
4969
5119
  appended: remoteTailAppended,
4970
5120
  displayedOnly: remoteTailDisplayedOnly,
5121
+ upgraded: remoteTailUpgraded,
4971
5122
  controlEventCount: remoteTailStats?.controlEventCount || 0,
4972
5123
  materialEventCount: remoteTailStats?.materialEventCount || 0,
4973
5124
  latestEvent: remoteTailStats?.latestEvent || null,
@@ -0,0 +1,119 @@
1
+ function normalizeString(value) {
2
+ return String(value || "").trim();
3
+ }
4
+
5
+ function normalizeLookup(value) {
6
+ return normalizeString(value).toLowerCase();
7
+ }
8
+
9
+ function titleFromIdentifier(value) {
10
+ const parts = normalizeString(value)
11
+ .replace(/[_-]+/g, " ")
12
+ .split(/\s+/)
13
+ .map((part) => part.trim())
14
+ .filter(Boolean);
15
+ if (!parts.length) return "";
16
+ return parts
17
+ .map((part) => {
18
+ if (/^[0-9]+$/.test(part)) return part;
19
+ return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
20
+ })
21
+ .join(" ");
22
+ }
23
+
24
+ function isWeakModel(value) {
25
+ const normalized = normalizeLookup(value);
26
+ return !normalized || ["anonymous", "cli", "unknown", "unreported"].includes(normalized);
27
+ }
28
+
29
+ function inferFamily(agentId, model) {
30
+ const haystack = `${agentId} ${model}`.toLowerCase();
31
+ if (haystack.includes("claude") || haystack.includes("sonnet") || haystack.includes("opus")) {
32
+ return "claude";
33
+ }
34
+ if (haystack.includes("codex") || haystack.includes("gpt-") || haystack.includes("openai")) {
35
+ return "codex";
36
+ }
37
+ if (haystack.includes("gemini") || haystack.includes("google")) return "gemini";
38
+ if (haystack.includes("grok") || haystack.includes("xai")) return "grok";
39
+ if (haystack.includes("senti") || haystack.includes("sentinelayer")) return "senti";
40
+ return "";
41
+ }
42
+
43
+ function inferModel(family) {
44
+ if (family === "codex") return "gpt-5-codex";
45
+ if (family === "claude") return "claude";
46
+ if (family === "gemini") return "gemini";
47
+ if (family === "grok") return "grok";
48
+ if (family === "senti") return "gpt-5.4-mini";
49
+ return "";
50
+ }
51
+
52
+ function inferProvider(family, model) {
53
+ const normalizedModel = normalizeLookup(model);
54
+ if (family === "claude" || normalizedModel.includes("claude")) return "anthropic";
55
+ if (family === "codex" || normalizedModel.includes("gpt") || normalizedModel.includes("openai")) {
56
+ return "openai";
57
+ }
58
+ if (family === "gemini" || normalizedModel.includes("gemini")) return "google";
59
+ if (family === "grok" || normalizedModel.includes("grok")) return "xai";
60
+ if (family === "senti") return "sentinelayer";
61
+ return "";
62
+ }
63
+
64
+ function inferClientKind(family) {
65
+ if (family === "codex") return "codex";
66
+ if (family === "claude") return "claude";
67
+ if (family === "gemini") return "gemini";
68
+ if (family === "grok") return "grok";
69
+ if (family === "senti") return "senti";
70
+ return "";
71
+ }
72
+
73
+ function inferDisplayName(agentId, family) {
74
+ const normalizedId = normalizeLookup(agentId);
75
+ if (normalizedId === "senti") return "Senti";
76
+ if (normalizedId.includes("claude-verifier")) return "Claude Verifier";
77
+ if (normalizedId.startsWith("claude")) return "Claude";
78
+ if (normalizedId.startsWith("codex")) return "Codex";
79
+ if (normalizedId.startsWith("gemini")) return "Gemini";
80
+ if (normalizedId.startsWith("grok")) return "Grok";
81
+ if (family) return titleFromIdentifier(family);
82
+ return titleFromIdentifier(agentId);
83
+ }
84
+
85
+ export function inferSessionAgentIdentity(input = {}) {
86
+ const source =
87
+ input && typeof input === "object" && !Array.isArray(input)
88
+ ? input
89
+ : {};
90
+ const {
91
+ agentId = "",
92
+ model = "",
93
+ displayName = "",
94
+ provider = "",
95
+ clientKind = "",
96
+ } = source;
97
+ const normalizedAgentId = normalizeString(agentId);
98
+ const explicitModel = normalizeString(model);
99
+ const family = inferFamily(normalizedAgentId, explicitModel);
100
+ const resolvedModel = isWeakModel(explicitModel)
101
+ ? inferModel(family) || explicitModel || "unknown"
102
+ : explicitModel;
103
+ const resolvedProvider = normalizeString(provider) || inferProvider(family, resolvedModel);
104
+ const resolvedClientKind = normalizeString(clientKind) || inferClientKind(family) || "cli";
105
+ const resolvedDisplayName =
106
+ normalizeString(displayName) || inferDisplayName(normalizedAgentId, family);
107
+
108
+ return Object.fromEntries(
109
+ Object.entries({
110
+ agentId: normalizedAgentId,
111
+ model: resolvedModel,
112
+ displayName: resolvedDisplayName,
113
+ provider: resolvedProvider,
114
+ clientKind: resolvedClientKind,
115
+ }).filter(([, value]) => normalizeString(value)),
116
+ );
117
+ }
118
+
119
+ export { titleFromIdentifier };
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
 
6
6
  import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
7
7
  import { createAgentEvent } from "../events/schema.js";
8
+ import { inferSessionAgentIdentity } from "./agent-identity.js";
8
9
  import { resolveSessionPaths } from "./paths.js";
9
10
  import { emitContextBriefing } from "./recap.js";
10
11
  import {
@@ -120,6 +121,9 @@ function normalizeAgentSnapshot(snapshot = {}, nowIso = new Date().toISOString()
120
121
  sessionId: normalizeString(snapshot.sessionId),
121
122
  agentId: normalizeString(snapshot.agentId),
122
123
  model: normalizeString(snapshot.model) || "unknown",
124
+ displayName: normalizeString(snapshot.displayName) || "",
125
+ provider: normalizeString(snapshot.provider) || "",
126
+ clientKind: normalizeString(snapshot.clientKind) || "",
123
127
  role: normalizeRole(snapshot.role || "observer"),
124
128
  status: normalizeStatus(snapshot.status, "idle"),
125
129
  detail: normalizeString(snapshot.detail) || "",
@@ -149,13 +153,30 @@ async function readAgentSnapshot(snapshotPath) {
149
153
  }
150
154
  }
151
155
 
152
- async function writeAgentSnapshot(snapshotPath, snapshot) {
156
+ const _snapshotWriteQueues = new Map();
157
+
158
+ async function writeAgentSnapshotFile(snapshotPath, snapshot) {
153
159
  await fsp.mkdir(path.dirname(snapshotPath), { recursive: true });
154
- const tmpPath = `${snapshotPath}.${process.pid}.${Date.now()}.tmp`;
160
+ const tmpPath = `${snapshotPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}.tmp`;
155
161
  await fsp.writeFile(tmpPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf-8");
156
162
  await fsp.rename(tmpPath, snapshotPath);
157
163
  }
158
164
 
165
+ async function writeAgentSnapshot(snapshotPath, snapshot) {
166
+ const previous = _snapshotWriteQueues.get(snapshotPath) || Promise.resolve();
167
+ const write = previous
168
+ .catch(() => {})
169
+ .then(() => writeAgentSnapshotFile(snapshotPath, snapshot));
170
+ _snapshotWriteQueues.set(snapshotPath, write);
171
+ try {
172
+ await write;
173
+ } finally {
174
+ if (_snapshotWriteQueues.get(snapshotPath) === write) {
175
+ _snapshotWriteQueues.delete(snapshotPath);
176
+ }
177
+ }
178
+ }
179
+
159
180
  async function emitAgentEvent(
160
181
  sessionId,
161
182
  event,
@@ -164,7 +185,14 @@ async function emitAgentEvent(
164
185
  ) {
165
186
  const envelope = createAgentEvent({
166
187
  event,
167
- agentId: payload.agentId,
188
+ agent: {
189
+ id: payload.agentId,
190
+ model: payload.model,
191
+ displayName: payload.displayName,
192
+ provider: payload.provider,
193
+ role: payload.role,
194
+ clientKind: payload.clientKind,
195
+ },
168
196
  sessionId,
169
197
  payload,
170
198
  });
@@ -198,13 +226,15 @@ function isActiveAgentSnapshot(snapshot) {
198
226
  return true;
199
227
  }
200
228
 
201
- function chooseAgentRefreshModel(value, existingModel) {
229
+ function chooseAgentRefreshModel(value, existingModel, sourceValue = value) {
202
230
  const candidate = normalizeString(value);
203
231
  const existing = normalizeString(existingModel);
232
+ const source = normalizeString(sourceValue).toLowerCase();
233
+ const sourceIsWeak = !source || ["anonymous", "cli", "unknown", "unreported"].includes(source);
204
234
  if (!candidate) {
205
235
  return existing || "unknown";
206
236
  }
207
- if (existing && ["cli", "unknown"].includes(candidate.toLowerCase())) {
237
+ if (existing && (sourceIsWeak || ["cli", "unknown"].includes(candidate.toLowerCase()))) {
208
238
  return existing;
209
239
  }
210
240
  return candidate;
@@ -222,6 +252,19 @@ function chooseAgentRefreshRole(value, existingRole) {
222
252
  return candidate;
223
253
  }
224
254
 
255
+ function chooseAgentRefreshMetadata(value, existingValue, sourceValue = value) {
256
+ const candidate = normalizeString(value);
257
+ const existing = normalizeString(existingValue);
258
+ const source = normalizeString(sourceValue);
259
+ if (!candidate) {
260
+ return existing;
261
+ }
262
+ if (existing && !source) {
263
+ return existing;
264
+ }
265
+ return candidate;
266
+ }
267
+
225
268
  // In-process registry of agents registered by *this* CLI process. The
226
269
  // dashboard treats any participant without a terminal agent_leave /
227
270
  // agent_killed / session_killed event as "active". When a CLI exits via
@@ -283,6 +326,9 @@ export async function registerAgent(
283
326
  {
284
327
  agentId = "",
285
328
  model = "",
329
+ displayName = "",
330
+ provider = "",
331
+ clientKind = "",
286
332
  role = "observer",
287
333
  targetPath = process.cwd(),
288
334
  trackProcessExit = true,
@@ -311,6 +357,14 @@ export async function registerAgent(
311
357
  }
312
358
  }
313
359
 
360
+ const identity = inferSessionAgentIdentity({
361
+ agentId: resolvedAgentId,
362
+ model,
363
+ displayName,
364
+ provider,
365
+ clientKind,
366
+ });
367
+
314
368
  const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
315
369
  const existing = await readAgentSnapshot(snapshotPath);
316
370
 
@@ -320,7 +374,10 @@ export async function registerAgent(
320
374
  ...existing,
321
375
  sessionId: paths.sessionId,
322
376
  agentId: resolvedAgentId,
323
- model: chooseAgentRefreshModel(model, existing.model),
377
+ model: chooseAgentRefreshModel(identity.model, existing.model, model),
378
+ displayName: chooseAgentRefreshMetadata(identity.displayName, existing.displayName, displayName),
379
+ provider: chooseAgentRefreshMetadata(identity.provider, existing.provider, provider),
380
+ clientKind: chooseAgentRefreshMetadata(identity.clientKind, existing.clientKind, clientKind),
324
381
  role: chooseAgentRefreshRole(role, existing.role),
325
382
  status: normalizeString(existing.status) || "idle",
326
383
  detail: normalizeString(existing.detail) || "",
@@ -350,7 +407,10 @@ export async function registerAgent(
350
407
  {
351
408
  sessionId: paths.sessionId,
352
409
  agentId: resolvedAgentId,
353
- model: normalizeString(model) || "unknown",
410
+ model: normalizeString(identity.model) || "unknown",
411
+ displayName: normalizeString(identity.displayName),
412
+ provider: normalizeString(identity.provider),
413
+ clientKind: normalizeString(identity.clientKind),
354
414
  role,
355
415
  status: "idle",
356
416
  detail: "",
@@ -369,6 +429,9 @@ export async function registerAgent(
369
429
  await emitAgentEvent(paths.sessionId, "agent_join", {
370
430
  agentId: snapshot.agentId,
371
431
  model: snapshot.model,
432
+ displayName: snapshot.displayName,
433
+ provider: snapshot.provider,
434
+ clientKind: snapshot.clientKind,
372
435
  role: snapshot.role,
373
436
  status: snapshot.status,
374
437
  }, { targetPath, awaitRemoteSync });
@@ -412,6 +475,9 @@ export async function rememberAgentIdentity(
412
475
  {
413
476
  agentId = "",
414
477
  model = "",
478
+ displayName = "",
479
+ provider = "",
480
+ clientKind = "",
415
481
  role = "observer",
416
482
  targetPath = process.cwd(),
417
483
  } = {}
@@ -422,6 +488,13 @@ export async function rememberAgentIdentity(
422
488
  if (!resolvedAgentId) {
423
489
  throw new Error("agentId is required.");
424
490
  }
491
+ const identity = inferSessionAgentIdentity({
492
+ agentId: resolvedAgentId,
493
+ model,
494
+ displayName,
495
+ provider,
496
+ clientKind,
497
+ });
425
498
 
426
499
  const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
427
500
  const existing = await readAgentSnapshot(snapshotPath);
@@ -430,7 +503,10 @@ export async function rememberAgentIdentity(
430
503
  ...(existing || {}),
431
504
  sessionId: paths.sessionId,
432
505
  agentId: resolvedAgentId,
433
- model: normalizeString(model) || normalizeString(existing?.model) || "unknown",
506
+ model: chooseAgentRefreshModel(identity.model, existing?.model, model),
507
+ displayName: chooseAgentRefreshMetadata(identity.displayName, existing?.displayName, displayName),
508
+ provider: chooseAgentRefreshMetadata(identity.provider, existing?.provider, provider),
509
+ clientKind: chooseAgentRefreshMetadata(identity.clientKind, existing?.clientKind, clientKind),
434
510
  role: normalizeString(role) || normalizeString(existing?.role) || "observer",
435
511
  status: normalizeString(existing?.status) || "idle",
436
512
  detail: normalizeString(existing?.detail) || "",
@@ -536,6 +612,9 @@ export async function unregisterAgent(
536
612
  reason: normalizedReason,
537
613
  role: snapshot.role,
538
614
  model: snapshot.model,
615
+ displayName: snapshot.displayName,
616
+ provider: snapshot.provider,
617
+ clientKind: snapshot.clientKind,
539
618
  }, { targetPath });
540
619
  // Already left explicitly — don't double-emit on process exit.
541
620
  _untrackLocalAgent(paths.sessionId, snapshot.agentId);
@@ -15,6 +15,24 @@ function timestampKey(...values) {
15
15
  return "";
16
16
  }
17
17
 
18
+ export function readSessionEventSequence(event = {}) {
19
+ const value = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
20
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
21
+ }
22
+
23
+ export function sessionEventHasDurableCursor(event = {}) {
24
+ return Boolean(keyString(event?.cursor));
25
+ }
26
+
27
+ export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
28
+ const existingSequence = readSessionEventSequence(existingEvent);
29
+ const candidateSequence = readSessionEventSequence(candidateEvent);
30
+ if (candidateSequence > 0 && existingSequence <= 0) {
31
+ return true;
32
+ }
33
+ return sessionEventHasDurableCursor(candidateEvent) && !sessionEventHasDurableCursor(existingEvent);
34
+ }
35
+
18
36
  function stableJsonValue(value) {
19
37
  if (Array.isArray(value)) {
20
38
  return value.map((item) => stableJsonValue(item));
@@ -51,6 +69,10 @@ export function sessionEventIdentityKeys(event = {}) {
51
69
  keys.push(`idempotency:${event.idempotencyToken.trim()}`);
52
70
  }
53
71
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
72
+ const clientMessageId = typeof payload.clientMessageId === "string" ? payload.clientMessageId.trim() : "";
73
+ if (clientMessageId) {
74
+ keys.push(`client-message:${clientMessageId}`);
75
+ }
54
76
  const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
55
77
  if (messageId) {
56
78
  keys.push(`message:${messageId}`);
@@ -29,20 +29,48 @@ import {
29
29
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
30
30
  import {
31
31
  addSessionEventIdentityKeys,
32
+ sessionEventIdentityKeys,
32
33
  sessionEventHasKnownIdentity,
34
+ sessionEventUpgradesExisting,
33
35
  } from "./event-identity.js";
34
36
 
35
37
  const EVENTS_CURSOR_SUFFIX = "events";
36
38
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
37
39
  const DEFAULT_MAX_EVENT_PAGES = 25;
38
40
 
39
- async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
41
+ function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
42
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
43
+ for (const key of sessionEventIdentityKeys(event)) {
44
+ const existingIndex = indexByKey.get(key);
45
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
46
+ ? eventList[existingIndex]
47
+ : null;
48
+ if (!existingEvent || sessionEventUpgradesExisting(existingEvent, event)) {
49
+ indexByKey.set(key, index);
50
+ }
51
+ }
52
+ }
53
+
54
+ function findRelayEventIdentityIndex(indexByKey, event = {}) {
55
+ if (!indexByKey) return -1;
56
+ for (const key of sessionEventIdentityKeys(event)) {
57
+ const index = indexByKey.get(key);
58
+ if (Number.isInteger(index) && index >= 0) {
59
+ return index;
60
+ }
61
+ }
62
+ return -1;
63
+ }
64
+
65
+ async function readExistingRelayState(sessionId, { targetPath = process.cwd() } = {}) {
40
66
  const knownKeys = new Set();
41
67
  const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
42
- for (const event of events) {
68
+ const indexByKey = new Map();
69
+ for (const [index, event] of events.entries()) {
43
70
  addSessionEventIdentityKeys(knownKeys, event);
71
+ indexRelayEventIdentityKeys(indexByKey, events, event, index);
44
72
  }
45
- return knownKeys;
73
+ return { knownKeys, events, indexByKey };
46
74
  }
47
75
 
48
76
  async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
@@ -92,10 +120,13 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
92
120
  return { materialized: true, refreshed: false, session: created, remoteStatus };
93
121
  }
94
122
 
95
- function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
123
+ function sourceFullyRelayed(events = [], successfulKeys = new Set(), failedKeys = new Set()) {
96
124
  const relayedEvents = Array.isArray(events) ? events : [];
97
125
  if (relayedEvents.length === 0) return true;
98
- return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
126
+ return relayedEvents.every((event) =>
127
+ !sessionEventHasKnownIdentity(event, failedKeys) &&
128
+ sessionEventHasKnownIdentity(event, successfulKeys)
129
+ );
99
130
  }
100
131
 
101
132
  function markPostKillEvent(event = {}) {
@@ -341,11 +372,26 @@ export async function hydrateSessionFromRemote({
341
372
  let relayed = 0;
342
373
  let materializedLocalSession = false;
343
374
  let remoteStatus = "";
344
- const successfulRelayKeys =
345
- merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
346
- const newEvents = successfulRelayKeys.size > 0
347
- ? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
348
- : merged;
375
+ const relayState = merged.length > 0
376
+ ? await readExistingRelayState(sessionId, { targetPath })
377
+ : { knownKeys: new Set(), events: [], indexByKey: new Map() };
378
+ const successfulRelayKeys = relayState.knownKeys;
379
+ const existingRelayEvents = relayState.events;
380
+ const existingRelayIndexByKey = relayState.indexByKey;
381
+ const newEvents = [];
382
+ for (const event of merged) {
383
+ const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
384
+ if (existingIndex >= 0) {
385
+ const existing = existingRelayEvents[existingIndex];
386
+ if (sessionEventUpgradesExisting(existing, event)) {
387
+ newEvents.push(event);
388
+ }
389
+ continue;
390
+ }
391
+ if (!sessionEventHasKnownIdentity(event, successfulRelayKeys)) {
392
+ newEvents.push(event);
393
+ }
394
+ }
349
395
  if (newEvents.length > 0) {
350
396
  try {
351
397
  const localSession = await _ensureLocalSession(sessionId, { targetPath });
@@ -361,20 +407,30 @@ export async function hydrateSessionFromRemote({
361
407
  remoteStatus && !["active", "pending"].includes(remoteStatus)
362
408
  ? newEvents.map((event) => markPostKillEvent(event))
363
409
  : newEvents;
410
+ const failedRelayKeys = new Set();
364
411
  for (const event of appendEvents) {
365
412
  try {
366
- await _append(sessionId, event, { targetPath, syncRemote: false });
413
+ const persisted = await _append(sessionId, event, { targetPath, syncRemote: false });
367
414
  relayed += 1;
368
- addSessionEventIdentityKeys(successfulRelayKeys, event);
415
+ const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
416
+ existingRelayEvents.push(persistedEvent);
417
+ addSessionEventIdentityKeys(successfulRelayKeys, persistedEvent);
418
+ indexRelayEventIdentityKeys(
419
+ existingRelayIndexByKey,
420
+ existingRelayEvents,
421
+ persistedEvent,
422
+ existingRelayEvents.length - 1,
423
+ );
369
424
  } catch {
425
+ addSessionEventIdentityKeys(failedRelayKeys, event);
370
426
  // Append errors are observable via the stream but should not
371
427
  // abort the rest of the batch — partial relay is still progress.
372
428
  }
373
429
  }
374
430
 
375
431
  let persistedCursor = false;
376
- const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
377
- const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
432
+ const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys, failedRelayKeys);
433
+ const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys, failedRelayKeys);
378
434
  if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
379
435
  const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
380
436
  persistedCursor = Boolean(result && result.written);