shennian 0.2.51 → 0.2.53

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.
@@ -7,6 +7,10 @@ import path from 'node:path';
7
7
  import { buildUserMessagePayload, isToolPayload } from '@shennian/wire';
8
8
  import { resolveBuiltinCommand, spawnResolvedCommandSync } from '../agents/command-spec.js';
9
9
  const MAX_JSONL_LINE_BYTES = 64 * 1024 * 1024;
10
+ const DEFAULT_NATIVE_SCAN_IGNORED_PATHS = [
11
+ '/root/.claude-mem/observer-session',
12
+ ];
13
+ const DEFAULT_NATIVE_SCAN_IGNORED_CLAUDE_PROJECT_DIRS = DEFAULT_NATIVE_SCAN_IGNORED_PATHS.map(encodeClaudeProjectDir);
10
14
  function normalizeText(text) {
11
15
  return stripGitDirectiveArtifacts(text.replace(/\r\n/g, '\n').trim());
12
16
  }
@@ -103,6 +107,33 @@ function readClaudeEventCwd(parsed) {
103
107
  function isClaudeSubagentTranscript(filePath) {
104
108
  return path.basename(path.dirname(filePath)) === 'subagents';
105
109
  }
110
+ function normalizePathForCompare(filePath) {
111
+ const normalized = path.resolve(filePath);
112
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
113
+ }
114
+ function isSameOrChildPath(filePath, parentPath) {
115
+ const normalizedFilePath = normalizePathForCompare(filePath);
116
+ const normalizedParentPath = normalizePathForCompare(parentPath);
117
+ return normalizedFilePath === normalizedParentPath
118
+ || normalizedFilePath.startsWith(normalizedParentPath + path.sep);
119
+ }
120
+ function encodeClaudeProjectDir(filePath) {
121
+ return path.resolve(filePath).replace(/\//g, '-');
122
+ }
123
+ function shouldIgnoreNativeScanPath(filePath) {
124
+ return DEFAULT_NATIVE_SCAN_IGNORED_PATHS.some((ignoredPath) => isSameOrChildPath(filePath, ignoredPath));
125
+ }
126
+ function shouldIgnoreClaudeProjectDir(projectDirName) {
127
+ return DEFAULT_NATIVE_SCAN_IGNORED_CLAUDE_PROJECT_DIRS.includes(projectDirName);
128
+ }
129
+ function shouldIgnoreClaudeTranscriptPath(filePath) {
130
+ const root = path.join(os.homedir(), '.claude', 'projects');
131
+ const relative = path.relative(root, filePath);
132
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
133
+ return false;
134
+ const [projectDirName] = relative.split(path.sep);
135
+ return projectDirName ? shouldIgnoreClaudeProjectDir(projectDirName) : false;
136
+ }
106
137
  function makeCursor(filePath, offset) {
107
138
  return `${filePath}:${offset}`;
108
139
  }
@@ -715,8 +746,11 @@ export function listClaudeTranscriptFiles() {
715
746
  const walk = (dir) => {
716
747
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
717
748
  const full = path.join(dir, entry.name);
718
- if (entry.isDirectory())
749
+ if (entry.isDirectory()) {
750
+ if (dir === root && shouldIgnoreClaudeProjectDir(entry.name))
751
+ continue;
719
752
  walk(full);
753
+ }
720
754
  else if (entry.isFile() && entry.name.endsWith('.jsonl'))
721
755
  files.push(full);
722
756
  }
@@ -805,12 +839,16 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
805
839
  return { nextOffset, events };
806
840
  }
807
841
  export function parseClaudeTranscriptChunk(filePath, startOffset) {
808
- if (isClaudeSubagentTranscript(filePath)) {
809
- return { nextOffset: fs.statSync(filePath).size, events: [] };
842
+ const fileSize = fs.statSync(filePath).size;
843
+ if (isClaudeSubagentTranscript(filePath) || shouldIgnoreClaudeTranscriptPath(filePath)) {
844
+ return { nextOffset: fileSize, events: [] };
810
845
  }
811
846
  const events = [];
812
847
  const sourceSessionKey = path.basename(filePath, '.jsonl');
813
848
  const fallbackWorkDir = relativeProjectDir(path.dirname(filePath).replace(path.join(os.homedir(), '.claude', 'projects') + path.sep, ''));
849
+ if (shouldIgnoreNativeScanPath(fallbackWorkDir)) {
850
+ return { nextOffset: fileSize, events: [] };
851
+ }
814
852
  let title = '';
815
853
  const nextOffset = readJsonlLines(filePath, startOffset, (line, lineOffset) => {
816
854
  const parsed = safeParse(line);
@@ -820,6 +858,9 @@ export function parseClaudeTranscriptChunk(filePath, startOffset) {
820
858
  const type = typeof parsed.type === 'string' ? parsed.type : '';
821
859
  if (!ts || !type)
822
860
  return;
861
+ const eventCwd = readClaudeEventCwd(parsed);
862
+ if (eventCwd && shouldIgnoreNativeScanPath(eventCwd))
863
+ return;
823
864
  if (type === 'user') {
824
865
  const message = typeof parsed.message === 'object' && parsed.message !== null
825
866
  ? parsed.message
@@ -839,7 +880,7 @@ export function parseClaudeTranscriptChunk(filePath, startOffset) {
839
880
  ts,
840
881
  payload: text,
841
882
  title,
842
- workDir: readClaudeEventCwd(parsed) ?? fallbackWorkDir,
883
+ workDir: eventCwd ?? fallbackWorkDir,
843
884
  });
844
885
  }
845
886
  else if (type === 'assistant') {
@@ -867,7 +908,7 @@ export function parseClaudeTranscriptChunk(filePath, startOffset) {
867
908
  payload: text,
868
909
  title,
869
910
  modelId,
870
- workDir: readClaudeEventCwd(parsed) ?? fallbackWorkDir,
911
+ workDir: eventCwd ?? fallbackWorkDir,
871
912
  });
872
913
  }
873
914
  });
@@ -33,11 +33,13 @@ export declare class CliRelayClient {
33
33
  /** Buffered agent events awaiting server ack, keyed by event id */
34
34
  private sendBuffer;
35
35
  private pendingAcks;
36
+ private pendingRequests;
36
37
  constructor(options: CliRelayOptions);
37
38
  connect(): void;
38
39
  disconnect(): void;
39
40
  sendRes(res: ResFrame): void;
40
41
  sendEvent(event: EventFrame): void;
42
+ sendReq(req: ReqFrame, timeoutMs?: number): Promise<ResFrame>;
41
43
  sendBufferedEvent(event: EventFrame, timeoutMs?: number): Promise<void>;
42
44
  /**
43
45
  * Send an agent event with at-least-once delivery guarantee.
@@ -26,6 +26,7 @@ export class CliRelayClient {
26
26
  /** Buffered agent events awaiting server ack, keyed by event id */
27
27
  sendBuffer = new Map();
28
28
  pendingAcks = new Map();
29
+ pendingRequests = new Map();
29
30
  constructor(options) {
30
31
  this.options = options;
31
32
  }
@@ -121,6 +122,26 @@ export class CliRelayClient {
121
122
  event.traceId = generateTraceId();
122
123
  this.ws.send(JSON.stringify(event));
123
124
  }
125
+ sendReq(req, timeoutMs = 60_000) {
126
+ if (this.state !== 'connected' || !this.ws) {
127
+ return Promise.reject(new Error('Relay is not connected'));
128
+ }
129
+ if (!req.traceId)
130
+ req.traceId = generateTraceId();
131
+ return new Promise((resolve, reject) => {
132
+ const existing = this.pendingRequests.get(req.id);
133
+ if (existing) {
134
+ clearTimeout(existing.timer);
135
+ existing.reject(new Error('Superseded by a newer relay request'));
136
+ }
137
+ const timer = setTimeout(() => {
138
+ this.pendingRequests.delete(req.id);
139
+ reject(new Error('Relay request timed out'));
140
+ }, timeoutMs);
141
+ this.pendingRequests.set(req.id, { resolve, reject, timer });
142
+ this.ws?.send(JSON.stringify(req));
143
+ });
144
+ }
124
145
  sendBufferedEvent(event, timeoutMs = 120_000) {
125
146
  if (!event.traceId)
126
147
  event.traceId = generateTraceId();
@@ -172,6 +193,12 @@ export class CliRelayClient {
172
193
  else
173
194
  pending.reject(new Error(frame.error ?? 'Relay event failed'));
174
195
  }
196
+ const pendingRequest = this.pendingRequests.get(frame.id);
197
+ if (pendingRequest) {
198
+ this.pendingRequests.delete(frame.id);
199
+ clearTimeout(pendingRequest.timer);
200
+ pendingRequest.resolve(frame);
201
+ }
175
202
  return;
176
203
  }
177
204
  if (frame.type === 'req') {
@@ -300,6 +327,11 @@ export class CliRelayClient {
300
327
  // already closed
301
328
  }
302
329
  }
330
+ for (const [id, pending] of this.pendingRequests) {
331
+ clearTimeout(pending.timer);
332
+ pending.reject(new Error('Relay client disconnected'));
333
+ this.pendingRequests.delete(id);
334
+ }
303
335
  if (rejectAll) {
304
336
  for (const [id, pending] of this.pendingAcks) {
305
337
  clearTimeout(pending.timer);
@@ -6,6 +6,7 @@ import { reportLog } from '../../log-reporter.js';
6
6
  import { lookupClaudeTranscriptCwd } from '../../native-fusion/parsers.js';
7
7
  import { appendMessage, recordSession } from '../store.js';
8
8
  import { mergeProjectedSessions } from '../projection.js';
9
+ import { getManagerRuntimeService } from '../../manager/runtime.js';
9
10
  function extractSummary(text) {
10
11
  const newline = text.indexOf('\n');
11
12
  const end = newline > 0 ? Math.min(newline, 80) : Math.min(text.length, 80);
@@ -30,9 +31,7 @@ function sendSessionMessageEvent(runtime, envelope, session) {
30
31
  modelId: session.modelId ?? null,
31
32
  workDir: session.workDir,
32
33
  status: 'active',
33
- ...(session.agentType === 'manager'
34
- ? { externalChannel: runtime.managerRuntime?.getExternalChannelStatus(envelope.sessionId) ?? null }
35
- : {}),
34
+ externalChannel: getSessionExternalChannel(runtime, envelope.sessionId, session.agentType),
36
35
  },
37
36
  },
38
37
  });
@@ -56,13 +55,53 @@ function sendSessionUpdateEvent(runtime, input) {
56
55
  modelId: input.modelId ?? null,
57
56
  workDir: input.workDir,
58
57
  status: 'active',
59
- ...(input.agentType === 'manager'
60
- ? { externalChannel: runtime.managerRuntime?.getExternalChannelStatus(input.sessionId) ?? null }
61
- : {}),
58
+ externalChannel: getSessionExternalChannel(runtime, input.sessionId, input.agentType),
62
59
  },
63
60
  },
64
61
  });
65
62
  }
