cursorconnect 0.1.7 → 0.1.9

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 (44) hide show
  1. package/bridge-runtime/.env.example +7 -1
  2. package/bridge-runtime/connector-version.json +1 -1
  3. package/bridge-runtime/dist/agent-completion-push.d.ts +27 -22
  4. package/bridge-runtime/dist/agent-completion-push.js +242 -122
  5. package/bridge-runtime/dist/agent-completion-readiness.d.ts +19 -0
  6. package/bridge-runtime/dist/agent-completion-readiness.js +42 -0
  7. package/bridge-runtime/dist/chat-display-store.d.ts +32 -7
  8. package/bridge-runtime/dist/chat-display-store.js +99 -21
  9. package/bridge-runtime/dist/chat-display.d.ts +36 -0
  10. package/bridge-runtime/dist/chat-display.js +287 -24
  11. package/bridge-runtime/dist/chat-sync.d.ts +3 -1
  12. package/bridge-runtime/dist/chat-sync.js +20 -0
  13. package/bridge-runtime/dist/config.js +2 -0
  14. package/bridge-runtime/dist/connector-client-version.js +1 -1
  15. package/bridge-runtime/dist/debug-chats-page.d.ts +1 -1
  16. package/bridge-runtime/dist/debug-chats-page.js +148 -26
  17. package/bridge-runtime/dist/dom-transcript-store.d.ts +3 -1
  18. package/bridge-runtime/dist/dom-transcript-store.js +18 -3
  19. package/bridge-runtime/dist/extract-page.js +5 -4
  20. package/bridge-runtime/dist/index.js +9 -0
  21. package/bridge-runtime/dist/keep-awake.d.ts +5 -0
  22. package/bridge-runtime/dist/keep-awake.js +48 -0
  23. package/bridge-runtime/dist/lenta-capture.d.ts +46 -0
  24. package/bridge-runtime/dist/lenta-capture.js +146 -0
  25. package/bridge-runtime/dist/lenta-debug.d.ts +42 -0
  26. package/bridge-runtime/dist/lenta-debug.js +221 -0
  27. package/bridge-runtime/dist/lenta-delivery.d.ts +3 -0
  28. package/bridge-runtime/dist/lenta-delivery.js +10 -0
  29. package/bridge-runtime/dist/lenta-seq-journal.d.ts +48 -0
  30. package/bridge-runtime/dist/lenta-seq-journal.js +109 -0
  31. package/bridge-runtime/dist/message-filter.d.ts +5 -0
  32. package/bridge-runtime/dist/message-filter.js +4 -0
  33. package/bridge-runtime/dist/relay-upstream.d.ts +3 -0
  34. package/bridge-runtime/dist/relay-upstream.js +21 -0
  35. package/bridge-runtime/dist/relay.d.ts +47 -3
  36. package/bridge-runtime/dist/relay.js +667 -96
  37. package/bridge-runtime/dist/types.d.ts +13 -4
  38. package/dist/bridge-build.js +50 -0
  39. package/dist/index.js +9 -6
  40. package/dist/launch.js +5 -1
  41. package/dist/run-service.js +10 -4
  42. package/dist/startup-check.js +6 -0
  43. package/package.json +1 -1
  44. package/version-policy.json +2 -2
@@ -3,6 +3,8 @@ SERVER_HOST=127.0.0.1
3
3
  SERVER_PORT=3847
4
4
  POLL_INTERVAL_MS=400
5
5
  DEBOUNCE_MS=150
6
+ # Coalesce socket agent:messages for DOM overlay (store ingest is immediate)
7
+ # DOM_OVERLAY_EMIT_MS=50
6
8
  # JSONL live lenta: poll subscribed (120ms), live watcher on subscribe (immediate), debounce off for live
7
9
  # JSONL_POLL_MS=120
8
10
  # JSONL_EMIT_DEBOUNCE_MS=0
@@ -13,7 +15,7 @@ WEBAPP_PASSWORD=
13
15
  # CURSOR_PROJECTS_DIR=
14
16
  LOG_LEVEL=info
15
17
 
16
- # Chat lenta: JSONL → agent:messages (liveMessages always []). DOM = state:patch (working, approve).
18
+ # Chat lenta: JSONL → agent:messages; liveMessages = DOM overlay until turn is in .jsonl.
17
19
  # CHAT_HISTORY_JSONL=1 — extra socket agent:history on app (legacy).
18
20
  # CHAT_HISTORY_JSONL=1
19
21
  # Follow active Cursor window in CDP (1.5s poll). Set FOCUS_SYNC_ENABLED=0 to disable.
@@ -25,3 +27,7 @@ LOG_LEVEL=info
25
27
  # RELAY_URL=https://your-domain.example
26
28
  # RELAY_TOKEN=same-as-relay-.env
27
29
  # RELAY_ROOM_ID=default
30
+ # macOS: не давать Mac уснуть, пока bridge запущен (cursorconnect start). Выключить: KEEP_AWAKE=0
31
+ # KEEP_AWAKE=0
32
+ # Периодический ping relay upstream (NAT/idle). 0 = выкл.
33
+ # RELAY_KEEPALIVE_MS=20000
@@ -1 +1 @@
1
- {"cliVersion":"0.1.7","bundledAt":"2026-05-25T18:20:35Z"}
1
+ {"cliVersion":"0.1.9","bundledAt":"2026-05-26T11:11:21Z"}
@@ -1,4 +1,5 @@
1
- import type { AgentsIndex, CursorState, HistoryMessage } from './types.js';
1
+ import { type AgentCompletionReadinessDeps } from './agent-completion-readiness.js';
2
+ import type { AgentsIndex, CursorState } from './types.js';
2
3
  export declare const AGENT_COMPLETION_PUSH_BODY = "\u0410\u0433\u0435\u043D\u0442 \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B \u0440\u0430\u0431\u043E\u0442\u0443";
3
4
  export declare function suppressAgentCompletionPush(ms?: number): void;
4
5
  export interface AgentCompletionPushPayload {
@@ -7,36 +8,40 @@ export interface AgentCompletionPushPayload {
7
8
  body: string;
8
9
  }
9
10
  export type AgentCompletionPushEmit = (payload: AgentCompletionPushPayload) => void;
11
+ export interface AgentCompletionPushDeps {
12
+ readiness: AgentCompletionReadinessDeps;
13
+ requestContentSync(agentId: string, state: CursorState): void;
14
+ }
10
15
  /**
11
- * Push when a new assistant row lands in JSONL for the current/recent task.
12
- * If JSONL flushes while DOM still shows "working", retry when DOM goes idle.
16
+ * Push when sidebar loader off + archive/lenta ready for open-from-push.
13
17
  */
14
18
  export declare class AgentCompletionPush {
15
19
  private emit;
16
20
  private agentsIndex;
17
21
  private getState;
18
- /** Last seen assistant line id in JSONL (line number / ts). */
19
- private lastJsonlAssistantKey;
20
- /** Agents that were generating on previous DOM poll. */
22
+ private deps;
21
23
  private prevWorking;
24
+ private workingPolls;
25
+ private confirmedWorking;
26
+ private idlePolls;
22
27
  private lastEmitAt;
23
- /** JSONL had a new assistant line while DOM was still working — emit after idle. */
24
- private pendingAfterIdle;
25
- private pendingDeferSince;
26
- private deferTimers;
27
- private lastJsonlMessages;
28
+ private emittedThisSession;
29
+ private pendingContent;
30
+ private contentTimers;
31
+ private lastSkipLogAt;
32
+ private static readonly WORKING_POLLS_REQUIRED;
33
+ private static readonly IDLE_POLLS_REQUIRED;
28
34
  private static readonly MIN_EMIT_GAP_MS;
29
- /** DOM can lie "working" after JSONL finished — emit anyway after this. */
30
- private static readonly MAX_DEFER_MS;
31
- constructor(emit: AgentCompletionPushEmit, agentsIndex: () => AgentsIndex | null, getState: () => CursorState);
32
- /** Sync working snapshot; flush pending push when a task leaves DOM "working". */
35
+ private static readonly CONTENT_WAIT_MAX_MS;
36
+ private static readonly SKIP_LOG_GAP_MS;
37
+ constructor(emit: AgentCompletionPushEmit, agentsIndex: () => AgentsIndex | null, getState: () => CursorState, deps: AgentCompletionPushDeps);
33
38
  observe(state: CursorState): void;
34
- /**
35
- * JSONL file updated — push if a new assistant message was appended for this task
36
- * and DOM no longer shows generation for that composer.
37
- */
38
- onJsonlUpdated(agentId: string, messages: HistoryMessage[], state: CursorState): void;
39
- private scheduleDeferTimeout;
40
- private clearDeferTimer;
39
+ retryPendingContent(state: CursorState): void;
40
+ private logSkip;
41
+ private trackWorkingSessions;
42
+ private detectIdleCompletions;
43
+ private syncPrevWorking;
41
44
  private tryEmit;
45
+ private scheduleContentTimeout;
46
+ private clearContentTimer;
42
47
  }
@@ -1,3 +1,5 @@
1
+ import { isComposerUuid, normalizeAgentTitle } from './chat-sync.js';
2
+ import { assessAgentCompletionReadiness, } from './agent-completion-readiness.js';
1
3
  export const AGENT_COMPLETION_PUSH_BODY = 'Агент завершил работу';
2
4
  let suppressUntil = 0;
3
5
  export function suppressAgentCompletionPush(ms = 6000) {
@@ -9,15 +11,63 @@ function isSuppressed() {
9
11
  function normalizeTitle(title) {
10
12
  return title.trim().replace(/\s+/g, ' ');
11
13
  }
12
- /** Ephemeral sidebar ids (tab-0) must not drive push — composerId can appear next poll. */
13
14
  function isStableComposerId(id) {
14
15
  if (!id?.trim())
15
16
  return false;
16
17
  const t = id.trim();
17
18
  if (t.startsWith('tab-'))
18
19
  return false;
20
+ if (t.startsWith('sidebar-'))
21
+ return false;
19
22
  return t.length >= 8;
20
23
  }
24
+ function lookupComposerIdByTitle(title, map) {
25
+ if (!title?.trim() || !map)
26
+ return undefined;
27
+ return map[normalizeAgentTitle(title)];
28
+ }
29
+ function resolveAgentIdFromIndex(title, agentsIndex) {
30
+ const norm = normalizeAgentTitle(title);
31
+ if (!norm || !agentsIndex)
32
+ return undefined;
33
+ for (const repo of agentsIndex.repos) {
34
+ for (const agent of repo.agents) {
35
+ if (normalizeAgentTitle(agent.title) !== norm)
36
+ continue;
37
+ if (isStableComposerId(agent.id) || isComposerUuid(agent.id))
38
+ return agent.id.trim();
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ function resolveSidebarAgentId(rawId, title, state, agentsIndex) {
44
+ if (isStableComposerId(rawId))
45
+ return rawId.trim();
46
+ if (isComposerUuid(rawId))
47
+ return rawId.trim();
48
+ const activeId = state.activeComposerId?.trim();
49
+ const activeTab = state.tabs.find((t) => t.active);
50
+ if (activeTab?.isWorking && activeId && isStableComposerId(activeId)) {
51
+ const raw = String(rawId).trim();
52
+ const tabComposer = activeTab.composerId ? String(activeTab.composerId).trim() : '';
53
+ const tabTitle = normalizeAgentTitle(activeTab.title ?? '');
54
+ const titleNorm = normalizeAgentTitle(title);
55
+ const sameRow = raw === activeTab.id ||
56
+ (tabComposer && raw === tabComposer) ||
57
+ !titleNorm ||
58
+ tabTitle === titleNorm;
59
+ if (sameRow && (!titleNorm || tabTitle === titleNorm || !tabTitle)) {
60
+ return activeId;
61
+ }
62
+ }
63
+ const fromTitle = lookupComposerIdByTitle(title, state.composerIdByTitle);
64
+ if (fromTitle && isStableComposerId(fromTitle))
65
+ return fromTitle;
66
+ const fromIndex = resolveAgentIdFromIndex(title, agentsIndex);
67
+ if (fromIndex)
68
+ return fromIndex;
69
+ return undefined;
70
+ }
21
71
  function titleForComposerId(agentId, state) {
22
72
  const activeTitle = state.activeChatTitle?.trim();
23
73
  if (state.activeComposerId === agentId && activeTitle)
@@ -26,11 +76,17 @@ function titleForComposerId(agentId, state) {
26
76
  const id = tab.composerId ?? tab.id;
27
77
  if (id === agentId)
28
78
  return tab.title?.trim() || undefined;
79
+ const resolved = resolveSidebarAgentId(tab.composerId ?? tab.id, tab.title ?? '', state);
80
+ if (resolved === agentId)
81
+ return tab.title?.trim() || undefined;
29
82
  }
30
83
  for (const repo of state.sidebarRepos ?? []) {
31
84
  for (const agent of repo.agents) {
32
85
  if (agent.id === agentId)
33
86
  return agent.title?.trim() || undefined;
87
+ const resolved = resolveSidebarAgentId(agent.id, agent.title, state);
88
+ if (resolved === agentId)
89
+ return agent.title?.trim() || undefined;
34
90
  }
35
91
  }
36
92
  return undefined;
@@ -47,174 +103,238 @@ function resolveAgentChatTitle(agentId, state, agentsIndex) {
47
103
  }
48
104
  return 'Чат';
49
105
  }
50
- /**
51
- * Working agents for correlating JSONL flush with the task that was running.
52
- * Sidebar: stable composerId only (no tab-N flicker).
53
- */
54
- function snapshotWorkingAgents(state, agentsIndex) {
106
+ /** Sidebar loading-indicator on list rows (`tabs` + `sidebarRepos`). */
107
+ function snapshotSidebarWorkingAgents(state, agentsIndex) {
55
108
  const map = new Map();
56
- const activeId = state.activeComposerId;
57
- const activeGenerating = state.agentStatus === 'working' &&
58
- !!activeId &&
59
- isStableComposerId(activeId);
60
- if (activeGenerating && activeId) {
61
- map.set(activeId, resolveAgentChatTitle(activeId, state, agentsIndex));
109
+ const add = (rawId, title) => {
110
+ const id = resolveSidebarAgentId(rawId, title, state, agentsIndex);
111
+ if (!id)
112
+ return;
113
+ if (agentPausedForInput(state, id))
114
+ return;
115
+ map.set(id, title.trim() || resolveAgentChatTitle(id, state, agentsIndex));
116
+ };
117
+ const activeId = state.activeComposerId?.trim();
118
+ const activeTab = state.tabs.find((t) => t.active);
119
+ if (activeTab?.isWorking && activeId && isStableComposerId(activeId)) {
120
+ add(activeId, activeTab.title ?? state.activeChatTitle ?? '');
62
121
  }
63
122
  for (const tab of state.tabs) {
64
123
  if (!tab.isWorking)
65
124
  continue;
66
- const id = tab.composerId?.trim();
67
- if (!id || !isStableComposerId(id))
68
- continue;
69
- map.set(id, tab.title?.trim() || resolveAgentChatTitle(id, state, agentsIndex));
125
+ add(tab.composerId ?? tab.id, tab.title ?? '');
126
+ }
127
+ for (const repo of state.sidebarRepos ?? []) {
128
+ for (const agent of repo.agents) {
129
+ if (!agent.isWorking)
130
+ continue;
131
+ add(agent.id, agent.title);
132
+ }
70
133
  }
71
134
  return map;
72
135
  }
73
- function agentPausedForInput(state) {
136
+ function agentPausedForInput(state, agentId) {
137
+ const isActive = state.activeComposerId === agentId;
138
+ if (!isActive)
139
+ return false;
74
140
  return ((state.pendingApprovals?.length ?? 0) > 0 ||
75
141
  !!state.pendingQuestionnaire ||
76
142
  state.agentStatus === 'waiting_approval' ||
77
- state.agentStatus === 'waiting_questionnaire');
143
+ state.agentStatus === 'waiting_questionnaire' ||
144
+ (state.agentStatus === 'background_shell' && state.agentWorking !== true));
78
145
  }
79
- function lastAssistantLineKey(messages) {
80
- for (let i = messages.length - 1; i >= 0; i--) {
81
- const m = messages[i];
82
- if (m.role === 'assistant')
83
- return m.ts ?? i;
146
+ function isStillWorkingWithTitle(title, now) {
147
+ const norm = normalizeTitle(title);
148
+ if (!norm)
149
+ return false;
150
+ for (const t of now.values()) {
151
+ if (normalizeTitle(t) === norm)
152
+ return true;
84
153
  }
85
- return 0;
154
+ return false;
86
155
  }
87
156
  /**
88
- * Push when a new assistant row lands in JSONL for the current/recent task.
89
- * If JSONL flushes while DOM still shows "working", retry when DOM goes idle.
157
+ * Push when sidebar loader off + archive/lenta ready for open-from-push.
90
158
  */
91
159
  export class AgentCompletionPush {
92
160
  emit;
93
161
  agentsIndex;
94
162
  getState;
95
- /** Last seen assistant line id in JSONL (line number / ts). */
96
- lastJsonlAssistantKey = new Map();
97
- /** Agents that were generating on previous DOM poll. */
98
- prevWorking = new Map();
163
+ deps;
164
+ prevWorking = null;
165
+ workingPolls = new Map();
166
+ confirmedWorking = new Set();
167
+ idlePolls = new Map();
99
168
  lastEmitAt = new Map();
100
- /** JSONL had a new assistant line while DOM was still working — emit after idle. */
101
- pendingAfterIdle = new Set();
102
- pendingDeferSince = new Map();
103
- deferTimers = new Map();
104
- lastJsonlMessages = new Map();
105
- static MIN_EMIT_GAP_MS = 45_000;
106
- /** DOM can lie "working" after JSONL finished — emit anyway after this. */
107
- static MAX_DEFER_MS = 12_000;
108
- constructor(emit, agentsIndex, getState) {
169
+ emittedThisSession = new Set();
170
+ pendingContent = new Set();
171
+ contentTimers = new Map();
172
+ lastSkipLogAt = new Map();
173
+ static WORKING_POLLS_REQUIRED = 2;
174
+ static IDLE_POLLS_REQUIRED = 1;
175
+ static MIN_EMIT_GAP_MS = 8_000;
176
+ static CONTENT_WAIT_MAX_MS = 12_000;
177
+ static SKIP_LOG_GAP_MS = 5_000;
178
+ constructor(emit, agentsIndex, getState, deps) {
109
179
  this.emit = emit;
110
180
  this.agentsIndex = agentsIndex;
111
181
  this.getState = getState;
182
+ this.deps = deps;
112
183
  }
113
- /** Sync working snapshot; flush pending push when a task leaves DOM "working". */
114
184
  observe(state) {
115
- const index = this.agentsIndex();
116
- if (isSuppressed() || agentPausedForInput(state)) {
117
- this.prevWorking = snapshotWorkingAgents(state, index);
185
+ if (isSuppressed()) {
186
+ this.syncPrevWorking(state);
118
187
  return;
119
188
  }
120
- const nowWorking = snapshotWorkingAgents(state, index);
121
- for (const agentId of this.pendingAfterIdle) {
122
- if (nowWorking.has(agentId))
123
- continue;
124
- const messages = this.lastJsonlMessages.get(agentId);
125
- if (messages?.length) {
126
- this.tryEmit(agentId, messages, state, 'dom-idle');
127
- }
189
+ const index = this.agentsIndex();
190
+ const now = snapshotSidebarWorkingAgents(state, index);
191
+ this.trackWorkingSessions(now, state);
192
+ this.detectIdleCompletions(now, state);
193
+ this.retryPendingContent(state);
194
+ }
195
+ retryPendingContent(state) {
196
+ for (const agentId of [...this.pendingContent]) {
197
+ this.tryEmit(agentId, state, { sidebarIdle: true });
128
198
  }
129
- this.prevWorking = nowWorking;
130
199
  }
131
- /**
132
- * JSONL file updated push if a new assistant message was appended for this task
133
- * and DOM no longer shows generation for that composer.
134
- */
135
- onJsonlUpdated(agentId, messages, state) {
136
- if (isSuppressed() || agentPausedForInput(state))
137
- return;
138
- if (!isStableComposerId(agentId))
139
- return;
140
- const lineKey = lastAssistantLineKey(messages);
141
- if (!lineKey)
142
- return;
143
- const prevKey = this.lastJsonlAssistantKey.get(agentId);
144
- if (prevKey === undefined) {
145
- this.lastJsonlAssistantKey.set(agentId, lineKey);
146
- this.lastJsonlMessages.set(agentId, messages);
200
+ logSkip(agentId, reason) {
201
+ const last = this.lastSkipLogAt.get(agentId) ?? 0;
202
+ if (Date.now() - last < AgentCompletionPush.SKIP_LOG_GAP_MS)
147
203
  return;
204
+ this.lastSkipLogAt.set(agentId, Date.now());
205
+ console.log(`[agent-completion-push] skip agentId=${agentId.slice(0, 12)} reason=${reason}`);
206
+ }
207
+ trackWorkingSessions(now, state) {
208
+ const activeId = state.activeComposerId?.trim();
209
+ if (activeId && isStableComposerId(activeId) && now.has(activeId)) {
210
+ this.confirmedWorking.add(activeId);
211
+ this.emittedThisSession.delete(activeId);
148
212
  }
149
- if (lineKey <= prevKey)
150
- return;
151
- this.lastJsonlAssistantKey.set(agentId, lineKey);
152
- this.lastJsonlMessages.set(agentId, messages);
153
- const nowWorking = snapshotWorkingAgents(state, this.agentsIndex());
154
- if (nowWorking.has(agentId)) {
155
- this.pendingAfterIdle.add(agentId);
156
- if (!this.pendingDeferSince.has(agentId)) {
157
- this.pendingDeferSince.set(agentId, Date.now());
213
+ for (const id of now.keys()) {
214
+ const polls = (this.workingPolls.get(id) ?? 0) + 1;
215
+ this.workingPolls.set(id, polls);
216
+ if (polls >= AgentCompletionPush.WORKING_POLLS_REQUIRED) {
217
+ this.confirmedWorking.add(id);
218
+ this.emittedThisSession.delete(id);
219
+ this.idlePolls.delete(id);
220
+ this.pendingContent.delete(id);
221
+ this.clearContentTimer(id);
158
222
  }
159
- this.scheduleDeferTimeout(agentId);
160
- console.log(`[agent-completion-push] defer agentId=${agentId.slice(0, 12)} line=${lineKey} (dom still working)`);
161
- return;
162
223
  }
163
- this.clearDeferTimer(agentId);
164
- this.pendingDeferSince.delete(agentId);
165
- this.tryEmit(agentId, messages, state, 'jsonl');
224
+ for (const id of this.workingPolls.keys()) {
225
+ if (!now.has(id))
226
+ this.workingPolls.delete(id);
227
+ }
166
228
  }
167
- scheduleDeferTimeout(agentId) {
168
- if (this.deferTimers.has(agentId))
229
+ detectIdleCompletions(now, state) {
230
+ const prev = this.prevWorking;
231
+ if (prev === null) {
232
+ this.prevWorking = new Map(now);
169
233
  return;
170
- const timer = setTimeout(() => {
171
- this.deferTimers.delete(agentId);
172
- const since = this.pendingDeferSince.get(agentId) ?? 0;
173
- if (!this.pendingAfterIdle.has(agentId))
174
- return;
175
- if (Date.now() - since < AgentCompletionPush.MAX_DEFER_MS - 100)
176
- return;
177
- const messages = this.lastJsonlMessages.get(agentId);
178
- if (!messages?.length)
179
- return;
180
- console.log(`[agent-completion-push] defer timeout agentId=${agentId.slice(0, 12)} (dom still working, jsonl done)`);
181
- this.tryEmit(agentId, messages, this.getState(), 'defer-timeout');
182
- }, AgentCompletionPush.MAX_DEFER_MS);
183
- this.deferTimers.set(agentId, timer);
234
+ }
235
+ for (const [agentId] of prev) {
236
+ if (now.has(agentId)) {
237
+ this.idlePolls.delete(agentId);
238
+ continue;
239
+ }
240
+ if (!this.confirmedWorking.has(agentId)) {
241
+ this.logSkip(agentId, 'no-confirmed-working');
242
+ continue;
243
+ }
244
+ const polls = (this.idlePolls.get(agentId) ?? 0) + 1;
245
+ this.idlePolls.set(agentId, polls);
246
+ if (polls < AgentCompletionPush.IDLE_POLLS_REQUIRED)
247
+ continue;
248
+ console.log(`[agent-completion-push] sidebar idle agentId=${agentId.slice(0, 12)} title=${(prev.get(agentId) ?? '').slice(0, 40)}`);
249
+ this.tryEmit(agentId, state, { sidebarIdle: true });
250
+ }
251
+ for (const id of this.idlePolls.keys()) {
252
+ if (now.has(id))
253
+ this.idlePolls.delete(id);
254
+ }
255
+ this.prevWorking = new Map(now);
184
256
  }
185
- clearDeferTimer(agentId) {
186
- const t = this.deferTimers.get(agentId);
187
- if (t)
188
- clearTimeout(t);
189
- this.deferTimers.delete(agentId);
257
+ syncPrevWorking(state) {
258
+ this.prevWorking = snapshotSidebarWorkingAgents(state, this.agentsIndex());
190
259
  }
191
- tryEmit(agentId, messages, state, reason) {
192
- const lineKey = lastAssistantLineKey(messages);
193
- if (!lineKey)
260
+ tryEmit(agentId, state, opts) {
261
+ if (agentPausedForInput(state, agentId)) {
262
+ this.logSkip(agentId, 'paused-for-input');
194
263
  return;
195
- const nowWorking = snapshotWorkingAgents(state, this.agentsIndex());
196
- if (reason !== 'defer-timeout' && nowWorking.has(agentId))
264
+ }
265
+ if (this.emittedThisSession.has(agentId)) {
266
+ this.logSkip(agentId, 'already-emitted');
197
267
  return;
198
- const isCurrentTask = state.activeComposerId === agentId ||
199
- this.prevWorking.has(agentId) ||
200
- this.pendingAfterIdle.has(agentId);
201
- if (!isCurrentTask)
268
+ }
269
+ if (!this.confirmedWorking.has(agentId)) {
270
+ this.logSkip(agentId, 'no-confirmed-working');
202
271
  return;
272
+ }
273
+ const index = this.agentsIndex();
274
+ const now = snapshotSidebarWorkingAgents(state, index);
275
+ if (now.has(agentId)) {
276
+ this.pendingContent.delete(agentId);
277
+ this.clearContentTimer(agentId);
278
+ this.logSkip(agentId, 'sidebar-still-working');
279
+ return;
280
+ }
281
+ const title = normalizeTitle(resolveAgentChatTitle(agentId, state, index)) || 'Чат';
282
+ if (isStillWorkingWithTitle(title, now)) {
283
+ this.logSkip(agentId, 'same-title-still-working');
284
+ return;
285
+ }
286
+ const sidebarIdle = opts?.sidebarIdle === true;
287
+ let contentDetail = 'no-check';
288
+ if (!opts?.forceContent) {
289
+ const readiness = assessAgentCompletionReadiness(agentId, state, this.deps.readiness, { sidebarIdle });
290
+ contentDetail = readiness.detail;
291
+ if (!readiness.ready) {
292
+ if (!this.pendingContent.has(agentId)) {
293
+ this.pendingContent.add(agentId);
294
+ console.log(`[agent-completion-push] wait content agentId=${agentId.slice(0, 12)} detail=${readiness.detail}`);
295
+ this.deps.requestContentSync(agentId, state);
296
+ this.scheduleContentTimeout(agentId);
297
+ }
298
+ return;
299
+ }
300
+ }
301
+ else {
302
+ contentDetail = 'content-timeout';
303
+ }
203
304
  const last = this.lastEmitAt.get(agentId) ?? 0;
204
- if (Date.now() - last < AgentCompletionPush.MIN_EMIT_GAP_MS)
305
+ if (Date.now() - last < AgentCompletionPush.MIN_EMIT_GAP_MS) {
306
+ this.logSkip(agentId, 'emit-gap');
205
307
  return;
206
- const chatTitle = normalizeTitle(this.prevWorking.get(agentId) ??
207
- resolveAgentChatTitle(agentId, state, this.agentsIndex())) || 'Чат';
308
+ }
208
309
  this.emit({
209
310
  agentId,
210
- chatTitle,
311
+ chatTitle: title,
211
312
  body: AGENT_COMPLETION_PUSH_BODY,
212
313
  });
213
314
  this.lastEmitAt.set(agentId, Date.now());
214
- this.pendingAfterIdle.delete(agentId);
215
- this.pendingDeferSince.delete(agentId);
216
- this.clearDeferTimer(agentId);
217
- this.prevWorking.delete(agentId);
218
- console.log(`[agent-completion-push] → relay reason=${reason} agentId=${agentId.slice(0, 12)} title=${chatTitle.slice(0, 48)} line=${lineKey}`);
315
+ this.emittedThisSession.add(agentId);
316
+ this.confirmedWorking.delete(agentId);
317
+ this.idlePolls.delete(agentId);
318
+ this.workingPolls.delete(agentId);
319
+ this.pendingContent.delete(agentId);
320
+ this.clearContentTimer(agentId);
321
+ console.log(`[agent-completion-push] → relay reason=sidebar-idle+${contentDetail} agentId=${agentId.slice(0, 12)} title=${title.slice(0, 48)}`);
322
+ }
323
+ scheduleContentTimeout(agentId) {
324
+ this.clearContentTimer(agentId);
325
+ const timer = setTimeout(() => {
326
+ this.contentTimers.delete(agentId);
327
+ if (!this.pendingContent.has(agentId))
328
+ return;
329
+ console.log(`[agent-completion-push] content wait timeout agentId=${agentId.slice(0, 12)} — push anyway`);
330
+ this.tryEmit(agentId, this.getState(), { forceContent: true, sidebarIdle: true });
331
+ }, AgentCompletionPush.CONTENT_WAIT_MAX_MS);
332
+ this.contentTimers.set(agentId, timer);
333
+ }
334
+ clearContentTimer(agentId) {
335
+ const t = this.contentTimers.get(agentId);
336
+ if (t)
337
+ clearTimeout(t);
338
+ this.contentTimers.delete(agentId);
219
339
  }
220
340
  }
@@ -0,0 +1,19 @@
1
+ import type { ChatMessage, CursorState } from './types.js';
2
+ export interface AgentCompletionReadinessDeps {
3
+ isSubscribed(agentId: string): boolean;
4
+ getSubscribeTitle(agentId: string): string | undefined;
5
+ getDisplayMessages(agentId: string): ChatMessage[];
6
+ getJsonlHistory(agentId: string): ChatMessage[];
7
+ }
8
+ export interface AgentCompletionReadinessOpts {
9
+ /** Sidebar loader already off for this agent (DOM list). */
10
+ sidebarIdle?: boolean;
11
+ }
12
+ /**
13
+ * Push when opening the chat shows a complete turn.
14
+ * When sidebar is idle, do not block on `lenta-pending` (DOM overlay may still churn seq).
15
+ */
16
+ export declare function assessAgentCompletionReadiness(agentId: string, state: CursorState, deps: AgentCompletionReadinessDeps, opts?: AgentCompletionReadinessOpts): {
17
+ ready: boolean;
18
+ detail: string;
19
+ };
@@ -0,0 +1,42 @@
1
+ import { isChatSyncedWithCursor } from './chat-sync.js';
2
+ import { isLentaDeliveryPending } from './lenta-delivery.js';
3
+ function hasAssistantTail(history) {
4
+ const last = history[history.length - 1];
5
+ return !!last && last.role === 'assistant' && !!(last.text?.trim() || last.html?.trim());
6
+ }
7
+ /**
8
+ * Push when opening the chat shows a complete turn.
9
+ * When sidebar is idle, do not block on `lenta-pending` (DOM overlay may still churn seq).
10
+ */
11
+ export function assessAgentCompletionReadiness(agentId, state, deps, opts) {
12
+ const messages = deps.getDisplayMessages(agentId);
13
+ const history = deps.getJsonlHistory(agentId);
14
+ const subscribed = deps.isSubscribed(agentId);
15
+ const title = deps.getSubscribeTitle(agentId);
16
+ const sidebarIdle = opts?.sidebarIdle === true;
17
+ if (sidebarIdle && hasAssistantTail(history)) {
18
+ if (subscribed && title && isChatSyncedWithCursor(agentId, title, state)) {
19
+ if (isLentaDeliveryPending(agentId, messages)) {
20
+ return { ready: true, detail: 'jsonl-ready-sidebar-idle' };
21
+ }
22
+ return { ready: true, detail: 'lenta-delivered' };
23
+ }
24
+ return { ready: true, detail: 'jsonl-archive' };
25
+ }
26
+ if (subscribed && title && isChatSyncedWithCursor(agentId, title, state)) {
27
+ if (!messages.length && !history.length) {
28
+ return { ready: false, detail: 'lenta-empty' };
29
+ }
30
+ if (isLentaDeliveryPending(agentId, messages)) {
31
+ return { ready: false, detail: 'lenta-pending' };
32
+ }
33
+ return { ready: true, detail: 'lenta-delivered' };
34
+ }
35
+ if (hasAssistantTail(history)) {
36
+ if (subscribed && messages.length && isLentaDeliveryPending(agentId, messages)) {
37
+ return { ready: false, detail: 'lenta-pending-bg' };
38
+ }
39
+ return { ready: true, detail: 'jsonl-archive' };
40
+ }
41
+ return { ready: false, detail: 'no-archive' };
42
+ }