lark-remote 0.1.13 → 0.2.1

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 (132) hide show
  1. package/CHANGELOG.md +36 -3
  2. package/README.en.md +7 -11
  3. package/README.md +7 -11
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/index.d.ts +0 -7
  7. package/dist/bridge/index.js +22 -64
  8. package/dist/bridge/queue-manager.js +4 -3
  9. package/dist/card/approval-render.js +39 -94
  10. package/dist/card/bash-renderer.js +12 -21
  11. package/dist/card/card-shared.d.ts +13 -1
  12. package/dist/card/card-shared.js +21 -30
  13. package/dist/card/run-renderer.d.ts +0 -22
  14. package/dist/card/run-renderer.js +206 -153
  15. package/dist/card/run-state.d.ts +29 -1
  16. package/dist/card/run-state.js +172 -84
  17. package/dist/card/text-truncate.d.ts +0 -9
  18. package/dist/card/text-truncate.js +0 -9
  19. package/dist/card/tool-render.d.ts +4 -1
  20. package/dist/card/tool-render.js +76 -32
  21. package/dist/cli.js +2 -2
  22. package/dist/common/fs.d.ts +14 -0
  23. package/dist/common/fs.js +57 -3
  24. package/dist/config/codex-config.js +2 -0
  25. package/dist/config/index.d.ts +0 -5
  26. package/dist/config/index.js +0 -11
  27. package/dist/config/kimi-config.js +1 -0
  28. package/dist/config/opencode-config.js +13 -2
  29. package/dist/config/qr.d.ts +36 -0
  30. package/dist/config/qr.js +124 -0
  31. package/dist/config/wizard.js +69 -27
  32. package/dist/connector/index.d.ts +3 -2
  33. package/dist/connector/index.js +5 -4
  34. package/dist/index.js +15 -42
  35. package/dist/instance-lock.js +7 -0
  36. package/dist/persistence/atomic-write.d.ts +2 -0
  37. package/dist/persistence/atomic-write.js +20 -5
  38. package/dist/persistence/load-json-file.d.ts +1 -2
  39. package/dist/persistence/load-json-file.js +2 -3
  40. package/dist/platform/agent-stopper.d.ts +22 -0
  41. package/dist/platform/agent-stopper.js +19 -0
  42. package/dist/platform/command.d.ts +11 -0
  43. package/dist/platform/command.js +107 -0
  44. package/dist/platform/identity.d.ts +33 -0
  45. package/dist/platform/identity.js +187 -0
  46. package/dist/platform/path.d.ts +24 -0
  47. package/dist/platform/path.js +110 -0
  48. package/dist/platform/select.d.ts +9 -0
  49. package/dist/platform/select.js +12 -0
  50. package/dist/platform/shell.d.ts +51 -0
  51. package/dist/platform/shell.js +75 -0
  52. package/dist/platform/spawn.d.ts +41 -0
  53. package/dist/platform/spawn.js +77 -0
  54. package/dist/platform/terminator-posix.d.ts +38 -0
  55. package/dist/platform/terminator-posix.js +103 -0
  56. package/dist/platform/terminator-win32.d.ts +21 -0
  57. package/dist/platform/terminator-win32.js +110 -0
  58. package/dist/platform/terminator.d.ts +44 -0
  59. package/dist/platform/terminator.js +24 -0
  60. package/dist/platform/types.d.ts +23 -0
  61. package/dist/platform/types.js +5 -0
  62. package/dist/restart.js +4 -2
  63. package/dist/router/card-helpers.d.ts +33 -6
  64. package/dist/router/card-helpers.js +67 -11
  65. package/dist/router/config/common/render.d.ts +1 -1
  66. package/dist/router/config/common/render.js +1 -1
  67. package/dist/router/config-diff.d.ts +4 -0
  68. package/dist/router/config-diff.js +70 -0
  69. package/dist/router/index.d.ts +66 -6
  70. package/dist/router/index.js +451 -316
  71. package/dist/router/utils.d.ts +6 -0
  72. package/dist/router/utils.js +12 -0
  73. package/dist/runner/bash/runner.d.ts +9 -2
  74. package/dist/runner/bash/runner.js +45 -20
  75. package/dist/runner/claude/session.d.ts +4 -1
  76. package/dist/runner/claude/session.js +18 -4
  77. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  78. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  79. package/dist/runner/codex/app-server/translator.js +146 -0
  80. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  81. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  82. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  83. package/dist/runner/common/acp/base-translator.js +271 -0
  84. package/dist/runner/common/acp/protocol-helpers.d.ts +39 -0
  85. package/dist/runner/common/acp/protocol-helpers.js +34 -0
  86. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  87. package/dist/runner/common/jsonrpc/transport.js +25 -5
  88. package/dist/runner/common/process-stopper.d.ts +4 -26
  89. package/dist/runner/common/process-stopper.js +4 -76
  90. package/dist/runner/common/runner-utils.d.ts +0 -5
  91. package/dist/runner/common/runner-utils.js +0 -7
  92. package/dist/runner/common/spawning-runner.d.ts +42 -132
  93. package/dist/runner/common/spawning-runner.js +52 -271
  94. package/dist/runner/common/usage.d.ts +2 -0
  95. package/dist/runner/common/usage.js +2 -0
  96. package/dist/runner/index.d.ts +1 -1
  97. package/dist/runner/kimi/acp/runner.d.ts +45 -42
  98. package/dist/runner/kimi/acp/runner.js +140 -293
  99. package/dist/runner/kimi/acp/translator.d.ts +18 -100
  100. package/dist/runner/kimi/acp/translator.js +56 -242
  101. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  102. package/dist/runner/opencode/acp/runner.js +73 -256
  103. package/dist/runner/opencode/acp/translator.d.ts +28 -103
  104. package/dist/runner/opencode/acp/translator.js +51 -236
  105. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  106. package/dist/runner/pi/rpc/translator.js +47 -9
  107. package/dist/runner/probe.d.ts +8 -11
  108. package/dist/runner/probe.js +17 -21
  109. package/dist/runner/registry.d.ts +4 -4
  110. package/dist/runner/registry.js +4 -4
  111. package/dist/runner/types.d.ts +38 -1
  112. package/dist/session/claude/sessions.js +35 -58
  113. package/dist/session/codex/rollout-reader.d.ts +10 -0
  114. package/dist/session/codex/rollout-reader.js +123 -41
  115. package/dist/session/common/constants.d.ts +7 -0
  116. package/dist/session/common/constants.js +9 -0
  117. package/dist/session/common/two-pass.d.ts +22 -0
  118. package/dist/session/common/two-pass.js +70 -0
  119. package/dist/session/dsh/sessions.js +7 -6
  120. package/dist/session/kimi/sessions.d.ts +6 -0
  121. package/dist/session/kimi/sessions.js +50 -73
  122. package/dist/session/opencode/sessions.js +26 -8
  123. package/dist/session/pi/sessions.js +54 -73
  124. package/dist/update/index.d.ts +1 -2
  125. package/dist/update/index.js +1 -2
  126. package/dist/update/install.d.ts +2 -1
  127. package/dist/update/install.js +58 -9
  128. package/dist/update/version-check.d.ts +5 -0
  129. package/dist/update/version-check.js +9 -0
  130. package/package.json +9 -6
  131. package/dist/update/startup-hint.d.ts +0 -5
  132. package/dist/update/startup-hint.js +0 -11
@@ -12,18 +12,19 @@
12
12
  * readSessionContent: session.history → map SessionEvents to display events +
13
13
  * session-wide usage accumulation (assistant/message.usage).
14
14
  */
15
- import { spawnSync } from 'node:child_process';
15
+ import { spawnProcessSync } from '../../platform/spawn.js';
16
16
  import { cumulativeUsageFields } from '../common/usage-accumulator.js';
17
17
  import { sortByRecencyDesc } from '../common/recency.js';
18
18
  import { getLogger } from '../../logger/index.js';
19
19
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
20
+ import { samePath } from '../../platform/path.js';
20
21
  import { capEvents, paginate } from '../common/pagination.js';
21
22
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
22
23
  /**
23
24
  * CC-05: history 拉取上限。DSH session.history 的 maxMessages 同时限制返回条数,
24
25
  * 若用它同时充当展示截断,>50 事件的会话累计 usage 会少算。这里拉取一个足够大的
25
26
  * 上限保证 usage 累加覆盖整个会话;展示仍由 capEvents(opts?.maxEvents ?? 50) 截断。
26
- * (若 DSH 支持分页 cursor 可改成分页,当前用有界大拉取,见 review-fix-plan-cc.md)
27
+ * (若 DSH 支持分页 cursor 可改成分页,当前用有界大拉取)
27
28
  */
28
29
  const DSH_HISTORY_FETCH_LIMIT = 10_000;
29
30
  /** Synchronous unary POST via curl (the reader interface is synchronous). */
@@ -34,7 +35,7 @@ function defaultSyncRequest(baseUrl, method, payload) {
34
35
  method,
35
36
  payload,
36
37
  });
