lark-remote 0.5.4 → 0.5.5

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  25. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  26. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  27. package/dist/runner/common/acp/protocol-types.js +3 -1
  28. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  29. package/dist/runner/common/connection-based-runner.js +40 -5
  30. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  31. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  32. package/dist/runner/dsh/approval.d.ts +53 -0
  33. package/dist/runner/dsh/approval.js +161 -0
  34. package/dist/runner/dsh/auth.d.ts +36 -0
  35. package/dist/runner/dsh/auth.js +118 -0
  36. package/dist/runner/dsh/client.d.ts +161 -42
  37. package/dist/runner/dsh/client.js +551 -244
  38. package/dist/runner/dsh/daemon.d.ts +85 -0
  39. package/dist/runner/dsh/daemon.js +272 -0
  40. package/dist/runner/dsh/merge.d.ts +9 -0
  41. package/dist/runner/dsh/merge.js +51 -0
  42. package/dist/runner/dsh/runner.d.ts +94 -11
  43. package/dist/runner/dsh/runner.js +553 -67
  44. package/dist/runner/dsh/translator.d.ts +43 -3
  45. package/dist/runner/dsh/translator.js +115 -38
  46. package/dist/runner/dsh/types.d.ts +189 -27
  47. package/dist/runner/dsh/types.js +11 -5
  48. package/dist/runner/kimi/acp/runner.js +15 -3
  49. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  50. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  51. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  52. package/dist/runner/opencode/acp/runner.js +40 -1
  53. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  54. package/dist/runner/opencode/acp/translator.js +12 -3
  55. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  56. package/dist/runner/pi/rpc/translator.js +27 -1
  57. package/dist/runner/types.d.ts +34 -0
  58. package/dist/runner/types.js +24 -0
  59. package/dist/session/dsh/sessions.d.ts +45 -6
  60. package/dist/session/dsh/sessions.js +171 -27
  61. package/dist/session/opencode/sessions.d.ts +18 -7
  62. package/dist/session/opencode/sessions.js +294 -15
  63. package/dist/session/pi/sessions.js +6 -2
  64. package/package.json +3 -1
@@ -1,10 +1,12 @@
1
1
  /**
2
- * OpencodeSessionReader: CLI-based session reader using `opencode session list` and `opencode export`.
2
+ * OpencodeSessionReader: CLI-based session reader using `opencode session list`
3
+ * and `opencode session export` (v2) / `opencode export` (v1).
3
4
  *
4
5
  * Design:
5
6
  * - Uses official CLI commands instead of HTTP API.
6
7
  * - `opencode session list --format json` returns sessions with `directory` field (first-class citizen).
7
- * - `opencode export <sessionID>` returns full session content.
8
+ * - `opencode session export <sessionID>` (v2) or `opencode export <sessionID>`
9
+ * (v1) returns full session content.
8
10
  * - Independent of runner (no setRunner circular dependency).
9
11
  *
10
12
  * Key features:
@@ -25,6 +27,60 @@ import { paginate, capEvents } from '../common/pagination.js';
25
27
  import { sortByRecencyDesc } from '../common/recency.js';
26
28
  import { TtlCache } from '../../common/ttl-cache.js';
27
29
  import { getLogger } from '../../logger/index.js';
30
+ /** v2 CLI defaults `session list` to 100 entries; keep the reader's historical
31
+ * "all sessions in this cwd" contract by asking for a high explicit limit. */
32
+ const MAX_SESSION_LIST_COUNT = 10_000;
33
+ function isV2Export(data) {
34
+ if (data.info && 'location' in data.info)
35
+ return true;
36
+ const messages = data.messages;
37
+ if (!Array.isArray(messages))
38
+ return false;
39
+ return messages.some((m) => m !== null &&
40
+ typeof m === 'object' &&
41
+ typeof m.type === 'string' &&
42
+ !('info' in m));
43
+ }
44
+ /** v1 stores the cwd at `info.directory`; v2 moved it under `info.location`. */
45
+ function exportDirectory(data) {
46
+ const info = data.info;
47
+ return info?.directory ?? info?.location?.directory;
48
+ }
49
+ function parseToolInput(value) {
50
+ if (typeof value !== 'string')
51
+ return value ?? {};
52
+ try {
53
+ return JSON.parse(value);
54
+ }
55
+ catch {
56
+ return value;
57
+ }
58
+ }
59
+ /** v2 export tool state carries results in `content[]` (text parts) or
60
+ * `error.message`; `result` is model/tool-specific and only used as a
61
+ * last-resort string. */
62
+ function v2ToolOutput(state) {
63
+ if (!state)
64
+ return undefined;
65
+ const texts = [];
66
+ for (const item of Array.isArray(state.content) ? state.content : []) {
67
+ if (typeof item?.text === 'string') {
68
+ texts.push(item.text);
69
+ }
70
+ else if (item?.content?.type === 'text' && typeof item.content.text === 'string') {
71
+ texts.push(item.content.text);
72
+ }
73
+ }
74
+ if (texts.length > 0)
75
+ return texts.join('\n');
76
+ if (typeof state.error === 'string')
77
+ return state.error;
78
+ if (state.error && typeof state.error.message === 'string')
79
+ return state.error.message;
80
+ if (typeof state.result === 'string')
81
+ return state.result;
82
+ return undefined;
83
+ }
28
84
  export class OpencodeSessionReader {
29
85
  binary;
30
86
  cacheTtlMs;
@@ -33,7 +89,7 @@ export class OpencodeSessionReader {
33
89
  /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
34
90
  listCache;
35
91
  constructor(opts = {}) {
36
- this.binary = 'opencode';
92
+ this.binary = opts.binary ?? 'opencode';
37
93
  this.cacheTtlMs = opts.cacheTtlMs ?? 10_000;
38
94
  this.listCache = new TtlCache(this.cacheTtlMs);
39
95
  this.captureExportOverride = opts.captureExport;
@@ -102,9 +158,13 @@ export class OpencodeSessionReader {
102
158
  try {
103
159
  // 与 listSessions 的过滤同口径(PathKit.samePath):export 里的 directory
104
160
  // 形态由 opencode 自行记录,裸 !== 会让会话列得出却读不出(win32 常态)
105
- if (!samePath(data.info.directory, realCwd)) {
161
+ const directory = exportDirectory(data);
162
+ if (!directory || !samePath(directory, realCwd)) {
106
163
  return { events: [] };
107
164
  }
165
+ if (isV2Export(data)) {
166
+ return this.readV2SessionContent(data, opts);
167
+ }
108
168
  const events = [];
109
169
  let usage;
110
170
  let aiTitle;
@@ -276,6 +336,163 @@ export class OpencodeSessionReader {
276
336
  return { events: [] };
277
337
  }
278
338
  }
339
+ /**
340
+ * v2 export: messages are flat (`type` + `text`/`content`) and assistant
341
+ * usage lives on the message instead of a `step-finish` part. Keep this
342
+ * mapping isolated from the v1 parser so v1 behaviour stays unchanged.
343
+ */
344
+ readV2SessionContent(data, opts) {
345
+ const messages = Array.isArray(data.messages) ? data.messages : [];
346
+ const events = [];
347
+ const aiTitle = typeof data.info.title === 'string' && data.info.title.length > 0
348
+ ? data.info.title
349
+ : undefined;
350
+ let displayTitle;
351
+ let lastUserIndex = -1;
352
+ for (let i = 0; i < messages.length; i++) {
353
+ if (messages[i]?.type === 'user')
354
+ lastUserIndex = i;
355
+ }
356
+ const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
357
+ for (let i = startIndex; i < messages.length; i++) {
358
+ const msg = messages[i];
359
+ if (!msg || typeof msg.type !== 'string')
360
+ continue;
361
+ const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : undefined;
362
+ if (msg.type === 'user') {
363
+ if (typeof msg.text === 'string' && msg.text.length > 0) {
364
+ if (!displayTitle)
365
+ displayTitle = msg.text;
366
+ events.push({ type: 'text', content: msg.text, timestamp });
367
+ }
368
+ continue;
369
+ }
370
+ if (msg.type === 'assistant') {
371
+ for (const part of Array.isArray(msg.content) ? msg.content : []) {
372
+ if (part?.type === 'text' && typeof part.text === 'string') {
373
+ events.push({ type: 'text', content: part.text, timestamp });
374
+ continue;
375
+ }
376
+ if (part?.type === 'reasoning' && typeof part.text === 'string') {
377
+ events.push({ type: 'thinking', content: part.text, timestamp });
378
+ continue;
379
+ }
380
+ if (part?.type === 'tool') {
381
+ const toolId = part.id;
382
+ const toolName = part.name ?? '';
383
+ const input = parseToolInput(part.state?.input);
384
+ if (toolId) {
385
+ events.push({
386
+ type: 'tool_use',
387
+ content: JSON.stringify({ id: toolId, name: toolName, input }),
388
+ timestamp,
389
+ });
390
+ }
391
+ const output = v2ToolOutput(part.state);
392
+ if (toolId && output !== undefined) {
393
+ events.push({
394
+ type: 'tool_result',
395
+ content: truncateUtf8(output, TOOL_RESULT_MAX_BYTES),
396
+ timestamp,
397
+ });
398
+ }
399
+ }
400
+ }
401
+ continue;
402
+ }
403
+ // User-invoked shell messages are a first-class message type in v2.
404
+ if (msg.type === 'shell' && typeof msg.command === 'string') {
405
+ events.push({
406
+ type: 'tool_use',
407
+ content: JSON.stringify({
408
+ id: msg.callID ?? msg.id ?? 'shell',
409
+ name: 'bash',
410
+ input: { command: msg.command },
411
+ }),
412
+ timestamp,
413
+ });
414
+ if (typeof msg.output === 'string') {
415
+ events.push({
416
+ type: 'tool_result',
417
+ content: truncateUtf8(msg.output, TOOL_RESULT_MAX_BYTES),
418
+ timestamp,
419
+ });
420
+ }
421
+ continue;
422
+ }
423
+ // Keep the compact summary visible in the catch-up tail; other control
424
+ // messages (system/synthetic/agent-switched/model-switched) are noise.
425
+ if (msg.type === 'compaction' && typeof msg.summary === 'string' && msg.summary) {
426
+ events.push({ type: 'text', content: msg.summary, timestamp });
427
+ }
428
+ }
429
+ // If the last user message had no text (e.g. attachment-only), fall back to
430
+ // the first human text anywhere in the session for the card title.
431
+ if (!displayTitle) {
432
+ for (const msg of messages) {
433
+ if (msg?.type === 'user' && typeof msg.text === 'string' && msg.text.length > 0) {
434
+ displayTitle = msg.text;
435
+ break;
436
+ }
437
+ }
438
+ }
439
+ let latest;
440
+ let cumInput = 0;
441
+ let cumOutput = 0;
442
+ let cumTotal = 0;
443
+ let cumCacheRead = 0;
444
+ let cumCacheWrite = 0;
445
+ for (const msg of messages) {
446
+ if (msg?.type !== 'assistant' || !msg.tokens)
447
+ continue;
448
+ const tokens = msg.tokens;
449
+ const cacheRead = tokens.cache?.read ?? 0;
450
+ const cacheWrite = tokens.cache?.write ?? 0;
451
+ const total = tokens.input + tokens.output + tokens.reasoning + cacheRead + cacheWrite;
452
+ cumInput += tokens.input;
453
+ cumOutput += tokens.output;
454
+ cumTotal += total;
455
+ cumCacheRead += cacheRead;
456
+ cumCacheWrite += cacheWrite;
457
+ // A long autonomous run can end on a degenerate assistant message with
458
+ // zero tokens; skip it so the card reflects the last real step.
459
+ if (total !== 0) {
460
+ latest = {
461
+ inputTokens: tokens.input,
462
+ outputTokens: tokens.output,
463
+ contextLength: tokens.input + cacheRead + cacheWrite,
464
+ cacheReadTokens: cacheRead,
465
+ cacheCreationTokens: cacheWrite,
466
+ totalTokens: total,
467
+ };
468
+ }
469
+ }
470
+ if (latest) {
471
+ latest.cumulativeTotalTokens = cumTotal;
472
+ latest.cumulativeInputTokens = cumInput;
473
+ latest.cumulativeOutputTokens = cumOutput;
474
+ latest.cumulativeCacheReadTokens = cumCacheRead;
475
+ latest.cumulativeCacheCreationTokens = cumCacheWrite;
476
+ }
477
+ else if (cumInput || cumOutput || cumTotal || cumCacheRead || cumCacheWrite) {
478
+ latest = {
479
+ inputTokens: 0,
480
+ outputTokens: 0,
481
+ contextLength: 0,
482
+ cumulativeTotalTokens: cumTotal,
483
+ cumulativeInputTokens: cumInput,
484
+ cumulativeOutputTokens: cumOutput,
485
+ cumulativeCacheReadTokens: cumCacheRead,
486
+ cumulativeCacheCreationTokens: cumCacheWrite,
487
+ };
488
+ }
489
+ return {
490
+ events: capEvents(events, opts?.maxEvents),
491
+ usage: latest,
492
+ aiTitle,
493
+ displayTitle,
494
+ };
495
+ }
279
496
  /**
280
497
  * Check if a session is active (updated within 1 hour).
281
498
  * Passes cwd to fetchSessionList so it returns sessions for the correct directory.
@@ -315,8 +532,14 @@ export class OpencodeSessionReader {
315
532
  // 的 .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析会直接 ENOENT,
316
533
  // 会话列表永远为空;cross-spawn 走与 runner spawn 相同的垫片执行路径
317
534
  // (posix 为 child_process.spawnSync 直通,行为不变)。
318
- // Pass cwd to execFileSync so opencode CLI returns sessions for the correct directory
319
- const res = spawnProcessSync(this.binary, ['session', 'list', '--format', 'json'], {
535
+ // Pass cwd to the CLI so opencode returns sessions for the correct directory.
536
+ //
537
+ // v2 runs CLI commands through a shared daemon and defaults the list to
538
+ // 100 entries. `--standalone` keeps this read-only path independent from
539
+ // the user's daemon state; `--max-count` preserves the reader's "all
540
+ // sessions in this cwd" contract. v1 accepts the same list/flags but not
541
+ // `--standalone`, so a failed first attempt falls back to the bare form.
542
+ const listOpts = {
320
543
  encoding: 'utf-8',
321
544
  timeout: 10000,
322
545
  // default maxBuffer is 1MiB — a large session list (>1MiB JSON)
@@ -325,7 +548,31 @@ export class OpencodeSessionReader {
325
548
  maxBuffer: 64 * 1024 * 1024,
326
549
  cwd: realCwd, // Key fix: pass user cwd to CLI
327
550
  windowsHide: true, // opencode 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
328
- });
551
+ };
552
+ const v2Args = [
553
+ 'session',
554
+ 'list',
555
+ '--format',
556
+ 'json',
557
+ '--max-count',
558
+ String(MAX_SESSION_LIST_COUNT),
559
+ '--standalone',
560
+ ];
561
+ const v1Args = [
562
+ 'session',
563
+ 'list',
564
+ '--format',
565
+ 'json',
566
+ '--max-count',
567
+ String(MAX_SESSION_LIST_COUNT),
568
+ ];
569
+ let res = spawnProcessSync(this.binary, v2Args, listOpts);
570
+ // `--standalone` is v2-only and can also fail if the private server is
571
+ // temporarily unavailable. Retry once without it: this is the exact v1
572
+ // command shape and also uses the shared daemon on v2.
573
+ if (res.error || res.status !== 0) {
574
+ res = spawnProcessSync(this.binary, v1Args, listOpts);
575
+ }
329
576
  if (res.error) {
330
577
  const enoent = res.error.code === 'ENOENT';
331
578
  if (enoent)
@@ -362,7 +609,7 @@ export class OpencodeSessionReader {
362
609
  }
363
610
  }
364
611
  /**
365
- * Capture raw `opencode export <sessionId>` output (JSON string). Injectable
612
+ * Capture raw export output (JSON string). Injectable
366
613
  * via the constructor; the default routes stdout to a temp file fd to bypass
367
614
  * opencode's ~128KB pipe truncation on large sessions.
368
615
  */
@@ -373,27 +620,59 @@ export class OpencodeSessionReader {
373
620
  return this.captureExportViaFileFd(sessionId);
374
621
  }
375
622
  /**
376
- * default transport: run `opencode export` with stdout pointed at a
623
+ * default transport: run the version-appropriate export command with stdout
624
+ * pointed at a
377
625
  * regular-file fd (NOT a pipe). opencode truncates piped stdout at ~128KB for
378
626
  * large sessions (huge tool_results), breaking JSON.parse; a file fd receives
379
- * the full output (verified: 1.3MB parses OK). execFileSync returns null when
380
- * stdout is not a pipe, so we read the file instead.
627
+ * the full output (verified: 1.3MB parses OK). spawnProcessSync returns null
628
+ * stdout when it is redirected to a file fd, so we read the file instead.
381
629
  */
382
630
  captureExportViaFileFd(sessionId) {
383
631
  const tmp = path.join(os.tmpdir(), `opencode-export-${sessionId}-${process.pid}-${Date.now()}.json`);
384
632
  let fd;
385
633
  try {
386
- fd = fs.openSync(tmp, 'w');
387
- spawnProcessSync(this.binary, ['export', sessionId], {
634
+ const exportOpts = {
388
635
  encoding: 'utf-8',
389
636
  timeout: 30000,
390
- stdio: ['ignore', fd, 'pipe'],
391
637
  // stderr is piped here; without maxBuffer a chatty CLI (>1MiB
392
638
  // stderr) would throw ENOBUFS and lose the export. Keep the default
393
639
  // stdout file-fd transport unaffected by raising the pipe cap.
394
640
  maxBuffer: 64 * 1024 * 1024,
395
641
  windowsHide: true,
396
- });
642
+ };
643
+ const run = (args) => {
644
+ if (fd !== undefined) {
645
+ try {
646
+ fs.closeSync(fd);
647
+ }
648
+ catch { }
649
+ }
650
+ fd = fs.openSync(tmp, 'w');
651
+ return spawnProcessSync(this.binary, args, {
652
+ ...exportOpts,
653
+ stdio: ['ignore', fd, 'pipe'],
654
+ });
655
+ };
656
+ // v2 moved export under `session`; v1 only has the top-level command.
657
+ let res = run(['session', 'export', '--standalone', sessionId]);
658
+ if (res.error || res.status !== 0) {
659
+ // v2 without a private server may still work through the shared
660
+ // daemon; on v1 this is another unknown-subcommand response.
661
+ res = run(['session', 'export', sessionId]);
662
+ }
663
+ // Last resort: v1's top-level command. Skip only when the server has
664
+ // already told us the session does not exist (a syntax fallback cannot
665
+ // help and would only add another spawn).
666
+ if ((res.error || res.status !== 0) && !/Session not found/i.test(String(res.stderr ?? ''))) {
667
+ res = run(['export', sessionId]);
668
+ }
669
+ if (res.error) {
670
+ throw res.error;
671
+ }
672
+ if (res.status !== 0) {
673
+ const stderr = String(res.stderr ?? '').trim();
674
+ getLogger().warn(`[opencode-session-reader] session export exited ${res.status}${stderr ? `: ${stderr}` : ''}`);
675
+ }
397
676
  return fs.readFileSync(tmp, 'utf-8');
398
677
  }
399
678
  finally {
@@ -124,8 +124,12 @@ function findSessionFile(sessionId, cwd, sessionsDir) {
124
124
  try {
125
125
  const files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
126
126
  for (const f of files) {
127
- // pi filenames: <timestamp>_<uuid>.jsonl — match by sessionId (uuid) suffix
128
- if (f.includes(sessionId)) {
127
+ // pi filenames: <timestamp>_<uuid>.jsonl — the session id is the exact
128
+ // uuid suffix. `includes()` would also match an id that is a prefix /
129
+ // substring of another session's uuid, so
130
+ // require the trailing `_<id>.jsonl` form (files without the timestamp
131
+ // prefix keep the plain `<id>.jsonl` form).
132
+ if (f === `${sessionId}.jsonl` || f.endsWith(`_${sessionId}.jsonl`)) {
129
133
  return path.join(dir, f);
130
134
  }
131
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -99,6 +99,7 @@
99
99
  "cross-spawn": "^7.0.6",
100
100
  "form-data": "^4.0.6",
101
101
  "qrcode-generator": "^2.0.4",
102
+ "ws": "^8.21.2",
102
103
  "yaml": "^2.9.0",
103
104
  "zod": "^4.4.3"
104
105
  },
@@ -106,6 +107,7 @@
106
107
  "@eslint/js": "^10.0.1",
107
108
  "@types/cross-spawn": "^6.0.6",
108
109
  "@types/node": "^26.1.2",
110
+ "@types/ws": "^8.18.1",
109
111
  "@vitest/coverage-v8": "4.1.10",
110
112
  "eslint": "^10.7.0",
111
113
  "eslint-config-prettier": "^10.1.8",