linkshell-cli 0.5.25 → 0.5.27

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.
@@ -1453,6 +1453,75 @@ function runningCodexSessionIds(nowMs: number): Set<string> {
1453
1453
  return running;
1454
1454
  }
1455
1455
 
1456
+ /** Read a Codex session's last-known settings (reasoning effort, plan mode,
1457
+ * permission/sandbox) from its on-disk rollout, so opening a session LinkShell
1458
+ * never drove shows the config it actually ran with. Codex records the live
1459
+ * turn config in `turn_context` records; the last one is the current config.
1460
+ * Matched by the filename session id (cheap — no per-file reads to locate it),
1461
+ * newest first. Only called on open (one file), never per-list. */
1462
+ function codexSessionConfig(sessionId: string): {
1463
+ reasoningEffort?: string;
1464
+ permissionMode?: AgentPermissionMode;
1465
+ collaborationMode?: AgentCollaborationMode;
1466
+ } {
1467
+ const root = join(homedir(), ".codex", "sessions");
1468
+ if (!existsSync(root)) return {};
1469
+ const ID_RE = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1470
+ const matches: { path: string; mt: number }[] = [];
1471
+ const walk = (dir: string): void => {
1472
+ let entries: string[];
1473
+ try { entries = readdirSync(dir); } catch { return; }
1474
+ for (const name of entries) {
1475
+ const path = join(dir, name);
1476
+ let stat;
1477
+ try { stat = statSync(path); } catch { continue; }
1478
+ if (stat.isDirectory()) { walk(path); continue; }
1479
+ const match = ID_RE.exec(name);
1480
+ if (match && match[1] === sessionId) matches.push({ path, mt: stat.mtimeMs });
1481
+ }
1482
+ };
1483
+ walk(root);
1484
+ if (matches.length === 0) return {};
1485
+ matches.sort((a, b) => b.mt - a.mt);
1486
+ const newest = matches[0];
1487
+ if (!newest) return {};
1488
+ let lastTurnContext: Record<string, unknown> | undefined;
1489
+ try {
1490
+ const text = readFileSync(newest.path, "utf8");
1491
+ for (const line of text.split(/\r?\n/)) {
1492
+ if (!line.trim()) continue;
1493
+ let record: Record<string, unknown> | undefined;
1494
+ try { record = asRecord(JSON.parse(line)); } catch { continue; }
1495
+ if (record?.type === "turn_context") {
1496
+ lastTurnContext = asRecord(record.payload) ?? record;
1497
+ }
1498
+ }
1499
+ } catch {
1500
+ return {};
1501
+ }
1502
+ if (!lastTurnContext) return {};
1503
+ const cfg: { reasoningEffort?: string; permissionMode?: AgentPermissionMode; collaborationMode?: AgentCollaborationMode } = {};
1504
+ const collab = asRecord(lastTurnContext.collaboration_mode);
1505
+ const effort = firstString(asRecord(collab?.settings), ["reasoning_effort"]);
1506
+ if (effort) cfg.reasoningEffort = effort;
1507
+ const mode = firstString(collab, ["mode"]);
1508
+ if (mode === "plan") cfg.collaborationMode = "plan";
1509
+ else if (mode) cfg.collaborationMode = "default";
1510
+ // Map Codex's sandbox policy (kebab-case, e.g. "danger-full-access") to ours.
1511
+ const sandbox = firstString(asRecord(lastTurnContext.sandbox_policy), ["type"]);
1512
+ if (sandbox) {
1513
+ const norm = sandbox.replace(/-/g, "").toLowerCase();
1514
+ cfg.permissionMode = norm.includes("readonly")
1515
+ ? "read_only"
1516
+ : norm.includes("workspace")
1517
+ ? "workspace_write"
1518
+ : norm.includes("danger") || norm.includes("full")
1519
+ ? "full_access"
1520
+ : undefined;
1521
+ }
1522
+ return cfg;
1523
+ }
1524
+
1456
1525
  function parseRemoteSessions(value: unknown): Array<{
1457
1526
  id: string;
1458
1527
  cwd?: string;
@@ -2369,6 +2438,34 @@ export class AgentWorkspaceProxy {
2369
2438
  const model = firstString(thread, ["model", "modelId", "currentModel"]);
2370
2439
  if (model && !conversation.model) conversation.model = model;
2371
2440
 
2441
+ // Fill the control settings (reasoning effort / permission / plan mode) from
2442
+ // the on-disk transcript when we don't already have them, so opening a
2443
+ // session LinkShell never drove shows what it actually ran with instead of
2444
+ // the UI's first-option default. Existing (LinkShell-set) values win — this
2445
+ // only backfills. One file read, on open only; never per-list.
2446
+ if (
2447
+ conversation.reasoningEffort === undefined &&
2448
+ conversation.permissionMode === undefined &&
2449
+ conversation.collaborationMode === undefined
2450
+ ) {
2451
+ try {
2452
+ let cfg: { reasoningEffort?: string; permissionMode?: AgentPermissionMode; collaborationMode?: AgentCollaborationMode } = {};
2453
+ if (conversation.provider === "codex") {
2454
+ cfg = codexSessionConfig(conversation.agentSessionId);
2455
+ } else {
2456
+ const readConfig = (client as { readSessionConfig?: (i: { sessionId: string }) => Promise<unknown> }).readSessionConfig;
2457
+ if (typeof readConfig === "function") {
2458
+ cfg = (asRecord(await readConfig.call(client, { sessionId: conversation.agentSessionId })) ?? {}) as typeof cfg;
2459
+ }
2460
+ }
2461
+ if (cfg.reasoningEffort && !conversation.reasoningEffort) conversation.reasoningEffort = cfg.reasoningEffort;
2462
+ if (cfg.permissionMode && !conversation.permissionMode) conversation.permissionMode = cfg.permissionMode;
2463
+ if (cfg.collaborationMode && !conversation.collaborationMode) conversation.collaborationMode = cfg.collaborationMode;
2464
+ } catch {
2465
+ // Best-effort — a missing/unreadable transcript just leaves the pills at 默认.
2466
+ }
2467
+ }
2468
+
2372
2469
  // Seed a scroll-back cursor for Claude, which hydrates via readSession (full
2373
2470
  // transcript) yet pages via listTurns. The timeline only keeps the last
2374
2471
  // MAX_TIMELINE_ITEMS, so anything older is dropped here but reachable via
@@ -602,6 +602,41 @@ export class ClaudeSdkClient {
602
602
  .find((candidate) => existsSync(candidate));
603
603
  }
604
604
 
605
+ /** Read a session's last-known settings (permission mode, plan mode) straight
606
+ * from its on-disk transcript, so opening a session LinkShell never drove
607
+ * shows the config it actually ran with instead of UI defaults. Claude
608
+ * records `permissionMode` on user records (not reasoning effort), so effort
609
+ * is left undefined. Only called on open (one file), never per-list. */
610
+ async readSessionConfig(input: { sessionId: string }): Promise<unknown> {
611
+ const filePath = this.resolveSessionFile(input.sessionId);
612
+ if (!filePath) return {};
613
+ let permissionModeRaw: string | undefined;
614
+ try {
615
+ const text = readFileSync(filePath, "utf8");
616
+ for (const line of text.split(/\r?\n/)) {
617
+ if (!line.trim()) continue;
618
+ let record: Record<string, unknown> | undefined;
619
+ try { record = asRecord(JSON.parse(line)); } catch { continue; }
620
+ const pm = stringField(record, ["permissionMode"]);
621
+ if (pm) permissionModeRaw = pm; // keep the last one — the current mode
622
+ }
623
+ } catch {
624
+ return {};
625
+ }
626
+ // Map Claude's permission mode to ours. "default" means no explicit
627
+ // override → leave undefined so the pill shows 默认 rather than a guess.
628
+ switch (permissionModeRaw) {
629
+ case "acceptEdits":
630
+ return { permissionMode: "workspace_write" };
631
+ case "bypassPermissions":
632
+ return { permissionMode: "full_access" };
633
+ case "plan":
634
+ return { permissionMode: "read_only", collaborationMode: "plan" };
635
+ default:
636
+ return {};
637
+ }
638
+ }
639
+
605
640
  /** Page OLDER transcript turns for scroll-back, mirroring Codex's
606
641
  * thread/turns/list. The whole transcript lives on disk and is already
607
642
  * parsed by parseClaudeJsonlSession, so we slice a window of chronological