shennian 0.2.68 → 0.2.69

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.
@@ -35,6 +35,7 @@ export declare abstract class AgentAdapter extends EventEmitter<AgentAdapterEven
35
35
  externalChannel?: ExternalChannelSessionStatus | null;
36
36
  env?: NodeJS.ProcessEnv;
37
37
  }): void;
38
+ setTitle?(agentSessionId: string, title: string, workDir?: string): Promise<void>;
38
39
  abstract start(sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
39
40
  abstract send(text: string, modelId?: string, reasoningEffort?: string, attachments?: ChatAttachmentMeta[]): Promise<void>;
40
41
  abstract resume(agentSessionId: string): Promise<void>;
@@ -38,6 +38,7 @@ export declare class CodexAdapter extends AgentAdapter {
38
38
  start(_sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
39
39
  send(text: string, modelId?: string, reasoningEffort?: string, attachments?: ChatAttachmentMeta[]): Promise<void>;
40
40
  resume(agentSessionId: string): Promise<void>;
41
+ setTitle(agentSessionId: string, title: string, workDir?: string): Promise<void>;
41
42
  stop(): Promise<void>;
42
43
  private spawnCodex;
43
44
  private spawnAppServer;
@@ -101,6 +101,18 @@ export class CodexAdapter extends AgentAdapter {
101
101
  this.namedThread = true;
102
102
  this.resetTextState();
103
103
  }
104
+ async setTitle(agentSessionId, title, workDir) {
105
+ const threadId = agentSessionId.trim();
106
+ const name = title.replace(/\s+/g, ' ').trim();
107
+ if (!threadId || !name)
108
+ return;
109
+ this.agentSessionId = threadId;
110
+ if (workDir?.trim())
111
+ this.workDir = workDir;
112
+ await this.ensureAppServer();
113
+ await this.sendRpc('thread/name/set', { threadId, name });
114
+ this.namedThread = true;
115
+ }
104
116
  async stop() {
105
117
  await this.interruptActiveTurn().catch(() => { });
106
118
  await this.killProcess();
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { convertMarkdownToPdf, defaultPdfOutputPath } from '../../tools/markdown-to-pdf.js';
6
+ import { convertMarkdownToPdf, defaultPdfOutputPath, MarkdownPdfBrowserMissingError, } from '../../tools/markdown-to-pdf.js';
7
7
  const FILE_SYSTEM_ROOTS_PATH = '__roots__';
8
8
  const MAX_FOLDER_UPLOAD_FILES = 2000;
9
9
  const MAX_FOLDER_UPLOAD_TOTAL_SIZE = 1024 * 1024 * 1024;
@@ -37,7 +37,9 @@ function listFileSystemRoots() {
37
37
  return [{ name: '/', path: '/', isDir: true }];
38
38
  }
39
39
  function fsErrorMessage(err, fallbackPath) {
40
- const code = typeof err === 'object' && err && 'code' in err ? String(err.code) : '';
40
+ const code = typeof err === 'object' && err && 'code' in err
41
+ ? String(err.code)
42
+ : '';
41
43
  if (code === 'ENOENT')
42
44
  return `Directory not found: ${fallbackPath}`;
43
45
  if (code === 'EACCES' || code === 'EPERM')
@@ -129,7 +131,12 @@ export async function handleFsLs(runtime, req) {
129
131
  });
130
132
  }
131
133
  catch (err) {
132
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: fsErrorMessage(err, dirPath) });
134
+ runtime.client.sendRes({
135
+ type: 'res',
136
+ id: req.id,
137
+ ok: false,
138
+ error: fsErrorMessage(err, dirPath),
139
+ });
133
140
  }
134
141
  }
135
142
  export async function handleFsRead(runtime, req) {
@@ -221,7 +228,12 @@ export async function handleFsWrite(runtime, req) {
221
228
  const content = req.params.content;
222
229
  const rootPath = req.params.rootPath || requestedPath;
223
230
  if (!requestedPath || typeof content !== 'string') {
224
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path and content are required' });
231
+ runtime.client.sendRes({
232
+ type: 'res',
233
+ id: req.id,
234
+ ok: false,
235
+ error: 'path and content are required',
236
+ });
225
237
  return;
226
238
  }
227
239
  const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
@@ -253,7 +265,12 @@ export async function handleFsRename(runtime, req) {
253
265
  const newName = req.params.newName;
254
266
  const rootPath = req.params.rootPath || requestedPath;
255
267
  if (!requestedPath || !newName) {
256
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path and newName are required' });
268
+ runtime.client.sendRes({
269
+ type: 'res',
270
+ id: req.id,
271
+ ok: false,
272
+ error: 'path and newName are required',
273
+ });
257
274
  return;
258
275
  }
259
276
  if (!isSafeRenameName(newName)) {
@@ -307,7 +324,12 @@ export async function handleFsExportMarkdownPdf(runtime, req) {
307
324
  return;
308
325
  }
309
326
  if (!/\.mdx?$/i.test(resolved.path)) {
310
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Only Markdown files can be exported to PDF' });
327
+ runtime.client.sendRes({
328
+ type: 'res',
329
+ id: req.id,
330
+ ok: false,
331
+ error: 'Only Markdown files can be exported to PDF',
332
+ });
311
333
  return;
312
334
  }
313
335
  const outputPath = defaultPdfOutputPath(resolved.path);
@@ -333,6 +355,16 @@ export async function handleFsExportMarkdownPdf(runtime, req) {
333
355
  });
334
356
  }
335
357
  catch (err) {
358
+ if (err instanceof MarkdownPdfBrowserMissingError) {
359
+ runtime.client.sendRes({
360
+ type: 'res',
361
+ id: req.id,
362
+ ok: false,
363
+ error: 'This machine needs the PDF export component before Markdown files can be exported to PDF.',
364
+ payload: err.setup,
365
+ });
366
+ return;
367
+ }
336
368
  runtime.client.sendRes({
337
369
  type: 'res',
338
370
  id: req.id,
@@ -344,7 +376,12 @@ export async function handleFsExportMarkdownPdf(runtime, req) {
344
376
  export async function handleFsTransfer(runtime, req) {
345
377
  const { name, targetPath, data, direct } = req.params;
346
378
  if (!name || !data) {
347
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'name and data are required' });
379
+ runtime.client.sendRes({
380
+ type: 'res',
381
+ id: req.id,
382
+ ok: false,
383
+ error: 'name and data are required',
384
+ });
348
385
  return;
349
386
  }
350
387
  try {
@@ -371,9 +408,14 @@ export async function handleFsTransferStart(runtime, req) {
371
408
  const { name, targetPath, totalSize, direct, kind, baseName } = req.params;
372
409
  const manifest = decodeManifest(req.params.manifest);
373
410
  const isFolder = kind === 'folder';
374
- const transferName = isFolder ? (baseName || name) : name;
411
+ const transferName = isFolder ? baseName || name : name;
375
412
  if (!transferName || (!isFolder && !totalSize)) {
376
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'name and totalSize are required' });
413
+ runtime.client.sendRes({
414
+ type: 'res',
415
+ id: req.id,
416
+ ok: false,
417
+ error: 'name and totalSize are required',
418
+ });
377
419
  return;
378
420
  }
379
421
  try {
@@ -390,11 +432,21 @@ export async function handleFsTransferStart(runtime, req) {
390
432
  const transferId = `tf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
391
433
  if (isFolder) {
392
434
  if (!manifest.length) {
393
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'manifest is required for folder uploads' });
435
+ runtime.client.sendRes({
436
+ type: 'res',
437
+ id: req.id,
438
+ ok: false,
439
+ error: 'manifest is required for folder uploads',
440
+ });
394
441
  return;
395
442
  }
396
443
  if (manifest.length > MAX_FOLDER_UPLOAD_FILES) {
397
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Too many files: ${manifest.length}` });
444
+ runtime.client.sendRes({
445
+ type: 'res',
446
+ id: req.id,
447
+ ok: false,
448
+ error: `Too many files: ${manifest.length}`,
449
+ });
398
450
  return;
399
451
  }
400
452
  const folderName = path.basename(transferName);
@@ -405,7 +457,12 @@ export async function handleFsTransferStart(runtime, req) {
405
457
  const targetDir = path.join(destinationDir, folderName);
406
458
  const checkedTargetDir = runtime.resolveAuthorizedPath(targetDir, rootPath);
407
459
  if (!checkedTargetDir.ok) {
408
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedTargetDir.error });
460
+ runtime.client.sendRes({
461
+ type: 'res',
462
+ id: req.id,
463
+ ok: false,
464
+ error: checkedTargetDir.error,
465
+ });
409
466
  return;
410
467
  }
411
468
  const seen = new Set();
@@ -413,32 +470,62 @@ export async function handleFsTransferStart(runtime, req) {
413
470
  const files = new Map();
414
471
  for (const item of manifest) {
415
472
  if (!isSafeRelativeUploadPath(item.relativePath)) {
416
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Invalid relativePath: ${item.relativePath}` });
473
+ runtime.client.sendRes({
474
+ type: 'res',
475
+ id: req.id,
476
+ ok: false,
477
+ error: `Invalid relativePath: ${item.relativePath}`,
478
+ });
417
479
  return;
418
480
  }
419
481
  if (!Number.isFinite(item.size) || item.size < 0) {
420
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Invalid size: ${item.relativePath}` });
482
+ runtime.client.sendRes({
483
+ type: 'res',
484
+ id: req.id,
485
+ ok: false,
486
+ error: `Invalid size: ${item.relativePath}`,
487
+ });
421
488
  return;
422
489
  }
423
490
  if (seen.has(item.relativePath)) {
424
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Duplicate relativePath: ${item.relativePath}` });
491
+ runtime.client.sendRes({
492
+ type: 'res',
493
+ id: req.id,
494
+ ok: false,
495
+ error: `Duplicate relativePath: ${item.relativePath}`,
496
+ });
425
497
  return;
426
498
  }
427
499
  seen.add(item.relativePath);
428
500
  aggregateSize += item.size;
429
501
  if (aggregateSize > MAX_FOLDER_UPLOAD_TOTAL_SIZE) {
430
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Folder too large: ${aggregateSize} bytes` });
502
+ runtime.client.sendRes({
503
+ type: 'res',
504
+ id: req.id,
505
+ ok: false,
506
+ error: `Folder too large: ${aggregateSize} bytes`,
507
+ });
431
508
  return;
432
509
  }
433
510
  const finalPath = path.join(checkedTargetDir.path, ...item.relativePath.split('/'));
434
511
  const checkedFinalPath = runtime.resolveAuthorizedPath(finalPath, rootPath);
435
512
  if (!checkedFinalPath.ok) {
436
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedFinalPath.error });
513
+ runtime.client.sendRes({
514
+ type: 'res',
515
+ id: req.id,
516
+ ok: false,
517
+ error: checkedFinalPath.error,
518
+ });
437
519
  return;
438
520
  }
439
521
  const tempPath = path.join(os.tmpdir(), `.shennian-upload-${transferId}-${files.size}`);
440
522
  fs.writeFileSync(tempPath, Buffer.alloc(0));
441
- files.set(item.relativePath, { relativePath: item.relativePath, tempPath, targetPath: checkedFinalPath.path, size: item.size });
523
+ files.set(item.relativePath, {
524
+ relativePath: item.relativePath,
525
+ tempPath,
526
+ targetPath: checkedFinalPath.path,
527
+ size: item.size,
528
+ });
442
529
  }
443
530
  runtime.pendingTransfers.set(transferId, {
444
531
  tempPath: '',
@@ -449,13 +536,23 @@ export async function handleFsTransferStart(runtime, req) {
449
536
  targetDir: checkedTargetDir.path,
450
537
  files,
451
538
  });
452
- runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { transferId, path: checkedTargetDir.path } });
539
+ runtime.client.sendRes({
540
+ type: 'res',
541
+ id: req.id,
542
+ ok: true,
543
+ payload: { transferId, path: checkedTargetDir.path },
544
+ });
453
545
  return;
454
546
  }
455
547
  const tempPath = path.join(os.tmpdir(), `.shennian-upload-${transferId}`);
456
548
  const finalPath = path.join(destinationDir, path.basename(transferName));
457
549
  fs.writeFileSync(tempPath, Buffer.alloc(0));
458
- runtime.pendingTransfers.set(transferId, { tempPath, targetPath: finalPath, totalSize, kind: 'file' });
550
+ runtime.pendingTransfers.set(transferId, {
551
+ tempPath,
552
+ targetPath: finalPath,
553
+ totalSize,
554
+ kind: 'file',
555
+ });
459
556
  runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { transferId } });
460
557
  }
461
558
  catch (err) {
@@ -479,7 +576,12 @@ export async function handleFsTransferChunk(runtime, req) {
479
576
  }
480
577
  const buffer = Buffer.from(data, 'base64');
481
578
  if (offset < 0 || offset + buffer.length > target.size) {
482
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Chunk exceeds declared size' });
579
+ runtime.client.sendRes({
580
+ type: 'res',
581
+ id: req.id,
582
+ ok: false,
583
+ error: 'Chunk exceeds declared size',
584
+ });
483
585
  return;
484
586
  }
485
587
  const fd = fs.openSync(target.tempPath, 'r+');
@@ -489,7 +591,12 @@ export async function handleFsTransferChunk(runtime, req) {
489
591
  finally {
490
592
  fs.closeSync(fd);
491
593
  }
492
- runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { written: buffer.length } });
594
+ runtime.client.sendRes({
595
+ type: 'res',
596
+ id: req.id,
597
+ ok: true,
598
+ payload: { written: buffer.length },
599
+ });
493
600
  }
494
601
  catch (err) {
495
602
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
@@ -521,14 +628,24 @@ export async function handleFsTransferFinish(runtime, req) {
521
628
  }
522
629
  fs.renameSync(transfer.tempPath, transfer.targetPath);
523
630
  runtime.pendingTransfers.delete(transferId);
524
- runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { path: transfer.targetPath } });
631
+ runtime.client.sendRes({
632
+ type: 'res',
633
+ id: req.id,
634
+ ok: true,
635
+ payload: { path: transfer.targetPath },
636
+ });
525
637
  }
526
638
  catch {
527
639
  try {
528
640
  fs.copyFileSync(transfer.tempPath, transfer.targetPath);
529
641
  fs.unlinkSync(transfer.tempPath);
530
642
  runtime.pendingTransfers.delete(transferId);
531
- runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { path: transfer.targetPath } });
643
+ runtime.client.sendRes({
644
+ type: 'res',
645
+ id: req.id,
646
+ ok: true,
647
+ payload: { path: transfer.targetPath },
648
+ });
532
649
  }
