shennian 0.2.65 → 0.2.67

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 (38) hide show
  1. package/dist/src/agent-env.js +2 -0
  2. package/dist/src/agents/command-spec.js +5 -0
  3. package/dist/src/agents/config-status.d.ts +5 -4
  4. package/dist/src/agents/config-status.js +30 -9
  5. package/dist/src/agents/manager.js +11 -1
  6. package/dist/src/agents/pi-context.d.ts +1 -1
  7. package/dist/src/agents/pi-context.js +4 -3
  8. package/dist/src/agents/pi.d.ts +1 -0
  9. package/dist/src/agents/pi.js +34 -5
  10. package/dist/src/commands/daemon.d.ts +7 -0
  11. package/dist/src/commands/daemon.js +28 -20
  12. package/dist/src/commands/external-attachments.d.ts +9 -0
  13. package/dist/src/commands/external-attachments.js +52 -0
  14. package/dist/src/commands/external.js +4 -52
  15. package/dist/src/commands/manager.js +49 -6
  16. package/dist/src/commands/tools.d.ts +2 -0
  17. package/dist/src/commands/tools.js +34 -0
  18. package/dist/src/commands/upgrade.js +1 -1
  19. package/dist/src/fs/boundary.js +7 -2
  20. package/dist/src/index.js +2 -0
  21. package/dist/src/manager/prompt.d.ts +1 -1
  22. package/dist/src/manager/prompt.js +10 -4
  23. package/dist/src/manager/registry.d.ts +4 -0
  24. package/dist/src/manager/registry.js +2 -0
  25. package/dist/src/manager/runtime.d.ts +8 -1
  26. package/dist/src/manager/runtime.js +35 -8
  27. package/dist/src/session/handlers/agent-config.js +3 -3
  28. package/dist/src/session/handlers/chat.js +40 -14
  29. package/dist/src/session/handlers/fs.d.ts +1 -0
  30. package/dist/src/session/handlers/fs.js +76 -2
  31. package/dist/src/session/manager.js +4 -1
  32. package/dist/src/session/queue.js +18 -2
  33. package/dist/src/session/remote-attachments.d.ts +15 -0
  34. package/dist/src/session/remote-attachments.js +72 -0
  35. package/dist/src/tools/markdown-to-pdf.d.ts +20 -0
  36. package/dist/src/tools/markdown-to-pdf.js +303 -0
  37. package/dist/src/upgrade/engine.js +5 -5
  38. package/package.json +1 -1
@@ -41,6 +41,7 @@ function readPosixShellEnv() {
41
41
  encoding: 'utf-8',
42
42
  stdio: ['ignore', 'pipe', 'ignore'],
43
43
  timeout: 1500,
44
+ windowsHide: true,
44
45
  });
45
46
  const parsed = typeof result.stdout === 'string' ? parseEnvJson(result.stdout) : null;
46
47
  if (parsed)
@@ -61,6 +62,7 @@ Write-Output '${SHELL_ENV_END}'
61
62
  encoding: 'utf-8',
62
63
  stdio: ['ignore', 'pipe', 'ignore'],
63
64
  timeout: 1500,
65
+ windowsHide: true,
64
66
  });
65
67
  return typeof result.stdout === 'string' ? parseEnvJson(result.stdout) : null;
66
68
  }
@@ -109,6 +109,7 @@ function lookupCommandPaths(command) {
109
109
  encoding: 'utf-8',
110
110
  stdio: ['ignore', 'pipe', 'pipe'],
111
111
  timeout: 3000,
112
+ windowsHide: true,
112
113
  env: {
113
114
  ...process.env,
114
115
  PATH: isWindows ? process.env.PATH : buildFallbackPathEnv(process.env.PATH, command),
@@ -192,6 +193,7 @@ function canUseWslBridge(command) {
192
193
  const result = spawnSync(wslPath, ['-e', 'sh', '-lc', `command -v ${quotePosixArg(command)} >/dev/null 2>&1`], {
193
194
  stdio: 'ignore',
194
195
  timeout: 5000,
196
+ windowsHide: true,
195
197
  });
196
198
  const ok = result.status === 0;
197
199
  wslAvailabilityCache.set(command, ok);
@@ -274,6 +276,7 @@ export function spawnResolvedCommand(spec, runtimeArgs, options = {}) {
274
276
  return spawn(launch.command, launch.args, {
275
277
  ...options,
276
278
  cwd: launch.cwd,
279
+ windowsHide: options.windowsHide ?? true,
277
280
  ...(getProcessPlatform() === 'win32' && spec.kind === 'cmd-shim'
278
281
  ? { windowsVerbatimArguments: true }
279
282
  : {}),
@@ -287,6 +290,7 @@ export function spawnResolvedCommandSync(spec, runtimeArgs, options = {}) {
287
290
  return spawnSync(launch.command, launch.args, {
288
291
  ...options,
289
292
  cwd: launch.cwd,
293
+ windowsHide: options.windowsHide ?? true,
290
294
  ...(getProcessPlatform() === 'win32' && spec.kind === 'cmd-shim'
291
295
  ? { windowsVerbatimArguments: true }
292
296
  : {}),
@@ -356,6 +360,7 @@ export function spawnCommandString(commandString, runtimeArgs, options = {}) {
356
360
  return spawn(launch.command, launch.args, {
357
361
  ...options,
358
362
  cwd: launch.cwd,
363
+ windowsHide: options.windowsHide ?? true,
359
364
  env: {
360
365
  ...options.env,
361
366
  PATH: buildFallbackPathEnv(options.env?.PATH ?? process.env.PATH, parts[0]),
@@ -1,17 +1,18 @@
1
1
  import type { AgentConfigSummary, AgentType } from '@shennian/wire';
2
+ export type ConfigurableAgent = 'codex' | 'claude' | 'pi';
2
3
  export type AgentProviderConfigRecord = {
3
- agent: 'codex' | 'claude';
4
+ agent: ConfigurableAgent;
4
5
  baseUrl?: string;
5
6
  token?: string;
6
7
  updatedAt: string;
7
8
  };
8
- export declare function getManagedAgentProviderConfig(agent: 'codex' | 'claude'): AgentProviderConfigRecord | undefined;
9
+ export declare function getManagedAgentProviderConfig(agent: ConfigurableAgent): AgentProviderConfigRecord | undefined;
9
10
  export declare function upsertManagedAgentProviderConfig(input: {
10
- agent: 'codex' | 'claude';
11
+ agent: ConfigurableAgent;
11
12
  baseUrl?: string;
12
13
  token?: string;
13
14
  }): AgentProviderConfigRecord;
14
- export declare function deleteManagedAgentProviderConfig(agent: 'codex' | 'claude'): void;
15
+ export declare function deleteManagedAgentProviderConfig(agent: ConfigurableAgent): void;
15
16
  export declare function buildManagedAgentEnv(agent: AgentType): NodeJS.ProcessEnv;
16
17
  export declare function getAgentConfigSummary(agent: AgentType, env?: NodeJS.ProcessEnv): AgentConfigSummary | undefined;
17
18
  export declare function maskToken(token: string): string;
@@ -4,9 +4,12 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { readLatestUserEnv } from '../agent-env.js';
7
- import { resolveShennianPath } from '../config/index.js';
7
+ import { loadConfig, resolveShennianPath } from '../config/index.js';
8
8
  const MANAGED_CONFIG_PATH = resolveShennianPath('agent-provider-config.json');
9
9
  const TOKEN_SUFFIX_LENGTH = 4;
10
+ function isConfigurableAgent(agent) {
11
+ return agent === 'codex' || agent === 'claude' || agent === 'pi';
12
+ }
10
13
  const AGENT_ENV_KEYS = {
11
14
  codex: {
12
15
  baseUrl: ['OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_URL'],
@@ -16,6 +19,10 @@ const AGENT_ENV_KEYS = {
16
19
  baseUrl: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_URL'],
17
20
  token: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'],
18
21
  },
22
+ pi: {
23
+ baseUrl: ['DASHSCOPE_BASE_URL', 'DASHSCOPE_API_BASE', 'DASHSCOPE_API_URL'],
24
+ token: ['DASHSCOPE_API_KEY', 'DASHSCOPE_TOKEN'],
25
+ },
19
26
  };
20
27
  export function getManagedAgentProviderConfig(agent) {
21
28
  return loadManagedConfig().configs[agent];
@@ -39,7 +46,7 @@ export function deleteManagedAgentProviderConfig(agent) {
39
46
  saveManagedConfig(file);
40
47
  }
41
48
  export function buildManagedAgentEnv(agent) {
42
- if (agent !== 'codex' && agent !== 'claude')
49
+ if (!isConfigurableAgent(agent))
43
50
  return {};
44
51
  const config = getManagedAgentProviderConfig(agent);
45
52
  if (!config)
@@ -50,13 +57,19 @@ export function buildManagedAgentEnv(agent) {
50
57
  OPENAI_API_KEY: config.token,
51
58
  });
52
59
  }
60
+ if (agent === 'claude') {
61
+ return compactEnv({
62
+ ANTHROPIC_BASE_URL: config.baseUrl,
63
+ ANTHROPIC_API_KEY: config.token,
64
+ });
65
+ }
53
66
  return compactEnv({
54
- ANTHROPIC_BASE_URL: config.baseUrl,
55
- ANTHROPIC_API_KEY: config.token,
67
+ DASHSCOPE_BASE_URL: config.baseUrl,
68
+ DASHSCOPE_API_KEY: config.token,
56
69
  });
57
70
  }
58
71
  export function getAgentConfigSummary(agent, env = readLatestUserEnv()) {
59
- if (agent !== 'codex' && agent !== 'claude')
72
+ if (!isConfigurableAgent(agent))
60
73
  return undefined;
61
74
  const managed = getManagedAgentProviderConfig(agent);
62
75
  if (managed?.token || managed?.baseUrl) {
@@ -141,9 +154,17 @@ function firstEnv(env, keys) {
141
154
  }
142
155
  function detectFromKnownConfigFiles(agent) {
143
156
  const home = os.homedir();
157
+ if (agent === 'pi') {
158
+ const config = loadConfig();
159
+ const token = config.apiKeys?.dashscope?.trim();
160
+ if (token)
161
+ return { token, tokenPresent: true };
162
+ }
144
163
  const candidates = agent === 'codex'
145
164
  ? [path.join(home, '.codex', 'config.toml'), path.join(home, '.codex', 'auth.json')]
146
- : [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude.json')];
165
+ : agent === 'claude'
166
+ ? [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude.json')]
167
+ : [path.join(home, '.dashscope', 'config.json')];
147
168
  for (const file of candidates) {
148
169
  const text = readSmallTextFile(file);
149
170
  if (!text)
@@ -167,15 +188,15 @@ function readSmallTextFile(file) {
167
188
  }
168
189
  }
169
190
  function extractBaseUrl(text) {
170
- const match = text.match(/(?:base_url|baseURL|api_url|apiUrl|ANTHROPIC_BASE_URL|OPENAI_BASE_URL)["'\s:=]+([^"'\s,}]+)/i);
191
+ const match = text.match(/(?:base_url|baseURL|api_url|apiUrl|ANTHROPIC_BASE_URL|OPENAI_BASE_URL|DASHSCOPE_BASE_URL)["'\s:=]+([^"'\s,}]+)/i);
171
192
  return match?.[1]?.trim();
172
193
  }
173
194
  function extractTokenLikeValue(text, agent) {
174
- const prefix = agent === 'claude' ? 'sk-ant-' : 'sk-';
195
+ const prefix = agent === 'claude' ? 'sk-ant-' : agent === 'pi' ? 'sk-' : 'sk-';
175
196
  const direct = text.match(new RegExp(`${prefix.replace(/-/g, '\\-')}[A-Za-z0-9_\\-]{8,}`));
176
197
  if (direct?.[0])
177
198
  return direct[0];
178
- const keyMatch = text.match(/(?:api[_-]?key|auth[_-]?token|token)["'\s:=]+([A-Za-z0-9_\-.]{12,})/i);
199
+ const keyMatch = text.match(/(?:api[_-]?key|auth[_-]?token|token|dashscope)["'\s:=]+([A-Za-z0-9_\-.]{12,})/i);
179
200
  return keyMatch?.[1]?.trim();
180
201
  }
181
202
  function toSummary(input) {
@@ -28,7 +28,14 @@ function managerInstructionsPath(workDir, sessionId) {
28
28
  }
29
29
  function buildStableManagerInstructions(workDir, managerSessionId) {
30
30
  const projectInstructions = readProjectAgentsMd(workDir);
31
- const channelInstructions = getManagerRuntimeService()?.getManagerExternalChannelSystemPrompt(managerSessionId) ?? '';
31
+ const managerRuntime = getManagerRuntimeService();
32
+ const channelInstructions = managerRuntime?.getManagerExternalChannelSystemPrompt(managerSessionId) ?? '';
33
+ const workerDefaults = typeof managerRuntime?.getManagerWorkerDefaults === 'function'
34
+ ? managerRuntime.getManagerWorkerDefaults(managerSessionId)
35
+ : undefined;
36
+ const workerDefaultInstructions = workerDefaults?.agentType
37
+ ? `默认 worker Agent:${workerDefaults.agentType}${workerDefaults.modelId ? `\n默认 worker 模型:${workerDefaults.modelId}` : ''}。除非用户明确要求或任务明显需要其他 Agent/模型,创建 worker 时优先使用这个默认组合。`
38
+ : '';
32
39
  const sections = [
33
40
  '# Shennian Manager Instructions',
34
41
  'This file is generated by Shennian for a Manager Agent substrate session. Do not edit it by hand.',
@@ -36,6 +43,9 @@ function buildStableManagerInstructions(workDir, managerSessionId) {
36
43
  ? `## Project Instructions\n\n${projectInstructions}`
37
44
  : '',
38
45
  `## Manager Instructions\n\n${MANAGER_SYSTEM_PROMPT}`,
46
+ workerDefaultInstructions
47
+ ? `## Worker Defaults\n\n${workerDefaultInstructions}`
48
+ : '',
39
49
  channelInstructions
40
50
  ? `## External Message Channel Instructions\n\n${channelInstructions}`
41
51
  : '',
@@ -7,7 +7,7 @@ type ShellCommandSpec = {
7
7
  shell: 'bash' | 'powershell';
8
8
  };
9
9
  export declare function buildShellCommandSpec(command: string, platform?: NodeJS.Platform): ShellCommandSpec;
10
- export declare function createPiModel(modelId?: string): Model<'openai-completions'>;
10
+ export declare function createPiModel(modelId?: string, overrides?: Partial<Pick<Model<'openai-completions'>, 'baseUrl' | 'provider' | 'compat'>>): Model<'openai-completions'>;
11
11
  export declare const SYSTEM_PROMPT = "\u4F60\u662F\u795E\u5FF5\u5185\u7F6E\u7F16\u7A0B\u52A9\u624B\uFF0C\u8FD0\u884C\u5728\u7528\u6237\u672C\u5730\u673A\u5668\u4E0A\u3002\n\u4F60\u53EF\u4EE5\u8BFB\u5199\u6587\u4EF6\u3001\u6267\u884C shell \u547D\u4EE4\u3001\u5E2E\u52A9\u7528\u6237\u5B8C\u6210\u7F16\u7A0B\u548C\u7CFB\u7EDF\u7BA1\u7406\u4EFB\u52A1\u3002\n\u5DE5\u4F5C\u76EE\u5F55\u5DF2\u8BBE\u7F6E\uFF0C\u64CD\u4F5C\u6587\u4EF6\u65F6\u4F7F\u7528\u76F8\u5BF9\u8DEF\u5F84\u6216\u7EDD\u5BF9\u8DEF\u5F84\u5747\u53EF\u3002\n\u5F53\u524D shell \u4F1A\u968F\u64CD\u4F5C\u7CFB\u7EDF\u9009\u62E9\uFF1AWindows \u4F7F\u7528 PowerShell\uFF0CmacOS/Linux \u4F7F\u7528 bash\u3002Windows \u4E0B\u9700\u8981\u771F\u5B9E curl \u65F6\u4F7F\u7528 curl.exe\uFF0C\u907F\u514D PowerShell \u7684 curl \u522B\u540D\u3002\n\u4FDD\u6301\u56DE\u590D\u7B80\u6D01\u3001\u51C6\u786E\uFF0C\u4E2D\u6587\u56DE\u590D\u3002";
12
12
  export declare const CONTEXT_TOKEN_THRESHOLD = 90000;
13
13
  export declare const KEEP_RECENT_MESSAGES = 6;
@@ -17,18 +17,19 @@ export function buildShellCommandSpec(command, platform = process.platform) {
17
17
  shell: 'bash',
18
18
  };
19
19
  }
20
- export function createPiModel(modelId = PI_DEFAULT_MODEL_ID) {
20
+ export function createPiModel(modelId = PI_DEFAULT_MODEL_ID, overrides = {}) {
21
21
  return {
22
22
  id: modelId,
23
23
  name: modelId,
24
24
  api: 'openai-completions',
25
- provider: 'shennian-proxy',
26
- baseUrl: '',
25
+ provider: overrides.provider ?? 'shennian-proxy',
26
+ baseUrl: overrides.baseUrl ?? '',
27
27
  reasoning: false,
28
28
  input: ['text', 'image'],
29
29
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
30
30
  contextWindow: 128000,
31
31
  maxTokens: 8192,
32
+ ...(overrides.compat ? { compat: overrides.compat } : {}),
32
33
  };
33
34
  }
34
35
  export const SYSTEM_PROMPT = `你是神念内置编程助手,运行在用户本地机器上。
@@ -12,6 +12,7 @@ export declare class PiAdapter extends AgentAdapter {
12
12
  private terminalState;
13
13
  private authToken;
14
14
  private proxyUrl;
15
+ private providerConfig;
15
16
  private sessionDir;
16
17
  private messagesPath;
17
18
  private snapshotPath;
@@ -6,14 +6,17 @@ import path from 'node:path';
6
6
  import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { Agent, streamProxy } from '@mariozechner/pi-agent-core';
9
+ import { streamOpenAICompletions } from '@mariozechner/pi-ai/openai-completions';
9
10
  import { Type } from '@sinclair/typebox';
10
11
  import { AgentAdapter, registerAgent } from './adapter.js';
11
12
  import { buildExternalChannelInstructions } from './external-channel-instructions.js';
12
13
  import { loadConfig } from '../config/index.js';
14
+ import { getManagedAgentProviderConfig } from './config-status.js';
13
15
  import { SERVERS } from '../region.js';
14
16
  import { buildRollingSummary, buildShellCommandSpec, cloneMessages, CONTEXT_TOKEN_THRESHOLD, createPiModel, estimateTokens, getSessionDir, KEEP_RECENT_MESSAGES, LEGACY_SUMMARY_FILENAME, longestCommonPrefixLength, MESSAGES_FILENAME, messagesToText, PI_DEFAULT_MODEL_ID, requestProxySummary, SNAPSHOT_FILENAME, SYSTEM_PROMPT, SUMMARY_FILENAME, } from './pi-context.js';
15
17
  export { buildShellCommandSpec } from './pi-context.js';
16
18
  const execFileAsync = promisify(execFile);
19
+ const DASHSCOPE_COMPATIBLE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
17
20
  // ── Local tools ───────────────────────────────────────────────────────────────
18
21
  function resolvePiToolPath(workDir, filePath) {
19
22
  return path.resolve(workDir, filePath);
@@ -26,6 +29,7 @@ async function executePiShellCommand(workDir, command, extraEnv, signal) {
26
29
  timeout: 30_000,
27
30
  signal,
28
31
  maxBuffer: 1024 * 1024,
32
+ windowsHide: true,
29
33
  });
30
34
  return { stdout, stderr, shell: spec.shell };
31
35
  }
@@ -239,6 +243,7 @@ export class PiAdapter extends AgentAdapter {
239
243
  terminalState = 'open';
240
244
  authToken = null;
241
245
  proxyUrl = null;
246
+ providerConfig;
242
247
  sessionDir = null;
243
248
  messagesPath = null;
244
249
  snapshotPath = null;
@@ -294,6 +299,11 @@ export class PiAdapter extends AgentAdapter {
294
299
  }
295
300
  this.authToken = authToken;
296
301
  this.proxyUrl = (config.serverUrl ?? SERVERS.cn.url).replace(/\/$/, '');
302
+ const managedProviderConfig = getManagedAgentProviderConfig('pi');
303
+ const dashscopeKey = config.apiKeys?.dashscope?.trim();
304
+ this.providerConfig = managedProviderConfig ?? (dashscopeKey
305
+ ? { agent: 'pi', token: dashscopeKey, updatedAt: '' }
306
+ : undefined);
297
307
  this.runId = randomUUID();
298
308
  this.seq = 0;
299
309
  this.emittedLengths.clear();
@@ -357,11 +367,30 @@ export class PiAdapter extends AgentAdapter {
357
367
  model: createPiModel(),
358
368
  tools,
359
369
  },
360
- streamFn: (model, context, options) => streamProxy(model, context, {
361
- ...options,
362
- authToken: this.authToken,
363
- proxyUrl: this.proxyUrl,
364
- }),
370
+ streamFn: (model, context, options) => {
371
+ const providerConfig = this.providerConfig;
372
+ if (providerConfig?.token) {
373
+ return streamOpenAICompletions(createPiModel(model.id, {
374
+ provider: 'dashscope',
375
+ baseUrl: providerConfig.baseUrl || DASHSCOPE_COMPATIBLE_BASE_URL,
376
+ compat: {
377
+ supportsDeveloperRole: false,
378
+ supportsStore: false,
379
+ supportsReasoningEffort: false,
380
+ maxTokensField: 'max_tokens',
381
+ thinkingFormat: 'qwen',
382
+ },
383
+ }), context, {
384
+ ...options,
385
+ apiKey: providerConfig.token,
386
+ });
387
+ }
388
+ return streamProxy(model, context, {
389
+ ...options,
390
+ authToken: this.authToken,
391
+ proxyUrl: this.proxyUrl,
392
+ });
393
+ },
365
394
  transformContext: (messages) => this.compressContext(messages),
366
395
  });
367
396
  this.agent = agent;
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import type { Command } from 'commander';
2
3
  export { buildWindowsLauncherCommand, buildWindowsScheduledTaskXml, buildWindowsStartupVbs, } from './daemon-windows.js';
3
4
  export declare function isSafeSnapshotEnvKey(key: string): boolean;
@@ -23,6 +24,11 @@ export type ServiceLaunchSpec = {
23
24
  args: string[];
24
25
  mode: ServiceLaunchMode;
25
26
  };
27
+ export type DetachedLaunchSpec = {
28
+ command: string;
29
+ args: string[];
30
+ windowsVerbatimArguments?: boolean;
31
+ };
26
32
  export declare function isEphemeralCliPath(candidate: string): boolean;
27
33
  export declare function resolveServiceLaunchSpec(input: {
28
34
  nodeExec: string;
@@ -39,6 +45,7 @@ export declare function recordStartedDaemon(childPid: number | undefined): void;
39
45
  export declare function getDaemonStatus(opts?: {
40
46
  cleanupStale?: boolean;
41
47
  }): DaemonStatus;
48
+ export declare function buildDaemonSpawnOptions(launch: DetachedLaunchSpec, logFd: number, env?: NodeJS.ProcessEnv): Parameters<typeof spawn>[2];
42
49
  export declare function captureEnvForService(): Record<string, string>;
43
50
  /**
44
51
  * Save current env vars so the daemon can load them at startup.
@@ -160,6 +160,7 @@ function inferDaemonLauncherFromProcess(pid) {
160
160
  encoding: 'utf-8',
161
161
  stdio: ['ignore', 'pipe', 'ignore'],
162
162
  timeout: 1000,
163
+ windowsHide: true,
163
164
  }).replace(/\\/g, '/');
164
165
  if (command.includes('/node_modules/shennian/') ||
165
166
  command.includes('/bin/shennian') ||
@@ -229,6 +230,7 @@ function findCommandPath(binary) {
229
230
  const output = execSync(command, {
230
231
  stdio: ['ignore', 'pipe', 'pipe'],
231
232
  encoding: 'utf-8',
233
+ windowsHide: true,
232
234
  });
233
235
  const first = output
234
236
  .split(/\r?\n/)
@@ -257,7 +259,7 @@ function resolveCurrentServiceLaunchSpec() {
257
259
  }
258
260
  function removeLegacyWindowsTask() {
259
261
  try {
260
- execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: 'pipe' });
262
+ execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: 'pipe', windowsHide: true });
261
263
  }
262
264
  catch {
263
265
  // Ignore missing legacy Task Scheduler entries during migration.
@@ -295,6 +297,15 @@ function buildDetachedLaunchSpec(spec) {
295
297
  args: spec.args,
296
298
  };
297
299
  }
300
+ export function buildDaemonSpawnOptions(launch, logFd, env = process.env) {
301
+ return {
302
+ detached: true,
303
+ stdio: ['ignore', logFd, logFd],
304
+ env,
305
+ windowsHide: true,
306
+ ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
307
+ };
308
+ }
298
309
  function installWindowsScheduledTask() {
299
310
  const launch = resolveCurrentServiceLaunchSpec();
300
311
  fs.writeFileSync(WINDOWS_STARTUP_CMD, buildWindowsLauncherCommand(launch, LOG_FILE));
@@ -310,8 +321,9 @@ function installWindowsScheduledTask() {
310
321
  try {
311
322
  execSync(`schtasks /create /tn "${WINDOWS_TASK_NAME}" /xml "${WINDOWS_TASK_XML}" /f`, {
312
323
  stdio: 'pipe',
324
+ windowsHide: true,
313
325
  });
314
- execSync(`schtasks /run /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe' });
326
+ execSync(`schtasks /run /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe', windowsHide: true });
315
327
  return true;
316
328
  }
317
329
  catch {
@@ -423,12 +435,7 @@ export function startDaemonProcess(opts = {}) {
423
435
  }
424
436
  const logFd = fs.openSync(LOG_FILE, 'a');
425
437
  const launch = buildDetachedLaunchSpec(resolveCurrentServiceLaunchSpec());
426
- const child = spawn(launch.command, launch.args, {
427
- detached: true,
428
- stdio: ['ignore', logFd, logFd],
429
- env: process.env,
430
- ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
431
- });
438
+ const child = spawn(launch.command, launch.args, buildDaemonSpawnOptions(launch, logFd));
432
439
  child.unref();
433
440
  fs.closeSync(logFd);
434
441
  recordStartedDaemon(child.pid);
@@ -456,12 +463,12 @@ export function installService() {
456
463
  fs.writeFileSync(LAUNCHD_PLIST, buildPlist());
457
464
  try {
458
465
  // Unload first (ignore errors), then reload - this starts the service immediately
459
- execSync(`launchctl unload "${LAUNCHD_PLIST}" 2>/dev/null; launchctl load -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe' });
466
+ execSync(`launchctl unload "${LAUNCHD_PLIST}" 2>/dev/null; launchctl load -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe', windowsHide: true });
460
467
  return true; // launchd started it; caller must NOT also call startDaemonProcess
461
468
  }
462
469
  catch {
463
470
  try {
464
- execSync(`launchctl load -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe' });
471
+ execSync(`launchctl load -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe', windowsHide: true });
465
472
  return true;
466
473
  }
467
474
  catch {
@@ -477,6 +484,7 @@ export function installService() {
477
484
  try {
478
485
  execSync('systemctl --user daemon-reload && systemctl --user enable shennian', {
479
486
  stdio: 'pipe',
487
+ windowsHide: true,
480
488
  });
481
489
  }
482
490
  catch {
@@ -486,13 +494,13 @@ export function installService() {
486
494
  // Enable linger so the user systemd session (and thus this service) persists
487
495
  // across reboots even without an active login session.
488
496
  try {
489
- execSync(`loginctl enable-linger ${os.userInfo().username}`, { stdio: 'pipe' });
497
+ execSync(`loginctl enable-linger ${os.userInfo().username}`, { stdio: 'pipe', windowsHide: true });
490
498
  }
491
499
  catch {
492
500
  // loginctl is unavailable on some distros/containers; auto-start still works after login.
493
501
  }
494
502
  try {
495
- execSync('systemctl --user restart shennian', { stdio: 'pipe' });
503
+ execSync('systemctl --user restart shennian', { stdio: 'pipe', windowsHide: true });
496
504
  return true;
497
505
  }
498
506
  catch {
@@ -572,7 +580,7 @@ async function disableRemoteAccess(opts = {}) {
572
580
  case 'darwin': {
573
581
  if (fs.existsSync(LAUNCHD_PLIST)) {
574
582
  try {
575
- execSync(`launchctl unload -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe' });
583
+ execSync(`launchctl unload -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe', windowsHide: true });
576
584
  }
577
585
  catch {
578
586
  // The job may already be unloaded; keep the plist for future re-enable.
@@ -582,7 +590,7 @@ async function disableRemoteAccess(opts = {}) {
582
590
  }
583
591
  case 'linux': {
584
592
  try {
585
- execSync('systemctl --user disable --now shennian', { stdio: 'pipe' });
593
+ execSync('systemctl --user disable --now shennian', { stdio: 'pipe', windowsHide: true });
586
594
  }
587
595
  catch {
588
596
  // systemd user services may be unavailable; still stop the manual daemon.
@@ -591,13 +599,13 @@ async function disableRemoteAccess(opts = {}) {
591
599
  }
592
600
  case 'win32': {
593
601
  try {
594
- execSync(`schtasks /change /tn "${WINDOWS_TASK_NAME}" /disable`, { stdio: 'pipe' });
602
+ execSync(`schtasks /change /tn "${WINDOWS_TASK_NAME}" /disable`, { stdio: 'pipe', windowsHide: true });
595
603
  }
596
604
  catch {
597
605
  // Task may not exist yet; still stop the manual daemon.
598
606
  }
599
607
  try {
600
- execSync(`schtasks /end /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe' });
608
+ execSync(`schtasks /end /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe', windowsHide: true });
601
609
  }
602
610
  catch {
603
611
  // Task may not be running.
@@ -722,7 +730,7 @@ function daemonLogs(opts) {
722
730
  try {
723
731
  const out = execSync(os.platform() === 'win32'
724
732
  ? `powershell Get-Content -Tail ${opts.lines} "${LOG_FILE}"`
725
- : `tail -n ${opts.lines} "${LOG_FILE}"`, { encoding: 'utf-8' });
733
+ : `tail -n ${opts.lines} "${LOG_FILE}"`, { encoding: 'utf-8', windowsHide: true });
726
734
  process.stdout.write(out);
727
735
  }
728
736
  catch {
@@ -737,7 +745,7 @@ async function daemonUninstall() {
737
745
  case 'darwin': {
738
746
  if (fs.existsSync(LAUNCHD_PLIST)) {
739
747
  try {
740
- execSync(`launchctl unload -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe' });
748
+ execSync(`launchctl unload -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe', windowsHide: true });
741
749
  }
742
750
  catch {
743
751
  // Continue removing the plist even if launchctl already forgot about it.
@@ -749,7 +757,7 @@ async function daemonUninstall() {
749
757
  }
750
758
  case 'linux': {
751
759
  try {
752
- execSync('systemctl --user disable --now shennian', { stdio: 'pipe' });
760
+ execSync('systemctl --user disable --now shennian', { stdio: 'pipe', windowsHide: true });
753
761
  }
754
762
  catch {
755
763
  // Service may already be absent or systemd user services may be unavailable.
@@ -757,7 +765,7 @@ async function daemonUninstall() {
757
765
  if (fs.existsSync(SYSTEMD_UNIT)) {
758
766
  fs.unlinkSync(SYSTEMD_UNIT);
759
767
  try {
760
- execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
768
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe', windowsHide: true });
761
769
  }
762
770
  catch {
763
771
  // Ignore reload errors during cleanup.
@@ -0,0 +1,9 @@
1
+ export type ExternalAttachmentKind = 'image' | 'video' | 'file';
2
+ export type ExternalAttachmentPayload = {
3
+ kind: ExternalAttachmentKind;
4
+ name: string;
5
+ mimeType: string;
6
+ size: number;
7
+ dataBase64: string;
8
+ };
9
+ export declare function readExternalAttachment(filePath: string, kind: ExternalAttachmentKind): ExternalAttachmentPayload;
@@ -0,0 +1,52 @@
1
+ // @arch docs/features/wecom-managed-channel.md
2
+ // @test src/__tests__/external-command.test.ts
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ const MIME_BY_EXT = {
6
+ '.jpg': 'image/jpeg',
7
+ '.jpeg': 'image/jpeg',
8
+ '.png': 'image/png',
9
+ '.gif': 'image/gif',
10
+ '.webp': 'image/webp',
11
+ '.mp4': 'video/mp4',
12
+ '.mov': 'video/quicktime',
13
+ '.pdf': 'application/pdf',
14
+ '.txt': 'text/plain',
15
+ '.md': 'text/markdown',
16
+ '.csv': 'text/csv',
17
+ '.doc': 'application/msword',
18
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
19
+ '.xls': 'application/vnd.ms-excel',
20
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
21
+ '.ppt': 'application/vnd.ms-powerpoint',
22
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
23
+ '.zip': 'application/zip',
24
+ };
25
+ const MAX_EXTERNAL_ATTACHMENT_BYTES = Number(process.env.SHENNIAN_EXTERNAL_ATTACHMENT_MAX_BYTES || 50 * 1024 * 1024);
26
+ function inferMimeType(filePath, kind) {
27
+ const ext = path.extname(filePath).toLowerCase();
28
+ if (MIME_BY_EXT[ext])
29
+ return MIME_BY_EXT[ext];
30
+ if (kind === 'image')
31
+ return 'image/jpeg';
32
+ if (kind === 'video')
33
+ return 'video/mp4';
34
+ return 'application/octet-stream';
35
+ }
36
+ export function readExternalAttachment(filePath, kind) {
37
+ const absolutePath = path.resolve(filePath);
38
+ const stat = fs.statSync(absolutePath);
39
+ if (!stat.isFile())
40
+ throw new Error(`Attachment is not a file: ${absolutePath}`);
41
+ if (stat.size > MAX_EXTERNAL_ATTACHMENT_BYTES) {
42
+ throw new Error(`Attachment is too large: ${stat.size} bytes. Max: ${MAX_EXTERNAL_ATTACHMENT_BYTES} bytes.`);
43
+ }
44
+ const buffer = fs.readFileSync(absolutePath);
45
+ return {
46
+ kind,
47
+ name: path.basename(absolutePath),
48
+ mimeType: inferMimeType(absolutePath, kind),
49
+ size: buffer.byteLength,
50
+ dataBase64: buffer.toString('base64'),
51
+ };
52
+ }