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.
- package/dist/cli/src/runtime/acp/agent-workspace.js +128 -10
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/runtime/acp/agent-workspace.ts +117 -10
- package/web/assets/{index-C6jrt3Ma.js → index-Bh3kcNhP.js} +1 -1
- package/web/index.html +1 -1
|
@@ -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 :
|
|
@@ -1756,6 +1859,11 @@ export class AgentWorkspaceProxy {
|
|
|
1756
1859
|
activeClient = recovered;
|
|
1757
1860
|
result = await activeClient.listSessions();
|
|
1758
1861
|
}
|
|
1862
|
+
// Codex's thread/list carries no live status and only knows threads
|
|
1863
|
+
// THIS app-server owns — so an externally-run `codex` shows idle. Read
|
|
1864
|
+
// running state off disk once per sync (CPU-frugal: stats all rollouts,
|
|
1865
|
+
// reads only the fresh few). Claude reports status via parseRemoteSessions.
|
|
1866
|
+
const codexRunning = provider === "codex" ? runningCodexSessionIds(Date.now()) : undefined;
|
|
1759
1867
|
for (const remote of parseRemoteSessions(result)) {
|
|
1760
1868
|
const agentSessionId = remote.id;
|
|
1761
1869
|
// Skip conversations the user explicitly forgot — don't resurrect them.
|
|
@@ -1778,18 +1886,19 @@ export class AgentWorkspaceProxy {
|
|
|
1778
1886
|
// conversation, the in-memory status is authoritative — the transcript
|
|
1779
1887
|
// lags (the SDK hasn't flushed the in-flight turn to disk), so trusting
|
|
1780
1888
|
// it would flicker a live "running" turn back to idle. Otherwise the
|
|
1781
|
-
// transcript-derived
|
|
1782
|
-
//
|
|
1783
|
-
// external process drives it, and let
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1889
|
+
// transcript-derived running signal is authoritative (Claude reports it
|
|
1890
|
+
// via remote.status; Codex via the on-disk rollout set): surface
|
|
1891
|
+
// "running" while an external process drives it, and let it clear once
|
|
1892
|
+
// the external turn finishes. We only fall back to the existing status
|
|
1893
|
+
// to keep error/waiting_permission sticky across a re-sync — the
|
|
1894
|
+
// transcript signals never emit those, so they live only in `existing`;
|
|
1895
|
+
// defaulting everything else to idle avoids a purely-external session
|
|
1896
|
+
// getting stuck at "running" forever.
|
|
1897
|
+
const remoteRunning = remote.status === "running" || (codexRunning?.has(agentSessionId) ?? false);
|
|
1789
1898
|
const linkshellDriving = this.currentTurnIds.has(conversationId);
|
|
1790
1899
|
const status = linkshellDriving
|
|
1791
1900
|
? existing?.status ?? "running"
|
|
1792
|
-
:
|
|
1901
|
+
: remoteRunning
|
|
1793
1902
|
? "running"
|
|
1794
1903
|
: existing?.status === "error" || existing?.status === "waiting_permission"
|
|
1795
1904
|
? existing.status
|
|
@@ -2045,7 +2154,16 @@ export class AgentWorkspaceProxy {
|
|
|
2045
2154
|
existingConversation.id !== payload.conversationId) {
|
|
2046
2155
|
existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
|
|
2047
2156
|
}
|
|
2048
|
-
|
|
2157
|
+
// Hydrate the transcript from disk when we have no timeline yet, UNLESS
|
|
2158
|
+
// LinkShell is itself driving a live turn for this conversation — in that
|
|
2159
|
+
// case the event stream is the source of truth and re-hydrating would be
|
|
2160
|
+
// redundant/racy. We key on currentTurnIds (LinkShell-driving), NOT on
|
|
2161
|
+
// status === "running": an EXTERNALLY-driven session also reports
|
|
2162
|
+
// "running" now (live-status feature) but has no event stream, so gating
|
|
2163
|
+
// on status would skip hydration and leave its timeline stuck empty
|
|
2164
|
+
// ("加载对话记录…" forever).
|
|
2165
|
+
if ((this.timelines.get(existingConversation.id) ?? []).length === 0 &&
|
|
2166
|
+
!this.currentTurnIds.has(existingConversation.id)) {
|
|
2049
2167
|
try {
|
|
2050
2168
|
const existingAgentSessionId = existingConversation.agentSessionId;
|
|
2051
2169
|
let result;
|