533
650
  catch (err) {
534
651
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
@@ -2,4 +2,6 @@ import type { ReqFrame } from '@shennian/wire';
2
2
  import type { SessionManagerRuntime } from '../types.js';
3
3
  export declare function handleSkillList(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
4
4
  export declare function handleSkillInstall(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
5
+ export declare function handleSkillDoctor(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
6
+ export declare function handleSkillSetup(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
5
7
  export declare function handleSkillUse(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  // @arch docs/features/skill-marketplace.md
2
2
  // @test src/__tests__/skill-registry.test.ts
3
- import { buildSkillUsePrompt, installSkillFromUrl, listInstalledSkills } from '../../skills/registry.js';
3
+ import { buildSkillUsePrompt, getInstalledSkill, installSkillFromUrl, listInstalledSkills, updateInstalledSkillSetup, } from '../../skills/registry.js';
4
+ import { runSkillDoctor, runSkillSetup, summarizeSetupStatus } from '../../skills/setup.js';
4
5
  export async function handleSkillList(runtime, req) {
5
6
  runtime.client.sendRes({
6
7
  type: 'res',
@@ -23,6 +24,76 @@ export async function handleSkillInstall(runtime, req) {
23
24
  payload: { skill: installed },
24
25
  });
25
26
  }
27
+ export async function handleSkillDoctor(runtime, req) {
28
+ const skillId = typeof req.params.skillId === 'string' ? req.params.skillId : '';
29
+ const doctorId = typeof req.params.doctorId === 'string' ? req.params.doctorId : undefined;
30
+ if (!skillId) {
31
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'skillId is required' });
32
+ return;
33
+ }
34
+ if (!getInstalledSkill(skillId)) {
35
+ runtime.client.sendRes({
36
+ type: 'res',
37
+ id: req.id,
38
+ ok: false,
39
+ error: `Skill not installed: ${skillId}`,
40
+ });
41
+ return;
42
+ }
43
+ const doctorResults = await runSkillDoctor(skillId, doctorId);
44
+ const setupStatus = summarizeSetupStatus(doctorResults);
45
+ updateInstalledSkillSetup(skillId, { setupStatus, doctorResults });
46
+ runtime.client.sendRes({
47
+ type: 'res',
48
+ id: req.id,
49
+ ok: true,
50
+ payload: { skill: getInstalledSkill(skillId), setupStatus, doctorResults },
51
+ });
52
+ }
53
+ export async function handleSkillSetup(runtime, req) {
54
+ const skillId = typeof req.params.skillId === 'string' ? req.params.skillId : '';
55
+ const repairActionId = typeof req.params.repairActionId === 'string' ? req.params.repairActionId : '';
56
+ if (!skillId || !repairActionId) {
57
+ runtime.client.sendRes({
58
+ type: 'res',
59
+ id: req.id,
60
+ ok: false,
61
+ error: 'skillId and repairActionId are required',
62
+ });
63
+ return;
64
+ }
65
+ if (!getInstalledSkill(skillId)) {
66
+ runtime.client.sendRes({
67
+ type: 'res',
68
+ id: req.id,
69
+ ok: false,
70
+ error: `Skill not installed: ${skillId}`,
71
+ });
72
+ return;
73
+ }
74
+ try {
75
+ const result = await runSkillSetup(skillId, repairActionId);
76
+ updateInstalledSkillSetup(skillId, {
77
+ setupStatus: result.status,
78
+ doctorResults: result.doctorResults,
79
+ });
80
+ runtime.client.sendRes({
81
+ type: 'res',
82
+ id: req.id,
83
+ ok: result.status === 'ready',
84
+ payload: { skill: getInstalledSkill(skillId), ...result },
85
+ error: result.status === 'ready' ? undefined : result.message,
86
+ });
87
+ }
88
+ catch (err) {
89
+ runtime.client.sendRes({
90
+ type: 'res',
91
+ id: req.id,
92
+ ok: false,
93
+ error: err instanceof Error ? err.message : String(err),
94
+ });
95
+ }
96
+ }
26
97
  export async function handleSkillUse(runtime, req) {
27
98
  const skillId = typeof req.params.skillId === 'string' ? req.params.skillId : '';
28
99
  const workDir = typeof req.params.workDir === 'string' ? req.params.workDir : process.cwd();
@@ -0,0 +1,3 @@
1
+ import type { ReqFrame } from '@shennian/wire';
2
+ import type { SessionManagerRuntime } from '../types.js';
3
+ export declare function handleSessionTitleSet(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
@@ -0,0 +1,60 @@
1
+ // @arch docs/architecture/cli/daemon.md#会话管理
2
+ // @test src/__tests__/session-manager.test.ts
3
+ import { createAgent } from '../../agents/adapter.js';
4
+ import { buildManagedAgentEnv } from '../../agents/config-status.js';
5
+ function cleanString(value) {
6
+ return typeof value === 'string' ? value.trim() : '';
7
+ }
8
+ export async function handleSessionTitleSet(runtime, req) {
9
+ const params = req.params;
10
+ const sessionId = cleanString(params.sessionId);
11
+ const title = cleanString(params.title);
12
+ const agentSessionId = cleanString(params.agentSessionId);
13
+ const agentType = cleanString(params.agentType);
14
+ const workDir = cleanString(params.workDir) || process.cwd();
15
+ if (!sessionId || !title) {
16
+ runtime.client.sendRes({
17
+ type: 'res',
18
+ id: req.id,
19
+ ok: false,
20
+ error: 'sessionId and title are required',
21
+ });
22
+ return;
23
+ }
24
+ if (agentType !== 'codex' || !agentSessionId) {
25
+ runtime.client.sendRes({
26
+ type: 'res',
27
+ id: req.id,
28
+ ok: true,
29
+ payload: { skipped: true },
30
+ });
31
+ return;
32
+ }
33
+ const active = runtime.sessions.get(sessionId);
34
+ if (active?.agentType === 'codex' && active.adapter.setTitle) {
35
+ await active.adapter.setTitle(agentSessionId, title, active.workDir || workDir);
36
+ active.agentSessionId = agentSessionId;
37
+ active.lastActiveAt = Date.now();
38
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { synced: true } });
39
+ return;
40
+ }
41
+ const adapter = createAgent('codex');
42
+ if (!adapter?.setTitle) {
43
+ runtime.client.sendRes({
44
+ type: 'res',
45
+ id: req.id,
46
+ ok: false,
47
+ error: 'Codex adapter does not support title sync',
48
+ });
49
+ return;
50
+ }
51
+ adapter.configure?.({ sessionId, env: buildManagedAgentEnv('codex') });
52
+ try {
53
+ await adapter.start(sessionId, workDir, agentSessionId);
54
+ await adapter.setTitle(agentSessionId, title, workDir);
55
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload: { synced: true } });
56
+ }
57
+ finally {
58
+ await adapter.stop().catch(() => { });
59
+ }
60
+ }
@@ -8,12 +8,13 @@ import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
8
8
  import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
9
9
  import { handleChatAbort, handleChatSend } from './handlers/chat.js';
10
10
  import { handleSessionRefresh } from './handlers/session-refresh.js';
11
+ import { handleSessionTitleSet } from './handlers/title.js';
11
12
  import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, handleFsExportMarkdownPdf, } from './handlers/fs.js';
12
- import { handleSkillInstall, handleSkillList, handleSkillUse } from './handlers/skills.js';
13
- import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
13
+ import { handleSkillDoctor, handleSkillInstall, handleSkillList, handleSkillSetup, handleSkillUse, } from './handlers/skills.js';
14
+ import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy, } from './handlers/control.js';
14
15
  import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
15
16
  import { ChatQueueManager } from './queue.js';
16
- import { createAuthorizedFsRoot, resolveAuthorizedPath, resolveSessionWorkDir } from '../fs/boundary.js';
17
+ import { createAuthorizedFsRoot, resolveAuthorizedPath, resolveSessionWorkDir, } from '../fs/boundary.js';
17
18
  // Side-effect imports to register built-in agent adapters.
18
19
  import '../agents/claude.js';
19
20
  import '../agents/codex.js';
@@ -108,6 +109,9 @@ export class SessionManager {
108
109
  case 'session.refresh':
109
110
  await handleSessionRefresh(runtime, req);
110
111
  break;
112
+ case 'session.title.set':
113
+ await handleSessionTitleSet(runtime, req);
114
+ break;
111
115
  case 'fs.ls':
112
116
  await handleFsLs(runtime, req);
113
117
  break;
@@ -129,6 +133,12 @@ export class SessionManager {
129
133
  case 'skill.install':
130
134
  await handleSkillInstall(runtime, req);
131
135
  break;
136
+ case 'skill.doctor':
137
+ await handleSkillDoctor(runtime, req);
138
+ break;
139
+ case 'skill.setup':
140
+ await handleSkillSetup(runtime, req);
141
+ break;
132
142
  case 'skill.use':
133
143
  await handleSkillUse(runtime, req);
134
144
  break;
@@ -187,12 +197,19 @@ export class SessionManager {
187
197
  await runtime.managerRuntime?.handleAppReq(req);
188
198
  break;
189
199
  default:
190
- this.client.sendRes({ type: 'res', id: req.id, ok: false, error: `Unknown method: ${req.method}` });
200
+ this.client.sendRes({
201
+ type: 'res',
202
+ id: req.id,
203
+ ok: false,
204
+ error: `Unknown method: ${req.method}`,
205
+ });
191
206
  }
192
207
  }
193
208
  catch (err) {
194
209
  this.client.sendRes({
195
- type: 'res', id: req.id, ok: false,
210
+ type: 'res',
211
+ id: req.id,
212
+ ok: false,
196
213
  error: err instanceof Error ? err.message : String(err),
197
214
  });
198
215
  }
@@ -1,8 +1,12 @@
1
- import type { InstalledShennianSkill } from '@shennian/wire';
1
+ import type { InstalledShennianSkill, SkillDoctorResult, SkillSetupStatus } from '@shennian/wire';
2
2
  export declare function getSkillsDir(): string;
3
3
  export declare function getSkillDir(skillId: string): string;
4
4
  export declare function listInstalledSkills(): InstalledShennianSkill[];
5
5
  export declare function getInstalledSkill(skillId: string): InstalledShennianSkill | null;
6
+ export declare function updateInstalledSkillSetup(skillId: string, update: {
7
+ setupStatus?: SkillSetupStatus;
8
+ doctorResults?: SkillDoctorResult[];
9
+ }): void;
6
10
  export declare function installSkillFromUrl(installUrl: string): Promise<InstalledShennianSkill>;
7
11
  export declare function buildSkillUsePrompt(skillId: string, workDir: string, attachments?: Array<{
8
12
  path: string;
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { resolveShennianPath } from '../config/index.js';
6
+ import { runSkillDoctor, summarizeSetupStatus } from './setup.js';
6
7
  function safeSkillId(skillId) {
7
8
  const normalized = skillId.trim();
8
9
  if (!/^[a-z0-9][a-z0-9._-]{1,80}$/i.test(normalized)) {
@@ -39,7 +40,8 @@ export function getSkillDir(skillId) {
39
40
  }
40
41
  export function listInstalledSkills() {
41
42
  const dir = getSkillsDir();
42
- return fs.readdirSync(dir, { withFileTypes: true })
43
+ return fs
44
+ .readdirSync(dir, { withFileTypes: true })
43
45
  .filter((entry) => entry.isDirectory())
44
46
  .map((entry) => {
45
47
  const skillPath = path.join(dir, entry.name);
@@ -47,11 +49,16 @@ export function listInstalledSkills() {
47
49
  const install = readJsonFile(path.join(skillPath, '.shennian-install.json'));
48
50
  if (!manifest?.id || !manifest.name)
49
51
  return null;
50
- return {
52
+ const item = {
51
53
  ...manifest,
52
54
  installedAt: install?.installedAt ?? new Date(0).toISOString(),
53
55
  path: skillPath,
54
56
  };
57
+ if (install?.setupStatus)
58
+ item.setupStatus = install.setupStatus;
59
+ if (install?.doctorResults)
60
+ item.doctorResults = install.doctorResults;
61
+ return item;
55
62
  })
56
63
  .filter((item) => item != null)
57
64
  .sort((left, right) => left.name.localeCompare(right.name));
@@ -59,16 +66,23 @@ export function listInstalledSkills() {
59
66
  export function getInstalledSkill(skillId) {
60
67
  return listInstalledSkills().find((skill) => skill.id === skillId) ?? null;
61
68
  }
69
+ export function updateInstalledSkillSetup(skillId, update) {
70
+ const skillDir = getSkillDir(skillId);
71
+ const installPath = path.join(skillDir, '.shennian-install.json');
72
+ const current = readJsonFile(installPath) ?? {};
73
+ fs.writeFileSync(installPath, JSON.stringify({ ...current, ...update, updatedAt: new Date().toISOString() }, null, 2) + '\n', 'utf8');
74
+ }
62
75
  async function fetchSkillBundle(installUrl) {
63
76
  if (!installUrl.startsWith('http://') && !installUrl.startsWith('https://')) {
64
- return readJsonFile(path.resolve(installUrl)) ?? (() => {
65
- throw new Error('Invalid skill bundle');
66
- })();
77
+ return (readJsonFile(path.resolve(installUrl)) ??
78
+ (() => {
79
+ throw new Error('Invalid skill bundle');
80
+ })());
67
81
  }
68
82
  const res = await fetch(installUrl);
69
83
  if (!res.ok)
70
84
  throw new Error(`Skill download failed: ${res.status} ${res.statusText}`);
71
- return await res.json();
85
+ return (await res.json());
72
86
  }
73
87
  function writeBundleFile(skillDir, file) {
74
88
  const relativePath = assertSafeBundlePath(file.path);
@@ -91,7 +105,11 @@ export async function installSkillFromUrl(installUrl) {
91
105
  for (const file of bundle.files) {
92
106
  writeBundleFile(skillDir, file);
93
107
  }
94
- fs.writeFileSync(path.join(skillDir, '.shennian-install.json'), JSON.stringify({ installedAt: new Date().toISOString(), installUrl }, null, 2) + '\n', 'utf8');
108
+ const doctorResults = bundle.manifest.setup?.doctors?.length
109
+ ? (await Promise.all(bundle.manifest.setup.doctors.map((doctorId) => runSkillDoctor(bundle.manifest.id, doctorId)))).flat()
110
+ : [];
111
+ const setupStatus = summarizeSetupStatus(doctorResults);
112
+ fs.writeFileSync(path.join(skillDir, '.shennian-install.json'), JSON.stringify({ installedAt: new Date().toISOString(), installUrl, setupStatus, doctorResults }, null, 2) + '\n', 'utf8');
95
113
  const installed = getInstalledSkill(bundle.manifest.id);
96
114
  if (!installed)
97
115
  throw new Error('Skill installation did not complete');
@@ -113,7 +131,9 @@ export function buildSkillUsePrompt(skillId, workDir, attachments) {
113
131
  attachmentLines ? `\nCurrent attachments:\n${attachmentLines}` : '',
114
132
  '',
115
133
  'Follow the skill output contract. Prefer local deterministic extraction first, then use agent document or vision capability only when needed.',
116
- ].filter(Boolean).join('\n');
134
+ ]
135
+ .filter(Boolean)
136
+ .join('\n');
117
137
  }
118
138
  export function buildInstalledSkillInstructions() {
119
139
  const skills = listInstalledSkills();
@@ -0,0 +1,11 @@
1
+ import type { SkillDoctorResult, SkillRepairAction, SkillSetupStatus } from '@shennian/wire';
2
+ export declare const MARKDOWN_PDF_REPAIR_ACTION: SkillRepairAction;
3
+ export declare function markdownPdfRepairActions(): SkillRepairAction[];
4
+ export declare function runMarkdownPdfBrowserDoctor(): Promise<SkillDoctorResult>;
5
+ export declare function runSkillDoctor(skillId: string, doctorId?: string): Promise<SkillDoctorResult[]>;
6
+ export declare function runSkillSetup(skillId: string, repairActionId: string): Promise<{
7
+ status: SkillSetupStatus;
8
+ doctorResults: SkillDoctorResult[];
9
+ message: string;
10
+ }>;
11
+ export declare function summarizeSetupStatus(doctorResults: SkillDoctorResult[]): SkillSetupStatus | undefined;
@@ -0,0 +1,101 @@
1
+ // @arch docs/features/skill-marketplace.md
2
+ // @test src/__tests__/skill-registry.test.ts
3
+ import { findChromiumExecutable, installManagedChromium, MARKDOWN_PDF_BROWSER_DOCTOR_ID, MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID, MARKDOWN_PDF_SKILL_ID, managedChromiumBrowsersPath, } from '../tools/markdown-to-pdf.js';
4
+ export const MARKDOWN_PDF_REPAIR_ACTION = {
5
+ id: MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID,
6
+ label: 'Install PDF export component',
7
+ description: 'Download a Shennian-managed Chromium browser into ~/.shennian/browsers without using sudo.',
8
+ requiresNetwork: true,
9
+ requiresSudo: false,
10
+ downloadSizeMb: 180,
11
+ };
12
+ export function markdownPdfRepairActions() {
13
+ return [MARKDOWN_PDF_REPAIR_ACTION];
14
+ }
15
+ function result(input) {
16
+ return {
17
+ skillId: MARKDOWN_PDF_SKILL_ID,
18
+ doctorId: MARKDOWN_PDF_BROWSER_DOCTOR_ID,
19
+ status: input.status,
20
+ issues: input.issues,
21
+ checkedAt: input.checkedAt ?? new Date().toISOString(),
22
+ recommendedRepairActionId: MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID,
23
+ repairActions: markdownPdfRepairActions(),
24
+ };
25
+ }
26
+ export async function runMarkdownPdfBrowserDoctor() {
27
+ try {
28
+ const browserPath = await findChromiumExecutable();
29
+ return result({
30
+ status: 'ready',
31
+ issues: [
32
+ {
33
+ code: 'MARKDOWN_PDF_BROWSER_READY',
34
+ severity: 'info',
35
+ message: `PDF export browser is ready: ${browserPath}`,
36
+ },
37
+ ],
38
+ });
39
+ }
40
+ catch {
41
+ return result({
42
+ status: 'needs_setup',
43
+ issues: [
44
+ {
45
+ code: 'MARKDOWN_PDF_BROWSER_MISSING',
46
+ severity: 'error',
47
+ message: 'This machine needs a headless browser before Markdown files can be exported to PDF.',
48
+ repairActionId: MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID,
49
+ },
50
+ ],
51
+ });
52
+ }
53
+ }
54
+ export async function runSkillDoctor(skillId, doctorId) {
55
+ if (skillId === MARKDOWN_PDF_SKILL_ID &&
56
+ (!doctorId || doctorId === MARKDOWN_PDF_BROWSER_DOCTOR_ID)) {
57
+ return [await runMarkdownPdfBrowserDoctor()];
58
+ }
59
+ return [
60
+ {
61
+ skillId,
62
+ doctorId: doctorId || 'unknown',
63
+ status: 'ready',
64
+ issues: [
65
+ {
66
+ code: 'SKILL_DOCTOR_NOT_REQUIRED',
67
+ severity: 'info',
68
+ message: 'This skill does not declare a local environment doctor.',
69
+ },
70
+ ],
71
+ checkedAt: new Date().toISOString(),
72
+ },
73
+ ];
74
+ }
75
+ export async function runSkillSetup(skillId, repairActionId) {
76
+ if (skillId !== MARKDOWN_PDF_SKILL_ID ||
77
+ repairActionId !== MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID) {
78
+ throw new Error('Unsupported skill repair action');
79
+ }
80
+ await installManagedChromium();
81
+ const doctorResults = await runSkillDoctor(skillId, MARKDOWN_PDF_BROWSER_DOCTOR_ID);
82
+ const status = doctorResults.every((item) => item.status === 'ready') ? 'ready' : 'setup_failed';
83
+ return {
84
+ status,
85
+ doctorResults,
86
+ message: status === 'ready'
87
+ ? `PDF export component installed in ${managedChromiumBrowsersPath()}`
88
+ : 'PDF export component was installed, but the browser is still not ready. The system may need additional Linux libraries.',
89
+ };
90
+ }
91
+ export function summarizeSetupStatus(doctorResults) {
92
+ if (doctorResults.length === 0)
93
+ return undefined;
94
+ if (doctorResults.every((item) => item.status === 'ready'))
95
+ return 'ready';
96
+ if (doctorResults.some((item) => item.status === 'blocked'))
97
+ return 'blocked';
98
+ if (doctorResults.some((item) => item.status === 'setup_failed'))
99
+ return 'setup_failed';
100
+ return 'needs_setup';
101
+ }
@@ -1,3 +1,20 @@
1
+ export declare const MARKDOWN_PDF_SKILL_ID = "markdown-to-pdf";
2
+ export declare const MARKDOWN_PDF_BROWSER_DOCTOR_ID = "markdownPdf.browser";
3
+ export declare const MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID = "markdownPdf.installManagedChromium";
4
+ export declare const MARKDOWN_PDF_SETUP_REQUIRED_CODE = "SKILL_SETUP_REQUIRED";
5
+ export declare const PLAYWRIGHT_VERSION_FOR_MANAGED_CHROMIUM = "1.59.1";
6
+ export type MarkdownPdfSetupRequiredPayload = {
7
+ code: typeof MARKDOWN_PDF_SETUP_REQUIRED_CODE;
8
+ skillId: typeof MARKDOWN_PDF_SKILL_ID;
9
+ doctorId: typeof MARKDOWN_PDF_BROWSER_DOCTOR_ID;
10
+ recommendedRepairActionId: typeof MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID;
11
+ };
12
+ export declare function markdownPdfSetupRequiredPayload(): MarkdownPdfSetupRequiredPayload;
13
+ export declare class MarkdownPdfBrowserMissingError extends Error {
14
+ readonly code = "SKILL_SETUP_REQUIRED";
15
+ readonly setup: MarkdownPdfSetupRequiredPayload;
16
+ constructor(message?: string);
17
+ }
1
18
  export type MarkdownToPdfOptions = {
2
19
  outputPath?: string;
3
20
  title?: string;
@@ -16,5 +33,10 @@ export declare function renderMarkdownHtml(markdown: string, options?: {
16
33
  title?: string;
17
34
  sourceDir?: string;
18
35
  }): string;
36
+ export declare function managedChromiumBrowsersPath(): string;
19
37
  export declare function findChromiumExecutable(explicitPath?: string): Promise<string>;
38
+ export declare function installManagedChromium(): Promise<{
39
+ browsersPath: string;
40
+ output: string;
41
+ }>;
20
42
  export declare function convertMarkdownToPdf(inputPath: string, options?: MarkdownToPdfOptions): Promise<MarkdownToPdfResult>;
@@ -6,7 +6,29 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
+ import { resolveShennianPath } from '../config/index.js';
9
10
  const execFileAsync = promisify(execFile);
11
+ export const MARKDOWN_PDF_SKILL_ID = 'markdown-to-pdf';
12
+ export const MARKDOWN_PDF_BROWSER_DOCTOR_ID = 'markdownPdf.browser';
13
+ export const MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID = 'markdownPdf.installManagedChromium';
14
+ export const MARKDOWN_PDF_SETUP_REQUIRED_CODE = 'SKILL_SETUP_REQUIRED';
15
+ export const PLAYWRIGHT_VERSION_FOR_MANAGED_CHROMIUM = '1.59.1';
16
+ export function markdownPdfSetupRequiredPayload() {
17
+ return {
18
+ code: MARKDOWN_PDF_SETUP_REQUIRED_CODE,
19
+ skillId: MARKDOWN_PDF_SKILL_ID,
20
+ doctorId: MARKDOWN_PDF_BROWSER_DOCTOR_ID,
21
+ recommendedRepairActionId: MARKDOWN_PDF_MANAGED_CHROMIUM_ACTION_ID,
22
+ };
23
+ }
24
+ export class MarkdownPdfBrowserMissingError extends Error {
25
+ code = MARKDOWN_PDF_SETUP_REQUIRED_CODE;
26
+ setup = markdownPdfSetupRequiredPayload();
27
+ constructor(message = 'PDF export needs a local headless browser. Install the PDF export component, then retry.') {
28
+ super(message);
29
+ this.name = 'MarkdownPdfBrowserMissingError';
30
+ }
31
+ }
10
32
  const FENCED_CODE_RE = /^```(\S*)\s*$/;
11
33
  function isWindowsAbsolutePath(value) {
12
34
  return /^[A-Za-z]:([\\/]|$)/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value);
@@ -68,7 +90,11 @@ function renderTable(lines, sourceDir) {
68
90
  const separator = lines[1].trim();
69
91
  if (!/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(separator))
70
92
  return null;
71
- const parseRow = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim());
93
+ const parseRow = (line) => line
94
+ .trim()
95
+ .replace(/^\||\|$/g, '')
96
+ .split('|')
97
+ .map((cell) => cell.trim());
72
98
  const header = parseRow(lines[0]);
73
99
  const rows = lines.slice(2).map(parseRow);
74
100
  return [
@@ -226,6 +252,7 @@ ${body}
226
252
  function executableCandidates() {
227
253
  const envPath = process.env.SHENNIAN_CHROME_PATH;
228
254
  const candidates = envPath ? [envPath] : [];
255
+ candidates.push(...managedChromiumExecutableCandidates());
229
256
  if (process.platform === 'darwin') {
230
257
  candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser');
231
258
  }
@@ -244,6 +271,30 @@ function executableCandidates() {
244
271
  }
245
272
  return candidates;
246
273
  }
274
+ export function managedChromiumBrowsersPath() {
275
+ return resolveShennianPath('browsers', 'ms-playwright');
276
+ }
277
+ function managedChromiumExecutableCandidates() {
278
+ const root = managedChromiumBrowsersPath();
279
+ if (!fs.existsSync(root))
280
+ return [];
281
+ const candidates = [];
282
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
283
+ if (!entry.isDirectory() || !entry.name.startsWith('chromium-'))
284
+ continue;
285
+ const dir = path.join(root, entry.name);
286
+ if (process.platform === 'darwin') {
287
+ candidates.push(path.join(dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
288
+ }
289
+ else if (process.platform === 'win32') {
290
+ candidates.push(path.join(dir, 'chrome-win', 'chrome.exe'));
291
+ }
292
+ else {
293
+ candidates.push(path.join(dir, 'chrome-linux', 'chrome'));
294
+ }
295
+ }
296
+ return candidates;
297
+ }
247
298
  export async function findChromiumExecutable(explicitPath) {
248
299
  const candidates = explicitPath ? [explicitPath] : executableCandidates();
249
300
  for (const candidate of candidates) {
@@ -260,7 +311,24 @@ export async function findChromiumExecutable(explicitPath) {
260
311
  // continue
261
312
  }
262
313
  }
263
- throw new Error('No Chrome/Edge/Chromium executable found. Install Chrome/Edge/Chromium or set SHENNIAN_CHROME_PATH.');
314
+ throw new MarkdownPdfBrowserMissingError();
315
+ }
316
+ export async function installManagedChromium() {
317
+ const browsersPath = managedChromiumBrowsersPath();
318
+ fs.mkdirSync(browsersPath, { recursive: true });
319
+ const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
320
+ const { stdout, stderr } = await execFileAsync(npx, ['-y', `playwright@${PLAYWRIGHT_VERSION_FOR_MANAGED_CHROMIUM}`, 'install', 'chromium'], {
321
+ timeout: 10 * 60_000,
322
+ maxBuffer: 4 * 1024 * 1024,
323
+ env: {
324
+ ...process.env,
325
+ PLAYWRIGHT_BROWSERS_PATH: browsersPath,
326
+ },
327
+ });
328
+ return {
329
+ browsersPath,
330
+ output: [stdout, stderr].filter(Boolean).join('\n').slice(0, 20_000),
331
+ };
264
332
  }
265
333
  export async function convertMarkdownToPdf(inputPath, options = {}) {
266
334
  const resolvedInput = path.resolve(inputPath);
@@ -277,7 +345,9 @@ export async function convertMarkdownToPdf(inputPath, options = {}) {
277
345
  sourceDir: path.dirname(resolvedInput),
278
346
  });
279
347
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shennian-mdpdf-'));
280
- const htmlPath = options.keepHtml ? outputPath.replace(/\.pdf$/i, '.html') : path.join(tempDir, 'document.html');
348
+ const htmlPath = options.keepHtml
349
+ ? outputPath.replace(/\.pdf$/i, '.html')
350
+ : path.join(tempDir, 'document.html');
281
351
  fs.writeFileSync(htmlPath, html, 'utf8');
282
352
  const browserPath = await findChromiumExecutable(options.chromePath);
283
353
  const args = [
@@ -299,5 +369,10 @@ export async function convertMarkdownToPdf(inputPath, options = {}) {
299
369
  if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
300
370
  throw new Error('PDF export failed: output file was not created');
301
371
  }
302
- return { inputPath: resolvedInput, outputPath, htmlPath: options.keepHtml ? htmlPath : null, browserPath };
372
+ return {
373
+ inputPath: resolvedInput,
374
+ outputPath,
375
+ htmlPath: options.keepHtml ? htmlPath : null,
376
+ browserPath,
377
+ };
303
378
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.68",
3
+ "version": "0.2.69",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {