@cjhyy/code-shell-core 0.9.5 → 0.9.7

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.
Files changed (89) hide show
  1. package/dist/automation/scheduler.d.ts +3 -0
  2. package/dist/automation/scheduler.js +21 -0
  3. package/dist/context/manager.d.ts +8 -2
  4. package/dist/context/manager.js +20 -4
  5. package/dist/context/notes.d.ts +39 -0
  6. package/dist/context/notes.js +314 -0
  7. package/dist/credentials/access.d.ts +9 -1
  8. package/dist/credentials/access.js +15 -4
  9. package/dist/credentials/store.d.ts +11 -0
  10. package/dist/credentials/store.js +44 -9
  11. package/dist/credentials/types.d.ts +4 -0
  12. package/dist/credentials/types.js +4 -0
  13. package/dist/credentials/use-credential-tool.js +12 -2
  14. package/dist/engine/engine-workspace-authority.js +10 -3
  15. package/dist/engine/engine.d.ts +8 -4
  16. package/dist/engine/engine.js +115 -15
  17. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  18. package/dist/engine/run-goal.js +7 -3
  19. package/dist/engine/run-tooling.d.ts +3 -0
  20. package/dist/engine/run-tooling.js +25 -23
  21. package/dist/engine/run-types.d.ts +20 -0
  22. package/dist/engine/run-workspace.js +5 -21
  23. package/dist/engine/subagent-spawner.d.ts +3 -0
  24. package/dist/engine/subagent-spawner.js +48 -17
  25. package/dist/engine/turn-loop.d.ts +14 -4
  26. package/dist/engine/turn-loop.js +120 -28
  27. package/dist/engine/types.d.ts +19 -0
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +2 -2
  30. package/dist/links/index.d.ts +1 -0
  31. package/dist/links/index.js +1 -0
  32. package/dist/links/link-action-tool.d.ts +2 -1
  33. package/dist/links/link-action-tool.js +80 -44
  34. package/dist/links/status.d.ts +53 -0
  35. package/dist/links/status.js +175 -0
  36. package/dist/llm/prompt-cache.d.ts +35 -3
  37. package/dist/llm/prompt-cache.js +63 -3
  38. package/dist/llm/providers/openai.d.ts +3 -0
  39. package/dist/llm/providers/openai.js +57 -14
  40. package/dist/prompt/section-loader.js +1 -0
  41. package/dist/prompt/sections/browser.md +4 -2
  42. package/dist/prompt/sections/context-notes.md +9 -0
  43. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  44. package/dist/protocol/background-result-wakeup.js +76 -38
  45. package/dist/protocol/chat-session-manager.d.ts +7 -1
  46. package/dist/protocol/chat-session-manager.js +44 -8
  47. package/dist/protocol/chat-session.d.ts +2 -0
  48. package/dist/protocol/chat-session.js +4 -1
  49. package/dist/protocol/server.d.ts +4 -0
  50. package/dist/protocol/server.js +355 -62
  51. package/dist/protocol/session-message-result.d.ts +12 -0
  52. package/dist/protocol/session-message-result.js +42 -0
  53. package/dist/protocol/session-message-workspace.d.ts +21 -0
  54. package/dist/protocol/session-message-workspace.js +57 -0
  55. package/dist/protocol/types.d.ts +4 -0
  56. package/dist/session/session-manager.js +8 -7
  57. package/dist/session/session-message.d.ts +17 -2
  58. package/dist/session/transcript.d.ts +17 -0
  59. package/dist/session/transcript.js +271 -16
  60. package/dist/settings/schema.d.ts +9 -0
  61. package/dist/settings/schema.js +4 -0
  62. package/dist/themes/paths.js +20 -1
  63. package/dist/tool-system/browser-bridge.d.ts +21 -1
  64. package/dist/tool-system/browser-discovery.d.ts +6 -0
  65. package/dist/tool-system/browser-discovery.js +17 -0
  66. package/dist/tool-system/builtin/agent.js +23 -9
  67. package/dist/tool-system/builtin/browser-tools.js +30 -9
  68. package/dist/tool-system/builtin/context-notes.d.ts +12 -0
  69. package/dist/tool-system/builtin/context-notes.js +188 -0
  70. package/dist/tool-system/builtin/index.js +50 -3
  71. package/dist/tool-system/builtin/mcp-tools.d.ts +5 -3
  72. package/dist/tool-system/builtin/mcp-tools.js +10 -10
  73. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  74. package/dist/tool-system/builtin/tool-search.js +15 -3
  75. package/dist/tool-system/context.d.ts +9 -0
  76. package/dist/tool-system/executor.js +5 -3
  77. package/dist/tool-system/mcp-compat.d.ts +3 -0
  78. package/dist/tool-system/mcp-compat.js +51 -0
  79. package/dist/tool-system/mcp-manager.d.ts +27 -26
  80. package/dist/tool-system/mcp-manager.js +273 -111
  81. package/dist/tool-system/mcp-workspace.d.ts +18 -0
  82. package/dist/tool-system/mcp-workspace.js +56 -0
  83. package/dist/tool-system/permission.d.ts +6 -0
  84. package/dist/tool-system/permission.js +45 -9
  85. package/dist/tool-system/plan-mode-allowlist.js +5 -0
  86. package/dist/tool-system/sandbox/seatbelt.js +71 -2
  87. package/dist/tool-system/session-tool-host.js +9 -1
  88. package/dist/types.d.ts +4 -2
  89. package/package.json +1 -1
@@ -102,6 +102,8 @@ export interface UpdateJobPatch {
102
102
  projectId?: string | null;
103
103
  rootId?: string | null;
104
104
  permissionLevel?: CronPermissionLevel;
105
+ /** Existing Session to resume; null returns the job to standalone execution. */
106
+ resumeSessionId?: string | null;
105
107
  }
106
108
  export declare class CronScheduler {
107
109
  private jobs;
@@ -212,6 +214,7 @@ export declare class CronScheduler {
212
214
  * or null if the id is unknown.
213
215
  */
214
216
  update(id: string, patch: UpdateJobPatch): CronJob | null;
217
+ private assertBindingEditable;
215
218
  stopAll(): void;
216
219
  /**
217
220
  * Fire a job immediately, out of band of its schedule (the "Run now" button).
@@ -75,6 +75,7 @@ function validateJobFields(input) {
75
75
  for (const [field, value] of [
76
76
  ["projectId", input.projectId],
77
77
  ["rootId", input.rootId],
78
+ ["resumeSessionId", input.resumeSessionId],
78
79
  ]) {
79
80
  if (value !== undefined &&
80
81
  value !== null &&
@@ -535,6 +536,7 @@ export class CronScheduler {
535
536
  if (j.id !== id)
536
537
  return j;
537
538
  const job = { ...j };
539
+ this.assertBindingEditable(job, patch);
538
540
  const nextSchedule = patch.schedule ?? job.schedule;
539
541
  const nextTimezone = patch.timezone ?? job.timezone;
540
542
  if (patch.schedule !== undefined || patch.timezone !== undefined) {
@@ -560,6 +562,10 @@ export class CronScheduler {
560
562
  delete job.rootId;
561
563
  else if (patch.rootId !== undefined)
562
564
  job.rootId = patch.rootId;
565
+ if (patch.resumeSessionId === null)
566
+ delete job.resumeSessionId;
567
+ else if (patch.resumeSessionId !== undefined)
568
+ job.resumeSessionId = patch.resumeSessionId;
563
569
  if (patch.permissionLevel !== undefined)
564
570
  job.permissionLevel = patch.permissionLevel;
565
571
  if (scheduleChanged)
@@ -575,6 +581,7 @@ export class CronScheduler {
575
581
  const job = this.jobs.get(id);
576
582
  if (!job)
577
583
  return null;
584
+ this.assertBindingEditable(job, patch);
578
585
  // Validate a new schedule/timezone BEFORE mutating anything.
579
586
  const nextSchedule = patch.schedule ?? job.schedule;
580
587
  const nextTimezone = patch.timezone ?? job.timezone;
@@ -601,6 +608,10 @@ export class CronScheduler {
601
608
  delete job.rootId;
602
609
  else if (patch.rootId !== undefined)
603
610
  job.rootId = patch.rootId;
611
+ if (patch.resumeSessionId === null)
612
+ delete job.resumeSessionId;
613
+ else if (patch.resumeSessionId !== undefined)
614
+ job.resumeSessionId = patch.resumeSessionId;
604
615
  if (patch.permissionLevel !== undefined)
605
616
  job.permissionLevel = patch.permissionLevel;
606
617
  // Re-arm only when the schedule definition changed, or when an enabled job
@@ -617,6 +628,16 @@ export class CronScheduler {
617
628
  this.persist();
618
629
  return job;
619
630
  }
631
+ assertBindingEditable(job, patch) {
632
+ const bindingChanged = (patch.resumeSessionId !== undefined &&
633
+ (patch.resumeSessionId ?? null) !== (job.resumeSessionId ?? null)) ||
634
+ (patch.cwd !== undefined && patch.cwd !== job.cwd) ||
635
+ (patch.projectId !== undefined && (patch.projectId ?? null) !== (job.projectId ?? null)) ||
636
+ (patch.rootId !== undefined && (patch.rootId ?? null) !== (job.rootId ?? null));
637
+ if (bindingChanged && this.running.has(job.id)) {
638
+ throw new Error("automation is running; wait for it to finish before changing its Session or workspace binding");
639
+ }
640
+ }
620
641
  stopAll() {
621
642
  for (const timer of this.timers.values()) {
622
643
  clearTimeout(timer);
@@ -31,7 +31,7 @@ export interface ContextManagerConfig {
31
31
  * Injected by the Engine so the ContextManager doesn't depend on LLM directly.
32
32
  */
33
33
  export type SummarizeFn = (prompt: string, signal?: AbortSignal) => Promise<string>;
34
- export type CompactStrategy = "micro" | "summary" | "window" | "snip" | "emergency" | "range";
34
+ export type CompactStrategy = "micro" | "summary" | "window" | "snip" | "emergency" | "range" | "notes";
35
35
  export type OnCompactFn = (info: {
36
36
  strategy: CompactStrategy;
37
37
  before: number;
@@ -66,6 +66,10 @@ export declare class ContextManager {
66
66
  private toolResultsDir;
67
67
  constructor(config?: Partial<ContextManagerConfig>);
68
68
  setOnCompact(fn: OnCompactFn): void;
69
+ /** Reserve one model step for a working note before the normal compact gate. */
70
+ shouldPrepareNote(messages: Message[]): boolean;
71
+ /** A persisted notes checkpoint supersedes any cached summary/usage anchor. */
72
+ recordNotesCompaction(beforeMessages: Message[], afterMessages: Message[]): void;
69
73
  /**
70
74
  * Record actual token usage from API response.
71
75
  * Used for hybrid estimation: actual + estimate for new messages.
@@ -111,7 +115,9 @@ export declare class ContextManager {
111
115
  * Async context management — attempts LLM summarization before falling back.
112
116
  * Call this when you have access to the LLM (between turns).
113
117
  */
114
- manageAsync(messages: Message[], signal?: AbortSignal): Promise<Message[]>;
118
+ manageAsync(messages: Message[], signal?: AbortSignal, options?: {
119
+ preferNotes?: boolean;
120
+ }): Promise<Message[]>;
115
121
  /**
116
122
  * Force maximum compaction, ignoring the ratio gates. This is what a manual
117
123
  * `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
@@ -73,6 +73,23 @@ export class ContextManager {
73
73
  setOnCompact(fn) {
74
74
  this.onCompact = fn;
75
75
  }
76
+ /** Reserve one model step for a working note before the normal compact gate. */
77
+ shouldPrepareNote(messages) {
78
+ return this.checkLimits(messages).ratio >= Math.max(0.05, this.config.compactAtRatio - 0.1);
79
+ }
80
+ /** A persisted notes checkpoint supersedes any cached summary/usage anchor. */
81
+ recordNotesCompaction(beforeMessages, afterMessages) {
82
+ const before = this.estimateTokensHybrid(beforeMessages);
83
+ this.lastSummary = undefined;
84
+ this.lastActualTokens = undefined;
85
+ this.lastActualAtMessageCount = undefined;
86
+ this.lastActualAnchorEstimate = undefined;
87
+ this.lastActualRecordedAt = undefined;
88
+ this.lastActualProvider = undefined;
89
+ this.lastActualModel = undefined;
90
+ this.suppressNoOpMicroSummaryUntilCompact = false;
91
+ this.onCompact?.({ strategy: "notes", before, after: estimateTokens(afterMessages) });
92
+ }
76
93
  /**
77
94
  * Record actual token usage from API response.
78
95
  * Used for hybrid estimation: actual + estimate for new messages.
@@ -132,8 +149,7 @@ export class ContextManager {
132
149
  */
133
150
  estimateTokensHybridInfo(messages) {
134
151
  const currentEstimate = estimateTokens(messages);
135
- if (this.lastActualTokens !== undefined &&
136
- this.lastActualAtMessageCount !== undefined) {
152
+ if (this.lastActualTokens !== undefined && this.lastActualAtMessageCount !== undefined) {
137
153
  if (this.lastActualAtMessageCount < messages.length) {
138
154
  const newMessages = messages.slice(this.lastActualAtMessageCount);
139
155
  const newTokens = estimateTokens(newMessages);
@@ -358,7 +374,7 @@ export class ContextManager {
358
374
  * Async context management — attempts LLM summarization before falling back.
359
375
  * Call this when you have access to the LLM (between turns).
360
376
  */
361
- async manageAsync(messages, signal) {
377
+ async manageAsync(messages, signal, options) {
362
378
  let result = messages;
363
379
  // Tier 0a: Persist large tool_results to disk + replace with preview.
364
380
  result = this.persistLargeToolResults(result);
@@ -421,7 +437,7 @@ export class ContextManager {
421
437
  const noOpMicroSpinBand = microNoOpAtFloor &&
422
438
  ratio >= this.config.microcompactFloorRatio &&
423
439
  ratio < this.config.compactAtRatio;
424
- const shouldEscalateNoOpMicro = noOpMicroSpinBand && !this.suppressNoOpMicroSummaryUntilCompact;
440
+ const shouldEscalateNoOpMicro = !options?.preferNotes && noOpMicroSpinBand && !this.suppressNoOpMicroSummaryUntilCompact;
425
441
  const snipGate = this.config.maxTokens * this.config.compactAtRatio;
426
442
  const windowGate = this.config.maxTokens * (this.config.compactAtRatio + 0.05);
427
443
  const emergencyGate = this.config.maxTokens * this.config.summarizeAtRatio;
@@ -0,0 +1,39 @@
1
+ import type { Message, TranscriptEventType } from "../types.js";
2
+ import { Transcript } from "../session/transcript.js";
3
+ export declare const MAX_CONTEXT_NOTE_CHARS = 12000;
4
+ export declare const MAX_CONTEXT_HISTORY_READ_CHARS = 12000;
5
+ export declare const MAX_CONTEXT_HISTORY_RESULTS = 20;
6
+ export interface ContextHistoryEntry {
7
+ eventId: string;
8
+ type: TranscriptEventType;
9
+ turnNumber: number;
10
+ text: string;
11
+ truncated: boolean;
12
+ untrusted: true;
13
+ }
14
+ /**
15
+ * One session's continuation notes. The model authors text; this class alone
16
+ * constructs replacement messages and commits them at a completed tool round.
17
+ */
18
+ export declare class SessionContextNotes {
19
+ private readonly transcript;
20
+ private modelBoundaryEventId;
21
+ private pendingNoteId;
22
+ constructor(transcript: Transcript);
23
+ /** Keep the normal tool/turn boundary free of replay work when no switch was requested. */
24
+ hasPendingRollover(): boolean;
25
+ /** Call immediately before a model request, before its assistant/tool events. */
26
+ markModelBoundary(): void;
27
+ save(text: string): string;
28
+ /** Queues a switch; the tool call itself must not alter an in-flight batch. */
29
+ requestRollover(): void;
30
+ /**
31
+ * Invoke after all results from the assistant's tool batch have been appended.
32
+ * Every failure consumes the request and leaves the prior replay unchanged.
33
+ */
34
+ applyRollover(currentMessages?: readonly Message[], retainedMessages?: readonly Message[]): Message[] | undefined;
35
+ private commitRollover;
36
+ search(query: string, limit?: number, beforeEventId?: string): ContextHistoryEntry[];
37
+ read(eventId: string): ContextHistoryEntry | undefined;
38
+ private originalEvents;
39
+ }
@@ -0,0 +1,314 @@
1
+ import { Transcript, hasCompleteContextToolPairs } from "../session/transcript.js";
2
+ import { downgradeImagePayloadsInHistory, estimateTokens } from "./compaction.js";
3
+ import { logger } from "../logging/logger.js";
4
+ export const MAX_CONTEXT_NOTE_CHARS = 12_000;
5
+ export const MAX_CONTEXT_HISTORY_READ_CHARS = 12_000;
6
+ export const MAX_CONTEXT_HISTORY_RESULTS = 20;
7
+ const HISTORY_SNIPPET_CHARS = 600;
8
+ const MODEL_BOUNDARY_EVENT_TYPES = new Set([
9
+ "message",
10
+ "tool_use",
11
+ "tool_result",
12
+ "summary",
13
+ "context_transfer",
14
+ "range_archive",
15
+ "context_note",
16
+ "context_checkpoint",
17
+ ]);
18
+ /**
19
+ * One session's continuation notes. The model authors text; this class alone
20
+ * constructs replacement messages and commits them at a completed tool round.
21
+ */
22
+ export class SessionContextNotes {
23
+ transcript;
24
+ modelBoundaryEventId;
25
+ pendingNoteId;
26
+ constructor(transcript) {
27
+ this.transcript = transcript;
28
+ }
29
+ /** Keep the normal tool/turn boundary free of replay work when no switch was requested. */
30
+ hasPendingRollover() {
31
+ return this.pendingNoteId !== undefined;
32
+ }
33
+ /** Call immediately before a model request, before its assistant/tool events. */
34
+ markModelBoundary() {
35
+ const events = this.transcript.getEvents();
36
+ // Skip audit-only receipts/metadata: forks intentionally omit those, and
37
+ // they do not represent any additional text the model has consumed.
38
+ const index = findLastEventIndex(events, (event) => MODEL_BOUNDARY_EVENT_TYPES.has(event.type));
39
+ this.modelBoundaryEventId = events[index]?.id;
40
+ }
41
+ save(text) {
42
+ if (typeof text !== "string" || text.trim().length === 0) {
43
+ throw new Error("A continuation note must contain non-empty text.");
44
+ }
45
+ if (text.length > MAX_CONTEXT_NOTE_CHARS) {
46
+ throw new Error(`A continuation note must not exceed ${MAX_CONTEXT_NOTE_CHARS} characters.`);
47
+ }
48
+ const cursor = this.modelBoundaryEventId;
49
+ if (!cursor || !this.transcript.getEvents().some((event) => event.id === cursor)) {
50
+ throw new Error("No model context boundary is available for this note.");
51
+ }
52
+ const note = this.transcript.appendContextNote(text, cursor);
53
+ if (!note)
54
+ throw new Error("The continuation note could not be saved; context was preserved.");
55
+ return note.id;
56
+ }
57
+ /** Queues a switch; the tool call itself must not alter an in-flight batch. */
58
+ requestRollover() {
59
+ const events = this.transcript.getEvents();
60
+ const noteIndex = findLastEventIndex(events, (event) => event.type === "context_note");
61
+ const note = events[noteIndex];
62
+ if (!note || !validNote(note)) {
63
+ throw new Error("Save a continuation note before starting a new context.");
64
+ }
65
+ if (events.slice(noteIndex + 1).some((event) => event.type === "context_checkpoint")) {
66
+ throw new Error("This note has already been used. Save a fresh continuation note first.");
67
+ }
68
+ if (this.transcript.flushFailed()) {
69
+ throw new Error("Transcript persistence is unavailable; the current context was preserved.");
70
+ }
71
+ this.pendingNoteId = note.id;
72
+ }
73
+ /**
74
+ * Invoke after all results from the assistant's tool batch have been appended.
75
+ * Every failure consumes the request and leaves the prior replay unchanged.
76
+ */
77
+ applyRollover(currentMessages, retainedMessages = []) {
78
+ const noteId = this.pendingNoteId;
79
+ this.pendingNoteId = undefined;
80
+ if (!noteId || this.transcript.flushFailed())
81
+ return undefined;
82
+ try {
83
+ return this.commitRollover(noteId, currentMessages, retainedMessages);
84
+ }
85
+ catch (error) {
86
+ logger.warn("context.notes.rollover_failed", {
87
+ noteId,
88
+ message: error instanceof Error ? error.message : String(error),
89
+ });
90
+ return undefined;
91
+ }
92
+ }
93
+ commitRollover(noteId, currentMessages, retainedMessages = []) {
94
+ const events = this.transcript.getEvents();
95
+ const noteIndex = events.findIndex((event) => event.id === noteId);
96
+ const note = events[noteIndex];
97
+ if (!note || !validNote(note))
98
+ return undefined;
99
+ if (events.slice(noteIndex + 1).some((event) => event.type === "context_checkpoint")) {
100
+ return undefined;
101
+ }
102
+ const cursor = note.data.coveredThroughEventId;
103
+ const boundaryIndex = events.findIndex((event) => event.id === cursor);
104
+ if (boundaryIndex < 0 || boundaryIndex >= noteIndex)
105
+ return undefined;
106
+ const tail = events.slice(boundaryIndex + 1);
107
+ // A misplaced boundary must not silently discard a late result whose
108
+ // opening call was before that boundary. Normal model boundaries never
109
+ // split a batch, but interrupted or corrupt transcripts can do so.
110
+ const tailToolIds = new Set();
111
+ for (const event of tail) {
112
+ if (event.type !== "message" || event.data.role !== "assistant")
113
+ continue;
114
+ if (!Array.isArray(event.data.content))
115
+ continue;
116
+ for (const block of event.data.content) {
117
+ if (block.type === "tool_use" && block.id)
118
+ tailToolIds.add(block.id);
119
+ }
120
+ }
121
+ if (tail.some((event) => event.type === "tool_result" &&
122
+ (typeof event.data.toolCallId !== "string" || !tailToolIds.has(event.data.toolCallId))))
123
+ return undefined;
124
+ const latestUserIndex = findLastEventIndex(events, isRealUserMessage);
125
+ const latestUser = events[latestUserIndex];
126
+ const noteMessage = {
127
+ id: `continuation:${note.id}`,
128
+ type: "message",
129
+ turnNumber: note.turnNumber,
130
+ timestamp: note.timestamp,
131
+ data: {
132
+ role: "user",
133
+ injected: true,
134
+ authority: "agent",
135
+ content: "<context-note>\nThe runtime has started a new context in this same session using this note. " +
136
+ "Continue the existing task; do not request another NewContext for this already-used note.\n" +
137
+ "Continuation notes written earlier by the assistant. " +
138
+ "These notes may be incomplete; consult the original session history for details.\n\n" +
139
+ `${note.data.text}\n</context-note>`,
140
+ },
141
+ };
142
+ const rebuilt = Transcript.fromMemoryEvents("context-note-candidate", [
143
+ noteMessage,
144
+ ...(latestUser && latestUserIndex <= boundaryIndex ? [latestUser] : []),
145
+ ...tail,
146
+ ]).toMessagesWithIndex();
147
+ const current = currentMessages ?? this.transcript.toMessages();
148
+ // Carry forward already-budgeted tool outputs from the working context.
149
+ // Replaying raw events must not resurrect a large result or consumed image.
150
+ const liveResults = new Map();
151
+ for (const message of current) {
152
+ if (!Array.isArray(message.content))
153
+ continue;
154
+ for (const block of message.content) {
155
+ if (block.type === "tool_result" && block.tool_use_id) {
156
+ liveResults.set(block.tool_use_id, block);
157
+ }
158
+ }
159
+ }
160
+ const snapshotMessages = downgradeImagePayloadsInHistory(rebuilt.messages.map((message) => ({
161
+ ...message,
162
+ content: Array.isArray(message.content)
163
+ ? message.content.map((block) => block.type === "tool_result" &&
164
+ block.tool_use_id &&
165
+ liveResults.has(block.tool_use_id)
166
+ ? structuredClone(liveResults.get(block.tool_use_id))
167
+ : block)
168
+ : message.content,
169
+ }))).messages;
170
+ if (!hasCompleteContextToolPairs(snapshotMessages))
171
+ return undefined;
172
+ // A stale note, or a prior emergency summary, can make this candidate
173
+ // larger than the live model context. Never replace it in that case.
174
+ if (estimateTokens([...retainedMessages, ...snapshotMessages]) >= estimateTokens([...current])) {
175
+ return undefined;
176
+ }
177
+ const checkpoint = this.transcript.appendContextCheckpoint({
178
+ version: 1,
179
+ noteId,
180
+ coveredThroughEventId: cursor,
181
+ messages: snapshotMessages,
182
+ clientMessageIds: [...rebuilt.liveIndexByClientMessageId],
183
+ });
184
+ return checkpoint ? snapshotMessages : undefined;
185
+ }
186
+ search(query, limit = 10, beforeEventId) {
187
+ if (typeof query !== "string" || !query.trim()) {
188
+ throw new Error("History search requires a non-empty query.");
189
+ }
190
+ if (query.length > MAX_CONTEXT_HISTORY_READ_CHARS) {
191
+ throw new Error("History search query is too long.");
192
+ }
193
+ const count = Math.min(MAX_CONTEXT_HISTORY_RESULTS, Math.max(1, Math.floor(limit)));
194
+ if (!Number.isFinite(count))
195
+ throw new Error("History search limit must be a finite number.");
196
+ const events = this.originalEvents();
197
+ const beforeIndex = beforeEventId
198
+ ? events.findIndex((event) => matchesHistoryEventId(event, beforeEventId))
199
+ : events.length;
200
+ if (beforeIndex < 0)
201
+ throw new Error("History cursor does not belong to this session.");
202
+ const needle = query.trim().toLocaleLowerCase();
203
+ const entries = [];
204
+ for (let index = beforeIndex - 1; index >= 0 && entries.length < count; index -= 1) {
205
+ const event = events[index];
206
+ const text = historyText(event);
207
+ if (!text)
208
+ continue;
209
+ const match = text.toLocaleLowerCase().indexOf(needle);
210
+ if (match < 0)
211
+ continue;
212
+ const start = Math.max(0, match - Math.floor(HISTORY_SNIPPET_CHARS / 3));
213
+ entries.push(historyEntry(event, text, start, HISTORY_SNIPPET_CHARS));
214
+ }
215
+ return entries;
216
+ }
217
+ read(eventId) {
218
+ const event = this.originalEvents().find((candidate) => matchesHistoryEventId(candidate, eventId));
219
+ if (!event)
220
+ return undefined;
221
+ const text = historyText(event);
222
+ return text === undefined
223
+ ? undefined
224
+ : historyEntry(event, text, 0, MAX_CONTEXT_HISTORY_READ_CHARS);
225
+ }
226
+ originalEvents() {
227
+ // Active transcripts can be loaded from a bounded tail. Retrieval uses
228
+ // only their own file and never accepts a model-supplied path/session id.
229
+ if (this.transcript.isPersistent()) {
230
+ const stored = Transcript.readEvents(this.transcript.getFilePath()).events;
231
+ const seen = new Set(stored.map((event) => event.id));
232
+ return [...stored, ...this.transcript.getEvents().filter((event) => !seen.has(event.id))];
233
+ }
234
+ return this.transcript.getEvents();
235
+ }
236
+ }
237
+ function findLastEventIndex(events, predicate) {
238
+ for (let index = events.length - 1; index >= 0; index -= 1) {
239
+ if (predicate(events[index]))
240
+ return index;
241
+ }
242
+ return -1;
243
+ }
244
+ function validNote(event) {
245
+ return (event.type === "context_note" &&
246
+ Boolean(event.data) &&
247
+ typeof event.data.text === "string" &&
248
+ event.data.text.trim().length > 0 &&
249
+ event.data.text.length <= MAX_CONTEXT_NOTE_CHARS &&
250
+ typeof event.data.coveredThroughEventId === "string");
251
+ }
252
+ function matchesHistoryEventId(event, eventId) {
253
+ return (event.id === eventId ||
254
+ (Array.isArray(event.data.contextHistorySourceIds) &&
255
+ event.data.contextHistorySourceIds.includes(eventId)));
256
+ }
257
+ function isRealUserMessage(event) {
258
+ return (event.type === "message" &&
259
+ event.data.role === "user" &&
260
+ event.data.injected !== true &&
261
+ (event.data.authority === undefined || event.data.authority === "user"));
262
+ }
263
+ function contentText(content) {
264
+ if (typeof content === "string")
265
+ return content;
266
+ if (!Array.isArray(content))
267
+ return "";
268
+ return content
269
+ .map((block) => {
270
+ if (!block || typeof block !== "object")
271
+ return "";
272
+ if (block.type === "text")
273
+ return block.text ?? "";
274
+ if (block.type === "image")
275
+ return "[image]";
276
+ if (block.type === "tool_use")
277
+ return `${block.name ?? "tool"}: ${JSON.stringify(block.input)}`;
278
+ if (block.type === "tool_result")
279
+ return contentText(block.content);
280
+ return "";
281
+ })
282
+ .filter(Boolean)
283
+ .join("\n");
284
+ }
285
+ function historyText(event) {
286
+ switch (event.type) {
287
+ case "message":
288
+ return contentText(event.data.content);
289
+ case "tool_use":
290
+ return `${event.data.toolName ?? "tool"}: ${JSON.stringify(event.data.args)}`;
291
+ case "tool_result":
292
+ return [event.data.error, event.data.result, contentText(event.data.contentBlocks)]
293
+ .filter((value) => typeof value === "string" && value.length > 0)
294
+ .join("\n");
295
+ case "summary":
296
+ case "context_transfer":
297
+ case "range_archive":
298
+ return typeof event.data.summary === "string" ? event.data.summary : undefined;
299
+ case "context_note":
300
+ return typeof event.data.text === "string" ? event.data.text : undefined;
301
+ default:
302
+ return undefined;
303
+ }
304
+ }
305
+ function historyEntry(event, text, start, length) {
306
+ return {
307
+ eventId: event.id,
308
+ type: event.type,
309
+ turnNumber: event.turnNumber,
310
+ text: text.slice(start, start + length),
311
+ truncated: start > 0 || text.length > length,
312
+ untrusted: true,
313
+ };
314
+ }
@@ -16,6 +16,11 @@ export interface CredentialMetadata {
16
16
  }
17
17
  export interface CredentialAccess {
18
18
  listMasked(cwd: string | undefined, scope: CredentialAccessScope): CredentialMetadata[];
19
+ /** Optional read diagnostics. False means the list may be incomplete, not that it is empty. */
20
+ listMaskedWithStatus?(cwd: string | undefined, scope: CredentialAccessScope): {
21
+ credentials: CredentialMetadata[];
22
+ readable: boolean;
23
+ };
19
24
  resolveMeta(cwd: string | undefined, id: string, scope: CredentialAccessScope): CredentialMetadata | undefined;
20
25
  envExposures(cwd: string | undefined, scope: CredentialAccessScope): Record<string, string>;
21
26
  /** Subscribe to host credential snapshot changes (desktop worker uses this to cancel in-flight Links). */
@@ -48,6 +53,9 @@ export interface CredentialSnapshotEntry {
48
53
  cwd?: string;
49
54
  full: CredentialMetadata[];
50
55
  project: CredentialMetadata[];
56
+ /** Older hosts omit these; their credential-store readability is unknown. */
57
+ readableFull?: boolean;
58
+ readableProject?: boolean;
51
59
  envFull: Record<string, string>;
52
60
  envProject: Record<string, string>;
53
61
  }
@@ -59,7 +67,7 @@ export declare function setDefaultCredentialAccess(access: CredentialAccess | nu
59
67
  export declare function getCredentialAccess(): CredentialAccess;
60
68
  export declare function createIpcCredentialAccess(transport: Pick<Transport, "send" | "onMessage">): CredentialAccess;
61
69
  export declare function credentialAccessScope(scope: SettingsScope | undefined): CredentialAccessScope;
62
- export declare function isCredentialSecretAvailable(secret: string | undefined): secret is string;
70
+ export { isCredentialSecretAvailable } from "./types.js";
63
71
  export declare const localCredentialAccess: CredentialAccess;
64
72
  export declare function materializeCookieSecret(credentialId: string, secret: string): {
65
73
  cookiesFile: string;
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from "node:f
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
5
+ import { credentialAllowsEnvExposure, credentialSecretHint, isCredentialSecretAvailable, } from "./types.js";
6
6
  import { CredentialStore } from "./store.js";
7
7
  import { formatNetscapeCookies, parseCookieJar } from "./cookie-jar.js";
8
8
  import { parseOAuthCredentialSecret, isBrowserOAuthLinkCredential, resolveLinkCredentialAccessToken, shouldRefreshOAuthCredential, summarizeOAuthCredentialSecret, } from "./oauth.js";
@@ -77,6 +77,15 @@ export function createIpcCredentialAccess(transport) {
77
77
  return [];
78
78
  return scope === "project" ? cloneMetadata(entry.project) : cloneMetadata(entry.full);
79
79
  },
80
+ listMaskedWithStatus(cwd, scope) {
81
+ const entry = entryFor(cwd);
82
+ if (!entry)
83
+ return { credentials: [], readable: false };
84
+ return {
85
+ credentials: cloneMetadata(scope === "project" ? entry.project : entry.full),
86
+ readable: (scope === "project" ? entry.readableProject : entry.readableFull) === true,
87
+ };
88
+ },
80
89
  resolveMeta(cwd, id, scope) {
81
90
  const entry = entryFor(cwd);
82
91
  if (!entry)
@@ -122,9 +131,7 @@ export function createIpcCredentialAccess(transport) {
122
131
  export function credentialAccessScope(scope) {
123
132
  return scope === "full" || scope === undefined ? "full" : "project";
124
133
  }
125
- export function isCredentialSecretAvailable(secret) {
126
- return typeof secret === "string" && secret.length > 0 && !secret.startsWith("enc:");
127
- }
134
+ export { isCredentialSecretAvailable } from "./types.js";
128
135
  function toMetadata(cred) {
129
136
  const secret = cred.secret;
130
137
  const available = isCredentialSecretAvailable(secret);
@@ -149,6 +156,10 @@ export const localCredentialAccess = {
149
156
  listMasked(cwd, scope) {
150
157
  return storeFor(cwd).list(scope).map(toMetadata);
151
158
  },
159
+ listMaskedWithStatus(cwd, scope) {
160
+ const result = storeFor(cwd).listWithStatus(scope);
161
+ return { credentials: result.credentials.map(toMetadata), readable: result.readable };
162
+ },
152
163
  resolveMeta(cwd, id, scope) {
153
164
  const cred = storeFor(cwd).resolve(id, scope);
154
165
  return cred ? toMetadata(cred) : undefined;
@@ -70,6 +70,12 @@ export declare class CredentialStore {
70
70
  * id 不存在则 no-op。
71
71
  */
72
72
  patch(scope: CredentialScope, id: string, fields: Partial<Pick<Credential, "label" | "exposeAsEnv" | "autoUseByAI" | "autoInjectByAI" | "meta">>): void;
73
+ /**
74
+ * Update an existing record while holding the store lock. The callback sees
75
+ * the latest decrypted record and may return undefined to decline the write.
76
+ * Unlike save(), this never recreates a deleted credential.
77
+ */
78
+ updateExisting(scope: CredentialScope, id: string, update: (current: Credential) => Credential | undefined): boolean;
73
79
  remove(scope: CredentialScope, id: string): void;
74
80
  /**
75
81
  * List credentials visible to an engine of the given settings scope.
@@ -80,6 +86,11 @@ export declare class CredentialStore {
80
86
  * same host-isolation contract as {@link envExposures} and top-level env.
81
87
  */
82
88
  list(scope?: "full" | "project"): Credential[];
89
+ /** Preserve read failures so diagnostics can distinguish unknown state from an empty store. */
90
+ listWithStatus(scope?: "full" | "project"): {
91
+ credentials: Credential[];
92
+ readable: boolean;
93
+ };
83
94
  resolve(id: string, scope?: "full" | "project"): Credential | undefined;
84
95
  /**
85
96
  * Credentials flagged "expose as env var" → a `{ ENV_NAME: secret }` map for