@cjhyy/code-shell-core 0.8.7 → 0.8.9

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.
@@ -148,25 +148,34 @@ function adoptCompatibilityGoalMutation(state) {
148
148
  // disk. The storage root remains part of the key to preserve identity/data-root
149
149
  // isolation.
150
150
  const processLocalSessionBundles = new Map();
151
- const FORK_COPY_EVENT_TYPES = new Set([
152
- "message",
153
- "tool_use",
154
- "tool_result",
155
- "summary",
156
- "context_transfer",
157
- "content_replace",
158
- "subagent",
159
- "external_file_changes",
160
- "goal_progress",
161
- "turn_boundary",
162
- "turn_stopped",
163
- "error",
164
- ]);
165
- const FORK_SKIP_EVENT_TYPES = new Set([
166
- "session_meta",
167
- "file_history",
168
- "plan_operation",
169
- ]);
151
+ /**
152
+ * Every known transcript event must make an explicit fork decision. The
153
+ * `satisfies` constraint turns new TranscriptEventType additions into a
154
+ * compile-time error instead of a quick-chat failure discovered at runtime.
155
+ * The runtime fallback below still rejects unknown events from newer or
156
+ * malformed persisted transcripts.
157
+ */
158
+ const FORK_EVENT_POLICY = {
159
+ message: "copy",
160
+ tool_use: "copy",
161
+ tool_result: "copy",
162
+ summary: "copy",
163
+ context_transfer: "copy",
164
+ range_archive: "copy",
165
+ content_replace: "copy",
166
+ file_history: "skip",
167
+ plan_operation: "skip",
168
+ session_meta: "skip",
169
+ subagent: "copy",
170
+ external_file_changes: "copy",
171
+ turn_boundary: "copy",
172
+ // Idempotency receipts belong to the source session and never contribute to
173
+ // model context. Copying them could replay a source response in the child.
174
+ run_result: "skip",
175
+ goal_progress: "copy",
176
+ turn_stopped: "copy",
177
+ error: "copy",
178
+ };
170
179
  const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
171
180
  const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
172
181
  const FORK_STAGING_CLEANUP_LIMIT = 32;
@@ -365,7 +374,7 @@ export class SessionManager {
365
374
  * Create a session. `qchat-` sessions stay process-local; ordinary sessions
366
375
  * materialize state.json + transcript.jsonl before return.
367
376
  */
368
- create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
377
+ create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work", ephemeral = false) {
369
378
  // External callers may pass any string; nanoid output is trusted. Either
370
379
  // way the ID gets joined into a filesystem path, so the public entry
371
380
  // point validates before that join.
@@ -393,7 +402,7 @@ export class SessionManager {
393
402
  // new top-level session (key present, null) apart from a legacy session
394
403
  // (key absent) and from a sub-agent (key present, non-empty string).
395
404
  parentSessionId: parentSessionId ?? null,
396
- ...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
405
+ ...(ephemeral || sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
397
406
  ...(origin ? { origin } : {}),
398
407
  };
399
408
  if (isEphemeralSessionState(state)) {
@@ -1048,6 +1057,10 @@ export class SessionManager {
1048
1057
  return undefined;
1049
1058
  }
1050
1059
  }
1060
+ /** Whether a live or persisted Session is explicitly process-local. */
1061
+ isEphemeralSession(sessionId) {
1062
+ return this.readSessionState(sessionId)?.ephemeral === true;
1063
+ }
1051
1064
  /**
1052
1065
  * Merge a field-level state update into the latest persisted snapshot.
1053
1066
  *
@@ -1568,7 +1581,10 @@ export class SessionManager {
1568
1581
  return this.freezeForkSnapshot(sourceSessionId, sourceState, parsed.events, throughEventId, snapshotMode);
1569
1582
  }
1570
1583
  freezeForkSnapshot(sourceSessionId, sourceState, events, throughEventId, snapshotMode) {
1571
- const sourceEvents = structuredClone([...events]);
1584
+ // Both process-local and disk readers provide a snapshot array. Fork
1585
+ // selection is synchronous, so keep event references here and clone once
1586
+ // when the independently-owned target transcript is constructed below.
1587
+ const sourceEvents = [...events];
1572
1588
  let frozen = sourceEvents;
1573
1589
  const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
1574
1590
  if (snapshotMode === "completed" && effectiveCursor === undefined) {
@@ -1599,12 +1615,13 @@ export class SessionManager {
1599
1615
  }
1600
1616
  const copiedEvents = [];
1601
1617
  for (const event of frozen) {
1602
- if (FORK_SKIP_EVENT_TYPES.has(event.type))
1618
+ const policy = FORK_EVENT_POLICY[event.type];
1619
+ if (policy === "skip")
1603
1620
  continue;
1604
- if (!FORK_COPY_EVENT_TYPES.has(event.type)) {
1621
+ if (policy !== "copy") {
1605
1622
  throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
1606
1623
  }
1607
- copiedEvents.push(structuredClone(event));
1624
+ copiedEvents.push(event);
1608
1625
  }
1609
1626
  validateForkToolPairs(copiedEvents);
1610
1627
  return { sourceState: structuredClone(sourceState), copiedEvents };
@@ -106,12 +106,48 @@ export declare class Transcript {
106
106
  */
107
107
  appendTurnStopped(): TranscriptEvent | undefined;
108
108
  appendSummary(summary: string, metadata: SummaryAppendMetadata): TranscriptEvent;
109
+ /**
110
+ * Persist a range-archival boundary. Span is [fromClientMessageId,
111
+ * toClientMessageId) over message events; an absent from means "from the
112
+ * beginning". Idempotent on segmentId so a crash-replayed closure cannot
113
+ * double-archive.
114
+ */
115
+ appendRangeArchive(data: {
116
+ summary: string;
117
+ toClientMessageId: string;
118
+ fromClientMessageId?: string;
119
+ segmentId?: string;
120
+ }): TranscriptEvent | undefined;
109
121
  appendError(error: string, details?: Record<string, unknown>): TranscriptEvent;
110
122
  /**
111
123
  * Derive Message[] from transcript events for sending to the LLM.
112
124
  * This is the critical boundary: the LLM never sees the event log directly.
113
125
  */
114
126
  toMessages(): Message[];
127
+ /**
128
+ * Marker-aware replay that ALSO reports, for every emitted message event
129
+ * carrying a clientMessageId, the LIVE index (its position in the returned
130
+ * messages array) of its FIRST emission. This is how the engine resolves an
131
+ * anchored archival window: raw transcript indices grow forever and go stale
132
+ * the moment a range_archive marker trims the replay, so any caller-held
133
+ * index range is meaningless — only client-message-id anchors resolved over
134
+ * THIS replay identify the right span.
135
+ *
136
+ * Contract details:
137
+ * - Messages dropped inside an archived span get NO index entry — they are
138
+ * not in the live list, so a window anchored on them cannot be built
139
+ * (the engine fails open in that case).
140
+ * - Only the FIRST emission of a duplicated clientMessageId is recorded
141
+ * (duplicates replay as plain messages per the one-shot span rule; the
142
+ * first index is the meaningful one).
143
+ * - The clientMessageId is deliberately NOT attached to the Message objects
144
+ * themselves: Message[] is exactly what gets serialized into LLM request
145
+ * payloads, and transport metadata must not leak into them.
146
+ */
147
+ toMessagesWithIndex(): {
148
+ messages: Message[];
149
+ liveIndexByClientMessageId: Map<string, number>;
150
+ };
115
151
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
116
152
  get turnNumber(): number;
117
153
  get eventCount(): number;
@@ -12,6 +12,7 @@ const CONTEXT_EVENT_TYPES = new Set([
12
12
  "tool_result",
13
13
  "summary",
14
14
  "context_transfer",
15
+ "range_archive",
15
16
  ]);
16
17
  const INTERRUPTED_TOOL_RESULT_ERROR = "[Tool result missing due to interrupted session]";
17
18
  function isSyntheticInterruptedToolResult(event) {
@@ -212,6 +213,19 @@ export class Transcript {
212
213
  },
213
214
  });
214
215
  }
216
+ /**
217
+ * Persist a range-archival boundary. Span is [fromClientMessageId,
218
+ * toClientMessageId) over message events; an absent from means "from the
219
+ * beginning". Idempotent on segmentId so a crash-replayed closure cannot
220
+ * double-archive.
221
+ */
222
+ appendRangeArchive(data) {
223
+ if (data.segmentId &&
224
+ this.events.some((e) => e.type === "range_archive" && e.data.segmentId === data.segmentId)) {
225
+ return undefined;
226
+ }
227
+ return this.append("range_archive", { ...data });
228
+ }
215
229
  appendError(error, details) {
216
230
  return this.append("error", { error, ...details });
217
231
  }
@@ -220,12 +234,128 @@ export class Transcript {
220
234
  * This is the critical boundary: the LLM never sees the event log directly.
221
235
  */
222
236
  toMessages() {
237
+ return this.toMessagesWithIndex().messages;
238
+ }
239
+ /**
240
+ * Marker-aware replay that ALSO reports, for every emitted message event
241
+ * carrying a clientMessageId, the LIVE index (its position in the returned
242
+ * messages array) of its FIRST emission. This is how the engine resolves an
243
+ * anchored archival window: raw transcript indices grow forever and go stale
244
+ * the moment a range_archive marker trims the replay, so any caller-held
245
+ * index range is meaningless — only client-message-id anchors resolved over
246
+ * THIS replay identify the right span.
247
+ *
248
+ * Contract details:
249
+ * - Messages dropped inside an archived span get NO index entry — they are
250
+ * not in the live list, so a window anchored on them cannot be built
251
+ * (the engine fails open in that case).
252
+ * - Only the FIRST emission of a duplicated clientMessageId is recorded
253
+ * (duplicates replay as plain messages per the one-shot span rule; the
254
+ * first index is the meaningful one).
255
+ * - The clientMessageId is deliberately NOT attached to the Message objects
256
+ * themselves: Message[] is exactly what gets serialized into LLM request
257
+ * payloads, and transport metadata must not leak into them.
258
+ */
259
+ toMessagesWithIndex() {
223
260
  const messages = [];
261
+ const liveIndexByClientMessageId = new Map();
224
262
  const selectedToolResults = preferredToolResults(this.events);
263
+ const hasRangeArchive = this.events.some((e) => e.type === "range_archive");
264
+ const spansByFromId = new Map();
265
+ let openingSpan;
266
+ if (hasRangeArchive) {
267
+ // First-occurrence event index per client message id, so a marker whose
268
+ // `to` does not come strictly after its `from` (out of order, or a
269
+ // degenerate from === to) can be rejected. Without this check the span
270
+ // opens at `from` but its close condition (`to`) was already passed
271
+ // while scanning forward, so it would never close — silently swallowing
272
+ // the rest of the conversation. Fail open instead: ignore the marker.
273
+ const firstIndexByClientId = new Map();
274
+ for (const [index, event] of this.events.entries()) {
275
+ if (event.type === "message" && typeof event.data.clientMessageId === "string") {
276
+ if (!firstIndexByClientId.has(event.data.clientMessageId)) {
277
+ firstIndexByClientId.set(event.data.clientMessageId, index);
278
+ }
279
+ }
280
+ }
281
+ const presentClientIds = new Set(firstIndexByClientId.keys());
282
+ for (const event of this.events) {
283
+ if (event.type !== "range_archive")
284
+ continue;
285
+ const { summary, toClientMessageId, fromClientMessageId } = event.data;
286
+ if (typeof summary !== "string" || !presentClientIds.has(toClientMessageId))
287
+ continue;
288
+ if (fromClientMessageId === undefined) {
289
+ // Multiple from-less markers compete for this single opening-span
290
+ // slot; the LAST one wins (matching spansByFromId's Map.set
291
+ // semantics below). Last-wins is CORRECT by construction, not
292
+ // merely a tiebreak: the engine resolves a from-less archival
293
+ // window as [0, to) over the LIVE replay, so the window that
294
+ // produced a LATER from-less marker began with the EARLIER
295
+ // marker's replayed summary message, and summarizeRange merge-fed
296
+ // that prior summary (extractAnchoredSummary) into the new one.
297
+ // The later summary therefore already contains the earlier one's
298
+ // content — dropping the earlier marker here loses nothing. (And
299
+ // in production from-less windows only advance: each new marker
300
+ // ends at a later boundary, so the surviving span is the widest.)
301
+ openingSpan = { summary, toClientMessageId };
302
+ }
303
+ else if (presentClientIds.has(fromClientMessageId)) {
304
+ const fromIndex = firstIndexByClientId.get(fromClientMessageId);
305
+ const toIndex = firstIndexByClientId.get(toClientMessageId);
306
+ if (toIndex <= fromIndex)
307
+ continue; // out of order or degenerate: fail open
308
+ spansByFromId.set(fromClientMessageId, { summary, toClientMessageId });
309
+ }
310
+ }
311
+ }
312
+ let activeSpan = null;
313
+ if (openingSpan) {
314
+ activeSpan = openingSpan;
315
+ messages.push({ role: "user", content: openingSpan.summary });
316
+ }
317
+ // tool_use ids actually emitted into assistant message content blocks so
318
+ // far. A tool_result whose tool_use_id isn't in this set — e.g. because
319
+ // its opening tool_use fell inside an archived span while the (later,
320
+ // preferred) real result landed outside it — would be an orphaned block
321
+ // that breaks provider validation; skip it instead of emitting it.
322
+ const emittedToolUseIds = new Set();
225
323
  for (const event of this.events) {
324
+ // Span bookkeeping runs on message events only: exit before entry so
325
+ // adjacent spans (A.to === B.from) hand over on the boundary message.
326
+ if (event.type === "message") {
327
+ const clientMessageId = typeof event.data.clientMessageId === "string" ? event.data.clientMessageId : undefined;
328
+ if (activeSpan && clientMessageId === activeSpan.toClientMessageId) {
329
+ activeSpan = null;
330
+ }
331
+ if (!activeSpan && clientMessageId && spansByFromId.has(clientMessageId)) {
332
+ activeSpan = spansByFromId.get(clientMessageId);
333
+ // One-shot: a duplicate `from` message (e.g. from a torn JSONL
334
+ // reload) must not reopen this span a second time — it would have
335
+ // no more `to` ahead of it and swallow the rest of the transcript.
336
+ spansByFromId.delete(clientMessageId);
337
+ messages.push({ role: "user", content: activeSpan.summary });
338
+ }
339
+ }
340
+ if (activeSpan)
341
+ continue; // archived span: drop every context event inside
226
342
  switch (event.type) {
227
343
  case "message": {
228
- const { role, content } = event.data;
344
+ const { role, content, clientMessageId } = event.data;
345
+ // Record the live index of this message's FIRST emission before
346
+ // pushing it (the index it is about to occupy). Dropped-in-span
347
+ // messages never reach this point, so they get no entry.
348
+ if (typeof clientMessageId === "string" &&
349
+ !liveIndexByClientMessageId.has(clientMessageId)) {
350
+ liveIndexByClientMessageId.set(clientMessageId, messages.length);
351
+ }
352
+ if (role === "assistant" && Array.isArray(content)) {
353
+ for (const block of content) {
354
+ if (block.type === "tool_use" && typeof block.id === "string") {
355
+ emittedToolUseIds.add(block.id);
356
+ }
357
+ }
358
+ }
229
359
  messages.push({ role: role, content });
230
360
  break;
231
361
  }
@@ -237,7 +367,8 @@ export class Transcript {
237
367
  case "tool_result": {
238
368
  const eventToolCallId = event.data.toolCallId;
239
369
  if (typeof eventToolCallId !== "string" ||
240
- selectedToolResults.get(eventToolCallId) !== event) {
370
+ selectedToolResults.get(eventToolCallId) !== event ||
371
+ !emittedToolUseIds.has(eventToolCallId)) {
241
372
  break;
242
373
  }
243
374
  const { toolCallId, result, error, contentBlocks } = event.data;
@@ -280,10 +411,12 @@ export class Transcript {
280
411
  break;
281
412
  }
282
413
  // turn_boundary, run_result, session_meta, file_history, plan_operation, error
283
- // are not included in LLM messages
414
+ // are not included in LLM messages. range_archive is handled entirely
415
+ // by the pre-pass above (it emits the summary at span entry and drops
416
+ // events inside the span); it never falls through to this switch.
284
417
  }
285
418
  }
286
- return messages;
419
+ return { messages, liveIndexByClientMessageId };
287
420
  }
288
421
  getEvents(type) {
289
422
  if (!type)
@@ -448,6 +581,18 @@ export class Transcript {
448
581
  });
449
582
  break;
450
583
  }
584
+ case "range_archive": {
585
+ // A hand-picked context range is an explicit user selection: inject
586
+ // the archive summary as context but do NOT replace/drop the
587
+ // messages inside its span the way toMessages() does — the caller
588
+ // asked for exactly this range and expects to see it in full.
589
+ const { summary } = event.data;
590
+ messages.push({
591
+ role: "user",
592
+ content: `<system-reminder>Archived summary for part of this range:\n${summary}</system-reminder>`,
593
+ });
594
+ break;
595
+ }
451
596
  }
452
597
  }
453
598
  return messages;
@@ -310,7 +310,10 @@ const BUILTIN_CONTRIBUTIONS = [
310
310
  timeoutMs: 1_800_000, // 30min — sub-agent runs may execute many tool calls
311
311
  },
312
312
  execute: agentTool,
313
- exposure: expose(HARNESS_TAGS, { defaultPermissionRules: allow(agentToolDef.name) }),
313
+ exposure: expose(HARNESS_TAGS, {
314
+ defaultPermissionRules: allow(agentToolDef.name),
315
+ availability: (ctx) => ctx.behaviorProfile !== "quickChatRestricted",
316
+ }),
314
317
  },
315
318
  {
316
319
  definition: {
@@ -449,8 +449,6 @@ function isRegisteredSkillResourceRead(resolved) {
449
449
  return false;
450
450
  if (isSkillTreeResource(resolved, join(codeShellRoot, "skills")))
451
451
  return true;
452
- if (isInstalledPanelAppSkillResourceRead(resolved, codeShellRoot))
453
- return true;
454
452
  let cacheRoot;
455
453
  try {
456
454
  cacheRoot = realpathSync(join(codeShellRoot, "plugins", "cache"));
@@ -479,20 +477,26 @@ function isRegisteredSkillResourceRead(resolved) {
479
477
  return false;
480
478
  }
481
479
  /**
482
- * Panel App Skills live under the otherwise-sensitive
483
- * `~/.code-shell/panel-apps` tree. Installation already reviews and copies the
484
- * package, and the Skill scanner exposes only entries declared by the app
485
- * manifest. Mirror that exact boundary here so reading a Skill reference does
486
- * not trigger a second approval prompt.
480
+ * Installed Panel Apps live under the otherwise-sensitive
481
+ * `~/.code-shell/panel-apps` tree. Their package contents are reviewed and
482
+ * copied by the installer, so ordinary source, manifests, assets and declared
483
+ * Agent resources should not require a second approval merely because their
484
+ * parent directory is `~/.code-shell`.
487
485
  *
488
- * Registry, app root, manifest, declared SKILL.md and target are all
489
- * realpathed and containment-checked. This deliberately does not trust an
490
- * undeclared Skill directory or a symlink escaping the installed app.
486
+ * Keep the exception read-only at the call site and require a valid installed
487
+ * registry entry plus a matching V2 manifest. Registry, app root, manifest and
488
+ * target are all realpathed and containment-checked. Credential-shaped files
489
+ * are still caught by `SENSITIVE_FILE_PATTERNS` before this exception, and a
490
+ * symlink escaping the installed package never inherits its read authority.
491
491
  */
492
- function isInstalledPanelAppSkillResourceRead(resolved, codeShellRoot) {
492
+ function isInstalledPanelAppResourceRead(resolved) {
493
+ let codeShellRoot;
493
494
  let appsRoot;
494
495
  let registryPath;
495
496
  try {
497
+ codeShellRoot = realpathSync(join(configuredUserHome(), ".code-shell"));
498
+ if (!isInsideDir(resolved, codeShellRoot))
499
+ return false;
496
500
  appsRoot = realpathSync(join(codeShellRoot, "panel-apps"));
497
501
  if (!isInsideDir(appsRoot, codeShellRoot))
498
502
  return false;
@@ -526,29 +530,10 @@ function isInstalledPanelAppSkillResourceRead(resolved, codeShellRoot) {
526
530
  if (!isInsideDir(manifestPath, appRoot) || !statSync(manifestPath).isFile())
527
531
  continue;
528
532
  const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
529
- if (manifest.schemaVersion !== 2 ||
530
- manifest.id !== id ||
531
- !Array.isArray(manifest.agent?.skills)) {
533
+ if (manifest.schemaVersion !== 2 || manifest.id !== id)
532
534
  continue;
533
- }
534
- for (const skillEntry of manifest.agent.skills) {
535
- if (typeof skillEntry !== "string")
536
- continue;
537
- const segments = skillEntry.split("/");
538
- if (segments.length !== 4 ||
539
- segments[0] !== "agent" ||
540
- segments[1] !== "skills" ||
541
- !/^[a-z][a-z0-9-]{0,63}$/.test(segments[2] ?? "") ||
542
- segments[3] !== "SKILL.md") {
543
- continue;
544
- }
545
- const skillManifest = realpathSync(join(appRoot, ...segments));
546
- if (!isInsideDir(skillManifest, appRoot) || !statSync(skillManifest).isFile())
547
- continue;
548
- const skillRoot = dirname(skillManifest);
549
- if (isInsideDir(resolved, skillRoot))
550
- return true;
551
- }
535
+ if (isInsideDir(resolved, appRoot))
536
+ return true;
552
537
  }
553
538
  catch {
554
539
  // A stale, malformed, or tampered Panel App entry grants no read access.
@@ -652,6 +637,13 @@ export function classifyPath(rawPath, opts) {
652
637
  resolvedPath: resolved,
653
638
  };
654
639
  }
640
+ if (opts.operation === "read" && !sensitiveFile && isInstalledPanelAppResourceRead(resolved)) {
641
+ return {
642
+ decision: "allow",
643
+ reason: "installed Panel App resource read",
644
+ resolvedPath: resolved,
645
+ };
646
+ }
655
647
  // Sensitive: write is always denied, read always asks. Workspace placement
656
648
  // doesn't soften the rule — an `.env` in the project still asks on read.
657
649
  if (sensitiveLabel) {
package/dist/types.d.ts CHANGED
@@ -145,7 +145,7 @@ export interface RegisteredTool {
145
145
  */
146
146
  timeoutMs?: number;
147
147
  }
148
- export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
148
+ export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "range_archive" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
149
149
  export interface TranscriptEvent {
150
150
  id: string;
151
151
  type: TranscriptEventType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",