linkshell-cli 0.5.23 → 0.5.25

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.
@@ -36,6 +36,13 @@ export declare class AgentWorkspaceProxy {
36
36
  });
37
37
  handleEnvelope(envelope: Envelope): Promise<void>;
38
38
  stop(): void;
39
+ private fsWatchers;
40
+ private fsSyncTimer;
41
+ private fsSyncRunning;
42
+ private static readonly FS_SYNC_THROTTLE_MS;
43
+ private startSessionWatchers;
44
+ private scheduleSessionSync;
45
+ private syncAndPushSessions;
39
46
  private clientForProvider;
40
47
  private shouldRecycleProviderClient;
41
48
  private recycleProviderClient;
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, relative } from "node:path";
4
4
  import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
@@ -1112,6 +1112,109 @@ function parseTimestamp(value) {
1112
1112
  }
1113
1113
  return undefined;
1114
1114
  }
1115
+ /** Derive running/idle for a Codex session from its on-disk rollout transcript.
1116
+ * Codex marks turn boundaries with event_msg payloads: task_started opens a
1117
+ * turn, task_complete / turn_aborted close it. token_count / response_item
1118
+ * records trail AFTER task_complete, so we must scan backward to the first
1119
+ * turn-lifecycle event rather than just looking at the last line. Combined with
1120
+ * an mtime freshness gate so a process that died mid-turn doesn't stick at
1121
+ * running. Backward string scan (not split) to avoid materializing every line
1122
+ * of a possibly-multi-MB rollout. */
1123
+ function deriveCodexRolloutStatus(text, mtimeMs, nowMs) {
1124
+ const FRESH_MS = 15_000; // Codex turns can pause between tool calls; a touch wider than Claude's 10s.
1125
+ if (nowMs - mtimeMs >= FRESH_MS)
1126
+ return "idle";
1127
+ let end = text.length;
1128
+ while (end > 0) {
1129
+ const nl = text.lastIndexOf("\n", end - 1);
1130
+ const line = text.slice(nl + 1, end).trim();
1131
+ end = nl;
1132
+ if (!line)
1133
+ continue;
1134
+ let record;
1135
+ try {
1136
+ record = asRecord(JSON.parse(line));
1137
+ }
1138
+ catch {
1139
+ continue; // half-written tail line or non-JSON; keep scanning up.
1140
+ }
1141
+ if (record?.type !== "event_msg")
1142
+ continue;
1143
+ const payloadType = firstString(asRecord(record.payload), ["type"]);
1144
+ if (payloadType === "task_started")
1145
+ return "running";
1146
+ if (payloadType === "task_complete" || payloadType === "turn_aborted")
1147
+ return "idle";
1148
+ }
1149
+ return "idle";
1150
+ }
1151
+ /** Session ids of Codex rollouts being actively written right now. Codex's
1152
+ * app-server thread/list reports no live status (and only knows threads THIS
1153
+ * process owns, not an external `codex` the user is running), so we read it off
1154
+ * disk like we do for Claude. CPU-frugal: stat every rollout (metadata only)
1155
+ * but READ only the few whose mtime is fresh — stale files are skipped without
1156
+ * opening. We add BOTH the filename id and the session_meta.session_id from the
1157
+ * first line, because a forked/continued rollout's filename id differs from the
1158
+ * meta id and we don't know which one thread/list reports — covering both makes
1159
+ * the match robust. Best-effort: any fs error yields an empty set. */
1160
+ function runningCodexSessionIds(nowMs) {
1161
+ const running = new Set();
1162
+ const root = join(homedir(), ".codex", "sessions");
1163
+ if (!existsSync(root))
1164
+ return running;
1165
+ const FRESH_MS = 15_000;
1166
+ const ID_RE = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
1167
+ const walk = (dir) => {
1168
+ let entries;
1169
+ try {
1170
+ entries = readdirSync(dir);
1171
+ }
1172
+ catch {
1173
+ return;
1174
+ }
1175
+ for (const name of entries) {
1176
+ const path = join(dir, name);
1177
+ let stat;
1178
+ try {
1179
+ stat = statSync(path);
1180
+ }
1181
+ catch {
1182
+ continue;
1183
+ }
1184
+ if (stat.isDirectory()) {
1185
+ walk(path);
1186
+ continue;
1187
+ }
1188
+ const match = ID_RE.exec(name);
1189
+ if (!match || !match[1])
1190
+ continue;
1191
+ if (nowMs - stat.mtimeMs >= FRESH_MS)
1192
+ continue; // stale — skip without reading
1193
+ try {
1194
+ const text = readFileSync(path, "utf8");
1195
+ if (deriveCodexRolloutStatus(text, stat.mtimeMs, nowMs) !== "running")
1196
+ continue;
1197
+ running.add(match[1]); // filename id
1198
+ // Also add the canonical session id from session_meta (first line), which
1199
+ // differs from the filename id for forked/continued rollouts.
1200
+ const firstLine = text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length);
1201
+ try {
1202
+ const metaId = firstString(asRecord(asRecord(JSON.parse(firstLine))?.payload), ["session_id", "id"]);
1203
+ if (metaId)
1204
+ running.add(metaId);
1205
+ }
1206
+ catch {
1207
+ // first line not parseable — filename id already added.
1208
+ }
1209
+ }
1210
+ catch {
1211
+ // Unreadable/raced file — ignore.
1212
+ }
1213
+ }
1214
+ };
1215
+ walk(root);
1216
+ return running;
1217
+ }
1115
1218
  function parseRemoteSessions(value) {
1116
1219
  const raw = asRecord(value);
1117
1220
  const sessionsValue = Array.isArray(value) ? value :
@@ -1557,11 +1660,88 @@ export class AgentWorkspaceProxy {
1557
1660
  }
1558
1661
  }
1559
1662
  stop() {
1663
+ for (const watcher of this.fsWatchers) {
1664
+ try {
1665
+ watcher.close();
1666
+ }
1667
+ catch { /* already closed */ }
1668
+ }
1669
+ this.fsWatchers = [];
1670
+ if (this.fsSyncTimer) {
1671
+ clearTimeout(this.fsSyncTimer);
1672
+ this.fsSyncTimer = undefined;
1673
+ }
1560
1674
  for (const client of this.clients.values()) {
1561
1675
  client.stop();
1562
1676
  }
1563
1677
  this.clients.clear();
1564
1678
  }
1679
+ // ── External-session live refresh ────────────────────────────────────
1680
+ // The console requests the session list only once, at connect. Without this,
1681
+ // a session another `claude`/`codex` process starts (or whose status changes)
1682
+ // after connect never shows up — the tree is frozen at connect time. Watch the
1683
+ // on-disk session stores and re-sync + push when they change, so externally-
1684
+ // driven sessions surface live (this is what CodeIsland does). Event-driven:
1685
+ // zero cost while idle, throttled to at most one re-scan per window during a
1686
+ // burst of writes (an active turn appends constantly).
1687
+ fsWatchers = [];
1688
+ fsSyncTimer;
1689
+ fsSyncRunning = false;
1690
+ static FS_SYNC_THROTTLE_MS = 1500;
1691
+ startSessionWatchers() {
1692
+ if (this.fsWatchers.length > 0)
1693
+ return; // already watching
1694
+ const roots = [
1695
+ join(homedir(), ".claude", "projects"),
1696
+ join(homedir(), ".codex", "sessions"),
1697
+ ];
1698
+ for (const root of roots) {
1699
+ if (!existsSync(root))
1700
+ continue;
1701
+ try {
1702
+ // recursive is supported on macOS/Windows; on Linux this throws, and we
1703
+ // simply don't get live updates (the connect-time scan still works).
1704
+ const watcher = watch(root, { recursive: true, persistent: false }, () => this.scheduleSessionSync());
1705
+ watcher.on("error", () => { });
1706
+ this.fsWatchers.push(watcher);
1707
+ }
1708
+ catch {
1709
+ // recursive watch unavailable on this platform — skip silently.
1710
+ }
1711
+ }
1712
+ }
1713
+ scheduleSessionSync() {
1714
+ if (this.fsSyncTimer)
1715
+ return; // a sync is already scheduled in this window
1716
+ this.fsSyncTimer = setTimeout(() => {
1717
+ this.fsSyncTimer = undefined;
1718
+ void this.syncAndPushSessions();
1719
+ }, AgentWorkspaceProxy.FS_SYNC_THROTTLE_MS);
1720
+ }
1721
+ async syncAndPushSessions() {
1722
+ if (this.fsSyncRunning)
1723
+ return; // don't overlap with an in-flight sync
1724
+ this.fsSyncRunning = true;
1725
+ try {
1726
+ await this.syncProviderSessions();
1727
+ // Push the conversation list (statuses) — lighter than a full snapshot and
1728
+ // exactly what refreshes the tree. The web handles an unsolicited
1729
+ // conversation.list.result the same as a requested one.
1730
+ this.input.send(createEnvelope({
1731
+ type: "agent.v2.conversation.list.result",
1732
+ sessionId: this.input.sessionId,
1733
+ payload: { conversations: [...this.conversations.values()].filter((c) => !c.archived) },
1734
+ }));
1735
+ }
1736
+ catch (error) {
1737
+ if (this.input.verbose) {
1738
+ process.stderr.write(`[agent:v2] fs-watch sync failed: ${error instanceof Error ? error.message : String(error)}\n`);
1739
+ }
1740
+ }
1741
+ finally {
1742
+ this.fsSyncRunning = false;
1743
+ }
1744
+ }
1565
1745
  clientForProvider(provider) {
1566
1746
  return this.clients.get(provider);
1567
1747
  }
@@ -1607,6 +1787,9 @@ export class AgentWorkspaceProxy {
1607
1787
  this.status = "idle";
1608
1788
  this.error = undefined;
1609
1789
  this.sendCapabilities();
1790
+ // Start watching the on-disk session stores so externally-started sessions
1791
+ // and status changes surface live, not just at connect time.
1792
+ this.startSessionWatchers();
1610
1793
  }
1611
1794
  async ensureProviderClient(provider) {
1612
1795
  const existing = this.clients.get(provider);
@@ -1756,6 +1939,11 @@ export class AgentWorkspaceProxy {
1756
1939
  activeClient = recovered;
1757
1940
  result = await activeClient.listSessions();
1758
1941
  }
1942
+ // Codex's thread/list carries no live status and only knows threads
1943
+ // THIS app-server owns — so an externally-run `codex` shows idle. Read
1944
+ // running state off disk once per sync (CPU-frugal: stats all rollouts,
1945
+ // reads only the fresh few). Claude reports status via parseRemoteSessions.
1946
+ const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
1759
1947
  for (const remote of parseRemoteSessions(result)) {
1760
1948
  const agentSessionId = remote.id;
1761
1949
  // Skip conversations the user explicitly forgot — don't resurrect them.
@@ -1778,18 +1966,19 @@ export class AgentWorkspaceProxy {
1778
1966
  // conversation, the in-memory status is authoritative — the transcript
1779
1967
  // lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
1780
1968
  // it would flicker a live "running" turn back to idle. Otherwise the
1781
- // transcript-derived remote.status is authoritative for Claude (it
1782
- // reports running/idle straight from disk): surface "running" while an
1783
- // external process drives it, and let its "idle" clear a previously
1784
- // observed "running" once the external turn finishes. We only fall
1785
- // back to the existing status to keep error/waiting_permission sticky
1786
- // across a re-sync — deriveTranscriptStatus never emits those, so they
1787
- // live only in `existing`; defaulting everything else to idle avoids a
1788
- // purely-external session getting stuck at "running" forever.
1969
+ // transcript-derived running signal is authoritative (Claude reports it
1970
+ // via remote.status; Codex via the on-disk rollout set): surface
1971
+ // "running" while an external process drives it, and let it clear once
1972
+ // the external turn finishes. We only fall back to the existing status
1973
+ // to keep error/waiting_permission sticky across a re-sync — the
1974
+ // transcript signals never emit those, so they live only in `existing`;
1975
+ // defaulting everything else to idle avoids a purely-external session
1976
+ // getting stuck at "running" forever.
1977
+ const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
1789
1978
  const linkshellDriving = this.currentTurnIds.has(conversationId);
1790
1979
  const status = linkshellDriving
1791
1980
  ? existing?.status ?? "running"
1792
- : remote.status === "running"
1981
+ : remoteRunning
1793
1982
  ? "running"
1794
1983
  : existing?.status === "error" || existing?.status === "waiting_permission"
1795
1984
  ? existing.status