63
+ function normalizeExternalChannel(value) {
64
+ if (!value || typeof value !== 'object')
65
+ return null;
66
+ const raw = value;
67
+ return {
68
+ configured: raw.configured === undefined ? undefined : Boolean(raw.configured),
69
+ connected: Boolean(raw.connected),
70
+ type: typeof raw.type === 'string' ? raw.type : null,
71
+ channelId: typeof raw.channelId === 'string' ? raw.channelId : null,
72
+ name: typeof raw.name === 'string' ? raw.name : null,
73
+ canReply: raw.canReply === undefined || raw.canReply === null ? null : Boolean(raw.canReply),
74
+ systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : null,
75
+ };
76
+ }
77
+ function externalChannelEnabled(channel) {
78
+ return Boolean(channel?.configured ?? channel?.connected);
79
+ }
80
+ function externalChannelEnv(sessionId, channel) {
81
+ if (!externalChannelEnabled(channel))
82
+ return {};
83
+ const service = getManagerRuntimeService();
84
+ const injected = service?.getInjectedEnv(sessionId, null, process.cwd(), 'external') ?? {};
85
+ return {
86
+ ...injected,
87
+ SHENNIAN_EXTERNAL_SESSION_ID: sessionId,
88
+ SHENNIAN_MANAGER_SESSION_ID: sessionId,
89
+ };
90
+ }
91
+ function configureAdapterForSession(adapter, sessionId, channel) {
92
+ adapter.configure?.({
93
+ externalChannel: channel ?? null,
94
+ env: externalChannelEnv(sessionId, channel),
95
+ });
96
+ }
97
+ function getSessionExternalChannel(runtime, sessionId, agentType) {
98
+ const active = runtime.sessions.get(sessionId);
99
+ if (active?.externalChannel)
100
+ return active.externalChannel;
101
+ if (agentType === 'manager')
102
+ return runtime.managerRuntime?.getExternalChannelStatus(sessionId) ?? null;
103
+ return null;
104
+ }
66
105
  function maybeResolveClaudeImportedWorkDir(agentType, workDir, agentSessionId) {
67
106
  if (agentType !== 'claude')
68
107
  return workDir;
@@ -239,11 +278,12 @@ async function disposeSession(session) {
239
278
  session.adapter.removeAllListeners();
240
279
  await session.adapter.stop().catch(() => { });
241
280
  }
242
- async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDir, incomingAgentSid) {
281
+ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDir, incomingAgentSid, externalChannel) {
243
282
  runtime.evictIdleSessions();
244
283
  const adapter = createAgent(agentType);
245
284
  if (!adapter)
246
285
  throw new Error(`Unsupported agent: ${agentType}`);
286
+ configureAdapterForSession(adapter, sessionId, externalChannel);
247
287
  await adapter.start(sessionId, resolvedWorkDir, incomingAgentSid);
248
288
  const session = {
249
289
  adapter,
@@ -254,6 +294,8 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
254
294
  currentRunId: null,
255
295
  nextEventSeq: 0,
256
296
  pendingTextEvent: null,
297
+ externalChannel: externalChannel ?? null,
298
+ externalChannelEnv: externalChannelEnv(sessionId, externalChannel),
257
299
  };
258
300
  runtime.sessions.set(sessionId, session);
259
301
  bindAdapterEvents(runtime, sessionId, agentType, adapter);
@@ -283,20 +325,27 @@ export async function handleChatSend(runtime, req) {
283
325
  return;
284
326
  }
285
327
  rememberProcessedReqId(runtime, req.id);
286
- const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, clientMessageId, sessionListProjection, waitForDispatch } = req.params;
328
+ const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, reasoningEffort, clientMessageId, sessionListProjection, waitForDispatch } = req.params;
287
329
  mergeProjectedSessions(sessionListProjection);
330
+ const incomingExternalChannel = normalizeExternalChannel(req.params.externalChannel);
288
331
  if (!sessionId || !text) {
289
332
  runtime.processedReqIds.delete(req.id);
290
333
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'sessionId and text are required' });
291
334
  return;
292
335
  }
293
336
  const requestedAgentType = agentType;
337
+ const resolvedReasoningEffort = (requestedAgentType === 'claude' || requestedAgentType === 'codex') &&
338
+ typeof reasoningEffort === 'string' &&
339
+ reasoningEffort.trim()
340
+ ? reasoningEffort.trim()
341
+ : undefined;
294
342
  const resolvedWorkDir = runtime.resolvePath(maybeResolveClaudeImportedWorkDir(requestedAgentType, workDir || os.homedir(), incomingAgentSid) || os.homedir());
295
343
  let session = runtime.sessions.get(sessionId);
296
344
  if (session) {
297
345
  session.lastActiveAt = Date.now();
298
346
  const sessionDrifted = session.agentType !== requestedAgentType ||
299
- session.workDir !== resolvedWorkDir;
347
+ session.workDir !== resolvedWorkDir ||
348
+ JSON.stringify(session.externalChannel ?? null) !== JSON.stringify(incomingExternalChannel ?? null);
300
349
  if (sessionDrifted) {
301
350
  runtime.sessions.delete(sessionId);
302
351
  try {
@@ -326,7 +375,7 @@ export async function handleChatSend(runtime, req) {
326
375
  }
327
376
  if (!session) {
328
377
  try {
329
- session = await createActiveSession(runtime, sessionId, requestedAgentType, resolvedWorkDir, incomingAgentSid);
378
+ session = await createActiveSession(runtime, sessionId, requestedAgentType, resolvedWorkDir, incomingAgentSid, incomingExternalChannel);
330
379
  }
331
380
  catch (err) {
332
381
  const message = err instanceof Error && err.message.startsWith('Unsupported agent:')
@@ -365,7 +414,7 @@ export async function handleChatSend(runtime, req) {
365
414
  level: 'info',
366
415
  sessionId,
367
416
  wsEvent: 'chat.send.start',
368
- metadata: { reqId: req.id, agentType: requestedAgentType, modelId },
417
+ metadata: { reqId: req.id, agentType: requestedAgentType, modelId, reasoningEffort: resolvedReasoningEffort },
369
418
  });
370
419
  const markAccepted = () => {
371
420
  sendSessionUpdateEvent(runtime, {
@@ -424,7 +473,10 @@ export async function handleChatSend(runtime, req) {
424
473
  };
425
474
  if (waitForDispatch) {
426
475
  try {
427
- await session.adapter.send(text, modelId);
476
+ if (resolvedReasoningEffort)
477
+ await session.adapter.send(text, modelId, resolvedReasoningEffort);
478
+ else
479
+ await session.adapter.send(text, modelId);
428
480
  reportLog({
429
481
  level: 'info',
430
482
  sessionId,
@@ -454,7 +506,10 @@ export async function handleChatSend(runtime, req) {
454
506
  wsEvent: 'chat.send.res',
455
507
  metadata: { reqId: req.id, ok: true },
456
508
  });
457
- void session.adapter.send(text, modelId)
509
+ const sendPromise = resolvedReasoningEffort
510
+ ? session.adapter.send(text, modelId, resolvedReasoningEffort)
511
+ : session.adapter.send(text, modelId);
512
+ void sendPromise
458
513
  .then(() => {
459
514
  reportLog({
460
515
  level: 'info',
@@ -15,6 +15,7 @@ export declare class ChatQueueManager {
15
15
  handleDelete(req: ReqFrame): Promise<void>;
16
16
  noteTerminal(sessionId: string): void;
17
17
  private drainNext;
18
+ private mergeExternalMessages;
18
19
  private dispatchQueuedMessage;
19
20
  private broadcast;
20
21
  }
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { resolveShennianPath } from '../config/index.js';
6
+ import { mergeProjectedSessions } from './projection.js';
6
7
  const QUEUE_FILE = resolveShennianPath('chat-queue.json');
7
8
  function emptyQueue() {
8
9
  return { sessions: {} };
@@ -53,8 +54,11 @@ function queueMessageFromParams(params) {
53
54
  workDir: params.workDir,
54
55
  agentSessionId: params.agentSessionId ?? null,
55
56
  modelId: params.modelId ?? null,
57
+ reasoningEffort: params.reasoningEffort ?? null,
56
58
  clientMessageId: params.clientMessageId ?? null,
57
59
  attachments: normalizeAttachments(params.attachments),
60
+ externalChannel: params.externalChannel ?? null,
61
+ origin: params.origin,
58
62
  createdAt: timestamp,
59
63
  updatedAt: timestamp,
60
64
  };
@@ -75,6 +79,7 @@ export class ChatQueueManager {
75
79
  async handleEnqueue(req) {
76
80
  const runtime = this.opts.getRuntime();
77
81
  const params = req.params;
82
+ mergeProjectedSessions(params.sessionListProjection);
78
83
  if (!params.sessionId || !params.text || !params.agentType || !params.workDir) {
79
84
  runtime.client.sendRes({
80
85
  type: 'res',
@@ -207,6 +212,9 @@ export class ChatQueueManager {
207
212
  this.broadcast(sessionId);
208
213
  return;
209
214
  }
215
+ const dispatchMessage = next.origin === 'external'
216
+ ? this.mergeExternalMessages(next, pending)
217
+ : next;
210
218
  if (pending.length)
211
219
  queue.sessions[sessionId] = pending;
212
220
  else
@@ -215,12 +223,30 @@ export class ChatQueueManager {
215
223
  this.broadcast(sessionId);
216
224
  this.draining.add(sessionId);
217
225
  try {
218
- await this.dispatchQueuedMessage(next);
226
+ await this.dispatchQueuedMessage(dispatchMessage);
219
227
  }
220
228
  finally {
221
229
  this.draining.delete(sessionId);
222
230
  }
223
231
  }
232
+ mergeExternalMessages(first, pending) {
233
+ const batch = [first];
234
+ while (pending[0]?.origin === 'external') {
235
+ batch.push(pending.shift());
236
+ }
237
+ if (batch.length === 1)
238
+ return first;
239
+ return {
240
+ ...first,
241
+ id: `external-batch-${first.id}`,
242
+ text: batch.map((message, index) => {
243
+ const label = batch.length > 1 ? `外部消息 ${index + 1}/${batch.length}` : '外部消息';
244
+ return `${label}\n${message.text}`;
245
+ }).join('\n\n'),
246
+ attachments: batch.flatMap((message) => message.attachments ?? []),
247
+ updatedAt: nowIso(),
248
+ };
249
+ }
224
250
  async dispatchQueuedMessage(message) {
225
251
  await this.opts.dispatchReq({
226
252
  type: 'req',
@@ -233,8 +259,10 @@ export class ChatQueueManager {
233
259
  workDir: message.workDir,
234
260
  agentSessionId: message.agentSessionId ?? null,
235
261
  modelId: message.modelId ?? undefined,
262
+ reasoningEffort: message.reasoningEffort ?? undefined,
236
263
  clientMessageId: message.clientMessageId ?? message.id,
237
264
  attachments: message.attachments,
265
+ externalChannel: message.externalChannel,
238
266
  },
239
267
  });
240
268
  }
@@ -1,4 +1,4 @@
1
- import type { AgentType } from '@shennian/wire';
1
+ import type { AgentType, ExternalChannelSessionStatus } 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';
@@ -17,6 +17,8 @@ export type ActiveSession = {
17
17
  text: string;
18
18
  thinking: boolean;
19
19
  } | null;
20
+ externalChannel?: ExternalChannelSessionStatus | null;
21
+ externalChannelEnv?: NodeJS.ProcessEnv;
20
22
  };
21
23
  export type PendingTransfer = {
22
24
  tempPath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {