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.
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +7 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +199 -10
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/runtime/acp/agent-workspace.ts +180 -10
- package/web/assets/{index-C6jrt3Ma.js → index-wEo8TBBQ.js} +2 -2
- package/web/index.html +1 -1
|
@@ -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 {
|
|
@@ -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;
|
|
@@ -1854,12 +1944,83 @@ export class AgentWorkspaceProxy {
|
|
|
1854
1944
|
}
|
|
1855
1945
|
|
|
1856
1946
|
stop(): void {
|
|
1947
|
+
for (const watcher of this.fsWatchers) {
|
|
1948
|
+
try { watcher.close(); } catch { /* already closed */ }
|
|
1949
|
+
}
|
|
1950
|
+
this.fsWatchers = [];
|
|
1951
|
+
if (this.fsSyncTimer) {
|
|
1952
|
+
clearTimeout(this.fsSyncTimer);
|
|
1953
|
+
this.fsSyncTimer = undefined;
|
|
1954
|
+
}
|
|
1857
1955
|
for (const client of this.clients.values()) {
|
|
1858
1956
|
client.stop();
|
|
1859
1957
|
}
|
|
1860
1958
|
this.clients.clear();
|
|
1861
1959
|
}
|
|
1862
1960
|
|
|
1961
|
+
// ── External-session live refresh ────────────────────────────────────
|
|
1962
|
+
// The console requests the session list only once, at connect. Without this,
|
|
1963
|
+
// a session another `claude`/`codex` process starts (or whose status changes)
|
|
1964
|
+
// after connect never shows up — the tree is frozen at connect time. Watch the
|
|
1965
|
+
// on-disk session stores and re-sync + push when they change, so externally-
|
|
1966
|
+
// driven sessions surface live (this is what CodeIsland does). Event-driven:
|
|
1967
|
+
// zero cost while idle, throttled to at most one re-scan per window during a
|
|
1968
|
+
// burst of writes (an active turn appends constantly).
|
|
1969
|
+
private fsWatchers: ReturnType<typeof watch>[] = [];
|
|
1970
|
+
private fsSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1971
|
+
private fsSyncRunning = false;
|
|
1972
|
+
private static readonly FS_SYNC_THROTTLE_MS = 1500;
|
|
1973
|
+
|
|
1974
|
+
private startSessionWatchers(): void {
|
|
1975
|
+
if (this.fsWatchers.length > 0) return; // already watching
|
|
1976
|
+
const roots = [
|
|
1977
|
+
join(homedir(), ".claude", "projects"),
|
|
1978
|
+
join(homedir(), ".codex", "sessions"),
|
|
1979
|
+
];
|
|
1980
|
+
for (const root of roots) {
|
|
1981
|
+
if (!existsSync(root)) continue;
|
|
1982
|
+
try {
|
|
1983
|
+
// recursive is supported on macOS/Windows; on Linux this throws, and we
|
|
1984
|
+
// simply don't get live updates (the connect-time scan still works).
|
|
1985
|
+
const watcher = watch(root, { recursive: true, persistent: false }, () => this.scheduleSessionSync());
|
|
1986
|
+
watcher.on("error", () => { /* transient watch error — ignore */ });
|
|
1987
|
+
this.fsWatchers.push(watcher);
|
|
1988
|
+
} catch {
|
|
1989
|
+
// recursive watch unavailable on this platform — skip silently.
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
private scheduleSessionSync(): void {
|
|
1995
|
+
if (this.fsSyncTimer) return; // a sync is already scheduled in this window
|
|
1996
|
+
this.fsSyncTimer = setTimeout(() => {
|
|
1997
|
+
this.fsSyncTimer = undefined;
|
|
1998
|
+
void this.syncAndPushSessions();
|
|
1999
|
+
}, AgentWorkspaceProxy.FS_SYNC_THROTTLE_MS);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
private async syncAndPushSessions(): Promise<void> {
|
|
2003
|
+
if (this.fsSyncRunning) return; // don't overlap with an in-flight sync
|
|
2004
|
+
this.fsSyncRunning = true;
|
|
2005
|
+
try {
|
|
2006
|
+
await this.syncProviderSessions();
|
|
2007
|
+
// Push the conversation list (statuses) — lighter than a full snapshot and
|
|
2008
|
+
// exactly what refreshes the tree. The web handles an unsolicited
|
|
2009
|
+
// conversation.list.result the same as a requested one.
|
|
2010
|
+
this.input.send(createEnvelope({
|
|
2011
|
+
type: "agent.v2.conversation.list.result",
|
|
2012
|
+
sessionId: this.input.sessionId,
|
|
2013
|
+
payload: { conversations: [...this.conversations.values()].filter((c) => !c.archived) },
|
|
2014
|
+
}));
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
if (this.input.verbose) {
|
|
2017
|
+
process.stderr.write(`[agent:v2] fs-watch sync failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2018
|
+
}
|
|
2019
|
+
} finally {
|
|
2020
|
+
this.fsSyncRunning = false;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1863
2024
|
private clientForProvider(provider: AgentProvider): AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined {
|
|
1864
2025
|
return this.clients.get(provider);
|
|
1865
2026
|
}
|
|
@@ -1906,6 +2067,9 @@ export class AgentWorkspaceProxy {
|
|
|
1906
2067
|
this.status = "idle";
|
|
1907
2068
|
this.error = undefined;
|
|
1908
2069
|
this.sendCapabilities();
|
|
2070
|
+
// Start watching the on-disk session stores so externally-started sessions
|
|
2071
|
+
// and status changes surface live, not just at connect time.
|
|
2072
|
+
this.startSessionWatchers();
|
|
1909
2073
|
}
|
|
1910
2074
|
|
|
1911
2075
|
private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
|
|
@@ -2057,6 +2221,11 @@ export class AgentWorkspaceProxy {
|
|
|
2057
2221
|
activeClient = recovered;
|
|
2058
2222
|
result = await activeClient.listSessions();
|
|
2059
2223
|
}
|
|
2224
|
+
// Codex's thread/list carries no live status and only knows threads
|
|
2225
|
+
// THIS app-server owns — so an externally-run `codex` shows idle. Read
|
|
2226
|
+
// running state off disk once per sync (CPU-frugal: stats all rollouts,
|
|
2227
|
+
// reads only the fresh few). Claude reports status via parseRemoteSessions.
|
|
2228
|
+
const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
|
|
2060
2229
|
for (const remote of parseRemoteSessions(result)) {
|
|
2061
2230
|
const agentSessionId = remote.id;
|
|
2062
2231
|
// Skip conversations the user explicitly forgot — don't resurrect them.
|
|
@@ -2078,18 +2247,19 @@ export class AgentWorkspaceProxy {
|
|
|
2078
2247
|
// conversation, the in-memory status is authoritative — the transcript
|
|
2079
2248
|
// lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
|
|
2080
2249
|
// it would flicker a live "running" turn back to idle. Otherwise the
|
|
2081
|
-
// transcript-derived
|
|
2082
|
-
//
|
|
2083
|
-
// external process drives it, and let
|
|
2084
|
-
//
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2250
|
+
// transcript-derived running signal is authoritative (Claude reports it
|
|
2251
|
+
// via remote.status; Codex via the on-disk rollout set): surface
|
|
2252
|
+
// "running" while an external process drives it, and let it clear once
|
|
2253
|
+
// the external turn finishes. We only fall back to the existing status
|
|
2254
|
+
// to keep error/waiting_permission sticky across a re-sync — the
|
|
2255
|
+
// transcript signals never emit those, so they live only in `existing`;
|
|
2256
|
+
// defaulting everything else to idle avoids a purely-external session
|
|
2257
|
+
// getting stuck at "running" forever.
|
|
2258
|
+
const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
|
|
2089
2259
|
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
2090
2260
|
const status: AgentStatus = linkshellDriving
|
|
2091
2261
|
? existing?.status ?? "running"
|
|
2092
|
-
:
|
|
2262
|
+
: remoteRunning
|
|
2093
2263
|
? "running"
|
|
2094
2264
|
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
2095
2265
|
? existing.status
|