@dorokuma/herdsman 0.9.0 → 0.11.5

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 (122) hide show
  1. package/README.ja.md +4 -2
  2. package/README.md +5 -3
  3. package/dist/src/agent-history/antigravity-proto.js +1 -1
  4. package/dist/src/agent-history/antigravity-proto.js.map +1 -1
  5. package/dist/src/agent-history/antigravity-reader.d.ts +13 -1
  6. package/dist/src/agent-history/antigravity-reader.d.ts.map +1 -1
  7. package/dist/src/agent-history/antigravity-reader.js +22 -1
  8. package/dist/src/agent-history/antigravity-reader.js.map +1 -1
  9. package/dist/src/agent-history/discovery.d.ts +8 -2
  10. package/dist/src/agent-history/discovery.d.ts.map +1 -1
  11. package/dist/src/agent-history/discovery.js +89 -9
  12. package/dist/src/agent-history/discovery.js.map +1 -1
  13. package/dist/src/agent-history/readers.d.ts +15 -1
  14. package/dist/src/agent-history/readers.d.ts.map +1 -1
  15. package/dist/src/agent-history/readers.js +77 -31
  16. package/dist/src/agent-history/readers.js.map +1 -1
  17. package/dist/src/agent-history/service.d.ts +4 -2
  18. package/dist/src/agent-history/service.d.ts.map +1 -1
  19. package/dist/src/agent-history/service.js +22 -22
  20. package/dist/src/agent-history/service.js.map +1 -1
  21. package/dist/src/agent-history/source-fingerprint.d.ts +7 -0
  22. package/dist/src/agent-history/source-fingerprint.d.ts.map +1 -0
  23. package/dist/src/agent-history/source-fingerprint.js +26 -0
  24. package/dist/src/agent-history/source-fingerprint.js.map +1 -0
  25. package/dist/src/agent-history/text.d.ts.map +1 -1
  26. package/dist/src/agent-history/text.js +5 -0
  27. package/dist/src/agent-history/text.js.map +1 -1
  28. package/dist/src/cli/herdsman.d.ts.map +1 -1
  29. package/dist/src/cli/herdsman.js +31 -9
  30. package/dist/src/cli/herdsman.js.map +1 -1
  31. package/dist/src/daemon/agent-event-reconciler.d.ts +25 -4
  32. package/dist/src/daemon/agent-event-reconciler.d.ts.map +1 -1
  33. package/dist/src/daemon/agent-event-reconciler.js +84 -6
  34. package/dist/src/daemon/agent-event-reconciler.js.map +1 -1
  35. package/dist/src/daemon/herdr-session-watch-manager.d.ts +1 -0
  36. package/dist/src/daemon/herdr-session-watch-manager.d.ts.map +1 -1
  37. package/dist/src/daemon/herdr-session-watch-manager.js +176 -55
  38. package/dist/src/daemon/herdr-session-watch-manager.js.map +1 -1
  39. package/dist/src/daemon/observability-server.d.ts +3 -0
  40. package/dist/src/daemon/observability-server.d.ts.map +1 -1
  41. package/dist/src/daemon/observability-server.js +73 -24
  42. package/dist/src/daemon/observability-server.js.map +1 -1
  43. package/dist/src/daemon/process-manager.d.ts +41 -2
  44. package/dist/src/daemon/process-manager.d.ts.map +1 -1
  45. package/dist/src/daemon/process-manager.js +340 -19
  46. package/dist/src/daemon/process-manager.js.map +1 -1
  47. package/dist/src/daemon/service.d.ts +46 -0
  48. package/dist/src/daemon/service.d.ts.map +1 -1
  49. package/dist/src/daemon/service.js +172 -12
  50. package/dist/src/daemon/service.js.map +1 -1
  51. package/dist/src/db/agent-events.d.ts +34 -1
  52. package/dist/src/db/agent-events.d.ts.map +1 -1
  53. package/dist/src/db/agent-events.js +168 -28
  54. package/dist/src/db/agent-events.js.map +1 -1
  55. package/dist/src/db/agent-history-cache.d.ts +4 -0
  56. package/dist/src/db/agent-history-cache.d.ts.map +1 -1
  57. package/dist/src/db/agent-history-cache.js +27 -0
  58. package/dist/src/db/agent-history-cache.js.map +1 -1
  59. package/dist/src/db/agent-orchestrator-scopes.d.ts +8 -0
  60. package/dist/src/db/agent-orchestrator-scopes.d.ts.map +1 -1
  61. package/dist/src/db/agent-orchestrator-scopes.js +13 -0
  62. package/dist/src/db/agent-orchestrator-scopes.js.map +1 -1
  63. package/dist/src/db/agents.d.ts +44 -0
  64. package/dist/src/db/agents.d.ts.map +1 -1
  65. package/dist/src/db/agents.js +91 -5
  66. package/dist/src/db/agents.js.map +1 -1
  67. package/dist/src/db/client.d.ts +1 -0
  68. package/dist/src/db/client.d.ts.map +1 -1
  69. package/dist/src/db/client.js +12 -0
  70. package/dist/src/db/client.js.map +1 -1
  71. package/dist/src/db/schema.d.ts +391 -0
  72. package/dist/src/db/schema.d.ts.map +1 -1
  73. package/dist/src/db/schema.js +34 -0
  74. package/dist/src/db/schema.js.map +1 -1
  75. package/dist/src/db/status-event-plans.d.ts +62 -0
  76. package/dist/src/db/status-event-plans.d.ts.map +1 -0
  77. package/dist/src/db/status-event-plans.js +147 -0
  78. package/dist/src/db/status-event-plans.js.map +1 -0
  79. package/dist/src/herdr/pane-identity-resolver.d.ts +4 -0
  80. package/dist/src/herdr/pane-identity-resolver.d.ts.map +1 -1
  81. package/dist/src/herdr/pane-identity-resolver.js +13 -1
  82. package/dist/src/herdr/pane-identity-resolver.js.map +1 -1
  83. package/dist/src/herdr/socket-client.d.ts +2 -0
  84. package/dist/src/herdr/socket-client.d.ts.map +1 -1
  85. package/dist/src/herdr/socket-client.js +64 -20
  86. package/dist/src/herdr/socket-client.js.map +1 -1
  87. package/dist/src/observability/agent-context-service.d.ts +17 -1
  88. package/dist/src/observability/agent-context-service.d.ts.map +1 -1
  89. package/dist/src/observability/agent-context-service.js +85 -21
  90. package/dist/src/observability/agent-context-service.js.map +1 -1
  91. package/dist/src/observability/agent-index-service.d.ts +47 -2
  92. package/dist/src/observability/agent-index-service.d.ts.map +1 -1
  93. package/dist/src/observability/agent-index-service.js +1125 -115
  94. package/dist/src/observability/agent-index-service.js.map +1 -1
  95. package/dist/src/observability/agent-orchestrator-service.d.ts.map +1 -1
  96. package/dist/src/observability/agent-orchestrator-service.js +34 -12
  97. package/dist/src/observability/agent-orchestrator-service.js.map +1 -1
  98. package/dist/src/observability/contracts.d.ts +2 -1
  99. package/dist/src/observability/contracts.d.ts.map +1 -1
  100. package/dist/src/observability/contracts.js.map +1 -1
  101. package/dist/src/observability/interactive-pi.d.ts +1 -1
  102. package/dist/src/observability/interactive-pi.d.ts.map +1 -1
  103. package/dist/src/observability/interactive-pi.js +1 -1
  104. package/dist/src/observability/interactive-pi.js.map +1 -1
  105. package/dist/src/observability/schemas.d.ts +1 -0
  106. package/dist/src/observability/schemas.d.ts.map +1 -1
  107. package/dist/src/observability/schemas.js +1 -0
  108. package/dist/src/observability/schemas.js.map +1 -1
  109. package/dist/src/observability/turn-completion.d.ts +2 -1
  110. package/dist/src/observability/turn-completion.d.ts.map +1 -1
  111. package/dist/src/observability/turn-completion.js +10 -1
  112. package/dist/src/observability/turn-completion.js.map +1 -1
  113. package/dist/src/shared/unix-peercred.d.ts +14 -0
  114. package/dist/src/shared/unix-peercred.d.ts.map +1 -0
  115. package/dist/src/shared/unix-peercred.js +144 -0
  116. package/dist/src/shared/unix-peercred.js.map +1 -0
  117. package/drizzle/0008_pane_tombstones.sql +9 -0
  118. package/drizzle/0009_high_lake.sql +24 -0
  119. package/drizzle/meta/0008_snapshot.json +823 -0
  120. package/drizzle/meta/0009_snapshot.json +975 -0
  121. package/drizzle/meta/_journal.json +14 -0
  122. package/package.json +24 -22
@@ -1,21 +1,60 @@
1
- import { safeAllowedSessionPath } from "../agent-history/discovery.js";
1
+ import { existsSync } from "node:fs";
2
+ import { safeAllowedSessionPath, sessionPathAllowedByShape } from "../agent-history/discovery.js";
2
3
  import { createAgentHistoryService } from "../agent-history/service.js";
4
+ import { hasNonEmptyAssistantMessage } from "../db/agent-events.js";
5
+ import { runSqliteTransaction } from "../db/client.js";
3
6
  import { normalizeHerdrSessionSnapshot } from "../herdr/session-snapshot.js";
4
7
  import { HerdrSocketClient } from "../herdr/socket-client.js";
5
8
  import { AgentContextService } from "../observability/agent-context-service.js";
6
9
  import { parseAgentStatus, } from "../observability/contracts.js";
7
10
  import { TURN_SIGNAL_WAIT_MS } from "../observability/turn-completion.js";
11
+ export function historyHasAdvanced(current, baseline, options) {
12
+ if (current.lastAssistantMessage?.ref &&
13
+ current.lastAssistantMessage.ref !== (baseline?.lastAssistantMessage?.ref ?? null)) {
14
+ return true;
15
+ }
16
+ if (current.lastAssistantMessage?.text &&
17
+ current.lastAssistantMessage.text !== (baseline?.lastAssistantMessage?.text ?? null)) {
18
+ return true;
19
+ }
20
+ if (options?.requireAssistantChange) {
21
+ return false;
22
+ }
23
+ return current.messageCount > (baseline?.messageCount ?? 0);
24
+ }
25
+ export const PLAN_WAITING_HISTORY = "PLAN_WAITING_HISTORY";
26
+ export class PlanWaitingHistoryError extends Error {
27
+ constructor() {
28
+ super(PLAN_WAITING_HISTORY);
29
+ this.name = "PlanWaitingHistoryError";
30
+ }
31
+ }
32
+ /**
33
+ * Sentinel returned by #appendStatusEvents when the plan must be CANCELLED
34
+ * rather than skipped (skip still maps to completed in #runPlanRow). The agent
35
+ * row is gone, so appending would create an undeliverable dangling event.
36
+ */
37
+ const PLAN_CANCELLED = Symbol("herdsman.status-event-plan-cancelled");
8
38
  export class AgentIndexService {
39
+ #activeWaiters = new Map();
40
+ #clearRetry;
9
41
  #clientFactory;
10
42
  #context;
11
43
  #stores;
12
44
  #mutationEpochBySession = new Map();
13
45
  #pendingPiSessionRefs = new Map();
46
+ #planTailByAgent = new Map();
14
47
  #refreshInFlightBySession = new Map();
48
+ #scheduleRetry;
15
49
  #sessionOperationTail = new Map();
50
+ #sleep;
16
51
  #turnCompletions;
52
+ #waitingHistoryTimers = new Map();
17
53
  constructor(options) {
54
+ this.#clearRetry = options.clearRetry ?? ((timer) => clearTimeout(timer));
18
55
  this.#clientFactory = options.clientFactory ?? ((input) => new HerdrSocketClient(input));
56
+ this.#scheduleRetry = options.scheduleRetry ?? ((cb, delay) => setTimeout(cb, delay));
57
+ this.#sleep = options.sleep ?? sleep;
19
58
  this.#stores = options.stores;
20
59
  this.#turnCompletions = options.turnCompletions;
21
60
  if (options.context) {
@@ -38,12 +77,26 @@ export class AgentIndexService {
38
77
  });
39
78
  }
40
79
  }
80
+ refreshHerdrSessionFast(input) {
81
+ this.#incrementMutationEpoch(input.herdrSessionName);
82
+ return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input));
83
+ }
41
84
  refreshHerdrSession(input) {
42
85
  const epoch = this.#mutationEpochBySession.get(input.herdrSessionName) ?? 0;
43
86
  const existing = this.#refreshInFlightBySession.get(input.herdrSessionName);
44
87
  if (existing?.epoch === epoch)
45
88
  return existing.promise;
46
- const promise = this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input));
89
+ const promise = this.#enqueueSessionOperation(input.herdrSessionName, () => this.#refreshHerdrSessionNow(input)).then(async (intermediate) => {
90
+ const statusEvents = await Promise.all(intermediate.statusEventPlans.map((plan) => this.executeStatusEventPlan(plan)));
91
+ return {
92
+ agents: intermediate.agents,
93
+ contextChangedScopes: intermediate.contextChangedScopes,
94
+ events: [
95
+ ...intermediate.events,
96
+ ...statusEvents.filter((event) => event !== undefined),
97
+ ],
98
+ };
99
+ });
47
100
  this.#refreshInFlightBySession.set(input.herdrSessionName, { epoch, promise });
48
101
  const clear = () => {
49
102
  if (this.#refreshInFlightBySession.get(input.herdrSessionName)?.promise === promise) {
@@ -53,10 +106,530 @@ export class AgentIndexService {
53
106
  void promise.then(clear, clear);
54
107
  return promise;
55
108
  }
56
- handleHerdrEvent(input) {
109
+ handleHerdrEventFast(input) {
57
110
  this.#incrementMutationEpoch(input.herdrSessionName);
58
111
  return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#handleHerdrEventNow(input));
59
112
  }
113
+ async handleHerdrEvent(input) {
114
+ const intermediate = await this.handleHerdrEventFast(input);
115
+ const statusEvents = await Promise.all(intermediate.statusEventPlans.map((plan) => this.executeStatusEventPlan(plan)));
116
+ return {
117
+ contextChangedScopes: intermediate.contextChangedScopes,
118
+ events: [
119
+ ...intermediate.events,
120
+ ...statusEvents.filter((event) => event !== undefined),
121
+ ],
122
+ };
123
+ }
124
+ stopWaitingHistoryRetries() {
125
+ for (const timer of this.#waitingHistoryTimers.values()) {
126
+ this.#clearRetry(timer);
127
+ }
128
+ this.#waitingHistoryTimers.clear();
129
+ }
130
+ async drainInFlightPlans() {
131
+ const tails = Array.from(this.#planTailByAgent.values());
132
+ await Promise.allSettled(tails);
133
+ }
134
+ #clearWaitingTimer(planId) {
135
+ const existing = this.#waitingHistoryTimers.get(planId);
136
+ if (existing !== undefined) {
137
+ this.#clearRetry(existing);
138
+ this.#waitingHistoryTimers.delete(planId);
139
+ }
140
+ }
141
+ #scheduleWaitingHistoryRetry(planId, plan) {
142
+ this.#clearWaitingTimer(planId);
143
+ const timer = this.#scheduleRetry(async () => {
144
+ try {
145
+ const store = this.#stores.statusEventPlans;
146
+ if (!store)
147
+ return;
148
+ const current = store.get(planId);
149
+ if (current.status !== "pending" || current.lastError !== PLAN_WAITING_HISTORY) {
150
+ this.#waitingHistoryTimers.delete(planId);
151
+ return;
152
+ }
153
+ await this.#enqueueAgentPlan(plan.agent.id, () => this.#retryWaitingPlanRow(planId, plan));
154
+ }
155
+ catch (error) {
156
+ console.debug("Herdsman waiting history retry callback error", error);
157
+ }
158
+ }, 10_000);
159
+ this.#waitingHistoryTimers.set(planId, timer);
160
+ }
161
+ async #retryWaitingPlanRow(planId, plan) {
162
+ const store = this.#stores.statusEventPlans;
163
+ if (!store)
164
+ return;
165
+ let current;
166
+ try {
167
+ current = store.get(planId);
168
+ }
169
+ catch {
170
+ this.#waitingHistoryTimers.delete(planId);
171
+ return;
172
+ }
173
+ if (current.status !== "pending" || current.lastError !== PLAN_WAITING_HISTORY) {
174
+ this.#waitingHistoryTimers.delete(planId);
175
+ return;
176
+ }
177
+ let retryPlan;
178
+ try {
179
+ const refreshed = await this.#context.refreshAgent({
180
+ agent: plan.agent,
181
+ forceRefresh: true,
182
+ identityChanged: false,
183
+ });
184
+ retryPlan = {
185
+ ...plan,
186
+ attempts: current.attempts,
187
+ compactHistory: refreshed.snapshot.compactHistory,
188
+ };
189
+ }
190
+ catch (err) {
191
+ console.warn("Herdsman failed to refresh agent before retry plan row, keeping waiting", {
192
+ planId,
193
+ agentId: plan.agent.id,
194
+ error: err,
195
+ });
196
+ store.markRetry(planId, new PlanWaitingHistoryError());
197
+ const updated = store.get(planId);
198
+ if (updated.status === "pending") {
199
+ this.#scheduleWaitingHistoryRetry(planId, plan);
200
+ }
201
+ else if (updated.status === "failed") {
202
+ console.warn("Herdsman status event plan failed after max attempts", {
203
+ planId,
204
+ agentId: plan.agent.id,
205
+ attempts: updated.attempts,
206
+ from: plan.from,
207
+ to: plan.to,
208
+ herdrSessionName: plan.agent.herdrSessionName,
209
+ });
210
+ this.#appendPlanFailedEvent({
211
+ agent: plan.agent,
212
+ attempts: updated.attempts,
213
+ compactHistory: plan.compactHistory,
214
+ from: plan.from,
215
+ planId,
216
+ reason: updated.lastError,
217
+ to: plan.to,
218
+ });
219
+ }
220
+ return;
221
+ }
222
+ await this.#runPlanRow(current, retryPlan);
223
+ }
224
+ async executeStatusEventPlan(plan) {
225
+ if (plan.from === plan.to)
226
+ return undefined;
227
+ if (!this.#stores.statusEventPlans) {
228
+ return this.#enqueueAgentPlan(plan.agent.id, async () => {
229
+ try {
230
+ const event = await this.#appendStatusEvents(plan);
231
+ return event === PLAN_CANCELLED ? undefined : event;
232
+ }
233
+ catch (error) {
234
+ if (error instanceof PlanWaitingHistoryError) {
235
+ return undefined;
236
+ }
237
+ throw error;
238
+ }
239
+ });
240
+ }
241
+ const inserted = plan.planId !== undefined
242
+ ? this.#stores.statusEventPlans.get(plan.planId)
243
+ : this.#stores.statusEventPlans.insertPending({
244
+ agent: plan.agent,
245
+ from: plan.from,
246
+ herdrEventKey: plan.herdrEventKey ?? null,
247
+ to: plan.to,
248
+ ...(plan.compactHistory ? { compactHistory: plan.compactHistory } : {}),
249
+ });
250
+ return this.#enqueueAgentPlan(plan.agent.id, () => this.#runPlanRow(inserted, plan));
251
+ }
252
+ async drainPendingPlans() {
253
+ if (!this.#stores.statusEventPlans)
254
+ return;
255
+ this.#stores.statusEventPlans.resetRunningToPending();
256
+ const rows = this.#stores.statusEventPlans.listUnfinished();
257
+ const tasks = rows.map((row) => {
258
+ try {
259
+ return this.#enqueueAgentPlan(row.agentId, () => this.#drainPlanRow(row));
260
+ }
261
+ catch (_error) {
262
+ // A row whose agent cannot even be resolved must never take down the
263
+ // daemon boot drain (that failure mode used to boot-loop systemd).
264
+ try {
265
+ this.#stores.statusEventPlans.markCancelled(row.id);
266
+ }
267
+ catch {
268
+ // The row may already be gone; the drain must continue regardless.
269
+ }
270
+ console.warn("Herdsman cancelling status event plan for missing agent", {
271
+ agentId: row.agentId,
272
+ herdrSessionName: row.herdrSessionName,
273
+ paneId: row.paneId,
274
+ from: row.fromStatus,
275
+ to: row.toStatus,
276
+ });
277
+ return Promise.resolve();
278
+ }
279
+ });
280
+ // Individual rows may reject (after markRetry) but the drain itself never
281
+ // rejects: every row is either drained, cancelled, or retried.
282
+ await Promise.allSettled(tasks);
283
+ await this.#backfillFailedPlanEvents();
284
+ }
285
+ async #backfillFailedPlanEvents() {
286
+ const store = this.#stores.statusEventPlans;
287
+ if (!store)
288
+ return;
289
+ for (const row of store.listFailed()) {
290
+ const existing = this.#stores.sqlite
291
+ .prepare("select 1 from agent_events where herdr_session_name = ? and idempotency_key = ?")
292
+ .get(row.herdrSessionName, `agent.failed:plan:${row.id}`);
293
+ if (existing)
294
+ continue;
295
+ let agent;
296
+ try {
297
+ agent = this.#stores.agents.get(row.agentId);
298
+ }
299
+ catch {
300
+ agent = this.#minimalAgentFromLatestEvent(row.agentId, row.herdrSessionName);
301
+ if (!agent) {
302
+ console.warn("Herdsman skipping agent.failed backfill; no agent row or events", {
303
+ agentId: row.agentId,
304
+ herdrSessionName: row.herdrSessionName,
305
+ planId: row.id,
306
+ });
307
+ continue;
308
+ }
309
+ }
310
+ this.#appendPlanFailedEvent({
311
+ agent,
312
+ attempts: row.attempts,
313
+ compactHistory: row.compactHistory,
314
+ from: row.fromStatus,
315
+ planId: row.id,
316
+ reason: row.lastError,
317
+ to: row.toStatus,
318
+ });
319
+ }
320
+ }
321
+ #minimalAgentFromLatestEvent(agentId, herdrSessionName) {
322
+ const latest = this.#stores.sqlite
323
+ .prepare(`select pane_id, pane_generation, terminal_id, workspace_id, herdr_session_name
324
+ from agent_events where agent_id = ? order by id desc limit 1`)
325
+ .get(agentId);
326
+ if (!latest?.pane_id || !latest.workspace_id)
327
+ return undefined;
328
+ return {
329
+ agent: null,
330
+ agentSession: null,
331
+ agentStatus: "unknown",
332
+ cwd: null,
333
+ firstSeenAt: new Date(0),
334
+ focused: false,
335
+ foregroundCwd: null,
336
+ herdrSessionName: latest.herdr_session_name || herdrSessionName,
337
+ id: agentId,
338
+ lastSeenAt: new Date(0),
339
+ name: null,
340
+ paneId: latest.pane_id,
341
+ paneRevision: null,
342
+ ...(latest.pane_generation === null ? {} : { paneGeneration: latest.pane_generation }),
343
+ tabId: null,
344
+ terminalId: latest.terminal_id,
345
+ workspaceId: latest.workspace_id,
346
+ };
347
+ }
348
+ async #drainPlanRow(row) {
349
+ let agent;
350
+ try {
351
+ agent =
352
+ this.#stores.agents.findByPane({
353
+ herdrSessionName: row.herdrSessionName,
354
+ paneId: row.paneId,
355
+ paneGeneration: row.paneGeneration,
356
+ }) ?? this.#stores.agents.get(row.agentId);
357
+ }
358
+ catch {
359
+ agent = undefined;
360
+ }
361
+ if (!agent) {
362
+ this.#stores.statusEventPlans.markCancelled(row.id);
363
+ console.warn("Herdsman cancelling status event plan for missing agent", {
364
+ agentId: row.agentId,
365
+ herdrSessionName: row.herdrSessionName,
366
+ paneId: row.paneId,
367
+ from: row.fromStatus,
368
+ to: row.toStatus,
369
+ });
370
+ return;
371
+ }
372
+ let activeCompact = row.compactHistory;
373
+ try {
374
+ const refreshed = await this.#context.refreshAgent({
375
+ agent,
376
+ forceRefresh: true,
377
+ identityChanged: false,
378
+ });
379
+ activeCompact = refreshed.snapshot.compactHistory;
380
+ }
381
+ catch (err) {
382
+ this.#stores.statusEventPlans.markRetry(row.id, new PlanWaitingHistoryError());
383
+ const updated = this.#stores.statusEventPlans.get(row.id);
384
+ if (updated.status === "pending") {
385
+ console.warn("Herdsman failed to refresh agent during drain plan row, keeping waiting", {
386
+ planId: row.id,
387
+ agentId: agent.id,
388
+ error: err,
389
+ });
390
+ this.#scheduleWaitingHistoryRetry(row.id, {
391
+ agent,
392
+ compactHistory: row.compactHistory,
393
+ from: row.fromStatus,
394
+ to: row.toStatus,
395
+ ...(row.herdrEventKey ? { herdrEventKey: row.herdrEventKey } : {}),
396
+ });
397
+ }
398
+ else if (updated.status === "failed") {
399
+ this.#clearWaitingTimer(row.id);
400
+ console.warn("Herdsman plan marked failed", {
401
+ planId: row.id,
402
+ agentId: agent.id,
403
+ from: row.fromStatus,
404
+ to: row.toStatus,
405
+ reason: updated.lastError,
406
+ });
407
+ this.#appendPlanFailedEvent({
408
+ agent,
409
+ attempts: updated.attempts,
410
+ compactHistory: row.compactHistory,
411
+ from: row.fromStatus,
412
+ planId: row.id,
413
+ reason: updated.lastError,
414
+ to: row.toStatus,
415
+ });
416
+ }
417
+ return;
418
+ }
419
+ const plan = {
420
+ agent,
421
+ attempts: row.attempts,
422
+ compactHistory: activeCompact,
423
+ from: row.fromStatus,
424
+ to: row.toStatus,
425
+ ...(row.herdrEventKey ? { herdrEventKey: row.herdrEventKey } : {}),
426
+ };
427
+ await this.#runPlanRow(row, plan);
428
+ }
429
+ #enqueueAgentPlan(agentId, operation) {
430
+ const previous = this.#planTailByAgent.get(agentId) ?? Promise.resolve();
431
+ const result = previous.then(operation, operation);
432
+ const tail = result.then(() => undefined, () => undefined);
433
+ this.#planTailByAgent.set(agentId, tail);
434
+ void tail.finally(() => {
435
+ if (this.#planTailByAgent.get(agentId) === tail) {
436
+ this.#planTailByAgent.delete(agentId);
437
+ }
438
+ });
439
+ return result;
440
+ }
441
+ async #runPlanRow(row, plan) {
442
+ const store = this.#stores.statusEventPlans;
443
+ if (!store)
444
+ return undefined;
445
+ const current = store.get(row.id);
446
+ if (current.status === "completed" ||
447
+ current.status === "cancelled" ||
448
+ current.status === "failed") {
449
+ this.#clearWaitingTimer(row.id);
450
+ return undefined;
451
+ }
452
+ store.markRunning(row.id);
453
+ let activePlan = {
454
+ ...plan,
455
+ attempts: row.attempts,
456
+ };
457
+ if (row.attempts > 0) {
458
+ try {
459
+ const refreshed = await this.#context.refreshAgent({
460
+ agent: plan.agent,
461
+ forceRefresh: true,
462
+ identityChanged: false,
463
+ });
464
+ activePlan = {
465
+ ...plan,
466
+ attempts: row.attempts,
467
+ compactHistory: refreshed.snapshot.compactHistory,
468
+ };
469
+ }
470
+ catch (err) {
471
+ console.warn("Herdsman failed to refresh agent before running retry row, keeping waiting", {
472
+ planId: row.id,
473
+ agentId: plan.agent.id,
474
+ error: err,
475
+ });
476
+ store.markRetry(row.id, new PlanWaitingHistoryError());
477
+ const updated = store.get(row.id);
478
+ if (updated.status === "pending") {
479
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
480
+ }
481
+ else {
482
+ this.#clearWaitingTimer(row.id);
483
+ if (updated.status === "failed") {
484
+ console.warn("Herdsman status event plan failed after max attempts", {
485
+ planId: row.id,
486
+ agentId: row.agentId,
487
+ attempts: updated.attempts,
488
+ from: row.fromStatus,
489
+ to: row.toStatus,
490
+ herdrSessionName: row.herdrSessionName,
491
+ });
492
+ return this.#appendPlanFailedEvent({
493
+ agent: plan.agent,
494
+ attempts: updated.attempts,
495
+ compactHistory: plan.compactHistory,
496
+ from: row.fromStatus,
497
+ planId: row.id,
498
+ reason: updated.lastError,
499
+ to: row.toStatus,
500
+ });
501
+ }
502
+ }
503
+ return undefined;
504
+ }
505
+ }
506
+ try {
507
+ const event = await this.#appendStatusEvents(activePlan);
508
+ if (event === PLAN_CANCELLED) {
509
+ this.#clearWaitingTimer(row.id);
510
+ store.markCancelled(row.id);
511
+ return undefined;
512
+ }
513
+ if (event === undefined &&
514
+ this.#stores.agents.isPaneClosed({
515
+ herdrSessionName: plan.agent.herdrSessionName,
516
+ paneId: plan.agent.paneId,
517
+ paneGeneration: plan.agent.paneGeneration ?? null,
518
+ })) {
519
+ this.#clearWaitingTimer(row.id);
520
+ store.markCancelled(row.id);
521
+ return undefined;
522
+ }
523
+ this.#clearWaitingTimer(row.id);
524
+ store.markCompleted(row.id);
525
+ return event;
526
+ }
527
+ catch (error) {
528
+ if (error instanceof PlanWaitingHistoryError) {
529
+ store.markRetry(row.id, error);
530
+ const updated = store.get(row.id);
531
+ if (updated.status === "pending") {
532
+ this.#scheduleWaitingHistoryRetry(row.id, plan);
533
+ }
534
+ else {
535
+ this.#clearWaitingTimer(row.id);
536
+ if (updated.status === "failed") {
537
+ console.warn("Herdsman status event plan failed after max attempts", {
538
+ planId: row.id,
539
+ agentId: row.agentId,
540
+ attempts: updated.attempts,
541
+ from: row.fromStatus,
542
+ to: row.toStatus,
543
+ herdrSessionName: row.herdrSessionName,
544
+ });
545
+ return this.#appendPlanFailedEvent({
546
+ agent: plan.agent,
547
+ attempts: updated.attempts,
548
+ compactHistory: activePlan.compactHistory,
549
+ from: row.fromStatus,
550
+ planId: row.id,
551
+ reason: updated.lastError,
552
+ to: row.toStatus,
553
+ });
554
+ }
555
+ }
556
+ return undefined;
557
+ }
558
+ const err = error instanceof Error ? error : undefined;
559
+ const isPaneClosed = this.#stores.agents.isPaneClosed({
560
+ herdrSessionName: plan.agent.herdrSessionName,
561
+ paneId: plan.agent.paneId,
562
+ paneGeneration: plan.agent.paneGeneration ?? null,
563
+ }) ||
564
+ err?.name === "AbortError" ||
565
+ err?.message?.includes("aborted");
566
+ if (isPaneClosed) {
567
+ this.#clearWaitingTimer(row.id);
568
+ store.markCancelled(row.id);
569
+ return undefined;
570
+ }
571
+ this.#clearWaitingTimer(row.id);
572
+ store.markRetry(row.id, error);
573
+ const updated = store.get(row.id);
574
+ if (updated.status === "failed") {
575
+ console.warn("Herdsman status event plan failed after max attempts", {
576
+ planId: row.id,
577
+ agentId: row.agentId,
578
+ attempts: updated.attempts,
579
+ from: row.fromStatus,
580
+ to: row.toStatus,
581
+ herdrSessionName: row.herdrSessionName,
582
+ });
583
+ return this.#appendPlanFailedEvent({
584
+ agent: plan.agent,
585
+ attempts: updated.attempts,
586
+ compactHistory: activePlan.compactHistory,
587
+ from: row.fromStatus,
588
+ planId: row.id,
589
+ reason: updated.lastError,
590
+ to: row.toStatus,
591
+ });
592
+ }
593
+ throw error;
594
+ }
595
+ }
596
+ async #waitForHistoryAdvance(input) {
597
+ const maxAttempts = input.maxAttempts ?? 8;
598
+ let refreshed = input.initial
599
+ ? { snapshot: { compactHistory: input.initial } }
600
+ : await this.#context.refreshAgent({
601
+ agent: input.agent,
602
+ forceRefresh: true,
603
+ identityChanged: false,
604
+ });
605
+ for (let attempt = 0; attempt < maxAttempts &&
606
+ !historyHasAdvanced(refreshed.snapshot.compactHistory, input.baseline, {
607
+ requireAssistantChange: input.requireAssistantChange,
608
+ }) &&
609
+ !input.controller.signal.aborted; attempt += 1) {
610
+ await this.#sleep(500, input.controller.signal);
611
+ if (input.controller.signal.aborted ||
612
+ this.#stores.agents.isPaneClosed({
613
+ herdrSessionName: input.agent.herdrSessionName,
614
+ paneId: input.agent.paneId,
615
+ paneGeneration: input.agent.paneGeneration ?? null,
616
+ })) {
617
+ console.debug("Herdsman skipping status event generation because wait was aborted during history refresh", {
618
+ aborted: input.controller.signal.aborted,
619
+ agentId: input.agent.id,
620
+ herdrSessionName: input.agent.herdrSessionName,
621
+ paneId: input.agent.paneId,
622
+ });
623
+ return undefined;
624
+ }
625
+ refreshed = await this.#context.refreshAgent({
626
+ agent: input.agent,
627
+ forceRefresh: true,
628
+ identityChanged: false,
629
+ });
630
+ }
631
+ return refreshed.snapshot.compactHistory;
632
+ }
60
633
  registerPiSessionRef(input) {
61
634
  this.#incrementMutationEpoch(input.herdrSessionName);
62
635
  return this.#enqueueSessionOperation(input.herdrSessionName, () => this.#registerPiSessionRefNow(input));
@@ -76,23 +649,38 @@ export class AgentIndexService {
76
649
  refreshEpoch,
77
650
  mutationEpoch,
78
651
  });
79
- return { agents: previous, contextChangedScopes: [], events: [] };
80
- }
81
- if (mutationEpoch !== refreshEpoch) {
82
- console.warn("Herdsman discarding stale Herdr session refresh", {
83
- sessionName: input.herdrSessionName,
84
- refreshEpoch,
85
- mutationEpoch,
86
- });
87
- return { agents: previous, contextChangedScopes: [], events: [] };
652
+ return { agents: previous, contextChangedScopes: [], events: [], statusEventPlans: [] };
88
653
  }
89
- const revisionByPane = new Map(snapshot.panes.flatMap((pane) => {
654
+ const overlayByPane = new Map();
655
+ for (const pane of snapshot.panes) {
90
656
  const value = record(pane);
91
657
  const paneId = stringValue(value.pane_id) ?? stringValue(value.paneId);
658
+ if (!paneId)
659
+ continue;
92
660
  const revision = integerValue(value.revision);
93
- return paneId && revision !== undefined ? [[paneId, revision]] : [];
94
- }));
95
- const snapshotAgents = snapshot.agents.map((agent) => withPaneRevision(agent, revisionByPane));
661
+ const terminalTitle = stringValue(value.terminal_title) ?? stringValue(value.terminalTitle);
662
+ if (revision === undefined && !terminalTitle)
663
+ continue;
664
+ overlayByPane.set(paneId, {
665
+ ...(revision === undefined ? {} : { revision }),
666
+ ...(terminalTitle ? { terminalTitle } : {}),
667
+ });
668
+ }
669
+ const snapshotAgents = snapshot.agents.map((agent) => withPaneRevision(agent, overlayByPane));
670
+ // A live pane that reports a pane_generation overrides any generation-less
671
+ // (legacy) close: drop the legacy tombstone before consulting
672
+ // isPaneClosed so the generation-carrying agent is indexed instead of
673
+ // being swallowed by the phantom close. Generation-scoped tombstones stay.
674
+ for (const agent of snapshotAgents) {
675
+ const paneId = stringValue(agent.pane_id) ?? stringValue(agent.paneId);
676
+ if (paneId && paneGenerationOf(agent)) {
677
+ this.#stores.agents.clearLegacyPaneTombstone({
678
+ herdrSessionName: input.herdrSessionName,
679
+ paneId,
680
+ });
681
+ }
682
+ }
683
+ const liveSnapshotAgents = snapshotAgents.filter((agent) => !this.#isClosedPaneAgent(input.herdrSessionName, agent));
96
684
  this.#stores.herdrSessions.upsertRunning({
97
685
  name: input.herdrSessionName,
98
686
  sessionDir: input.sessionDir,
@@ -103,7 +691,7 @@ export class AgentIndexService {
103
691
  workspaces: snapshot.workspaces.map(record),
104
692
  });
105
693
  const indexedAgents = this.#stores.agents.replaceForSession({
106
- agents: snapshotAgents,
694
+ agents: liveSnapshotAgents,
107
695
  herdrSessionName: input.herdrSessionName,
108
696
  });
109
697
  const agents = indexedAgents.map((agent) => {
@@ -122,6 +710,7 @@ export class AgentIndexService {
122
710
  });
123
711
  const scopes = new Map();
124
712
  const events = [];
713
+ const statusEventPlans = [];
125
714
  const currentIds = new Set(agents.map((agent) => agent.id));
126
715
  for (const prior of previous) {
127
716
  if (!currentIds.has(prior.id))
@@ -133,10 +722,21 @@ export class AgentIndexService {
133
722
  const identityChanged = !prior || !sameIdentity(prior, agent);
134
723
  const metadataChanged = !prior || !sameContextMetadata(prior, agent);
135
724
  const cached = this.#context.getAgentSnapshot(agent.id);
725
+ const occupiedByOthers = this.#context.occupiedSessionPathsFor(agent);
726
+ const occupancyConflict = Boolean(cached?.historyRef?.path && occupiedByOthers.has(cached.historyRef.path));
727
+ const sessionReady = agent.agentSession?.kind === "path" &&
728
+ safeAllowedSessionPath(agent.agentSession.value) !== null;
729
+ const sessionUnbound = sessionReady &&
730
+ (cached?.historyRef?.kind !== "agent_session" ||
731
+ cached.historyRef.path !== agent.agentSession?.value);
136
732
  const dirty = !cached ||
137
733
  agent.paneRevision === null ||
138
734
  cached.paneRevision !== agent.paneRevision ||
139
- identityChanged;
735
+ identityChanged ||
736
+ cached.historyRef == null ||
737
+ cached.historyRef.kind === "discovered_file" ||
738
+ occupancyConflict ||
739
+ sessionUnbound;
140
740
  let refreshed = cached;
141
741
  if (dirty) {
142
742
  const occupiedForCurrent = new Set(occupiedSessionPaths);
@@ -157,14 +757,12 @@ export class AgentIndexService {
157
757
  addScope(scopes, scopeOf(agent));
158
758
  }
159
759
  if (prior && prior.agentStatus !== agent.agentStatus) {
160
- const event = await this.#appendStatusEvents({
760
+ statusEventPlans.push({
161
761
  agent,
162
762
  compactHistory: refreshed?.compactHistory ?? this.#context.getAgentSnapshot(agent.id)?.compactHistory,
163
763
  from: prior.agentStatus,
164
764
  to: agent.agentStatus,
165
765
  });
166
- if (event)
167
- events.push(event);
168
766
  addScope(scopes, scopeOf(agent));
169
767
  }
170
768
  }
@@ -172,6 +770,7 @@ export class AgentIndexService {
172
770
  agents,
173
771
  contextChangedScopes: sortedScopes(scopes),
174
772
  events,
773
+ statusEventPlans,
175
774
  };
176
775
  }
177
776
  finally {
@@ -182,9 +781,14 @@ export class AgentIndexService {
182
781
  const event = record(input.event);
183
782
  const paneId = stringValue(event.pane_id) ?? stringValue(event.paneId);
184
783
  if (!paneId)
185
- return { contextChangedScopes: [], events: [] };
784
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
186
785
  if (event.type === "pane.closed") {
187
786
  const closedGeneration = paneGenerationFromEvent(event);
787
+ this.#abortPendingWaiters({
788
+ herdrSessionName: input.herdrSessionName,
789
+ paneId,
790
+ paneGeneration: closedGeneration,
791
+ });
188
792
  this.#stores.agentEvents.invalidatePane({
189
793
  herdrSessionName: input.herdrSessionName,
190
794
  paneId,
@@ -204,7 +808,7 @@ export class AgentIndexService {
204
808
  });
205
809
  const scopes = new Map();
206
810
  const closedTerminalId = stringValue(event.terminal_id) ?? stringValue(event.terminalId);
207
- if (closedGeneration && closedTerminalId) {
811
+ if (closedTerminalId) {
208
812
  for (const agent of retired) {
209
813
  const scope = scopeOf(agent);
210
814
  const change = this.#stores.agentOrchestratorScopes?.releaseIfOwnerIdentity({
@@ -223,10 +827,12 @@ export class AgentIndexService {
223
827
  return {
224
828
  contextChangedScopes: sortedScopes(scopes),
225
829
  events: [],
830
+ statusEventPlans: [],
226
831
  };
227
832
  }
228
- if (event.type !== "pane.agent_status_changed")
229
- return { contextChangedScopes: [], events: [] };
833
+ if (event.type !== "pane.agent_status_changed") {
834
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
835
+ }
230
836
  const eventGeneration = paneGenerationFromEvent(event);
231
837
  let agent = this.#stores.agents.findByPane({
232
838
  herdrSessionName: input.herdrSessionName,
@@ -235,6 +841,27 @@ export class AgentIndexService {
235
841
  });
236
842
  let recovered;
237
843
  if (!agent) {
844
+ if (eventGeneration) {
845
+ // A generation-scoped tombstone still closes its own generation.
846
+ if (this.#stores.agents.hasGenerationScopedTombstone({
847
+ herdrSessionName: input.herdrSessionName,
848
+ paneId,
849
+ paneGeneration: eventGeneration,
850
+ })) {
851
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
852
+ }
853
+ // No generation-scoped close: a live generation-carrying status
854
+ // overrides a generation-less close, so recover the pane (the refresh
855
+ // below clears the legacy tombstone only when the agent is actually
856
+ // present in the snapshot).
857
+ }
858
+ else if (this.#stores.agents.isPaneClosed({
859
+ herdrSessionName: input.herdrSessionName,
860
+ paneId,
861
+ paneGeneration: null,
862
+ })) {
863
+ return { contextChangedScopes: [], events: [], statusEventPlans: [] };
864
+ }
238
865
  recovered = await this.#refreshHerdrSessionNow(input);
239
866
  agent = this.#stores.agents.findByPane({
240
867
  herdrSessionName: input.herdrSessionName,
@@ -244,19 +871,17 @@ export class AgentIndexService {
244
871
  }
245
872
  if (!agent) {
246
873
  return recovered
247
- ? { contextChangedScopes: recovered.contextChangedScopes, events: recovered.events }
248
- : { contextChangedScopes: [], events: [] };
874
+ ? {
875
+ contextChangedScopes: recovered.contextChangedScopes,
876
+ events: recovered.events,
877
+ statusEventPlans: recovered.statusEventPlans,
878
+ }
879
+ : { contextChangedScopes: [], events: [], statusEventPlans: [] };
249
880
  }
250
881
  const from = recovered ? "unknown" : agent.agentStatus;
251
882
  const to = parseAgentStatus(event.agent_status);
252
883
  const herdrEventKey = herdrInputIdempotencyKey(input.herdrSessionName, paneId, event, to);
253
- const updated = this.#stores.agents.updateStatus({
254
- agentStatus: to,
255
- herdrSessionName: input.herdrSessionName,
256
- paneId,
257
- paneGeneration: eventGeneration,
258
- });
259
- const current = updated ?? { ...agent, agentStatus: to };
884
+ const current = { ...agent, agentStatus: to };
260
885
  const refreshed = await this.#context.refreshAgent({ agent: current, identityChanged: false });
261
886
  const scopes = new Map();
262
887
  for (const scope of recovered?.contextChangedScopes ?? [])
@@ -264,26 +889,52 @@ export class AgentIndexService {
264
889
  if (refreshed.changed || from !== to)
265
890
  addScope(scopes, scopeOf(current));
266
891
  const events = [...(recovered?.events ?? [])];
267
- const equivalent = events.some((candidate) => candidate.agentId === current.id &&
268
- candidate.type === statusEventType(to) &&
269
- candidate.payload.to === to);
270
- if (!equivalent) {
271
- const statusEvent = await this.#appendStatusEvents({
272
- agent: current,
892
+ const statusEventPlans = [...(recovered?.statusEventPlans ?? [])];
893
+ const equivalent = statusEventPlans.some((candidate) => candidate.agent.id === current.id && candidate.to === to);
894
+ const statusInput = {
895
+ agentStatus: to,
896
+ herdrSessionName: input.herdrSessionName,
897
+ paneId,
898
+ paneGeneration: eventGeneration,
899
+ };
900
+ if (from === to) {
901
+ this.#stores.agents.updateStatus(statusInput);
902
+ }
903
+ else if (!equivalent) {
904
+ const inserted = runSqliteTransaction(this.#stores.sqlite, () => {
905
+ const updated = this.#stores.agents.updateStatus(statusInput) ?? current;
906
+ const plan = this.#stores.statusEventPlans.insertPending({
907
+ agent: updated,
908
+ from,
909
+ herdrEventKey: herdrEventKey ?? null,
910
+ to,
911
+ ...(refreshed.snapshot.compactHistory
912
+ ? { compactHistory: refreshed.snapshot.compactHistory }
913
+ : {}),
914
+ });
915
+ return { plan, updated };
916
+ });
917
+ statusEventPlans.push({
918
+ agent: inserted.updated,
273
919
  compactHistory: refreshed.snapshot.compactHistory,
274
920
  from,
275
- herdrEventKey,
921
+ ...(herdrEventKey ? { herdrEventKey } : {}),
922
+ planId: inserted.plan.id,
276
923
  to,
277
924
  });
278
- if (statusEvent)
279
- events.push(statusEvent);
280
925
  }
281
- return { contextChangedScopes: sortedScopes(scopes), events };
926
+ else {
927
+ this.#stores.agents.updateStatus(statusInput);
928
+ }
929
+ return { contextChangedScopes: sortedScopes(scopes), events, statusEventPlans };
282
930
  }
283
931
  async #registerPiSessionRefNow(input) {
284
932
  const key = terminalSessionKey(input.herdrSessionName, input.terminalId);
285
933
  if (input.sessionRef.kind === "path" && !safeAllowedSessionPath(input.sessionRef.value)) {
286
- return { agent: undefined, contextChangedScopes: [] };
934
+ if (existsSync(input.sessionRef.value) ||
935
+ !sessionPathAllowedByShape(input.sessionRef.value)) {
936
+ return { agent: undefined, contextChangedScopes: [] };
937
+ }
287
938
  }
288
939
  const previous = this.#stores.agents.findByTerminal(input);
289
940
  if (!previous) {
@@ -320,89 +971,392 @@ export class AgentIndexService {
320
971
  });
321
972
  return result;
322
973
  }
974
+ #isClosedPaneAgent(herdrSessionName, agent) {
975
+ const paneId = stringValue(agent.pane_id) ?? stringValue(agent.paneId);
976
+ if (!paneId)
977
+ return false;
978
+ return this.#stores.agents.isPaneClosed({
979
+ herdrSessionName,
980
+ paneId,
981
+ paneGeneration: paneGenerationOf(agent),
982
+ });
983
+ }
984
+ #registerActiveWaiter(agent, controller) {
985
+ const key = `${agent.herdrSessionName}\0${agent.paneId}\0${agent.paneGeneration ?? ""}`;
986
+ let set = this.#activeWaiters.get(key);
987
+ if (!set) {
988
+ set = new Set();
989
+ this.#activeWaiters.set(key, set);
990
+ }
991
+ set.add(controller);
992
+ return () => {
993
+ set.delete(controller);
994
+ if (set.size === 0 && this.#activeWaiters.get(key) === set) {
995
+ this.#activeWaiters.delete(key);
996
+ }
997
+ };
998
+ }
999
+ #abortPendingWaiters(input) {
1000
+ for (const [key, controllers] of this.#activeWaiters) {
1001
+ const [sessionName, paneId, paneGen] = key.split("\0");
1002
+ if (sessionName === input.herdrSessionName && paneId === input.paneId) {
1003
+ if (!input.paneGeneration || !paneGen || input.paneGeneration === paneGen) {
1004
+ for (const controller of controllers) {
1005
+ controller.abort();
1006
+ }
1007
+ controllers.clear();
1008
+ this.#activeWaiters.delete(key);
1009
+ }
1010
+ }
1011
+ }
1012
+ }
323
1013
  async #appendStatusEvents(input) {
1014
+ // Invariant: compactHistory is always populated as an object before calling #appendStatusEvents; !input.compactHistory check below is defensive and unreachable in normal operation.
324
1015
  if (input.from === input.to || !input.compactHistory)
325
1016
  return undefined;
326
- const latest = this.#stores.agentEvents.latestStatusTransition(input.agent.id, input.agent.herdrSessionName);
327
- if (latest && statusTransitionMatches(latest, input.from, input.to))
328
- return undefined;
329
- const observationId = `transition:${latest?.id ?? 0}`;
330
- let compactHistory = input.compactHistory;
331
- if (this.#turnCompletions !== undefined &&
332
- (input.to === "done" || input.to === "blocked") &&
333
- input.agent.agent === "pi" &&
334
- input.agent.terminalId !== null) {
335
- const turn = await this.#turnCompletions.waitForSignal({
1017
+ if (this.#stores.agents.isPaneClosed({
1018
+ herdrSessionName: input.agent.herdrSessionName,
1019
+ paneId: input.agent.paneId,
1020
+ paneGeneration: input.agent.paneGeneration ?? null,
1021
+ })) {
1022
+ console.debug("Herdsman skipping status event generation because pane is closed", {
1023
+ agentId: input.agent.id,
336
1024
  herdrSessionName: input.agent.herdrSessionName,
337
- recordedAfterMs: Date.now() - TURN_SIGNAL_WAIT_MS,
338
- terminalId: input.agent.terminalId,
1025
+ paneId: input.agent.paneId,
339
1026
  });
340
- if (turn.received) {
341
- let refreshed = await this.#context.refreshAgent({
342
- agent: input.agent,
343
- identityChanged: false,
344
- forceRefresh: true,
345
- });
346
- for (let attempt = 0; attempt < 3 && refreshed.snapshot.compactHistory.lastAssistantMessage === null; attempt += 1) {
347
- await new Promise((resolve) => setTimeout(resolve, 300));
348
- refreshed = await this.#context.refreshAgent({
349
- agent: input.agent,
350
- identityChanged: false,
351
- forceRefresh: true,
1027
+ return undefined;
1028
+ }
1029
+ const latest = this.#stores.agentEvents.latestStatusTransition(input.agent.id, input.agent.herdrSessionName);
1030
+ let skipStatusChanged = false;
1031
+ if (latest && statusTransitionMatches(latest, input.from, input.to)) {
1032
+ const hasTerminalAfter = this.#stores.agentEvents.hasTerminalEventAfter(input.agent.id, input.agent.herdrSessionName, latest.id);
1033
+ if (!hasTerminalAfter) {
1034
+ if (statusEventType(input.to) === undefined) {
1035
+ console.debug("Herdsman skipping duplicate status transition event", {
1036
+ agentId: input.agent.id,
1037
+ from: input.from,
1038
+ to: input.to,
352
1039
  });
1040
+ return undefined;
353
1041
  }
354
- compactHistory = refreshed.snapshot.compactHistory;
355
- console.log(`Herdsman emitted pi agent.${input.to} after turn completion signal (confirmed=${turn.confirmed})`, {
356
- agentId: input.agent.id,
1042
+ skipStatusChanged = true;
1043
+ }
1044
+ if (hasTerminalAfter && !input.herdrEventKey) {
1045
+ const lastTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1046
+ if (sameTerminalAssistantContent(input.compactHistory, lastTerminal?.compactHistory, input.agent.agent)) {
1047
+ if ((input.attempts ?? 0) > 0) {
1048
+ throw new PlanWaitingHistoryError();
1049
+ }
1050
+ console.debug("Herdsman skipping legacy duplicate status transition event with identical history ref", {
1051
+ agentId: input.agent.id,
1052
+ from: input.from,
1053
+ to: input.to,
1054
+ });
1055
+ return undefined;
1056
+ }
1057
+ }
1058
+ }
1059
+ const observationId = `transition:${latest?.id ?? 0}`;
1060
+ const controller = new AbortController();
1061
+ const unregister = this.#registerActiveWaiter(input.agent, controller);
1062
+ try {
1063
+ let compactHistory = input.compactHistory;
1064
+ let payloadExtra = {};
1065
+ if (this.#turnCompletions !== undefined &&
1066
+ (input.to === "done" || input.to === "blocked") &&
1067
+ input.agent.terminalId !== null &&
1068
+ input.agent.agent === "pi") {
1069
+ const isRetry = (input.attempts ?? 0) > 0;
1070
+ const latestTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1071
+ const baseline = isRetry && latestTerminal?.compactHistory
1072
+ ? latestTerminal.compactHistory
1073
+ : input.compactHistory;
1074
+ const isAlreadyEmittedInRetry = isRetry &&
1075
+ latestTerminal?.compactHistory &&
1076
+ sameTerminalAssistantContent(input.compactHistory, latestTerminal.compactHistory, "pi");
1077
+ const turn = await this.#turnCompletions.waitForSignal({
357
1078
  herdrSessionName: input.agent.herdrSessionName,
1079
+ recordedAfterMs: Date.now() - TURN_SIGNAL_WAIT_MS,
1080
+ signal: controller.signal,
358
1081
  terminalId: input.agent.terminalId,
359
1082
  });
360
- }
361
- else {
362
- for (let attempt = 0; attempt < 3 && compactHistory.lastAssistantMessage === null; attempt += 1) {
363
- await new Promise((resolve) => setTimeout(resolve, 300));
364
- const refreshed = await this.#context.refreshAgent({
1083
+ if (controller.signal.aborted ||
1084
+ this.#stores.agents.isPaneClosed({
1085
+ herdrSessionName: input.agent.herdrSessionName,
1086
+ paneId: input.agent.paneId,
1087
+ paneGeneration: input.agent.paneGeneration ?? null,
1088
+ })) {
1089
+ console.debug("Herdsman skipping status event generation because turn wait was aborted or pane closed", {
1090
+ aborted: controller.signal.aborted,
1091
+ agentId: input.agent.id,
1092
+ herdrSessionName: input.agent.herdrSessionName,
1093
+ paneId: input.agent.paneId,
1094
+ });
1095
+ return undefined;
1096
+ }
1097
+ if (turn?.received) {
1098
+ const fresh = (await this.#context.refreshAgent({
365
1099
  agent: input.agent,
366
- identityChanged: false,
367
1100
  forceRefresh: true,
1101
+ identityChanged: false,
1102
+ })).snapshot.compactHistory;
1103
+ if (!isAlreadyEmittedInRetry && hasNonEmptyAssistantMessage(fresh)) {
1104
+ compactHistory = fresh;
1105
+ }
1106
+ else {
1107
+ const advanced = await this.#waitForHistoryAdvance({
1108
+ agent: input.agent,
1109
+ baseline,
1110
+ controller,
1111
+ initial: fresh,
1112
+ maxAttempts: 8,
1113
+ requireAssistantChange: isRetry,
1114
+ });
1115
+ if (advanced === undefined)
1116
+ return undefined;
1117
+ if (isRetry &&
1118
+ !historyHasAdvanced(advanced, baseline, { requireAssistantChange: true })) {
1119
+ throw new PlanWaitingHistoryError();
1120
+ }
1121
+ compactHistory = advanced;
1122
+ }
1123
+ console.log(`Herdsman emitted pi agent.${input.to} after turn completion signal (confirmed=${turn.confirmed})`, {
1124
+ agentId: input.agent.id,
1125
+ herdrSessionName: input.agent.herdrSessionName,
1126
+ terminalId: input.agent.terminalId,
368
1127
  });
