shennian 0.2.67 → 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.
- package/dist/src/agents/adapter.d.ts +1 -0
- package/dist/src/agents/codex.d.ts +1 -0
- package/dist/src/agents/codex.js +12 -0
- package/dist/src/session/handlers/chat.js +2 -0
- package/dist/src/session/handlers/fs.js +141 -24
- package/dist/src/session/handlers/skills.d.ts +2 -0
- package/dist/src/session/handlers/skills.js +72 -1
- package/dist/src/session/handlers/title.d.ts +3 -0
- package/dist/src/session/handlers/title.js +60 -0
- package/dist/src/session/manager.js +22 -5
- package/dist/src/session/queue.d.ts +1 -0
- package/dist/src/session/queue.js +16 -0
- package/dist/src/skills/registry.d.ts +5 -1
- package/dist/src/skills/registry.js +28 -8
- package/dist/src/skills/setup.d.ts +11 -0
- package/dist/src/skills/setup.js +101 -0
- package/dist/src/tools/markdown-to-pdf.d.ts +22 -0
- package/dist/src/tools/markdown-to-pdf.js +79 -4
- package/package.json +1 -1
|
@@ -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;
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -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();
|
|
@@ -298,6 +298,7 @@ function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
|
|
|
298
298
|
adapter.on('error', (error) => {
|
|
299
299
|
console.error(`[chat.send] adapter error sessionId=${sessionId} agentType=${agentType}: ${error.message}`);
|
|
300
300
|
runtime.sessions.delete(sessionId);
|
|
301
|
+
runtime.chatQueue?.noteTerminal(sessionId);
|
|
301
302
|
runtime.client.sendEvent({
|
|
302
303
|
type: 'event',
|
|
303
304
|
event: 'agent',
|
|
@@ -359,6 +360,7 @@ function emitSyntheticAbort(runtime, sessionId) {
|
|
|
359
360
|
seq,
|
|
360
361
|
id: `agent-evt-${runId}-${seq}`,
|
|
361
362
|
});
|
|
363
|
+
runtime.chatQueue?.noteTerminal(sessionId);
|
|
362
364
|
}
|
|
363
365
|
export async function handleChatSend(runtime, req) {
|
|
364
366
|
if (runtime.processedReqIds.has(req.id)) {
|
|
@@ -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
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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 ?
|
|
411
|
+
const transferName = isFolder ? baseName || name : name;
|
|
375
412
|
if (!transferName || (!isFolder && !totalSize)) {
|
|
376
|
-
runtime.client.sendRes({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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, {
|
|
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({
|
|
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, {
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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,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({
|
|
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',
|
|
210
|
+
type: 'res',
|
|
211
|
+
id: req.id,
|
|
212
|
+
ok: false,
|
|
196
213
|
error: err instanceof Error ? err.message : String(err),
|
|
197
214
|
});
|
|
198
215
|
}
|
|
@@ -14,6 +14,7 @@ export declare class ChatQueueManager {
|
|
|
14
14
|
handleEdit(req: ReqFrame): Promise<void>;
|
|
15
15
|
handleDelete(req: ReqFrame): Promise<void>;
|
|
16
16
|
noteTerminal(sessionId: string): void;
|
|
17
|
+
private drainIdleQueues;
|
|
17
18
|
private drainNext;
|
|
18
19
|
private mergeExternalMessages;
|
|
19
20
|
private dispatchQueuedMessage;
|
|
@@ -73,6 +73,8 @@ export class ChatQueueManager {
|
|
|
73
73
|
draining = new Set();
|
|
74
74
|
constructor(opts) {
|
|
75
75
|
this.opts = opts;
|
|
76
|
+
const timer = setTimeout(() => this.drainIdleQueues(), 0);
|
|
77
|
+
timer.unref?.();
|
|
76
78
|
}
|
|
77
79
|
getSnapshot(sessionId) {
|
|
78
80
|
return {
|
|
@@ -136,6 +138,9 @@ export class ChatQueueManager {
|
|
|
136
138
|
...(materialized.localized ? { localizedAttachments: true } : {}),
|
|
137
139
|
},
|
|
138
140
|
});
|
|
141
|
+
if (!isBusy) {
|
|
142
|
+
void this.drainNext(params.sessionId);
|
|
143
|
+
}
|
|
139
144
|
}
|
|
140
145
|
async handleGet(req) {
|
|
141
146
|
const runtime = this.opts.getRuntime();
|
|
@@ -150,6 +155,9 @@ export class ChatQueueManager {
|
|
|
150
155
|
ok: true,
|
|
151
156
|
payload: { queue: this.getSnapshot(params.sessionId) },
|
|
152
157
|
});
|
|
158
|
+
if (!this.getSnapshot(params.sessionId).busy) {
|
|
159
|
+
void this.drainNext(params.sessionId);
|
|
160
|
+
}
|
|
153
161
|
}
|
|
154
162
|
async handleEdit(req) {
|
|
155
163
|
const runtime = this.opts.getRuntime();
|
|
@@ -221,6 +229,14 @@ export class ChatQueueManager {
|
|
|
221
229
|
noteTerminal(sessionId) {
|
|
222
230
|
void this.drainNext(sessionId);
|
|
223
231
|
}
|
|
232
|
+
drainIdleQueues() {
|
|
233
|
+
const queue = readQueue();
|
|
234
|
+
for (const sessionId of Object.keys(queue.sessions)) {
|
|
235
|
+
if (!this.getSnapshot(sessionId).busy) {
|
|
236
|
+
void this.drainNext(sessionId);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
224
240
|
async drainNext(sessionId) {
|
|
225
241
|
if (this.draining.has(sessionId))
|
|
226
242
|
return;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
372
|
+
return {
|
|
373
|
+
inputPath: resolvedInput,
|
|
374
|
+
outputPath,
|
|
375
|
+
htmlPath: options.keepHtml ? htmlPath : null,
|
|
376
|
+
browserPath,
|
|
377
|
+
};
|
|
303
378
|
}
|