shennian 0.2.86 → 0.2.88

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.
@@ -49,6 +49,18 @@ function buildRelayAgentPayload(event, sessionId, extra = {}) {
49
49
  }
50
50
  return { ...event, sessionId, ...extra };
51
51
  }
52
+ const SESSION_ACTIVITY_HEARTBEAT_INTERVAL_MS = 30_000;
53
+ function runPhaseFromAgentEvent(event) {
54
+ if (event.state === 'heartbeat')
55
+ return event.runPhase ?? null;
56
+ if (event.state === 'tool-call' || event.state === 'tool-result')
57
+ return 'tool_running';
58
+ if (event.state === 'delta')
59
+ return event.thinking ? 'thinking' : 'streaming_text';
60
+ if (event.state === 'init' || event.state === 'start')
61
+ return 'thinking';
62
+ return null;
63
+ }
52
64
  function formatAgentSendFailure(agentType, err) {
53
65
  const raw = err instanceof Error ? err.message : String(err);
54
66
  if (agentType === 'pi' &&
@@ -132,8 +144,6 @@ function normalizeExternalChannel(value) {
132
144
  noRestore: raw.noRestore === undefined || raw.noRestore === null ? null : Boolean(raw.noRestore),
133
145
  downloadAttachments: raw.downloadAttachments === undefined || raw.downloadAttachments === null ? null : Boolean(raw.downloadAttachments),
134
146
  downloadAttachmentsDir: typeof raw.downloadAttachmentsDir === 'string' ? raw.downloadAttachmentsDir : null,
135
- cloudOcrUrl: typeof raw.cloudOcrUrl === 'string' ? raw.cloudOcrUrl : null,
136
- cloudOcrMode: typeof raw.cloudOcrMode === 'string' ? raw.cloudOcrMode : null,
137
147
  };
138
148
  }
139
149
  function externalChannelEnabled(channel) {
@@ -194,6 +204,38 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
194
204
  id: `agent-evt-${event.runId}-${event.seq}`,
195
205
  });
196
206
  }
207
+ function stopActivityHeartbeat(activeSession) {
208
+ if (!activeSession?.heartbeatTimer)
209
+ return;
210
+ clearInterval(activeSession.heartbeatTimer);
211
+ activeSession.heartbeatTimer = null;
212
+ }
213
+ function sendActivityHeartbeat(activeSession) {
214
+ if (!activeSession.currentRunId || !activeSession.currentRunPhase)
215
+ return;
216
+ const seq = activeSession.heartbeatSeq++;
217
+ runtime.client.sendAgentEvent({
218
+ type: 'event',
219
+ event: 'agent',
220
+ payload: {
221
+ state: 'heartbeat',
222
+ sessionId,
223
+ runId: activeSession.currentRunId,
224
+ seq,
225
+ runPhase: activeSession.currentRunPhase,
226
+ },
227
+ seq,
228
+ id: `agent-heartbeat-${activeSession.currentRunId}-${seq}-${Date.now()}`,
229
+ });
230
+ }
231
+ function ensureActivityHeartbeat(activeSession) {
232
+ if (!activeSession || activeSession.heartbeatTimer)
233
+ return;
234
+ activeSession.heartbeatTimer = setInterval(() => {
235
+ sendActivityHeartbeat(activeSession);
236
+ }, SESSION_ACTIVITY_HEARTBEAT_INTERVAL_MS);
237
+ activeSession.heartbeatTimer.unref?.();
238
+ }
197
239
  function flushTextBuffer(activeSession) {
198
240
  const textBuffer = activeSession?.pendingTextEvent;
199
241
  if (!activeSession || !textBuffer || !textBuffer.text)
@@ -209,9 +251,16 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
209
251
  }
210
252
  adapter.on('agentEvent', (event) => {
211
253
  const activeSession = runtime.sessions.get(sessionId);
254
+ const runPhase = runPhaseFromAgentEvent(event);
255
+ const isTerminalEvent = event.state === 'final' || event.state === 'error' || event.state === 'aborted';
212
256
  if (activeSession) {
213
- activeSession.currentRunId = event.runId;
214
257
  activeSession.nextEventSeq = event.seq + 1;
258
+ if (!isTerminalEvent)
259
+ activeSession.currentRunId = event.runId;
260
+ if (!isTerminalEvent && runPhase) {
261
+ activeSession.currentRunPhase = runPhase;
262
+ ensureActivityHeartbeat(activeSession);
263
+ }
215
264
  if (event.agentSessionId)
216
265
  activeSession.agentSessionId = event.agentSessionId;
217
266
  }
@@ -328,10 +377,12 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
328
377
  else if (event.state === 'tool-call' || event.state === 'tool-result' || event.state === 'approval-pending') {
329
378
  flushTextBuffer(activeSession);
330
379
  }
331
- if ((event.state === 'final' || event.state === 'error' || event.state === 'aborted') &&
380
+ if (isTerminalEvent &&
332
381
  activeSession?.currentRunId === event.runId) {
333
382
  activeSession.currentRunId = null;
383
+ activeSession.currentRunPhase = null;
334
384
  activeSession.nextEventSeq = 0;
385
+ stopActivityHeartbeat(activeSession);
335
386
  runtime.chatQueue?.noteTerminal(sessionId);
336
387
  }
337
388
  sendAgentEvent(event, extra);
@@ -355,6 +406,10 @@ function rememberProcessedReqId(runtime, reqId) {
355
406
  }
356
407
  }
357
408
  async function disposeSession(session) {
409
+ if (session.heartbeatTimer) {
410
+ clearInterval(session.heartbeatTimer);
411
+ session.heartbeatTimer = null;
412
+ }
358
413
  session.adapter.removeAllListeners();
359
414
  await session.adapter.stop().catch(() => { });
360
415
  }
@@ -372,7 +427,10 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
372
427
  agentSessionId: incomingAgentSid ?? null,
373
428
  lastActiveAt: Date.now(),
374
429
  currentRunId: null,
430
+ currentRunPhase: null,
375
431
  nextEventSeq: 0,
432
+ heartbeatSeq: 0,
433
+ heartbeatTimer: null,
376
434
  pendingTextEvent: null,
377
435
  externalChannel: externalChannel ?? null,
378
436
  externalReplyTarget: externalReplyTarget ?? null,
@@ -394,7 +452,12 @@ function emitSyntheticAbort(runtime, sessionId) {
394
452
  runtime.runTextAcc.delete(`${sessionId}:${runId}`);
395
453
  session.pendingTextEvent = null;
396
454
  session.currentRunId = null;
455
+ session.currentRunPhase = null;
397
456
  session.nextEventSeq = 0;
457
+ if (session.heartbeatTimer) {
458
+ clearInterval(session.heartbeatTimer);
459
+ session.heartbeatTimer = null;
460
+ }
398
461
  runtime.client.sendAgentEvent({
399
462
  type: 'event',
400
463
  event: 'agent',
@@ -228,6 +228,10 @@ export class SessionManager {
228
228
  }
229
229
  }
230
230
  if (oldest) {
231
+ if (oldest.session.heartbeatTimer) {
232
+ clearInterval(oldest.session.heartbeatTimer);
233
+ oldest.session.heartbeatTimer = null;
234
+ }
231
235
  oldest.session.adapter.removeAllListeners();
232
236
  oldest.session.adapter.stop().catch(() => { });
233
237
  this.sessions.delete(oldest.key);
@@ -242,6 +246,10 @@ export class SessionManager {
242
246
  }
243
247
  cleanup() {
244
248
  for (const [, session] of this.sessions) {
249
+ if (session.heartbeatTimer) {
250
+ clearInterval(session.heartbeatTimer);
251
+ session.heartbeatTimer = null;
252
+ }
245
253
  session.adapter.stop().catch(() => { });
246
254
  }
247
255
  this.sessions.clear();
@@ -1,4 +1,4 @@
1
- import type { AgentType, ExternalChannelSessionStatus } from '@shennian/wire';
1
+ import type { AgentType, ExternalChannelSessionStatus, SessionRunPhase } from '@shennian/wire';
2
2
  import type { AgentAdapter } from '../agents/adapter.js';
3
3
  import type { CliRelayClient } from '../relay/client.js';
4
4
  import type { NativeSessionFusionService } from '../native-fusion/service.js';
@@ -11,7 +11,10 @@ export type ActiveSession = {
11
11
  agentSessionId: string | null;
12
12
  lastActiveAt: number;
13
13
  currentRunId: string | null;
14
+ currentRunPhase: SessionRunPhase | null;
14
15
  nextEventSeq: number;
16
+ heartbeatSeq: number;
17
+ heartbeatTimer: ReturnType<typeof setInterval> | null;
15
18
  pendingTextEvent: {
16
19
  runId: string;
17
20
  seq: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.86",
3
+ "version": "0.2.88",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {