linkshell-cli 0.5.22 → 0.5.24

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.
@@ -1363,6 +1363,96 @@ function parseTimestamp(value: unknown): number | undefined {
1363
1363
  return undefined;
1364
1364
  }
1365
1365
 
1366
+ /** Derive running/idle for a Codex session from its on-disk rollout transcript.
1367
+ * Codex marks turn boundaries with event_msg payloads: task_started opens a
1368
+ * turn, task_complete / turn_aborted close it. token_count / response_item
1369
+ * records trail AFTER task_complete, so we must scan backward to the first
1370
+ * turn-lifecycle event rather than just looking at the last line. Combined with
1371
+ * an mtime freshness gate so a process that died mid-turn doesn't stick at
1372
+ * running. Backward string scan (not split) to avoid materializing every line
1373
+ * of a possibly-multi-MB rollout. */
1374
+ function deriveCodexRolloutStatus(text: string, mtimeMs: number, nowMs: number): "running" | "idle" {
1375
+ const FRESH_MS = 15_000; // Codex turns can pause between tool calls; a touch wider than Claude's 10s.
1376
+ if (nowMs - mtimeMs >= FRESH_MS) return "idle";
1377
+ let end = text.length;
1378
+ while (end > 0) {
1379
+ const nl = text.lastIndexOf("\n", end - 1);
1380
+ const line = text.slice(nl + 1, end).trim();
1381
+ end = nl;
1382
+ if (!line) continue;
1383
+ let record: Record<string, unknown> | undefined;
1384
+ try {
1385
+ record = asRecord(JSON.parse(line));
1386
+ } catch {
1387
+ continue; // half-written tail line or non-JSON; keep scanning up.
1388
+ }
1389
+ if (record?.type !== "event_msg") continue;
1390
+ const payloadType = firstString(asRecord(record.payload), ["type"]);
1391
+ if (payloadType === "task_started") return "running";
1392
+ if (payloadType === "task_complete" || payloadType === "turn_aborted") return "idle";
1393
+ }
1394
+ return "idle";
1395
+ }
1396
+
1397
+ /** Session ids of Codex rollouts being actively written right now. Codex's
1398
+ * app-server thread/list reports no live status (and only knows threads THIS
1399
+ * process owns, not an external `codex` the user is running), so we read it off
1400
+ * disk like we do for Claude. CPU-frugal: stat every rollout (metadata only)
1401
+ * but READ only the few whose mtime is fresh — stale files are skipped without
1402
+ * opening. We add BOTH the filename id and the session_meta.session_id from the
1403
+ * first line, because a forked/continued rollout's filename id differs from the
1404
+ * meta id and we don't know which one thread/list reports — covering both makes
1405
+ * the match robust. Best-effort: any fs error yields an empty set. */
1406
+ function runningCodexSessionIds(nowMs: number): Set<string> {
1407
+ const running = new Set<string>();
1408
+ const root = join(homedir(), ".codex", "sessions");
1409
+ if (!existsSync(root)) return running;
1410
+ const FRESH_MS = 15_000;
1411
+ const ID_RE = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1412
+ const walk = (dir: string): void => {
1413
+ let entries: string[];
1414
+ try {
1415
+ entries = readdirSync(dir);
1416
+ } catch {
1417
+ return;
1418
+ }
1419
+ for (const name of entries) {
1420
+ const path = join(dir, name);
1421
+ let stat;
1422
+ try {
1423
+ stat = statSync(path);
1424
+ } catch {
1425
+ continue;
1426
+ }
1427
+ if (stat.isDirectory()) {
1428
+ walk(path);
1429
+ continue;
1430
+ }
1431
+ const match = ID_RE.exec(name);
1432
+ if (!match || !match[1]) continue;
1433
+ if (nowMs - stat.mtimeMs >= FRESH_MS) continue; // stale — skip without reading
1434
+ try {
1435
+ const text = readFileSync(path, "utf8");
1436
+ if (deriveCodexRolloutStatus(text, stat.mtimeMs, nowMs) !== "running") continue;
1437
+ running.add(match[1]); // filename id
1438
+ // Also add the canonical session id from session_meta (first line), which
1439
+ // differs from the filename id for forked/continued rollouts.
1440
+ const firstLine = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
1441
+ try {
1442
+ const metaId = firstString(asRecord(asRecord(JSON.parse(firstLine))?.payload), ["session_id", "id"]);
1443
+ if (metaId) running.add(metaId);
1444
+ } catch {
1445
+ // first line not parseable — filename id already added.
1446
+ }
1447
+ } catch {
1448
+ // Unreadable/raced file — ignore.
1449
+ }
1450
+ }
1451
+ };
1452
+ walk(root);
1453
+ return running;
1454
+ }
1455
+
1366
1456
  function parseRemoteSessions(value: unknown): Array<{
1367
1457
  id: string;
1368
1458
  cwd?: string;
@@ -2057,6 +2147,11 @@ export class AgentWorkspaceProxy {
2057
2147
  activeClient = recovered;
2058
2148
  result = await activeClient.listSessions();
2059
2149
  }
2150
+ // Codex's thread/list carries no live status and only knows threads
2151
+ // THIS app-server owns — so an externally-run `codex` shows idle. Read
2152
+ // running state off disk once per sync (CPU-frugal: stats all rollouts,
2153
+ // reads only the fresh few). Claude reports status via parseRemoteSessions.
2154
+ const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
2060
2155
  for (const remote of parseRemoteSessions(result)) {
2061
2156
  const agentSessionId = remote.id;
2062
2157
  // Skip conversations the user explicitly forgot — don't resurrect them.
@@ -2078,18 +2173,19 @@ export class AgentWorkspaceProxy {
2078
2173
  // conversation, the in-memory status is authoritative — the transcript
2079
2174
  // lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
2080
2175
  // it would flicker a live "running" turn back to idle. Otherwise the
2081
- // transcript-derived remote.status is authoritative for Claude (it
2082
- // reports running/idle straight from disk): surface "running" while an
2083
- // external process drives it, and let its "idle" clear a previously
2084
- // observed "running" once the external turn finishes. We only fall
2085
- // back to the existing status to keep error/waiting_permission sticky
2086
- // across a re-sync — deriveTranscriptStatus never emits those, so they
2087
- // live only in `existing`; defaulting everything else to idle avoids a
2088
- // purely-external session getting stuck at "running" forever.
2176
+ // transcript-derived running signal is authoritative (Claude reports it
2177
+ // via remote.status; Codex via the on-disk rollout set): surface
2178
+ // "running" while an external process drives it, and let it clear once
2179
+ // the external turn finishes. We only fall back to the existing status
2180
+ // to keep error/waiting_permission sticky across a re-sync — the
2181
+ // transcript signals never emit those, so they live only in `existing`;
2182
+ // defaulting everything else to idle avoids a purely-external session
2183
+ // getting stuck at "running" forever.
2184
+ const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
2089
2185
  const linkshellDriving = this.currentTurnIds.has(conversationId);
2090
2186
  const status: AgentStatus = linkshellDriving
2091
2187
  ? existing?.status ?? "running"
2092
- : remote.status === "running"
2188
+ : remoteRunning
2093
2189
  ? "running"
2094
2190
  : existing?.status === "error" || existing?.status === "waiting_permission"
2095
2191
  ? existing.status
@@ -2370,7 +2466,18 @@ export class AgentWorkspaceProxy {
2370
2466
  ) {
2371
2467
  existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
2372
2468
  }
2373
- if ((this.timelines.get(existingConversation.id) ?? []).length === 0 && existingConversation.status !== "running") {
2469
+ // Hydrate the transcript from disk when we have no timeline yet, UNLESS
2470
+ // LinkShell is itself driving a live turn for this conversation — in that
2471
+ // case the event stream is the source of truth and re-hydrating would be
2472
+ // redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
2473
+ // status === "running": an EXTERNALLY-driven session also reports
2474
+ // "running" now (live-status feature) but has no event stream, so gating
2475
+ // on status would skip hydration and leave its timeline stuck empty
2476
+ // ("加载对话记录…" forever).
2477
+ if (
2478
+ (this.timelines.get(existingConversation.id) ?? []).length === 0 &&
2479
+ !this.currentTurnIds.has(existingConversation.id)
2480
+ ) {
2374
2481
  try {
2375
2482
  const existingAgentSessionId = existingConversation.agentSessionId!;
2376
2483
  let result: unknown;