37
- const res = spawnSync('curl', [
38
+ const res = spawnProcessSync('curl', [
38
39
  '-s',
39
40
  '-X',
40
41
  'POST',
@@ -49,7 +50,7 @@ function defaultSyncRequest(baseUrl, method, payload) {
49
50
  }
50
51
  let parsed;
51
52
  try {
52
- parsed = JSON.parse(res.stdout);
53
+ parsed = JSON.parse(String(res.stdout));
53
54
  }
54
55
  catch (err) {
55
56
  throw new Error(`DSH ${method} returned non-JSON: ${err.message}`, {
@@ -82,7 +83,7 @@ export class DshSessionReader {
82
83
  const value = this.unary('session.list', {});
83
84
  const items = value.items ?? [];
84
85
  const filtered = items
85
- .filter((i) => i.cwd === cwd)
86
+ .filter((i) => i.cwd !== undefined && samePath(i.cwd, cwd))
86
87
  .map((i) => ({
87
88
  sessionId: String(i.sessionId),
88
89
  summary: '',
@@ -173,7 +174,7 @@ export class DshSessionReader {
173
174
  isSessionActive(sessionId, cwd) {
174
175
  try {
175
176
  const value = this.unary('session.list', {});
176
- const found = (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd === cwd);
177
+ const found = (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd !== undefined && samePath(i.cwd, cwd));
177
178
  return found?.running === true;
178
179
  }
179
180
  catch (error) {
@@ -117,6 +117,12 @@ export declare class KimiSessionReader implements AgentSessionReader {
117
117
  inFlightSince?: number;
118
118
  records: KimiCompactionRecord[];
119
119
  };
120
+ /**
121
+ * W2.5 单源:resolveSessionWirePath 与 isSessionActive 共用的 cwd 守卫
122
+ * (realpathSync → state.json workDir/cwd 三态 → index workDir 兜底 →
123
+ * fail-closed)。返回 sessionDir;会话未知或守卫不过返回 null。
124
+ */
125
+ private verifySessionCwd;
120
126
  /**
121
127
  * Resolve the main wire.jsonl path for a session after passing the cwd
122
128
  * guard (state.json + session index fallback). Returns null when the
@@ -4,7 +4,8 @@ import os from 'node:os';
4
4
  import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../common/jsonl.js';
5
5
  import { UsageAccumulator, cumulativeUsageFields } from '../common/usage-accumulator.js';
6
6
  import { getLogger } from '../../logger/index.js';
7
- import { STALE_MS } from '../common/constants.js';
7
+ import { isStale } from '../common/constants.js';
8
+ import { samePath } from '../../platform/path.js';
8
9
  import { capEvents, paginate } from '../common/pagination.js';
9
10
  import { sortByRecencyDesc } from '../common/recency.js';
10
11
  import { TtlCache } from '../../common/ttl-cache.js';
@@ -124,7 +125,7 @@ export function checkCwdGuard(state, realCwd) {
124
125
  const sessionWorkDir = extractWorkDir(state);
125
126
  if (sessionWorkDir === undefined)
126
127
  return 'unverifiable';
127
- if (sessionWorkDir === realCwd)
128
+ if (samePath(sessionWorkDir, realCwd))
128
129
  return 'verified';
129
130
  return 'failed';
130
131
  }
@@ -209,35 +210,36 @@ export class KimiSessionReader {
209
210
  const realCwd = fs.realpathSync(cwd);
210
211
  const sessions = [];
211
212
  for (const entry of this.scanSessionIndex()) {
212
- // Index workDir is v1-only; v2 sessions may have empty/missing workDir.
213
- // When index workDir is absent, fall back to state.json's cwd field.
214
- let entryWorkDir = entry.workDir;
215
- if (!entryWorkDir) {
216
- const statePath = path.join(entry.sessionDir, 'state.json');
213
+ // W2.7:state.json 一次读取共用(cwd 兜底 + summary),I/O 减半。
214
+ // 两次 catch 语义保留:解析失败 = 无法判 cwd → 跳过(continue);
215
+ // 解析成功但字段缺失 = summary 落 wire.jsonl 提取。
216
+ const statePath = path.join(entry.sessionDir, 'state.json');
217
+ let state = null;
218
+ let stateParsed = false;
219
+ if (fs.existsSync(statePath)) {
217
220
  try {
218
- if (fs.existsSync(statePath)) {
219
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
220
- entryWorkDir = extractWorkDir(state);
221
- }
221
+ state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
222
+ stateParsed = true;
222
223
  }
223
224
  catch {
224
- // Unreadable state — cannot determine cwd, skip
225
+ // Corrupt state.json — cannot determine cwd → skip below
225
226
  }
226
227
  }
228
+ // Index workDir is v1-only; v2 sessions may have empty/missing workDir.
229
+ // When index workDir is absent, fall back to state.json's cwd field.
230
+ let entryWorkDir = entry.workDir;
231
+ if (!entryWorkDir) {
232
+ if (stateParsed && state)
233
+ entryWorkDir = extractWorkDir(state);
234
+ // Unreadable state — cannot determine cwd, entryWorkDir stays undefined
235
+ }
227
236
  if (entryWorkDir !== realCwd) {
228
237
  continue;
229
238
  }
230
- const statePath = path.join(entry.sessionDir, 'state.json');
231
239
  let summary = 'New Session';
232
- if (fs.existsSync(statePath)) {
233
- try {
234
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
235
- // v1: state.title / state.lastPrompt; v2: these fields may be absent
236
- summary = state.title || state.lastPrompt || '';
237
- }
238
- catch {
239
- // Corrupt state.json — fall through to wire.jsonl extraction
240
- }
240
+ if (stateParsed && state) {
241
+ // v1: state.title / state.lastPrompt; v2: these fields may be absent
242
+ summary = state.title || state.lastPrompt || '';
241
243
  }
242
244
  // v2 state.json omits title/lastPrompt: extract from wire.jsonl's last
243
245
  // turn.prompt instead. Only scan a small tail to find it — the summary
@@ -549,11 +551,11 @@ export class KimiSessionReader {
549
551
  };
550
552
  }
551
553
  /**
552
- * Resolve the main wire.jsonl path for a session after passing the cwd
553
- * guard (state.json + session index fallback). Returns null when the
554
- * session is unknown, fails the guard, or has no wire file.
554
+ * W2.5 单源:resolveSessionWirePath 与 isSessionActive 共用的 cwd 守卫
555
+ * (realpathSync → state.json workDir/cwd 三态 → index workDir 兜底 →
556
+ * fail-closed)。返回 sessionDir;会话未知或守卫不过返回 null。
555
557
  */
556
- resolveSessionWirePath(sessionId, cwd) {
558
+ verifySessionCwd(sessionId, cwd) {
557
559
  // Find session directory from index (also retrieve index entry for
558
560
  // fallback cwd guard when state.json is missing or unparseable)
559
561
  const indexEntry = this.findSessionIndexEntry(sessionId);
@@ -561,7 +563,6 @@ export class KimiSessionReader {
561
563
  if (!sessionDir) {
562
564
  return null;
563
565
  }
564
- const statePath = path.join(sessionDir, 'state.json');
565
566
  let realCwd;
566
567
  try {
567
568
  realCwd = fs.realpathSync(cwd);
@@ -571,6 +572,7 @@ export class KimiSessionReader {
571
572
  return null;
572
573
  }
573
574
  let cwdGuardPassed = false;
575
+ const statePath = path.join(sessionDir, 'state.json');
574
576
  try {
575
577
  if (fs.existsSync(statePath)) {
576
578
  const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
@@ -578,13 +580,12 @@ export class KimiSessionReader {
578
580
  if (guardResult === 'failed') {
579
581
  return null;
580
582
  }
581
- if (guardResult === 'unverifiable') {
582
- getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
583
- // Fall through to index-based guard below
583
+ if (guardResult === 'verified') {
584
+ cwdGuardPassed = true;
584
585
  }
585
586
  else {
586
- // verified — state.json cwd matches
587
- cwdGuardPassed = true;
587
+ getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
588
+ // Fall through to index-based guard below
588
589
  }
589
590
  }
590
591
  }
@@ -610,7 +611,18 @@ export class KimiSessionReader {
610
611
  return null;
611
612
  }
612
613
  }
613
- const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
614
+ return { sessionDir };
615
+ }
616
+ /**
617
+ * Resolve the main wire.jsonl path for a session after passing the cwd
618
+ * guard (state.json + session index fallback). Returns null when the
619
+ * session is unknown, fails the guard, or has no wire file.
620
+ */
621
+ resolveSessionWirePath(sessionId, cwd) {
622
+ const verified = this.verifySessionCwd(sessionId, cwd);
623
+ if (!verified)
624
+ return null;
625
+ const wirePath = path.join(verified.sessionDir, 'agents', 'main', 'wire.jsonl');
614
626
  if (!fs.existsSync(wirePath)) {
615
627
  return null;
616
628
  }
@@ -620,46 +632,11 @@ export class KimiSessionReader {
620
632
  * Check if a session is currently active
621
633
  */
622
634
  isSessionActive(sessionId, cwd) {
623
- const indexEntry = this.findSessionIndexEntry(sessionId);
624
- const sessionDir = indexEntry?.sessionDir;
625
- if (!sessionDir) {
635
+ // W2.5:cwd 守卫与 resolveSessionWirePath 共用 verifySessionCwd(单源)。
636
+ const verified = this.verifySessionCwd(sessionId, cwd);
637
+ if (!verified)
626
638
  return false;
627
- }
628
- // Cwd guard: the session's working directory must match the requested
629
- // cwd (align with claude/pi/opencode). Mirrors readSessionContent's
630
- // fail-closed cwd check (state.json workDir/cwd, then index workDir).
631
- let realCwd;
632
- try {
633
- realCwd = fs.realpathSync(cwd);
634
- }
635
- catch {
636
- return false;
637
- }
638
- let cwdGuardPassed = false;
639
- const statePath = path.join(sessionDir, 'state.json');
640
- try {
641
- if (fs.existsSync(statePath)) {
642
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
643
- const guardResult = checkCwdGuard(state, realCwd);
644
- if (guardResult === 'failed')
645
- return false;
646
- if (guardResult === 'verified')
647
- cwdGuardPassed = true;
648
- }
649
- }
650
- catch {
651
- // state.json unparseable — fall through to index-based guard
652
- }
653
- if (!cwdGuardPassed) {
654
- const indexWorkDir = indexEntry?.workDir;
655
- if (indexWorkDir && indexWorkDir !== realCwd)
656
- return false;
657
- // v2 sessions have empty index workDir (v1-only field) and may lack
658
- // state.json cwd — fail-closed to prevent cross-workspace access.
659
- if (!indexWorkDir)
660
- return false;
661
- }
662
- const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
639
+ const wirePath = path.join(verified.sessionDir, 'agents', 'main', 'wire.jsonl');
663
640
  if (!fs.existsSync(wirePath)) {
664
641
  return false;
665
642
  }
@@ -667,7 +644,7 @@ export class KimiSessionReader {
667
644
  const stat = fs.statSync(wirePath);
668
645
  const now = Date.now();
669
646
  // Check mtime freshness
670
- if (now - stat.mtimeMs > STALE_MS) {
647
+ if (isStale(stat.mtimeMs, now)) {
671
648
  return false;
672
649
  }
673
650
  // Read the tail to check for active indicators. kimi appends
@@ -13,14 +13,15 @@
13
13
  * - isSessionActive: use `updated` timestamp with 1 hour stale window.
14
14
  * - Simple memory cache with TTL.
15
15
  */
16
- import { execFileSync } from 'node:child_process';
16
+ import { spawnProcessSync } from '../../platform/spawn.js';
17
17
  import fs from 'node:fs';
18
18
  import { silentlyUnlink } from '../../common/fs.js';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
22
22
  import { getLogger } from '../../logger/index.js';
23
- import { STALE_MS } from '../common/constants.js';
23
+ import { isStale } from '../common/constants.js';
24
+ import { samePath } from '../../platform/path.js';
24
25
  import { paginate, capEvents } from '../common/pagination.js';
25
26
  import { sortByRecencyDesc } from '../common/recency.js';
26
27
  import { TtlCache } from '../../common/ttl-cache.js';
@@ -46,7 +47,7 @@ export class OpencodeSessionReader {
46
47
  const realCwd = this.realpath(cwd);
47
48
  const entries = this.fetchSessionList(realCwd);
48
49
  // Filter by directory === realpath(cwd)
49
- const filtered = entries.filter((e) => e.directory === realCwd);
50
+ const filtered = entries.filter((e) => samePath(e.directory, realCwd));
50
51
  // Sort by updated descending; same-updated ties use id as a deterministic
51
52
  // secondary key so CLI/list-cache rebuilds never reorder the page.
52
53
  sortByRecencyDesc(filtered, (s) => s.updated, (s) => s.id);
@@ -281,11 +282,11 @@ export class OpencodeSessionReader {
281
282
  const realCwd = this.realpath(cwd);
282
283
  try {
283
284
  const entries = this.fetchSessionList(realCwd);
284
- const entry = entries.find((e) => e.id === sessionId && e.directory === realCwd);
285
+ const entry = entries.find((e) => e.id === sessionId && samePath(e.directory, realCwd));
285
286
  if (!entry)
286
287
  return false;
287
288
  // Check if updated within STALE_MS
288
- return Date.now() - entry.updated < STALE_MS;
289
+ return !isStale(entry.updated);
289
290
  }
290
291
  catch {
291
292
  return false;
@@ -308,8 +309,12 @@ export class OpencodeSessionReader {
308
309
  return cached;
309
310
  }
310
311
  try {
312
+ // spawnProcessSync(cross-spawn)而非 execFileSync:opencode 是 npm 安装
313
+ // 的 .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析会直接 ENOENT,
314
+ // 会话列表永远为空;cross-spawn 走与 runner spawn 相同的垫片执行路径
315
+ // (posix 为 child_process.spawnSync 直通,行为不变)。
311
316
  // Pass cwd to execFileSync so opencode CLI returns sessions for the correct directory
312
- const output = execFileSync(this.binary, ['session', 'list', '--format', 'json'], {
317
+ const res = spawnProcessSync(this.binary, ['session', 'list', '--format', 'json'], {
313
318
  encoding: 'utf-8',
314
319
  timeout: 10000,
315
320
  // P1-15: default maxBuffer is 1MiB — a large session list (>1MiB JSON)
@@ -317,7 +322,19 @@ export class OpencodeSessionReader {
317
322
  // user sees "no sessions" even though the CLI returned healthy data.
318
323
  maxBuffer: 64 * 1024 * 1024,
319
324
  cwd: realCwd, // Key fix: pass user cwd to CLI
320
- }).trim();
325
+ windowsHide: true, // opencode 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
326
+ });
327
+ if (res.error) {
328
+ const enoent = res.error.code === 'ENOENT';
329
+ if (enoent)
330
+ return [];
331
+ throw res.error;
332
+ }
333
+ if (res.status !== 0) {
334
+ const stderr = String(res.stderr ?? '').trim();
335
+ throw new Error(`exit ${res.status}${stderr ? `: ${stderr}` : ''}`);
336
+ }
337
+ const output = String(res.stdout ?? '').trim();
321
338
  // CLI returns empty string when no sessions exist - treat as empty array
322
339
  if (!output) {
323
340
  return [];
@@ -365,7 +382,7 @@ export class OpencodeSessionReader {
365
382
  let fd;
366
383
  try {
367
384
  fd = fs.openSync(tmp, 'w');
368
- execFileSync(this.binary, ['export', sessionId], {
385
+ spawnProcessSync(this.binary, ['export', sessionId], {
369
386
  encoding: 'utf-8',
370
387
  timeout: 30000,
371
388
  stdio: ['ignore', fd, 'pipe'],
@@ -373,6 +390,7 @@ export class OpencodeSessionReader {
373
390
  // stderr) would throw ENOBUFS and lose the export. Keep the default
374
391
  // stdout file-fd transport unaffected by raising the pipe cap.
375
392
  maxBuffer: 64 * 1024 * 1024,
393
+ windowsHide: true,
376
394
  });
377
395
  return fs.readFileSync(tmp, 'utf-8');
378
396
  }
@@ -1,19 +1,27 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { findJsonlLine, scanJsonlLines, readJsonlLinesFromOffset } from '../common/jsonl.js';
4
+ import { findJsonlLine } from '../common/jsonl.js';
5
+ import { scanJsonlOnce, tailOffsetAfter, scanTailEvents } from '../common/two-pass.js';
5
6
  import { extractContentBlocks } from '../common/content-blocks.js';
6
7
  import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from '../common/usage-accumulator.js';
7
- import { STALE_MS } from '../common/constants.js';
8
- import { capEvents, paginate } from '../common/pagination.js';
8
+ import { isStale } from '../common/constants.js';
9
+ import { encodeProjectDirName } from '../../platform/path.js';
10
+ import { paginate } from '../common/pagination.js';
9
11
  import { sortByRecencyDesc } from '../common/recency.js';
10
12
  import { TtlCache } from '../../common/ttl-cache.js';
11
13
  function defaultPiDir() {
12
14
  return path.join(os.homedir(), '.pi', 'agent');
13
15
  }
14
- /** Encode cwd to pi's directory name format: '--<cwd-with-/->- + '-' --' */
16
+ /**
17
+ * Encode cwd to pi's directory name format: '--<cwd with / \ : -> - --'
18
+ *
19
+ * 与 claude 共用 `encodeProjectDirName`(同一套 win32 归一规则),再去掉
20
+ * 前导分隔符产生的首个 `-`:`/Users/x` -> `Users-x`(不是 `-Users-x`)。
21
+ * win32 同理:`C:\Users\x` -> `C--Users-x`。
22
+ */
15
23
  function projectDirForCwd(cwd, sessionsDir) {
16
- const encodedCwd = cwd.replace(/^\//, '').replace(/\//g, '-');
24
+ const encodedCwd = encodeProjectDirName(cwd).replace(/^-+/, '');
17
25
  const encoded = `--${encodedCwd}--`;
18
26
  return path.join(sessionsDir, encoded);
19
27
  }
@@ -143,14 +151,8 @@ function piScalarScan(filePath) {
143
151
  const acc = new UsageAccumulator();
144
152
  let tailOffset = -1;
145
153
  let lastUserText;
146
- scanJsonlLines(filePath, (line, offset) => {
147
- let obj;
148
- try {
149
- obj = JSON.parse(line);
150
- }
151
- catch {
152
- return;
153
- }
154
+ scanJsonlOnce(filePath, (rawObj, line, offset) => {
155
+ const obj = rawObj;
154
156
  if (obj.type === 'message') {
155
157
  const msg = obj.message;
156
158
  if (!msg)
@@ -168,9 +170,9 @@ function piScalarScan(filePath) {
168
170
  }
169
171
  // last user message offset + displayTitle. Record the byte offset
170
172
  // where the tail should start: the beginning of the NEXT line after
171
- // this user message.
173
+ // this user message (see tailOffsetAfter).
172
174
  if (msg.role === 'user') {
173
- tailOffset = offset + Buffer.byteLength(line, 'utf-8') + 1;
175
+ tailOffset = tailOffsetAfter(line, offset);
174
176
  const text = extractPiText(msg.content);
175
177
  // Compress skill-injection bodies so the "最近输入" label shows what
176
178
  // the user invoked (skill:x) + any trailing real input, not a
@@ -219,44 +221,35 @@ function buildPiUsage(acc) {
219
221
  };
220
222
  }
221
223
  /**
222
- * P2-6 + P2-5 second-pass: extract content-block events from the tail
223
- * (lines after the last user message), re-parsing only those lines.
224
- * `tailLines` is already the tail-only slice (read via
225
- * `readJsonlLinesFromOffset`), so both raw line-string memory and parsed-
226
- * object memory are O(tail), not O(whole file) — P2-6 + P2-5.
224
+ * P2-6 + P2-5 second-pass tail mapper: expand one parsed tail line into
225
+ * content-block events. Re-parses only tail lines (scanTailEvents does the
226
+ * offset re-read + line filtering), so both raw line-string memory and
227
+ * parsed-object memory are O(tail), not O(whole file) — P2-6 + P2-5.
228
+ * `maxEvents` capping is applied by scanTailEvents.
227
229
  */
228
- function extractPiEventsFromTail(tailLines) {
230
+ function mapPiTailLine(rawObj) {
229
231
  const events = [];
230
- for (const line of tailLines) {
231
- if (!line.trim())
232
- continue;
233
- try {
234
- const obj = JSON.parse(line);
235
- if (obj.type !== 'message' || !obj.message)
236
- continue;
237
- const message = obj.message;
238
- // Handle error messages: when stopReason is "error", content is empty
239
- // but errorMessage has details
240
- if (message.stopReason === 'error' && message.errorMessage) {
241
- events.push({
242
- type: 'error',
243
- content: `❌ ${message.errorMessage}`,
244
- timestamp: obj.timestamp,
245
- });
246
- continue;
247
- }
248
- const blocks = extractContentBlocks(message.content, PI_MAPPING);
249
- const role = message.role ?? 'unknown';
250
- for (const block of blocks) {
251
- events.push({ type: block.type, content: block.content, timestamp: obj.timestamp });
252
- }
253
- if (blocks.length === 0 && message.role) {
254
- events.push({ type: role, content: `(${role} event)`, timestamp: obj.timestamp });
255
- }
256
- }
257
- catch {
258
- /* skip */
259
- }
232
+ const obj = rawObj;
233
+ if (obj.type !== 'message' || !obj.message)
234
+ return events;
235
+ const message = obj.message;
236
+ // Handle error messages: when stopReason is "error", content is empty
237
+ // but errorMessage has details
238
+ if (message.stopReason === 'error' && message.errorMessage) {
239
+ events.push({
240
+ type: 'error',
241
+ content: `❌ ${message.errorMessage}`,
242
+ timestamp: obj.timestamp,
243
+ });
244
+ return events;
245
+ }
246
+ const blocks = extractContentBlocks(message.content, PI_MAPPING);
247
+ const role = message.role ?? 'unknown';
248
+ for (const block of blocks) {
249
+ events.push({ type: block.type, content: block.content, timestamp: obj.timestamp });
250
+ }
251
+ if (blocks.length === 0 && message.role) {
252
+ events.push({ type: role, content: `(${role} event)`, timestamp: obj.timestamp });
260
253
  }
261
254
  return events;
262
255
  }
@@ -297,29 +290,17 @@ export class PiSessionReader {
297
290
  if (fileCwd && fileCwd !== cwd) {
298
291
  return { events: [] };
299
292
  }
300
- // P2-5 + P2-6: First pass STREAMS the file once via `scanJsonlLines`
301
- // (no `string[]` materialized), parsing each line once to collect
302
- // scalars (usage, tailOffset, displayTitle) while retaining no parsed
303
- // objects and no whole-file line array. The tailOffset is the byte
304
- // offset where the tail begins (start of the line after the last user
305
- // message). A second pass re-reads ONLY the tail from that offset via
306
- // `readJsonlLinesFromOffset` and re-parses it for events — O(tail)
307
- // memory for both raw line strings and parsed objects, instead of
308
- // O(whole file).
309
- //
310
- // Parse ratio: ≈1.0–1.5× line count when a user message exists.
311
- // When the session has NO user message, tailOffset stays -1 and the
312
- // tail IS the whole file → scan(N) + tail(N) = 2.0× — the known
313
- // asymptotic upper bound of the two-phase design.
293
+ // P2-5 + P2-6: Two-pass scan (see `scanJsonlOnce` / `scanTailEvents` in
294
+ // session/common/two-pass.ts): pass 1 streams the file once collecting
295
+ // scalars (usage, tailOffset, displayTitle) with O(1) parsed-object
296
+ // retention; pass 2 re-reads ONLY the tail and re-parses it for events,
297
+ // capped at maxEvents (keeps the LAST N events, matching
298
+ // CodexSessionReader's slice(-maxEvents), so auto-resume cards don't
299
+ // load the entire session).
314
300
  const scan = piScalarScan(filePath);
315
- const tailLines = readJsonlLinesFromOffset(filePath, scan.tailOffset >= 0 ? scan.tailOffset : 0);
316
- const events = extractPiEventsFromTail(tailLines);
317
- // Apply maxEvents cap: keep the LAST N events (most recent), matching
318
- // CodexSessionReader's slice(-maxEvents). maxEvents limits the catch-up
319
- // tail so auto-resume cards don't load the entire session.
320
- const cappedEvents = capEvents(events, opts?.maxEvents);
301
+ const events = scanTailEvents(filePath, scan.tailOffset, mapPiTailLine, opts?.maxEvents);
321
302
  return {
322
- events: cappedEvents,
303
+ events,
323
304
  usage: scan.usage,
324
305
  displayTitle: scan.displayTitle,
325
306
  };
@@ -330,7 +311,7 @@ export class PiSessionReader {
330
311
  return false;
331
312
  try {
332
313
  const st = fs.statSync(filePath);
333
- return Date.now() - st.mtimeMs < STALE_MS;
314
+ return !isStale(st.mtimeMs);
334
315
  }
335
316
  catch {
336
317
  return false;
@@ -5,6 +5,5 @@
5
5
  * package installation (detect package manager + global install),
6
6
  * and the combined workflow for the /update command and --update CLI flag.
7
7
  */
8
- export { isNewer, checkLatestVersion, type VersionCheckResult } from './version-check.js';
8
+ export { isNewer, checkLatestVersion, formatUpdateHint, type VersionCheckResult, } from './version-check.js';
9
9
  export { runInstallLatest, type InstallResult } from './install.js';
10
- export { formatUpdateHint } from './startup-hint.js';
@@ -5,7 +5,6 @@
5
5
  * package installation (detect package manager + global install),
6
6
  * and the combined workflow for the /update command and --update CLI flag.
7
7
  */
8
- export { isNewer, checkLatestVersion } from './version-check.js';
8
+ export { isNewer, checkLatestVersion, formatUpdateHint, } from './version-check.js';
9
9
  export { runInstallLatest } from './install.js';
10
- export { formatUpdateHint } from './startup-hint.js';
11
10
  //# sourceMappingURL=index.js.map
@@ -38,7 +38,8 @@ export declare function detectPackageManager(scriptPath?: string): PackageManage
38
38
  * Run the global install command to upgrade lark-remote to the latest version.
39
39
  *
40
40
  * @param opts.packageManager - Override detected package manager
41
- * @param opts.execFn - Override execFile for testing
41
+ * @param opts.execFn - Override exec for testing; default routes through
42
+ * platform/spawn.ts (cross-spawn, win32 .cmd shim safe)
42
43
  */
43
44
  export declare function runInstallLatest(opts?: {
44
45
  packageManager?: PackageManager | null;