369
- compactHistory = refreshed.snapshot.compactHistory;
370
1128
  }
371
- console.warn(`Herdsman emitted pi agent.${input.to} without a turn completion signal after ${TURN_SIGNAL_WAIT_MS}ms`, {
1129
+ else if (!controller.signal.aborted) {
1130
+ const advanced = await this.#waitForHistoryAdvance({
1131
+ agent: input.agent,
1132
+ baseline,
1133
+ controller,
1134
+ maxAttempts: 8,
1135
+ requireAssistantChange: isRetry,
1136
+ });
1137
+ if (advanced === undefined)
1138
+ return undefined;
1139
+ if (isRetry &&
1140
+ !historyHasAdvanced(advanced, baseline, { requireAssistantChange: true })) {
1141
+ throw new PlanWaitingHistoryError();
1142
+ }
1143
+ if (!isRetry &&
1144
+ !historyHasAdvanced(advanced, input.compactHistory, { requireAssistantChange: true })) {
1145
+ compactHistory = { ...advanced, lastAssistantMessage: null };
1146
+ payloadExtra = { noAdvance: true, staleSnapshot: false };
1147
+ }
1148
+ else {
1149
+ compactHistory = advanced;
1150
+ }
1151
+ console.warn(`Herdsman emitted pi agent.${input.to} without a turn completion signal after ${TURN_SIGNAL_WAIT_MS}ms`, {
1152
+ agentId: input.agent.id,
1153
+ herdrSessionName: input.agent.herdrSessionName,
1154
+ terminalId: input.agent.terminalId,
1155
+ });
1156
+ }
1157
+ }
1158
+ else if (input.agent.agent !== "pi" && (input.to === "idle" || input.to === "done")) {
1159
+ // Note: blocked is an interactive intermediate state, exempt from ready gate and empty delivery gate.
1160
+ const isReadyNonPi = (compact) => {
1161
+ if (!compact || !hasNonEmptyAssistantMessage(compact))
1162
+ return false;
1163
+ const latestTerminal = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1164
+ if (compact.source === "antigravity-sqlite" || input.agent.agent === "agy") {
1165
+ const prevRef = latestTerminal?.compactHistory?.lastAssistantMessage?.ref ?? null;
1166
+ const currentRef = compact.lastAssistantMessage?.ref ?? null;
1167
+ return currentRef !== null && currentRef !== prevRef;
1168
+ }
1169
+ if (!latestTerminal)
1170
+ return true;
1171
+ const currentMsg = compact.lastAssistantMessage;
1172
+ const prevMsg = latestTerminal.compactHistory?.lastAssistantMessage;
1173
+ const refChanged = (currentMsg?.ref ?? null) !== (prevMsg?.ref ?? null);
1174
+ const textChanged = (currentMsg?.text ?? "") !== (prevMsg?.text ?? "");
1175
+ const same = sameTerminalAssistantContent(compact, latestTerminal.compactHistory, input.agent.agent);
1176
+ return !same || refChanged || textChanged;
1177
+ };
1178
+ const latestTerminalForSkip = this.#stores.agentEvents.latestTerminalEvent(input.agent.id, input.agent.herdrSessionName);
1179
+ if (input.agent.agent !== "agy" &&
1180
+ compactHistory?.source !== "antigravity-sqlite" &&
1181
+ input.herdrEventKey &&
1182
+ latestTerminalForSkip &&
1183
+ hasNonEmptyAssistantMessage(compactHistory)) {
1184
+ const currentMsg = compactHistory.lastAssistantMessage;
1185
+ const prevMsg = latestTerminalForSkip.compactHistory?.lastAssistantMessage;
1186
+ const refChanged = (currentMsg?.ref ?? null) !== (prevMsg?.ref ?? null);
1187
+ const textChanged = (currentMsg?.text ?? "") !== (prevMsg?.text ?? "");
1188
+ const same = sameTerminalAssistantContent(compactHistory, latestTerminalForSkip.compactHistory, input.agent.agent);
1189
+ if (same && !refChanged && !textChanged) {
1190
+ return undefined;
1191
+ }
1192
+ }
1193
+ if (!isReadyNonPi(compactHistory)) {
1194
+ for (let attempt = 0; attempt < 8; attempt += 1) {
1195
+ await this.#sleep(500, controller.signal);
1196
+ if (controller.signal.aborted ||
1197
+ this.#stores.agents.isPaneClosed({
1198
+ herdrSessionName: input.agent.herdrSessionName,
1199
+ paneId: input.agent.paneId,
1200
+ paneGeneration: input.agent.paneGeneration ?? null,
1201
+ })) {
1202
+ console.debug("Herdsman skipping status event generation because wait was aborted or pane closed", {
1203
+ aborted: controller.signal.aborted,
1204
+ agentId: input.agent.id,
1205
+ herdrSessionName: input.agent.herdrSessionName,
1206
+ paneId: input.agent.paneId,
1207
+ });
1208
+ return undefined;
1209
+ }
1210
+ try {
1211
+ const refreshed = await this.#context.refreshAgent({
1212
+ agent: input.agent,
1213
+ forceRefresh: true,
1214
+ identityChanged: false,
1215
+ });
1216
+ compactHistory = refreshed.snapshot.compactHistory;
1217
+ }
1218
+ catch (err) {
1219
+ console.debug("Herdsman failed to refresh agent during ready wait", err);
1220
+ }
1221
+ if (isReadyNonPi(compactHistory)) {
1222
+ break;
1223
+ }
1224
+ }
1225
+ }
1226
+ if (!isReadyNonPi(compactHistory)) {
1227
+ throw new PlanWaitingHistoryError();
1228
+ }
1229
+ }
1230
+ if (controller.signal.aborted ||
1231
+ this.#stores.agents.isPaneClosed({
1232
+ herdrSessionName: input.agent.herdrSessionName,
1233
+ paneId: input.agent.paneId,
1234
+ paneGeneration: input.agent.paneGeneration ?? null,
1235
+ })) {
1236
+ console.debug("Herdsman skipping status event generation because turn wait was aborted or pane closed before append", {
1237
+ aborted: controller.signal.aborted,
372
1238
  agentId: input.agent.id,
373
1239
  herdrSessionName: input.agent.herdrSessionName,
374
- terminalId: input.agent.terminalId,
1240
+ paneId: input.agent.paneId,
375
1241
  });
1242
+ return undefined;
376
1243
  }
377
- }
378
- const lastEvent = this.#appendAndAckSelfEvent({
379
- agentId: input.agent.id,
380
- compactHistory,
381
- herdrSessionName: input.agent.herdrSessionName,
382
- idempotencyKey: idempotencyKey("agent.status.changed", input.agent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:agent.status.changed`),
383
- paneId: input.agent.paneId,
384
- paneGeneration: input.agent.paneGeneration ?? null,
385
- payload: payload(input.agent, input.from, input.to),
386
- terminalId: input.agent.terminalId,
387
- type: "agent.status.changed",
388
- workspaceId: input.agent.workspaceId,
389
- });
390
- const statusType = statusEventType(input.to);
391
- if (statusType) {
392
- return this.#appendAndAckSelfEvent({
393
- agentId: input.agent.id,
394
- compactHistory,
1244
+ const currentAgent = this.#stores.agents.findByPane({
395
1245
  herdrSessionName: input.agent.herdrSessionName,
396
- idempotencyKey: idempotencyKey(statusType, input.agent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:${statusType}`),
397
1246
  paneId: input.agent.paneId,
398
1247
  paneGeneration: input.agent.paneGeneration ?? null,
399
- payload: payload(input.agent, input.from, input.to),
400
- terminalId: input.agent.terminalId,
401
- type: statusType,
402
- workspaceId: input.agent.workspaceId,
403
1248
  });
1249
+ if (!currentAgent) {
1250
+ // The agent row is gone: appending would create a dangling event with
1251
+ // a dead agent_id that only the reconciler could sweep. Cancel the
1252
+ // plan instead of marking it completed (the skip->completed branch
1253
+ // must stay reserved for genuine skips).
1254
+ console.debug("Herdsman cancelling terminal status plan because agent row is gone", {
1255
+ agentId: input.agent.id,
1256
+ herdrSessionName: input.agent.herdrSessionName,
1257
+ paneId: input.agent.paneId,
1258
+ from: input.from,
1259
+ to: input.to,
1260
+ });
1261
+ return PLAN_CANCELLED;
1262
+ }
1263
+ let targetAgent;
1264
+ if (currentAgent.agentStatus !== input.to) {
1265
+ if (input.to === "done" ||
1266
+ input.to === "blocked" ||
1267
+ (input.to === "idle" && currentAgent.agent === "agy")) {
1268
+ console.info("Herdsman emitting terminal status event after subsequent status change", {
1269
+ agentId: input.agent.id,
1270
+ current: currentAgent.agentStatus,
1271
+ expected: input.to,
1272
+ herdrSessionName: input.agent.herdrSessionName,
1273
+ paneId: input.agent.paneId,
1274
+ });
1275
+ targetAgent = currentAgent;
1276
+ }
1277
+ else {
1278
+ console.debug("Herdsman skipping status event generation due to status mismatch", {
1279
+ agentId: input.agent.id,
1280
+ current: currentAgent.agentStatus,
1281
+ expected: input.to,
1282
+ herdrSessionName: input.agent.herdrSessionName,
1283
+ paneId: input.agent.paneId,
1284
+ });
1285
+ return undefined;
1286
+ }
1287
+ }
1288
+ else {
1289
+ targetAgent = currentAgent;
1290
+ }
1291
+ const eventPayload = { ...payload(targetAgent, input.from, input.to), ...payloadExtra };
1292
+ const lastEvent = skipStatusChanged
1293
+ ? undefined
1294
+ : this.#appendAndAckSelfEvent({
1295
+ agentId: targetAgent.id,
1296
+ compactHistory,
1297
+ herdrSessionName: targetAgent.herdrSessionName,
1298
+ idempotencyKey: idempotencyKey("agent.status.changed", targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:agent.status.changed`),
1299
+ paneId: targetAgent.paneId,
1300
+ paneGeneration: targetAgent.paneGeneration ?? null,
1301
+ payload: eventPayload,
1302
+ terminalId: targetAgent.terminalId,
1303
+ type: "agent.status.changed",
1304
+ workspaceId: targetAgent.workspaceId,
1305
+ });
1306
+ const statusType = statusEventType(input.to);
1307
+ if (statusType) {
1308
+ return this.#appendAndAckSelfEvent({
1309
+ agentId: targetAgent.id,
1310
+ compactHistory,
1311
+ herdrSessionName: targetAgent.herdrSessionName,
1312
+ idempotencyKey: idempotencyKey(statusType, targetAgent, input.from, input.to, `${observationId}:${input.herdrEventKey ?? "legacy"}:${statusType}`),
1313
+ paneId: targetAgent.paneId,
1314
+ paneGeneration: targetAgent.paneGeneration ?? null,
1315
+ payload: eventPayload,
1316
+ terminalId: targetAgent.terminalId,
1317
+ type: statusType,
1318
+ workspaceId: targetAgent.workspaceId,
1319
+ });
1320
+ }
1321
+ return lastEvent;
1322
+ }
1323
+ finally {
1324
+ unregister();
1325
+ }
1326
+ }
1327
+ #appendPlanFailedEvent(input) {
1328
+ let agent = input.agent;
1329
+ try {
1330
+ agent = this.#stores.agents.get(input.agent.id);
404
1331
  }
405
- return lastEvent;
1332
+ catch {
1333
+ const stitched = this.#minimalAgentFromLatestEvent(input.agent.id, input.agent.herdrSessionName);
1334
+ if (stitched)
1335
+ agent = stitched;
1336
+ }
1337
+ return this.#appendAndAckSelfEvent({
1338
+ agentId: agent.id,
1339
+ compactHistory: input.compactHistory ?? null,
1340
+ herdrSessionName: agent.herdrSessionName,
1341
+ idempotencyKey: `agent.failed:plan:${input.planId}`,
1342
+ paneId: agent.paneId,
1343
+ paneGeneration: agent.paneGeneration ?? null,
1344
+ payload: {
1345
+ agent: agent.agent,
1346
+ attempts: input.attempts,
1347
+ from: input.from,
1348
+ herdrSessionName: agent.herdrSessionName,
1349
+ name: agent.name,
1350
+ paneId: agent.paneId,
1351
+ reason: input.reason ?? "unknown",
1352
+ terminalId: agent.terminalId,
1353
+ to: input.to,
1354
+ workspaceId: agent.workspaceId,
1355
+ },
1356
+ terminalId: agent.terminalId,
1357
+ type: "agent.failed",
1358
+ workspaceId: agent.workspaceId,
1359
+ });
406
1360
  }
407
1361
  #appendAndAckSelfEvent(input) {
408
1362
  const event = this.#stores.agentEvents.append(input);
@@ -417,13 +1371,33 @@ export class AgentIndexService {
417
1371
  return event;
418
1372
  }
419
1373
  }
420
- function withPaneRevision(agent, revisionByPane) {
1374
+ function withPaneRevision(agent, overlayByPane) {
421
1375
  const raw = record(agent);
422
- if (integerValue(raw.revision) !== undefined)
423
- return raw;
424
1376
  const paneId = stringValue(raw.pane_id) ?? stringValue(raw.paneId);
425
- const revision = paneId ? revisionByPane.get(paneId) : undefined;
426
- return revision === undefined ? raw : { ...raw, revision };
1377
+ const overlay = paneId ? overlayByPane.get(paneId) : undefined;
1378
+ const revision = integerValue(raw.revision) ?? overlay?.revision;
1379
+ const terminalTitle = stringValue(raw.terminal_title) ?? stringValue(raw.terminalTitle) ?? overlay?.terminalTitle;
1380
+ if (revision === undefined && !terminalTitle)
1381
+ return raw;
1382
+ // Collapse dual keys onto herdr-canonical snake_case `terminal_title`.
1383
+ // Incoming snapshots may carry camelCase `terminalTitle`; spreading `raw`
1384
+ // would otherwise leave both keys on the overlay object. Downstream
1385
+ // HerdrAgentLike readers (AgentStore.replaceForSession) still accept
1386
+ // camelCase as fallback, and AgentIndexRecord.terminalTitle is populated
1387
+ // later from whichever key is present.
1388
+ const rest = { ...raw };
1389
+ delete rest.terminalTitle;
1390
+ return {
1391
+ ...rest,
1392
+ ...(revision === undefined ? {} : { revision }),
1393
+ ...(terminalTitle ? { terminal_title: terminalTitle } : {}),
1394
+ };
1395
+ }
1396
+ function paneGenerationOf(agent) {
1397
+ return (stringValue(agent.pane_generation) ??
1398
+ stringValue(agent.paneGeneration) ??
1399
+ stringValue(agent.creation_id) ??
1400
+ stringValue(agent.creationId));
427
1401
  }
428
1402
  function matchingPrior(agent, previousByTerminal, previousByPane) {
429
1403
  const terminalMatch = agent.terminalId ? previousByTerminal.get(agent.terminalId) : undefined;
@@ -495,12 +1469,11 @@ function payload(agent, from, to) {
495
1469
  function idempotencyKey(type, agent, from, to, observationId) {
496
1470
  return `${type}:${agent.herdrSessionName}:${agent.paneId}:${from}:${to}:${observationId}`;
497
1471
  }
498
- function herdrInputIdempotencyKey(sessionName, paneId, event, targetStatus) {
1472
+ function herdrInputIdempotencyKey(sessionName, paneId, event, _targetStatus) {
499
1473
  const eventId = stringValue(event.id) ?? stringValue(event.event_id) ?? stringValue(event.eventId);
500
- if (eventId)
501
- return `${sessionName}:${paneId}:${String(event.type)}:${eventId}`;
502
- const revision = integerValue(event.revision) ?? integerValue(event.pane_revision) ?? "unknown";
503
- return `${sessionName}:${paneId}:${revision}:${targetStatus}`;
1474
+ if (!eventId)
1475
+ return undefined;
1476
+ return `${sessionName}:${paneId}:${String(event.type)}:${eventId}`;
504
1477
  }
505
1478
  function statusTransitionMatches(event, from, to) {
506
1479
  const eventPayload = event.payload;
@@ -521,4 +1494,41 @@ function stringValue(value) {
521
1494
  function integerValue(value) {
522
1495
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
523
1496
  }
1497
+ function sleep(ms, signal) {
1498
+ if (signal?.aborted)
1499
+ return Promise.resolve();
1500
+ return new Promise((resolve) => {
1501
+ let timer;
1502
+ const onAbort = () => {
1503
+ if (timer !== undefined)
1504
+ clearTimeout(timer);
1505
+ resolve();
1506
+ };
1507
+ timer = setTimeout(() => {
1508
+ signal?.removeEventListener("abort", onAbort);
1509
+ resolve();
1510
+ }, ms);
1511
+ signal?.addEventListener("abort", onAbort, { once: true });
1512
+ });
1513
+ }
1514
+ function sameTerminalAssistantContent(current, previous, agentKind) {
1515
+ const currentMsg = current?.lastAssistantMessage;
1516
+ const prevMsg = previous?.lastAssistantMessage;
1517
+ if (!currentMsg || !prevMsg)
1518
+ return false;
1519
+ if (agentKind === "agy" || current?.source === "antigravity-sqlite") {
1520
+ if (currentMsg.ref !== null && prevMsg.ref !== null) {
1521
+ return currentMsg.ref === prevMsg.ref;
1522
+ }
1523
+ return Boolean(currentMsg.text && currentMsg.text === prevMsg.text);
1524
+ }
1525
+ // Non-agy (pi, claude, codex, grok, opencode):
1526
+ if (currentMsg.ref !== null && prevMsg.ref !== null) {
1527
+ return currentMsg.ref === prevMsg.ref;
1528
+ }
1529
+ if (currentMsg.text.length > 0 && prevMsg.text.length > 0) {
1530
+ return currentMsg.text === prevMsg.text;
1531
+ }
1532
+ return false;
1533
+ }
524
1534
  //# sourceMappingURL=agent-index-service.js.map