@cjhyy/code-shell-core 0.8.2 → 0.8.4
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/credentials/use-credential-tool.js +5 -2
- package/dist/engine/engine.js +17 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/llm/providers/openai.js +49 -2
- package/dist/protocol/chat-session-manager.js +9 -0
- package/dist/run/EngineRunner.js +5 -4
- package/dist/session/session-manager.d.ts +12 -13
- package/dist/session/session-manager.js +214 -28
- package/dist/session/transcript.d.ts +20 -4
- package/dist/session/transcript.js +97 -36
- package/dist/settings/schema.d.ts +99 -0
- package/dist/settings/schema.js +13 -0
- package/dist/tool-system/context.d.ts +6 -0
- package/dist/tool-system/external-tool-exposure.js +50 -26
- package/dist/tool-system/session-tool-host.d.ts +1 -1
- package/dist/tool-system/session-tool-host.js +31 -3
- package/dist/types.d.ts +7 -3
- package/package.json +1 -1
|
@@ -19,8 +19,11 @@ import { logger } from "../logging/logger.js";
|
|
|
19
19
|
import { credentialAccessScope, getCredentialAccess, materializeCookieSecret, sweepStaleCredentialCookieFiles, } from "./access.js";
|
|
20
20
|
const TOOL_NAME = "UseCredential";
|
|
21
21
|
const BASE_DESCRIPTION = "Use a stored credential (token / API key / login cookie) to run a command. " +
|
|
22
|
-
"
|
|
23
|
-
"
|
|
22
|
+
"Prefer this tool over reading a browser credential store, asking the user to log in again, " +
|
|
23
|
+
"or manually exporting credentials. Before `--cookies-from-browser` or retrying an " +
|
|
24
|
+
"authentication failure, check stored credentials here. If `Currently available` names a " +
|
|
25
|
+
"matching id, fetch it directly; otherwise call with NO arguments first to list available " +
|
|
26
|
+
"credentials (id + label + type), then call again with `id`. Token/link credentials return their secret " +
|
|
24
27
|
"value; cookie credentials are materialized to a temporary Netscape cookies.txt file " +
|
|
25
28
|
"(use it as `yt-dlp --cookies <cookiesFile>` / `curl -b <cookiesFile>`). " +
|
|
26
29
|
"Each use is gated by a quick user approval unless auto-approve is on.";
|
package/dist/engine/engine.js
CHANGED
|
@@ -1501,7 +1501,9 @@ export class Engine {
|
|
|
1501
1501
|
this.lastMessages = messages;
|
|
1502
1502
|
// Wire up LLM summarization for context compaction
|
|
1503
1503
|
// Uses a lightweight call without tools
|
|
1504
|
-
|
|
1504
|
+
if (session.transcript.isPersistent()) {
|
|
1505
|
+
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
1506
|
+
}
|
|
1505
1507
|
// Re-derive frozen persistence decisions from the messages we just
|
|
1506
1508
|
// loaded. Skipped on cold start (messages == [userContextMsg] only).
|
|
1507
1509
|
// Critical for resume — otherwise a result that was persisted last
|
|
@@ -1704,14 +1706,17 @@ export class Engine {
|
|
|
1704
1706
|
// capture the persisted total at run start and fold this run's usage onto
|
|
1705
1707
|
// it (see foldRunUsage). Snapshot now, before any turn boundary fires.
|
|
1706
1708
|
const usageBaseline = { ...session.state.tokenUsage };
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1709
|
+
const fileHistoryHook = isEphemeralSessionState(session.state)
|
|
1710
|
+
? { dispose() { } }
|
|
1711
|
+
: registerFileHistoryHook({
|
|
1712
|
+
hooks: this.hooks,
|
|
1713
|
+
sessionDir: join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId),
|
|
1714
|
+
cwd,
|
|
1715
|
+
getTurnSeq: () => session.state.turnSeq,
|
|
1716
|
+
contributions: this.capabilities.flatMap((capability) => [
|
|
1717
|
+
...(capability.fileHistory ?? []),
|
|
1718
|
+
]),
|
|
1719
|
+
});
|
|
1715
1720
|
// Hook: agent start
|
|
1716
1721
|
await this.emitHook("on_agent_start", {
|
|
1717
1722
|
sessionId: session.state.sessionId,
|
|
@@ -2800,7 +2805,9 @@ export class Engine {
|
|
|
2800
2805
|
maxTokens: this.resolveMaxContextTokens(),
|
|
2801
2806
|
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
2802
2807
|
});
|
|
2803
|
-
|
|
2808
|
+
if (session.transcript.isPersistent()) {
|
|
2809
|
+
contextManager.setTranscriptPath(session.transcript.getFilePath());
|
|
2810
|
+
}
|
|
2804
2811
|
contextManager.initReplacementStateFromMessages(sourceMessages);
|
|
2805
2812
|
this.lastContextManager = contextManager;
|
|
2806
2813
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Public API exports.
|
|
5
5
|
*/
|
|
6
|
-
export declare const VERSION = "0.8.
|
|
6
|
+
export declare const VERSION = "0.8.4";
|
|
7
7
|
export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
|
|
8
8
|
export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
|
|
9
9
|
export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Public API exports.
|
|
5
5
|
*/
|
|
6
|
-
export const VERSION = "0.8.
|
|
6
|
+
export const VERSION = "0.8.4";
|
|
7
7
|
// ─── Exceptions ──────────────────────────────────────────────────
|
|
8
8
|
export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
|
|
9
9
|
// ─── Engine (primary API) ────────────────────────────────────────
|
|
@@ -145,6 +145,52 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
|
|
|
145
145
|
}
|
|
146
146
|
return text;
|
|
147
147
|
}
|
|
148
|
+
const MISSING_TOOL_RESULT_WIRE_TEXT = "Error: Tool execution did not complete before the conversation resumed.";
|
|
149
|
+
/**
|
|
150
|
+
* OpenAI requires each assistant tool_calls batch to be followed immediately
|
|
151
|
+
* by exactly one role:tool message per id. Normalize at the provider boundary
|
|
152
|
+
* as a final guard against legacy/corrupt transcripts: keep the latest result
|
|
153
|
+
* for a duplicate id, synthesize a request-local result for a missing id, and
|
|
154
|
+
* discard tool messages that were never declared by the preceding assistant.
|
|
155
|
+
*/
|
|
156
|
+
function normalizeOpenAIToolMessagePairs(messages) {
|
|
157
|
+
const normalized = [];
|
|
158
|
+
for (let index = 0; index < messages.length; index++) {
|
|
159
|
+
const message = messages[index];
|
|
160
|
+
if (message.role === "tool")
|
|
161
|
+
continue;
|
|
162
|
+
normalized.push(message);
|
|
163
|
+
if (message.role !== "assistant")
|
|
164
|
+
continue;
|
|
165
|
+
const toolCalls = message.tool_calls;
|
|
166
|
+
const expectedIds = Array.isArray(toolCalls)
|
|
167
|
+
? toolCalls
|
|
168
|
+
.map((toolCall) => toolCall.id)
|
|
169
|
+
.filter((id) => typeof id === "string" && id.length > 0)
|
|
170
|
+
: [];
|
|
171
|
+
if (expectedIds.length === 0)
|
|
172
|
+
continue;
|
|
173
|
+
const expected = new Set(expectedIds);
|
|
174
|
+
const latestResultById = new Map();
|
|
175
|
+
let cursor = index + 1;
|
|
176
|
+
while (cursor < messages.length && messages[cursor]?.role === "tool") {
|
|
177
|
+
const toolMessage = messages[cursor];
|
|
178
|
+
if (expected.has(toolMessage.tool_call_id)) {
|
|
179
|
+
latestResultById.set(toolMessage.tool_call_id, toolMessage);
|
|
180
|
+
}
|
|
181
|
+
cursor++;
|
|
182
|
+
}
|
|
183
|
+
for (const id of expectedIds) {
|
|
184
|
+
normalized.push(latestResultById.get(id) ?? {
|
|
185
|
+
role: "tool",
|
|
186
|
+
tool_call_id: id,
|
|
187
|
+
content: MISSING_TOOL_RESULT_WIRE_TEXT,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
index = cursor - 1;
|
|
191
|
+
}
|
|
192
|
+
return normalized;
|
|
193
|
+
}
|
|
148
194
|
export class OpenAIClient extends LLMClientBase {
|
|
149
195
|
_client = null;
|
|
150
196
|
// Sticky override: once the endpoint tells us `max_tokens` is rejected for
|
|
@@ -839,10 +885,11 @@ export class OpenAIClient extends LLMClientBase {
|
|
|
839
885
|
}
|
|
840
886
|
}
|
|
841
887
|
}
|
|
888
|
+
const normalized = normalizeOpenAIToolMessagePairs(result);
|
|
842
889
|
if (this.isOpenRouterAnthropic) {
|
|
843
|
-
this.applyAnthropicCacheBreakpoints(
|
|
890
|
+
this.applyAnthropicCacheBreakpoints(normalized);
|
|
844
891
|
}
|
|
845
|
-
return
|
|
892
|
+
return normalized;
|
|
846
893
|
}
|
|
847
894
|
/**
|
|
848
895
|
* In-place: add prompt-cache breakpoints for Anthropic-over-OpenRouter.
|
|
@@ -190,6 +190,14 @@ export class ChatSessionManager {
|
|
|
190
190
|
return alreadyClosing;
|
|
191
191
|
const s = this.sessions.get(sessionId);
|
|
192
192
|
if (!s) {
|
|
193
|
+
if (sessionId.startsWith("qchat-")) {
|
|
194
|
+
try {
|
|
195
|
+
this.engineSessionManager(this.factory({}))?.forgetEphemeralSession?.(sessionId);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Closing an already-expired process-local chat is idempotent.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
193
201
|
if (markClosed)
|
|
194
202
|
this.rememberClosedSession(sessionId);
|
|
195
203
|
return Promise.resolve();
|
|
@@ -204,6 +212,7 @@ export class ChatSessionManager {
|
|
|
204
212
|
clearCredentialSessionAllow(sessionId);
|
|
205
213
|
clearInjectCredentialSessionAllow(sessionId);
|
|
206
214
|
const finishClose = () => {
|
|
215
|
+
sessionManager?.forgetEphemeralSession?.(sessionId);
|
|
207
216
|
this.unregisterMcpOwner(s);
|
|
208
217
|
if (this.sessions.get(sessionId) === s)
|
|
209
218
|
this.sessions.delete(sessionId);
|
package/dist/run/EngineRunner.js
CHANGED
|
@@ -27,10 +27,11 @@ export const AUTOMATION_RUN_SOURCE = "automation";
|
|
|
27
27
|
export const AUTOMATION_PROMPT_NOTE = "This is an unattended, scheduled automation run. No human is watching, and " +
|
|
28
28
|
"AskUserQuestion will not reach anyone. You ARE the automation — do not ask " +
|
|
29
29
|
"the user questions and do not offer to set up or schedule automation. " +
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
30
|
+
"When uncertain, state your assumption and proceed. When the work is ready, " +
|
|
31
|
+
"first call UpdateAutomationMemory exactly once with a concise summary of this " +
|
|
32
|
+
"run's key findings/state for the next run. After that call succeeds, return the " +
|
|
33
|
+
"complete requested output as the final assistant message. Do not replace the " +
|
|
34
|
+
"requested output with a completion acknowledgement or put it in a formatting tool.";
|
|
34
35
|
/** Compose the run's appendSystemPrompt: prepend the automation note when the
|
|
35
36
|
* run is tagged source "automation", preserving any host-provided append. */
|
|
36
37
|
export function buildAppendSystemPrompt(hostAppend, metadata) {
|
|
@@ -18,7 +18,7 @@ export interface ForkSessionOptions {
|
|
|
18
18
|
throughEventId?: string;
|
|
19
19
|
/** `completed` is the interrupted snapshot used by ephemeral side chats. */
|
|
20
20
|
snapshotMode?: "tail" | "completed";
|
|
21
|
-
/**
|
|
21
|
+
/** Keep this temporary fork in process memory only. */
|
|
22
22
|
ephemeral?: boolean;
|
|
23
23
|
}
|
|
24
24
|
export interface ForkSessionResult {
|
|
@@ -94,24 +94,22 @@ export declare class SessionManager {
|
|
|
94
94
|
private readonly registeredCloseEpochs;
|
|
95
95
|
private readonly workspaceCapability?;
|
|
96
96
|
constructor(storageDir?: string, workspaceCapability?: SessionWorkspaceCapability);
|
|
97
|
+
private processLocalKey;
|
|
98
|
+
private processLocalBundle;
|
|
99
|
+
private storeProcessLocalBundle;
|
|
100
|
+
/** Forget a Quick Chat/side-chat bundle immediately; nothing remains on disk. */
|
|
101
|
+
forgetEphemeralSession(sessionId: string): boolean;
|
|
97
102
|
private cleanupStaleForkStaging;
|
|
98
103
|
/** Bind one Engine/session pair to the current close epoch without advancing it. */
|
|
99
104
|
registerSessionGeneration(sessionId: string): number;
|
|
100
105
|
/** Advance the close epoch once before close waits for the old run to settle. */
|
|
101
106
|
incrementSessionGeneration(sessionId: string): number;
|
|
102
107
|
/**
|
|
103
|
-
* Create a
|
|
104
|
-
*
|
|
105
|
-
* "tui-main" and expect us to honor it). Otherwise generate one with
|
|
106
|
-
* nanoid. Either way the on-disk directory is materialized and the
|
|
107
|
-
* state.json + transcript.jsonl files are written before return.
|
|
108
|
+
* Create a session. `qchat-` sessions stay process-local; ordinary sessions
|
|
109
|
+
* materialize state.json + transcript.jsonl before return.
|
|
108
110
|
*/
|
|
109
111
|
create(cwd: string, model: string, provider: string, explicitSessionId?: string, parentSessionId?: string | null, origin?: import("../types.js").SessionOrigin, kind?: SessionKind): SessionBundle;
|
|
110
|
-
/**
|
|
111
|
-
* Whether a session directory exists on disk. Used by ChatSession-driven
|
|
112
|
-
* cold starts to decide between resume vs create-with-explicit-sid
|
|
113
|
-
* without catching SessionError.
|
|
114
|
-
*/
|
|
112
|
+
/** Whether a persisted or process-local session exists. */
|
|
115
113
|
exists(sessionId: string): boolean;
|
|
116
114
|
/**
|
|
117
115
|
* Cheap persisted-main-root probe — reads only state.json, NOT the transcript
|
|
@@ -142,10 +140,10 @@ export declare class SessionManager {
|
|
|
142
140
|
}): string[];
|
|
143
141
|
/** @deprecated Use readSessionMainRoot; retained for public API compatibility. */
|
|
144
142
|
readCwd(sessionId: string): string | undefined;
|
|
145
|
-
/**
|
|
143
|
+
/** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
|
|
146
144
|
readParentSessionId(sessionId: string): string | null | undefined;
|
|
147
145
|
/**
|
|
148
|
-
*
|
|
146
|
+
* Workspace pointer reader. Legacy sessions written before
|
|
149
147
|
* `workspace` existed are treated as main-workspace sessions rooted at
|
|
150
148
|
* `state.cwd`; the read is intentionally non-mutating.
|
|
151
149
|
*/
|
|
@@ -306,6 +304,7 @@ export declare class SessionManager {
|
|
|
306
304
|
/** Publish a summary-only top-level fork after summarization has succeeded. */
|
|
307
305
|
createSummaryFork(sourceSessionId: string, options: SummaryForkOptions): ForkSessionResult;
|
|
308
306
|
private readForkSnapshot;
|
|
307
|
+
private freezeForkSnapshot;
|
|
309
308
|
private publishSessionAtomically;
|
|
310
309
|
list(limit?: number, opts?: {
|
|
311
310
|
excludeKinds?: readonly string[];
|
|
@@ -142,6 +142,12 @@ function adoptCompatibilityGoalMutation(state) {
|
|
|
142
142
|
state.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
// Quick Chat sessions are intentionally process-local. SessionManager
|
|
146
|
+
// instances are created per Engine, so this registry lives at module scope to
|
|
147
|
+
// let a fork created by one Engine be resumed by another without touching
|
|
148
|
+
// disk. The storage root remains part of the key to preserve identity/data-root
|
|
149
|
+
// isolation.
|
|
150
|
+
const processLocalSessionBundles = new Map();
|
|
145
151
|
const FORK_COPY_EVENT_TYPES = new Set([
|
|
146
152
|
"message",
|
|
147
153
|
"tool_use",
|
|
@@ -292,6 +298,26 @@ export class SessionManager {
|
|
|
292
298
|
mkdirSync(this.sessionsDir, { recursive: true });
|
|
293
299
|
this.cleanupStaleForkStaging();
|
|
294
300
|
}
|
|
301
|
+
processLocalKey(sessionId) {
|
|
302
|
+
return `${this.sessionsDir}\0${sessionId}`;
|
|
303
|
+
}
|
|
304
|
+
processLocalBundle(sessionId) {
|
|
305
|
+
return processLocalSessionBundles.get(this.processLocalKey(sessionId));
|
|
306
|
+
}
|
|
307
|
+
storeProcessLocalBundle(bundle) {
|
|
308
|
+
processLocalSessionBundles.set(this.processLocalKey(bundle.state.sessionId), {
|
|
309
|
+
state: structuredClone(bundle.state),
|
|
310
|
+
transcript: bundle.transcript,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Forget a Quick Chat/side-chat bundle immediately; nothing remains on disk. */
|
|
314
|
+
forgetEphemeralSession(sessionId) {
|
|
315
|
+
assertSafeSessionId(sessionId);
|
|
316
|
+
const bundle = this.processLocalBundle(sessionId);
|
|
317
|
+
if (!bundle || !isEphemeralSessionState(bundle.state))
|
|
318
|
+
return false;
|
|
319
|
+
return processLocalSessionBundles.delete(this.processLocalKey(sessionId));
|
|
320
|
+
}
|
|
295
321
|
cleanupStaleForkStaging() {
|
|
296
322
|
let removed = 0;
|
|
297
323
|
let entries;
|
|
@@ -336,11 +362,8 @@ export class SessionManager {
|
|
|
336
362
|
return next;
|
|
337
363
|
}
|
|
338
364
|
/**
|
|
339
|
-
* Create a
|
|
340
|
-
*
|
|
341
|
-
* "tui-main" and expect us to honor it). Otherwise generate one with
|
|
342
|
-
* nanoid. Either way the on-disk directory is materialized and the
|
|
343
|
-
* state.json + transcript.jsonl files are written before return.
|
|
365
|
+
* Create a session. `qchat-` sessions stay process-local; ordinary sessions
|
|
366
|
+
* materialize state.json + transcript.jsonl before return.
|
|
344
367
|
*/
|
|
345
368
|
create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
|
|
346
369
|
// External callers may pass any string; nanoid output is trusted. Either
|
|
@@ -349,16 +372,6 @@ export class SessionManager {
|
|
|
349
372
|
if (explicitSessionId !== undefined)
|
|
350
373
|
assertSafeSessionId(explicitSessionId);
|
|
351
374
|
const sessionId = explicitSessionId ?? nanoid(16);
|
|
352
|
-
const sessionDir = join(this.sessionsDir, sessionId);
|
|
353
|
-
try {
|
|
354
|
-
mkdirSync(sessionDir);
|
|
355
|
-
}
|
|
356
|
-
catch (err) {
|
|
357
|
-
if (err.code === "EEXIST") {
|
|
358
|
-
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
359
|
-
}
|
|
360
|
-
throw err;
|
|
361
|
-
}
|
|
362
375
|
const state = {
|
|
363
376
|
sessionId,
|
|
364
377
|
kind,
|
|
@@ -383,6 +396,33 @@ export class SessionManager {
|
|
|
383
396
|
...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
|
|
384
397
|
...(origin ? { origin } : {}),
|
|
385
398
|
};
|
|
399
|
+
if (isEphemeralSessionState(state)) {
|
|
400
|
+
if (this.processLocalBundle(sessionId)) {
|
|
401
|
+
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
402
|
+
}
|
|
403
|
+
const transcript = Transcript.inMemory(sessionId);
|
|
404
|
+
transcript.append("session_meta", {
|
|
405
|
+
sessionId,
|
|
406
|
+
cwd,
|
|
407
|
+
model,
|
|
408
|
+
provider,
|
|
409
|
+
startedAt: state.startedAt,
|
|
410
|
+
kind,
|
|
411
|
+
});
|
|
412
|
+
const bundle = { state, transcript };
|
|
413
|
+
this.storeProcessLocalBundle(bundle);
|
|
414
|
+
return bundle;
|
|
415
|
+
}
|
|
416
|
+
const sessionDir = join(this.sessionsDir, sessionId);
|
|
417
|
+
try {
|
|
418
|
+
mkdirSync(sessionDir);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err.code === "EEXIST") {
|
|
422
|
+
throw new SessionError(`Session already exists: ${sessionId}`);
|
|
423
|
+
}
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
386
426
|
// Atomic write (tmp+rename) like saveState, so a crash during this one-time
|
|
387
427
|
// create can't leave a torn state.json that resume() then fails to parse.
|
|
388
428
|
const stateTarget = join(sessionDir, "state.json");
|
|
@@ -400,11 +440,7 @@ export class SessionManager {
|
|
|
400
440
|
});
|
|
401
441
|
return { state, transcript };
|
|
402
442
|
}
|
|
403
|
-
/**
|
|
404
|
-
* Whether a session directory exists on disk. Used by ChatSession-driven
|
|
405
|
-
* cold starts to decide between resume vs create-with-explicit-sid
|
|
406
|
-
* without catching SessionError.
|
|
407
|
-
*/
|
|
443
|
+
/** Whether a persisted or process-local session exists. */
|
|
408
444
|
exists(sessionId) {
|
|
409
445
|
// exists() is a probe — callers use it to decide between resume and
|
|
410
446
|
// create-with-explicit-sid. Treat an invalid id as "not present"
|
|
@@ -415,6 +451,12 @@ export class SessionManager {
|
|
|
415
451
|
catch {
|
|
416
452
|
return false;
|
|
417
453
|
}
|
|
454
|
+
if (this.processLocalBundle(sessionId))
|
|
455
|
+
return true;
|
|
456
|
+
// Never resurrect a legacy Quick Chat directory after the process-local
|
|
457
|
+
// record has expired.
|
|
458
|
+
if (sessionId.startsWith("qchat-"))
|
|
459
|
+
return false;
|
|
418
460
|
return existsSync(join(this.sessionsDir, sessionId));
|
|
419
461
|
}
|
|
420
462
|
/**
|
|
@@ -430,6 +472,11 @@ export class SessionManager {
|
|
|
430
472
|
catch {
|
|
431
473
|
return undefined;
|
|
432
474
|
}
|
|
475
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
476
|
+
if (processLocal)
|
|
477
|
+
return sessionMainRoot(processLocal.state);
|
|
478
|
+
if (sessionId.startsWith("qchat-"))
|
|
479
|
+
return undefined;
|
|
433
480
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
434
481
|
if (!existsSync(stateFile))
|
|
435
482
|
return undefined;
|
|
@@ -449,6 +496,11 @@ export class SessionManager {
|
|
|
449
496
|
catch {
|
|
450
497
|
return undefined;
|
|
451
498
|
}
|
|
499
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
500
|
+
if (processLocal)
|
|
501
|
+
return normalizedSessionKind(processLocal.state.kind);
|
|
502
|
+
if (sessionId.startsWith("qchat-"))
|
|
503
|
+
return undefined;
|
|
452
504
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
453
505
|
if (!existsSync(stateFile))
|
|
454
506
|
return undefined;
|
|
@@ -464,6 +516,13 @@ export class SessionManager {
|
|
|
464
516
|
readSessionWorkspaceProfile(sessionId) {
|
|
465
517
|
try {
|
|
466
518
|
assertSafeSessionId(sessionId);
|
|
519
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
520
|
+
if (processLocal) {
|
|
521
|
+
const profile = processLocal.state.workspaceProfile;
|
|
522
|
+
return typeof profile === "string" && profile ? profile : undefined;
|
|
523
|
+
}
|
|
524
|
+
if (sessionId.startsWith("qchat-"))
|
|
525
|
+
return undefined;
|
|
467
526
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
468
527
|
if (!existsSync(stateFile))
|
|
469
528
|
return undefined;
|
|
@@ -534,7 +593,7 @@ export class SessionManager {
|
|
|
534
593
|
readCwd(sessionId) {
|
|
535
594
|
return this.readSessionMainRoot(sessionId);
|
|
536
595
|
}
|
|
537
|
-
/**
|
|
596
|
+
/** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
|
|
538
597
|
readParentSessionId(sessionId) {
|
|
539
598
|
try {
|
|
540
599
|
assertSafeSessionId(sessionId);
|
|
@@ -542,6 +601,13 @@ export class SessionManager {
|
|
|
542
601
|
catch {
|
|
543
602
|
return undefined;
|
|
544
603
|
}
|
|
604
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
605
|
+
if (processLocal) {
|
|
606
|
+
const parent = processLocal.state.parentSessionId;
|
|
607
|
+
return parent === null || typeof parent === "string" ? parent : undefined;
|
|
608
|
+
}
|
|
609
|
+
if (sessionId.startsWith("qchat-"))
|
|
610
|
+
return undefined;
|
|
545
611
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
546
612
|
if (!existsSync(stateFile))
|
|
547
613
|
return undefined;
|
|
@@ -556,7 +622,7 @@ export class SessionManager {
|
|
|
556
622
|
}
|
|
557
623
|
}
|
|
558
624
|
/**
|
|
559
|
-
*
|
|
625
|
+
* Workspace pointer reader. Legacy sessions written before
|
|
560
626
|
* `workspace` existed are treated as main-workspace sessions rooted at
|
|
561
627
|
* `state.cwd`; the read is intentionally non-mutating.
|
|
562
628
|
*/
|
|
@@ -567,6 +633,16 @@ export class SessionManager {
|
|
|
567
633
|
catch {
|
|
568
634
|
return undefined;
|
|
569
635
|
}
|
|
636
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
637
|
+
if (processLocal) {
|
|
638
|
+
const state = processLocal.state;
|
|
639
|
+
if (isSessionWorkspace(state.workspace))
|
|
640
|
+
return structuredClone(state.workspace);
|
|
641
|
+
const mainRoot = sessionMainRoot(state);
|
|
642
|
+
return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
|
|
643
|
+
}
|
|
644
|
+
if (sessionId.startsWith("qchat-"))
|
|
645
|
+
return undefined;
|
|
570
646
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
571
647
|
if (!existsSync(stateFile))
|
|
572
648
|
return undefined;
|
|
@@ -601,6 +677,11 @@ export class SessionManager {
|
|
|
601
677
|
catch {
|
|
602
678
|
return undefined;
|
|
603
679
|
}
|
|
680
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
681
|
+
if (processLocal)
|
|
682
|
+
return processLocal.state.archivedAt;
|
|
683
|
+
if (sessionId.startsWith("qchat-"))
|
|
684
|
+
return undefined;
|
|
604
685
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
605
686
|
if (!existsSync(stateFile))
|
|
606
687
|
return undefined;
|
|
@@ -618,6 +699,19 @@ export class SessionManager {
|
|
|
618
699
|
}
|
|
619
700
|
recordWorkspaceHandoff(sessionId, from, to) {
|
|
620
701
|
assertSafeSessionId(sessionId);
|
|
702
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
703
|
+
if (processLocal) {
|
|
704
|
+
processLocal.transcript.append("session_meta", {
|
|
705
|
+
sessionId,
|
|
706
|
+
cwd: to.root,
|
|
707
|
+
workspace: to,
|
|
708
|
+
handoffFrom: from?.root,
|
|
709
|
+
handoffAt: Date.now(),
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (sessionId.startsWith("qchat-"))
|
|
714
|
+
return;
|
|
621
715
|
const transcriptFile = join(this.sessionsDir, sessionId, "transcript.jsonl");
|
|
622
716
|
if (!existsSync(transcriptFile))
|
|
623
717
|
return;
|
|
@@ -645,15 +739,13 @@ export class SessionManager {
|
|
|
645
739
|
*/
|
|
646
740
|
async resolveSessionWorkspaceForResume(sessionId) {
|
|
647
741
|
assertSafeSessionId(sessionId);
|
|
648
|
-
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
649
|
-
if (!existsSync(stateFile)) {
|
|
650
|
-
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
651
|
-
}
|
|
652
742
|
let state;
|
|
653
743
|
try {
|
|
654
|
-
state =
|
|
744
|
+
state = this.readPersistedState(sessionId);
|
|
655
745
|
}
|
|
656
746
|
catch (err) {
|
|
747
|
+
if (err instanceof SessionError)
|
|
748
|
+
throw err;
|
|
657
749
|
throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
658
750
|
}
|
|
659
751
|
const mainRoot = sessionMainRoot(state);
|
|
@@ -731,6 +823,26 @@ export class SessionManager {
|
|
|
731
823
|
catch {
|
|
732
824
|
return undefined;
|
|
733
825
|
}
|
|
826
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
827
|
+
if (processLocal) {
|
|
828
|
+
try {
|
|
829
|
+
const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
830
|
+
const lifecycle = state.goalLifecycle;
|
|
831
|
+
if (!lifecycle || lifecycle.phase === "terminal")
|
|
832
|
+
return undefined;
|
|
833
|
+
const goal = goalConfigFromLifecycle(lifecycle);
|
|
834
|
+
return {
|
|
835
|
+
...goal,
|
|
836
|
+
goalId: goal.goalId ?? deriveLegacyGoalId(sessionId, goal),
|
|
837
|
+
revision: goal.revision ?? 1,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (sessionId.startsWith("qchat-"))
|
|
845
|
+
return undefined;
|
|
734
846
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
735
847
|
if (!existsSync(stateFile))
|
|
736
848
|
return undefined;
|
|
@@ -873,6 +985,18 @@ export class SessionManager {
|
|
|
873
985
|
}
|
|
874
986
|
resume(sessionId) {
|
|
875
987
|
assertSafeSessionId(sessionId);
|
|
988
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
989
|
+
if (processLocal) {
|
|
990
|
+
const state = hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
991
|
+
state.kind = normalizedSessionKind(state.kind);
|
|
992
|
+
state.status = "active";
|
|
993
|
+
delete state.lastCompletionKind;
|
|
994
|
+
Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
|
|
995
|
+
return { state, transcript: processLocal.transcript };
|
|
996
|
+
}
|
|
997
|
+
if (sessionId.startsWith("qchat-")) {
|
|
998
|
+
throw new SessionError(`Session not found: ${sessionId}`);
|
|
999
|
+
}
|
|
876
1000
|
const sessionDir = join(this.sessionsDir, sessionId);
|
|
877
1001
|
if (!existsSync(sessionDir)) {
|
|
878
1002
|
throw new SessionError(`Session not found: ${sessionId}`);
|
|
@@ -1166,6 +1290,34 @@ export class SessionManager {
|
|
|
1166
1290
|
(currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
|
|
1167
1291
|
return { ok: false, reason: "generation_conflict" };
|
|
1168
1292
|
}
|
|
1293
|
+
const processLocal = this.processLocalBundle(state.sessionId);
|
|
1294
|
+
if (processLocal) {
|
|
1295
|
+
const persisted = structuredClone(processLocal.state);
|
|
1296
|
+
const incomingKind = normalizedSessionKind(state.kind);
|
|
1297
|
+
const persistedKind = normalizedSessionKind(persisted.kind);
|
|
1298
|
+
if (incomingKind !== persistedKind)
|
|
1299
|
+
return { ok: false, reason: "kind_conflict" };
|
|
1300
|
+
state.kind = persistedKind;
|
|
1301
|
+
const persistedRevision = persisted.stateRevision;
|
|
1302
|
+
const incomingRevision = state.stateRevision;
|
|
1303
|
+
const revisionsMatch = (persistedRevision === undefined && incomingRevision === undefined) ||
|
|
1304
|
+
(typeof persistedRevision === "number" && incomingRevision === persistedRevision);
|
|
1305
|
+
if (!revisionsMatch)
|
|
1306
|
+
return { ok: false, reason: "revision_conflict" };
|
|
1307
|
+
if (persisted.title !== undefined && !("title" in state))
|
|
1308
|
+
state.title = persisted.title;
|
|
1309
|
+
state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
|
|
1310
|
+
const next = hydrateGoalLifecycle(structuredClone(stateForPersistence(state)));
|
|
1311
|
+
this.rebaseLiveState(processLocal.state, next);
|
|
1312
|
+
if (state !== processLocal.state)
|
|
1313
|
+
this.rebaseLiveState(state, next);
|
|
1314
|
+
return { ok: true };
|
|
1315
|
+
}
|
|
1316
|
+
// A closed process-local session must stay gone even if a late writer
|
|
1317
|
+
// races the close fence.
|
|
1318
|
+
if (isEphemeralSessionState(state)) {
|
|
1319
|
+
return { ok: false, reason: "revision_conflict" };
|
|
1320
|
+
}
|
|
1169
1321
|
const sessionDir = join(this.sessionsDir, state.sessionId);
|
|
1170
1322
|
mkdirSync(sessionDir, { recursive: true });
|
|
1171
1323
|
const target = join(sessionDir, "state.json");
|
|
@@ -1272,6 +1424,12 @@ export class SessionManager {
|
|
|
1272
1424
|
}
|
|
1273
1425
|
}
|
|
1274
1426
|
readPersistedState(sessionId) {
|
|
1427
|
+
const processLocal = this.processLocalBundle(sessionId);
|
|
1428
|
+
if (processLocal)
|
|
1429
|
+
return hydrateGoalLifecycle(structuredClone(processLocal.state));
|
|
1430
|
+
if (sessionId.startsWith("qchat-")) {
|
|
1431
|
+
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
1432
|
+
}
|
|
1275
1433
|
const stateFile = join(this.sessionsDir, sessionId, "state.json");
|
|
1276
1434
|
if (!existsSync(stateFile)) {
|
|
1277
1435
|
throw new SessionError(`Session state file not found: ${sessionId}`);
|
|
@@ -1322,6 +1480,13 @@ export class SessionManager {
|
|
|
1322
1480
|
/** Freeze and validate an inclusive source range before any model call. */
|
|
1323
1481
|
selectContextPackage(sourceSessionId, range) {
|
|
1324
1482
|
assertSafeSessionId(sourceSessionId);
|
|
1483
|
+
const processLocal = this.processLocalBundle(sourceSessionId);
|
|
1484
|
+
if (processLocal) {
|
|
1485
|
+
return Transcript.selectContextRange(processLocal.transcript.getEvents(), range);
|
|
1486
|
+
}
|
|
1487
|
+
if (sourceSessionId.startsWith("qchat-")) {
|
|
1488
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1489
|
+
}
|
|
1325
1490
|
const transcriptFile = join(this.sessionsDir, sourceSessionId, "transcript.jsonl");
|
|
1326
1491
|
const stateFile = join(this.sessionsDir, sourceSessionId, "state.json");
|
|
1327
1492
|
if (!existsSync(stateFile))
|
|
@@ -1377,6 +1542,13 @@ export class SessionManager {
|
|
|
1377
1542
|
return { bundle, lineage, copiedEventCount: 0 };
|
|
1378
1543
|
}
|
|
1379
1544
|
readForkSnapshot(sourceSessionId, throughEventId, snapshotMode) {
|
|
1545
|
+
const processLocal = this.processLocalBundle(sourceSessionId);
|
|
1546
|
+
if (processLocal) {
|
|
1547
|
+
return this.freezeForkSnapshot(sourceSessionId, structuredClone(processLocal.state), processLocal.transcript.getEvents(), throughEventId, snapshotMode);
|
|
1548
|
+
}
|
|
1549
|
+
if (sourceSessionId.startsWith("qchat-")) {
|
|
1550
|
+
throw new SessionError(`Session not found: ${sourceSessionId}`);
|
|
1551
|
+
}
|
|
1380
1552
|
const sessionDir = join(this.sessionsDir, sourceSessionId);
|
|
1381
1553
|
const stateFile = join(sessionDir, "state.json");
|
|
1382
1554
|
const transcriptFile = join(sessionDir, "transcript.jsonl");
|
|
@@ -1393,7 +1565,10 @@ export class SessionManager {
|
|
|
1393
1565
|
if (parsed.malformedLineCount > 0) {
|
|
1394
1566
|
throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
|
|
1395
1567
|
}
|
|
1396
|
-
|
|
1568
|
+
return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
|
|
1569
|
+
}
|
|
1570
|
+
freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
|
|
1571
|
+
const sourceEvents = structuredClone([...events]);
|
|
1397
1572
|
let frozen = sourceEvents;
|
|
1398
1573
|
const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
|
|
1399
1574
|
if (snapshotMode === "completed" && effectiveCursor === undefined) {
|
|
@@ -1436,6 +1611,17 @@ export class SessionManager {
|
|
|
1436
1611
|
}
|
|
1437
1612
|
publishSessionAtomically(targetSessionId, state, events) {
|
|
1438
1613
|
const targetDir = join(this.sessionsDir, targetSessionId);
|
|
1614
|
+
if (isEphemeralSessionState(state)) {
|
|
1615
|
+
if (this.processLocalBundle(targetSessionId) || existsSync(targetDir)) {
|
|
1616
|
+
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
1617
|
+
}
|
|
1618
|
+
const bundle = {
|
|
1619
|
+
state: hydrateGoalLifecycle(structuredClone(state)),
|
|
1620
|
+
transcript: Transcript.fromMemoryEvents(targetSessionId, events),
|
|
1621
|
+
};
|
|
1622
|
+
this.storeProcessLocalBundle(bundle);
|
|
1623
|
+
return bundle;
|
|
1624
|
+
}
|
|
1439
1625
|
if (existsSync(targetDir))
|
|
1440
1626
|
throw new SessionError(`Session already exists: ${targetSessionId}`);
|
|
1441
1627
|
const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
|