shennian 0.2.85 → 0.2.87

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.
@@ -1,11 +1,12 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { AgentType, ChatAttachmentMeta, ExternalChannelSessionStatus } from '@shennian/wire';
2
+ import type { AgentType, ChatAttachmentMeta, ExternalChannelSessionStatus, SessionRunPhase } from '@shennian/wire';
3
3
  export type AgentEvent = {
4
4
  state: string;
5
5
  runId: string;
6
6
  seq: number;
7
7
  text?: string;
8
8
  thinking?: boolean;
9
+ runPhase?: SessionRunPhase;
9
10
  name?: string;
10
11
  args?: Record<string, unknown>;
11
12
  result?: string;
@@ -38,6 +38,7 @@ const SAFE_SNAPSHOT_ENV_KEYS = new Set([
38
38
  'LOCALAPPDATA',
39
39
  'SHENNIAN_DESKTOP_SERVER_URL',
40
40
  'SHENNIAN_HOME',
41
+ 'SHENNIAN_NATIVE_FUSION_DISABLED',
41
42
  ]);
42
43
  export function isSafeSnapshotEnvKey(key) {
43
44
  return SAFE_SNAPSHOT_ENV_KEYS.has(key);
@@ -250,7 +251,7 @@ function inferDaemonLauncherFromProcess(pid) {
250
251
  }
251
252
  export function isShennianRunServiceCommand(command) {
252
253
  const normalized = command.replace(/\\/g, '/').trim();
253
- return (/^(?:\S*\/)?node\s+\S*(?:\/node_modules\/shennian\/|\/dist\/bin\/shennian\.js)\s+run-service(?:\s|$)/.test(normalized) ||
254
+ return (/^(?:\S*\/)?node\s+\S*(?:\/node_modules\/shennian\/|\/dist\/bin\/shennian\.js|\/bin\/shennian)\s+run-service(?:\s|$)/.test(normalized) ||
254
255
  /^(?:\S*\/)?shennian(?:\.cmd)?\s+run-service(?:\s|$)/.test(normalized) ||
255
256
  /^(?:\S*\/)?npx(?:\.cmd)?\s+(?:--yes\s+)?shennian\s+run-service(?:\s|$)/.test(normalized));
256
257
  }
package/dist/src/index.js CHANGED
@@ -25,6 +25,7 @@ const AUTO_UPGRADE_INITIAL_DELAY_MS = 30_000;
25
25
  const AUTO_UPGRADE_POLL_INTERVAL_MS = 5 * 60_000;
26
26
  import { getCachedAgentInfos, resolveAgentInfos } from './agents/model-registry.js';
27
27
  import { initCliLogReporter, reportLog } from './log-reporter.js';
28
+ import { isNativeFusionEnabled } from './native-fusion/config.js';
28
29
  import { NativeSessionFusionService } from './native-fusion/service.js';
29
30
  import { startDaemonLogRetention } from './daemon-log.js';
30
31
  const SHENNIAN_DIR = getShennianDir();
@@ -285,7 +286,7 @@ program
285
286
  },
286
287
  });
287
288
  nativeFusion =
288
- process.env.SHENNIAN_NATIVE_FUSION_ENABLED === '1'
289
+ isNativeFusionEnabled()
289
290
  ? new NativeSessionFusionService(client)
290
291
  : null;
291
292
  const sessionManager = new SessionManager(client, nativeFusion, currentCliVersion);
@@ -0,0 +1 @@
1
+ export declare function isNativeFusionEnabled(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,5 @@
1
+ // @arch docs/architecture/cli/native-session-fusion.md
2
+ // @test src/__tests__/native-fusion-config.test.ts
3
+ export function isNativeFusionEnabled(env = process.env) {
4
+ return env.SHENNIAN_NATIVE_FUSION_DISABLED !== '1';
5
+ }
@@ -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' &&
@@ -194,6 +206,38 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
194
206
  id: `agent-evt-${event.runId}-${event.seq}`,
195
207
  });
196
208
  }
209
+ function stopActivityHeartbeat(activeSession) {
210
+ if (!activeSession?.heartbeatTimer)
211
+ return;
212
+ clearInterval(activeSession.heartbeatTimer);
213
+ activeSession.heartbeatTimer = null;
214
+ }
215
+ function sendActivityHeartbeat(activeSession) {
216
+ if (!activeSession.currentRunId || !activeSession.currentRunPhase)
217
+ return;
218
+ const seq = activeSession.heartbeatSeq++;
219
+ runtime.client.sendAgentEvent({
220
+ type: 'event',
221
+ event: 'agent',
222
+ payload: {
223
+ state: 'heartbeat',
224
+ sessionId,
225
+ runId: activeSession.currentRunId,
226
+ seq,
227
+ runPhase: activeSession.currentRunPhase,
228
+ },
229
+ seq,
230
+ id: `agent-heartbeat-${activeSession.currentRunId}-${seq}-${Date.now()}`,
231
+ });
232
+ }
233
+ function ensureActivityHeartbeat(activeSession) {
234
+ if (!activeSession || activeSession.heartbeatTimer)
235
+ return;
236
+ activeSession.heartbeatTimer = setInterval(() => {
237
+ sendActivityHeartbeat(activeSession);
238
+ }, SESSION_ACTIVITY_HEARTBEAT_INTERVAL_MS);
239
+ activeSession.heartbeatTimer.unref?.();
240
+ }
197
241
  function flushTextBuffer(activeSession) {
198
242
  const textBuffer = activeSession?.pendingTextEvent;
199
243
  if (!activeSession || !textBuffer || !textBuffer.text)
@@ -209,9 +253,16 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
209
253
  }
210
254
  adapter.on('agentEvent', (event) => {
211
255
  const activeSession = runtime.sessions.get(sessionId);
256
+ const runPhase = runPhaseFromAgentEvent(event);
257
+ const isTerminalEvent = event.state === 'final' || event.state === 'error' || event.state === 'aborted';
212
258
  if (activeSession) {
213
- activeSession.currentRunId = event.runId;
214
259
  activeSession.nextEventSeq = event.seq + 1;
260
+ if (!isTerminalEvent)
261
+ activeSession.currentRunId = event.runId;
262
+ if (!isTerminalEvent && runPhase) {
263
+ activeSession.currentRunPhase = runPhase;
264
+ ensureActivityHeartbeat(activeSession);
265
+ }
215
266
  if (event.agentSessionId)
216
267
  activeSession.agentSessionId = event.agentSessionId;
217
268
  }
@@ -328,10 +379,12 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
328
379
  else if (event.state === 'tool-call' || event.state === 'tool-result' || event.state === 'approval-pending') {
329
380
  flushTextBuffer(activeSession);
330
381
  }
331
- if ((event.state === 'final' || event.state === 'error' || event.state === 'aborted') &&
382
+ if (isTerminalEvent &&
332
383
  activeSession?.currentRunId === event.runId) {
333
384
  activeSession.currentRunId = null;
385
+ activeSession.currentRunPhase = null;
334
386
  activeSession.nextEventSeq = 0;
387
+ stopActivityHeartbeat(activeSession);
335
388
  runtime.chatQueue?.noteTerminal(sessionId);
336
389
  }
337
390
  sendAgentEvent(event, extra);
@@ -355,6 +408,10 @@ function rememberProcessedReqId(runtime, reqId) {
355
408
  }
356
409
  }
357
410
  async function disposeSession(session) {
411
+ if (session.heartbeatTimer) {
412
+ clearInterval(session.heartbeatTimer);
413
+ session.heartbeatTimer = null;
414
+ }
358
415
  session.adapter.removeAllListeners();
359
416
  await session.adapter.stop().catch(() => { });
360
417
  }
@@ -372,7 +429,10 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
372
429
  agentSessionId: incomingAgentSid ?? null,
373
430
  lastActiveAt: Date.now(),
374
431
  currentRunId: null,
432
+ currentRunPhase: null,
375
433
  nextEventSeq: 0,
434
+ heartbeatSeq: 0,
435
+ heartbeatTimer: null,
376
436
  pendingTextEvent: null,
377
437
  externalChannel: externalChannel ?? null,
378
438
  externalReplyTarget: externalReplyTarget ?? null,
@@ -394,7 +454,12 @@ function emitSyntheticAbort(runtime, sessionId) {
394
454
  runtime.runTextAcc.delete(`${sessionId}:${runId}`);
395
455
  session.pendingTextEvent = null;
396
456
  session.currentRunId = null;
457
+ session.currentRunPhase = null;
397
458
  session.nextEventSeq = 0;
459
+ if (session.heartbeatTimer) {
460
+ clearInterval(session.heartbeatTimer);
461
+ session.heartbeatTimer = null;
462
+ }
398
463
  runtime.client.sendAgentEvent({
399
464
  type: 'event',
400
465
  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.85",
3
+ "version": "0.2.87",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {