@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.
@@ -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
- "Call with NO arguments first to list available credentials (id + label + type); " +
23
- "then call again with `id` to fetch one. Token/link credentials return their secret " +
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.";
@@ -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
- contextManager.setTranscriptPath(session.transcript.getFilePath());
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 sessionDir = join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId);
1708
- const fileHistoryHook = registerFileHistoryHook({
1709
- hooks: this.hooks,
1710
- sessionDir,
1711
- cwd,
1712
- getTurnSeq: () => session.state.turnSeq,
1713
- contributions: this.capabilities.flatMap((capability) => [...(capability.fileHistory ?? [])]),
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
- contextManager.setTranscriptPath(session.transcript.getFilePath());
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.2";
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.2";
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(result);
890
+ this.applyAnthropicCacheBreakpoints(normalized);
844
891
  }
845
- return result;
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);
@@ -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
- "Produce the requested output directly; when uncertain, state your assumption " +
31
- "and proceed." +
32
- " When finished, call UpdateAutomationMemory exactly once with a concise " +
33
- "summary of this run's key findings/state for the next run.";
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
- /** Hide this temporary fork from ordinary session lists and resume pickers. */
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 new on-disk session. If `explicitSessionId` is passed, use
104
- * it verbatim (ChatSessionManager-driven hosts choose a logical sid like
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
- /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
143
+ /** Direct-parent ACL metadata. Undefined means unprovable/corrupt. */
146
144
  readParentSessionId(sessionId: string): string | null | undefined;
147
145
  /**
148
- * Disk-only workspace pointer reader. Legacy sessions written before
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 new on-disk session. If `explicitSessionId` is passed, use
340
- * it verbatim (ChatSessionManager-driven hosts choose a logical sid like
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
- /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
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
- * Disk-only workspace pointer reader. Legacy sessions written before
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 = JSON.parse(readFileSync(stateFile, "utf-8"));
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
- const sourceEvents = structuredClone(parsed.events);
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)}`);