@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.
@@ -215,11 +215,13 @@ function freshSettings() {
215
215
  }
216
216
  const chatManager = new ChatSessionManager({
217
217
  runtime,
218
- // resolvedLlmConfig is the bootstrap-time snapshot. When the user
219
- // hot-switches models via configure() the modelPool.activeKey moves
220
- // ahead of it, so newly-created sessions must re-resolve from the pool
221
- // each time the factory fires; fall back to the snapshot only when the
222
- // pool can't resolve (no active key shouldn't happen in practice).
218
+ // resolvedLlmConfig is the bootstrap-time snapshot. Explicit global model
219
+ // changes can move modelPool.activeKey ahead of it, so newly-created
220
+ // sessions re-resolve from the pool each time the factory fires. A
221
+ // per-session configure intentionally does NOT move that shared key; this
222
+ // keeps one Session's selected model from leaking into the next Session.
223
+ // Fall back to the snapshot only when the pool can't resolve (no active key
224
+ // — shouldn't happen in practice).
223
225
  engineFactory: (slice) => {
224
226
  // Effective cwd for THIS session: explicit slice.cwd → that project; absent
225
227
  // → the no-repo sandbox (NOT the worker's stale boot cwd). See
@@ -547,8 +547,16 @@ export class ContextManager {
547
547
  const window = messages.slice(start, end);
548
548
  // Feed a prior anchored summary back so the LLM merge-updates rather than
549
549
  // re-summarizes from scratch, matching the rolling-summary behavior of
550
- // trySummaryCompact().
551
- const priorSummary = extractAnchoredSummary(messages) ?? this.lastSummary;
550
+ // trySummaryCompact() — but ONLY when that anchored summary sits inside
551
+ // the window being replaced. A summary inside [start, end) is about to be
552
+ // overwritten by this call, so it must be merged in or its content is
553
+ // lost. A summary OUTSIDE the window belongs to a different, untouched
554
+ // span and must NOT be merged in: doing so (via `?? this.lastSummary`,
555
+ // which is a single most-recent-summary carryover with no span
556
+ // awareness) made every subsequent range archive fold in the text of
557
+ // every prior one, so N archived spans produced N copies of the same
558
+ // growing summary instead of N independent summaries.
559
+ const priorSummary = extractAnchoredSummary(window);
552
560
  const prompt = buildSummarizationPrompt(window, priorSummary);
553
561
  const summary = await this.summarizeFn(prompt, opts.signal);
554
562
  if (!summary || summary.length <= 50)
@@ -457,6 +457,13 @@ export declare class Engine {
457
457
  * Switch the active model by pool key. Takes effect on the next run() call.
458
458
  * Returns the new model entry.
459
459
  *
460
+ * Per-session switches (`persist:false`) must not mutate the shared
461
+ * ModelPool active key. Every desktop ChatSession owns an Engine, but those
462
+ * Engines share one runtime pool; changing the pool selection here made the
463
+ * next newly-created Session inherit whichever model another Session had
464
+ * just selected (for example a Mimi manager model leaking into its delegated
465
+ * Work Session).
466
+ *
460
467
  * Persists settings.defaults.text (= the connection id / pool key) so the
461
468
  * next process startup defaults to the same model — without this, switches
462
469
  * only live in memory and every restart reverts to the previously persisted
@@ -623,22 +630,67 @@ export declare class Engine {
623
630
  */
624
631
  private prepareContextManagerForSession;
625
632
  /**
626
- * Archive a caller-chosen contiguous message-index window `[range.start,
627
- * range.end)` of a session into a single anchored summary, leaving everything
628
- * outside the window untouched, and cache the result so a later
629
- * forceCompact/resume reads the archived history. This is a generic
630
- * range-archival facade over ContextManager.summarizeRange the caller
631
- * decides which span to collapse (the range is a half-open message-index
632
- * window, matching summarizeRange). Returns token stats before/after; equal
633
- * before/after means the window was empty or the summary was rejected.
633
+ * Archive a contiguous message window of a session into a single anchored
634
+ * summary, leaving everything outside the window untouched, and cache the
635
+ * result so a later forceCompact/resume reads the archived history. This is
636
+ * a generic range-archival facade over ContextManager.summarizeRange.
637
+ * Returns token stats before/after; equal before/after means the window was
638
+ * empty/unresolvable or the summary was rejected.
639
+ *
640
+ * Window resolution depends on whether the caller supplied anchors:
641
+ * - With `anchors`, the caller's `range` is IGNORED and the window is
642
+ * resolved from the client-message-id anchors over a fresh, marker-aware
643
+ * transcript replay. Callers (the pet segment closure) compute index
644
+ * ranges over the RAW transcript message list, whose indices grow
645
+ * forever — but the live list is trimmed by every persisted marker
646
+ * (replay after restart) and by each in-process archival, so a raw index
647
+ * range clamps to an empty window or, worse, onto the WRONG tail
648
+ * messages, which would then be persistently mis-summarized against
649
+ * correct anchors. Anchors are the only stable coordinates.
650
+ * - Without `anchors`, legacy behavior: `range` is a half-open index window
651
+ * over the cached/in-process message list, and nothing is persisted.
634
652
  */
635
653
  archiveTurnRange(sessionId: string, range: {
636
654
  start: number;
637
655
  end: number;
656
+ }, anchors?: {
657
+ toClientMessageId: string;
658
+ fromClientMessageId?: string;
659
+ segmentId?: string;
638
660
  }): Promise<{
639
661
  before: number;
640
662
  after: number;
641
663
  }>;
664
+ /**
665
+ * Guard against writing a marker anchored to a client message id the
666
+ * transcript doesn't actually contain. toMessages()'s replay silently
667
+ * ignores a range_archive event whose anchor doesn't resolve (fail open),
668
+ * so a bad anchor wouldn't corrupt anything on its own — but if the marker
669
+ * carries a segmentId, appendRangeArchive's idempotency check treats that
670
+ * segmentId as "already recorded" forever, with no retry path (e.g. a
671
+ * one-time migration keyed "migration-v1" would be permanently burned on
672
+ * its first, failed attempt). Reject before writing so the caller can
673
+ * retry with a corrected anchor. fromClientMessageId is optional in the
674
+ * data model (absent means "from the beginning"), so only validate it when
675
+ * present; missing it is fail-open by design, same as the replay path.
676
+ */
677
+ private hasLiveArchiveAnchors;
678
+ /**
679
+ * Persist an archive boundary WITHOUT a summarization call — the caller
680
+ * already has the summary text (e.g. the one-time migration built from
681
+ * pet journal entries). Wraps the plain text in the anchored-summary
682
+ * envelope so replay and rolling-merge treat it like a real archive.
683
+ * Returns false when the segmentId was already recorded (idempotent) OR
684
+ * when the anchors don't resolve to real messages in this transcript —
685
+ * see hasLiveArchiveAnchors for why a dead anchor must be rejected before
686
+ * the (potentially one-shot) segmentId gets burned.
687
+ */
688
+ appendArchiveMarker(sessionId: string, marker: {
689
+ summary: string;
690
+ toClientMessageId: string;
691
+ fromClientMessageId?: string;
692
+ segmentId?: string;
693
+ }): Promise<boolean>;
642
694
  private recordCacheReadDiagnostics;
643
695
  private getSettingsManager;
644
696
  /**
@@ -12,6 +12,7 @@ import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-q
12
12
  import { RunEnvironmentResolver } from "./run-environment.js";
13
13
  import { BUILTIN_TOOLS, } from "../tool-system/builtin/index.js";
14
14
  import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
15
+ import { skillToolDef } from "../tool-system/builtin/skill.js";
15
16
  import { backgroundShellManager } from "../runtime/background-shell.js";
16
17
  import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
17
18
  import { HookRegistry } from "../hooks/registry.js";
@@ -20,7 +21,7 @@ import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
20
21
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
21
22
  import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
22
23
  import { ContextManager } from "../context/manager.js";
23
- import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
24
+ import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildAnchoredSummaryMessage, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
24
25
  import { PromptComposer } from "../prompt/composer.js";
25
26
  import { SessionManager, assertSafeSessionId, isEphemeralSessionState, sessionsRoot, } from "../session/session-manager.js";
26
27
  import { createRunUsageAccounting, wireRunModelFacade } from "./run-accounting.js";
@@ -43,7 +44,7 @@ import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
43
44
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
44
45
  import { PromptCacheDiagnosticRecorder, promptCacheDropHint, } from "./prompt-cache-diagnostics.js";
45
46
  import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
46
- import { QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
47
+ import { ISOLATED_TASK_PROFILE, QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
47
48
  import { createSubAgentSpawner } from "./subagent-spawner.js";
48
49
  import { AuxiliaryPipeline, sameLlmIdentity } from "./auxiliary-pipeline.js";
49
50
  import { PermissionController } from "./permission-controller.js";
@@ -412,6 +413,7 @@ export class Engine {
412
413
  // extension modules — later registrations override earlier ones by id.
413
414
  this.behaviorProfiles = new Map([
414
415
  QUICK_CHAT_RESTRICTED_PROFILE,
416
+ ISOLATED_TASK_PROFILE,
415
417
  ...(config.behaviorProfiles ?? []),
416
418
  ...(config.extensionModules ?? []).flatMap((module) => module.behaviorProfiles ?? []),
417
419
  ].map((profile) => [profile.id, profile]));
@@ -1098,16 +1100,18 @@ export class Engine {
1098
1100
  session = openedResult.opened.session;
1099
1101
  this.stampRunToolContext(toolCtx, session, options);
1100
1102
  const sessionRun = runWithSid(session.state.sessionId, async () => {
1101
- const hookMessages = await this.runSessionStartHooks({
1102
- session,
1103
- task,
1104
- cwd,
1105
- runPermissionMode,
1106
- resumedFromDisk,
1107
- options,
1108
- taskText,
1109
- messages,
1110
- });
1103
+ const hookMessages = profile?.disableHooks
1104
+ ? []
1105
+ : await this.runSessionStartHooks({
1106
+ session,
1107
+ task,
1108
+ cwd,
1109
+ runPermissionMode,
1110
+ resumedFromDisk,
1111
+ options,
1112
+ taskText,
1113
+ messages,
1114
+ });
1111
1115
  const sid = session.state.sessionId;
1112
1116
  const { contextManager, llmClientPromise, toolExecutor } = this.wireRunContextAndPermission({
1113
1117
  session,
@@ -1935,16 +1939,26 @@ export class Engine {
1935
1939
  (visibilityStoredGoal !== undefined && visibilityStoredGoal.paused !== true) ||
1936
1940
  (visibilityDefaultGoal !== undefined && visibilityDefaultGoal.paused !== true));
1937
1941
  const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, sessionProfileOverrides);
1942
+ // A profile whose tool allowlist excludes the Skill tool can never invoke
1943
+ // a skill, so the full skills listing would be dead context for every one
1944
+ // of its turns (e.g. the Pet manager) — inject none via an empty allowlist.
1945
+ const runAllowedToolNames = toolCtx.allowedToolNames;
1946
+ const profileCanUseSkills = !runAllowedToolNames || runAllowedToolNames.has(skillToolDef.name);
1938
1947
  const promptComposer = new PromptComposer(buildPromptComposerConfig({
1939
1948
  cwd,
1940
1949
  model: this.config.llm.model,
1941
1950
  preset: this.preset,
1942
- customSystemPrompt: this.config.customSystemPrompt,
1943
- appendSystemPrompt: [this.config.appendSystemPrompt, profile?.systemPromptAppend]
1951
+ customSystemPrompt: profile?.disableInstructions
1952
+ ? undefined
1953
+ : this.config.customSystemPrompt,
1954
+ appendSystemPrompt: [
1955
+ profile?.disableInstructions ? undefined : this.config.appendSystemPrompt,
1956
+ profile?.systemPromptAppend,
1957
+ ]
1944
1958
  .filter(Boolean)
1945
1959
  .join("\n\n") || undefined,
1946
1960
  responseLanguage: this.config.responseLanguage,
1947
- userProfile: this.config.userProfile,
1961
+ userProfile: profile?.disableInstructions ? undefined : this.config.userProfile,
1948
1962
  workspaceProfile: runWorkspaceProfile,
1949
1963
  // Read from the Session's own persisted state, so every turn — not just
1950
1964
  // the first — carries the standing brief.
@@ -1954,14 +1968,21 @@ export class Engine {
1954
1968
  instructionBoundaryFinder: (scanCwd) => resolveInstructionBoundary(scanCwd, this.capabilities),
1955
1969
  disabledSkills,
1956
1970
  disabledPlugins,
1957
- skillAllowlist: this.config.skillAllowlist,
1971
+ skillAllowlist: profileCanUseSkills ? toolCtx.skillAllowlist : [],
1958
1972
  memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1959
- goalToolState: !profile?.allowedToolNames ||
1960
- profile.allowedToolNames.has("complete_goal") ||
1961
- profile.allowedToolNames.has("cancel_goal")
1973
+ memoryCurrentProjectOnly: profile?.memoryCurrentProjectOnly,
1974
+ disableInstructions: profile?.disableInstructions,
1975
+ disableMemoryContext: profile?.disableMemoryContext,
1976
+ disableCapabilityContext: profile?.disableCapabilityContext,
1977
+ disableSourcesContext: profile?.disableSourcesContext,
1978
+ goalToolState: !runAllowedToolNames ||
1979
+ runAllowedToolNames.has("complete_goal") ||
1980
+ runAllowedToolNames.has("cancel_goal")
1962
1981
  ? { hasGoal: hasRunnableGoal }
1963
1982
  : undefined,
1964
- capabilityPromptSections: this.capabilityPromptSections,
1983
+ capabilityPromptSections: profile?.disableCapabilityContext
1984
+ ? {}
1985
+ : this.capabilityPromptSections,
1965
1986
  dynamicContextProviders: this.capabilityDynamicContextProviders,
1966
1987
  getSettingsManager: () => this.getSettingsManager(),
1967
1988
  toolCatalog: this.toolCatalog,
@@ -2018,7 +2039,7 @@ export class Engine {
2018
2039
  toolRewriters: this.toolRewriters,
2019
2040
  toolFeatureFlags: TOOL_FEATURE_FLAGS,
2020
2041
  applyBuiltinOverrideVisibility,
2021
- profileAllowedToolNames: profile?.allowedToolNames,
2042
+ profileAllowedToolNames: runAllowedToolNames,
2022
2043
  runPlanMode,
2023
2044
  });
2024
2045
  return { promptComposer, toolDefs };
@@ -2228,13 +2249,28 @@ export class Engine {
2228
2249
  * Switch the active model by pool key. Takes effect on the next run() call.
2229
2250
  * Returns the new model entry.
2230
2251
  *
2252
+ * Per-session switches (`persist:false`) must not mutate the shared
2253
+ * ModelPool active key. Every desktop ChatSession owns an Engine, but those
2254
+ * Engines share one runtime pool; changing the pool selection here made the
2255
+ * next newly-created Session inherit whichever model another Session had
2256
+ * just selected (for example a Mimi manager model leaking into its delegated
2257
+ * Work Session).
2258
+ *
2231
2259
  * Persists settings.defaults.text (= the connection id / pool key) so the
2232
2260
  * next process startup defaults to the same model — without this, switches
2233
2261
  * only live in memory and every restart reverts to the previously persisted
2234
2262
  * defaults.text.
2235
2263
  */
2236
2264
  switchModel(key, opts) {
2237
- const entry = this.modelPool.switch(key);
2265
+ const perSession = opts?.persist === false;
2266
+ const entry = perSession ? this.modelPool.get(key) : this.modelPool.switch(key);
2267
+ if (!entry) {
2268
+ const available = this.modelPool
2269
+ .list()
2270
+ .map((candidate) => candidate.key)
2271
+ .join(", ");
2272
+ throw new Error(`Model "${key}" not found. Available: ${available}`);
2273
+ }
2238
2274
  // LLMConfig is pure model identity now — rotate it wholesale. Cross-model
2239
2275
  // runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
2240
2276
  // this.config.clientDefaults and survive the switch untouched.
@@ -2243,7 +2279,7 @@ export class Engine {
2243
2279
  // persist: false is the per-session path (ChatSession) — switching one
2244
2280
  // session's model must not rewrite settings.defaults.text, the boot
2245
2281
  // default every future session inherits.
2246
- if (opts?.persist !== false)
2282
+ if (!perSession)
2247
2283
  this.persistActiveModel(entry);
2248
2284
  return entry;
2249
2285
  }
@@ -2839,33 +2875,149 @@ export class Engine {
2839
2875
  return contextManager;
2840
2876
  }
2841
2877
  /**
2842
- * Archive a caller-chosen contiguous message-index window `[range.start,
2843
- * range.end)` of a session into a single anchored summary, leaving everything
2844
- * outside the window untouched, and cache the result so a later
2845
- * forceCompact/resume reads the archived history. This is a generic
2846
- * range-archival facade over ContextManager.summarizeRange the caller
2847
- * decides which span to collapse (the range is a half-open message-index
2848
- * window, matching summarizeRange). Returns token stats before/after; equal
2849
- * before/after means the window was empty or the summary was rejected.
2850
- */
2851
- async archiveTurnRange(sessionId, range) {
2878
+ * Archive a contiguous message window of a session into a single anchored
2879
+ * summary, leaving everything outside the window untouched, and cache the
2880
+ * result so a later forceCompact/resume reads the archived history. This is
2881
+ * a generic range-archival facade over ContextManager.summarizeRange.
2882
+ * Returns token stats before/after; equal before/after means the window was
2883
+ * empty/unresolvable or the summary was rejected.
2884
+ *
2885
+ * Window resolution depends on whether the caller supplied anchors:
2886
+ * - With `anchors`, the caller's `range` is IGNORED and the window is
2887
+ * resolved from the client-message-id anchors over a fresh, marker-aware
2888
+ * transcript replay. Callers (the pet segment closure) compute index
2889
+ * ranges over the RAW transcript message list, whose indices grow
2890
+ * forever — but the live list is trimmed by every persisted marker
2891
+ * (replay after restart) and by each in-process archival, so a raw index
2892
+ * range clamps to an empty window or, worse, onto the WRONG tail
2893
+ * messages, which would then be persistently mis-summarized against
2894
+ * correct anchors. Anchors are the only stable coordinates.
2895
+ * - Without `anchors`, legacy behavior: `range` is a half-open index window
2896
+ * over the cached/in-process message list, and nothing is persisted.
2897
+ */
2898
+ async archiveTurnRange(sessionId, range, anchors) {
2852
2899
  const effectiveSessionId = sessionId || this.lastSessionId;
2853
2900
  if (!effectiveSessionId)
2854
2901
  return { before: 0, after: 0 };
2855
2902
  const session = this.sessionManager.resume(effectiveSessionId);
2856
- const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2903
+ let sourceMessages;
2904
+ let window;
2905
+ if (anchors) {
2906
+ const { messages: liveMessages, liveIndexByClientMessageId } = session.transcript.toMessagesWithIndex();
2907
+ sourceMessages = liveMessages;
2908
+ // An absent from-anchor means "from the beginning" — over the live
2909
+ // list that is index 0, which includes any previously replayed
2910
+ // from-less summary at the head, so summarizeRange merge-feeds it
2911
+ // (extractAnchoredSummary) instead of losing it.
2912
+ const start = anchors.fromClientMessageId !== undefined
2913
+ ? liveIndexByClientMessageId.get(anchors.fromClientMessageId)
2914
+ : 0;
2915
+ const end = liveIndexByClientMessageId.get(anchors.toClientMessageId);
2916
+ if (start === undefined || end === undefined || end <= start) {
2917
+ // Fail open: no summarization, no persistence. Falling back to the
2918
+ // caller's raw range is exactly the bug this path exists to fix —
2919
+ // it would summarize the wrong messages and persist that against
2920
+ // the (correct) anchors, with segmentId dedupe blocking correction.
2921
+ logger.warn("engine.archive_range.anchor_window_unresolved", {
2922
+ sessionId: effectiveSessionId,
2923
+ segmentId: anchors.segmentId,
2924
+ fromClientMessageId: anchors.fromClientMessageId,
2925
+ toClientMessageId: anchors.toClientMessageId,
2926
+ });
2927
+ const before = estimateTokens(sourceMessages);
2928
+ return { before, after: before };
2929
+ }
2930
+ window = { start, end };
2931
+ }
2932
+ else {
2933
+ sourceMessages =
2934
+ this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2935
+ window = range;
2936
+ }
2857
2937
  const before = estimateTokens(sourceMessages);
2858
2938
  const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "archive_range");
2859
2939
  // Range archival is initiated deliberately, not by a pressure heuristic;
2860
2940
  // don't let a stale run callback retained on lastContextManager double-emit.
2861
2941
  contextManager.setOnCompact(() => { });
2862
- const archived = await contextManager.summarizeRange(sourceMessages, range);
2942
+ const archived = await contextManager.summarizeRange(sourceMessages, window);
2863
2943
  const after = estimateTokens(archived);
2944
+ // Persist the boundary so a restart replays the trimmed context. Only when
2945
+ // summarizeRange actually replaced the span (identity return means empty
2946
+ // window / rejected summary / no summarizer available) and the caller
2947
+ // supplied stable anchors to record it against.
2948
+ if (anchors && archived !== sourceMessages) {
2949
+ const clampedStart = Math.max(0, Math.min(window.start, archived.length - 1));
2950
+ const summaryMessage = archived[clampedStart];
2951
+ const summaryText = typeof summaryMessage?.content === "string" ? summaryMessage.content : undefined;
2952
+ if (summaryText && this.hasLiveArchiveAnchors(session, anchors)) {
2953
+ session.transcript.appendRangeArchive({ summary: summaryText, ...anchors });
2954
+ }
2955
+ }
2956
+ // In the anchored path this replaces any in-process-only cache state with
2957
+ // the fresh-replay-based result — safe, because the transcript is the
2958
+ // superset of the cache and pressure compaction re-runs if needed.
2864
2959
  this.compactedMessagesBySession.set(effectiveSessionId, archived);
2865
2960
  this.lastSessionId = effectiveSessionId;
2866
2961
  this.lastMessages = archived;
2867
2962
  return { before, after };
2868
2963
  }
2964
+ /**
2965
+ * Guard against writing a marker anchored to a client message id the
2966
+ * transcript doesn't actually contain. toMessages()'s replay silently
2967
+ * ignores a range_archive event whose anchor doesn't resolve (fail open),
2968
+ * so a bad anchor wouldn't corrupt anything on its own — but if the marker
2969
+ * carries a segmentId, appendRangeArchive's idempotency check treats that
2970
+ * segmentId as "already recorded" forever, with no retry path (e.g. a
2971
+ * one-time migration keyed "migration-v1" would be permanently burned on
2972
+ * its first, failed attempt). Reject before writing so the caller can
2973
+ * retry with a corrected anchor. fromClientMessageId is optional in the
2974
+ * data model (absent means "from the beginning"), so only validate it when
2975
+ * present; missing it is fail-open by design, same as the replay path.
2976
+ */
2977
+ hasLiveArchiveAnchors(session, anchors) {
2978
+ if (!session.transcript.hasClientMessageId(anchors.toClientMessageId))
2979
+ return false;
2980
+ if (anchors.fromClientMessageId !== undefined &&
2981
+ !session.transcript.hasClientMessageId(anchors.fromClientMessageId)) {
2982
+ return false;
2983
+ }
2984
+ return true;
2985
+ }
2986
+ /**
2987
+ * Persist an archive boundary WITHOUT a summarization call — the caller
2988
+ * already has the summary text (e.g. the one-time migration built from
2989
+ * pet journal entries). Wraps the plain text in the anchored-summary
2990
+ * envelope so replay and rolling-merge treat it like a real archive.
2991
+ * Returns false when the segmentId was already recorded (idempotent) OR
2992
+ * when the anchors don't resolve to real messages in this transcript —
2993
+ * see hasLiveArchiveAnchors for why a dead anchor must be rejected before
2994
+ * the (potentially one-shot) segmentId gets burned.
2995
+ */
2996
+ async appendArchiveMarker(sessionId, marker) {
2997
+ const session = this.sessionManager.resume(sessionId);
2998
+ if (!this.hasLiveArchiveAnchors(session, marker)) {
2999
+ logger.warn("engine.archive_marker.dead_anchor", {
3000
+ sessionId,
3001
+ segmentId: marker.segmentId,
3002
+ toClientMessageId: marker.toClientMessageId,
3003
+ fromClientMessageId: marker.fromClientMessageId,
3004
+ });
3005
+ return false;
3006
+ }
3007
+ const wrapped = buildAnchoredSummaryMessage(marker.summary, {
3008
+ ...(session.transcript.isPersistent()
3009
+ ? { transcriptPath: session.transcript.getFilePath() }
3010
+ : {}),
3011
+ });
3012
+ const content = typeof wrapped.content === "string" ? wrapped.content : marker.summary;
3013
+ const appended = session.transcript.appendRangeArchive({ ...marker, summary: content });
3014
+ if (!appended)
3015
+ return false;
3016
+ // The in-memory cache (if any) predates the marker; drop it so the next
3017
+ // run rebuilds from the trimmed transcript replay.
3018
+ this.compactedMessagesBySession.delete(sessionId);
3019
+ return true;
3020
+ }
2869
3021
  recordCacheReadDiagnostics(sessionId, sample) {
2870
3022
  const result = this.promptCacheDiagnostics.record(sessionId, sample);
2871
3023
  if (result.kind === "scope_changed") {
@@ -88,11 +88,13 @@ export async function finalizeRunSuccess(args) {
88
88
  // the turn loop has resolved (completion, error, or abort). Handlers
89
89
  // are notify-only — any returned messages are dropped because the run
90
90
  // is already over and there's no next turn to inject into.
91
- await args.emitHook("on_session_end", {
92
- sessionId: session.state.sessionId,
93
- reason: result.reason,
94
- turnCount,
95
- }, options?.signal);
91
+ if (profile?.disableHooks !== true) {
92
+ await args.emitHook("on_session_end", {
93
+ sessionId: session.state.sessionId,
94
+ reason: result.reason,
95
+ turnCount,
96
+ }, options?.signal);
97
+ }
96
98
  // Ephemeral side chats must never leak into durable memory, even after
97
99
  // the user explicitly elevates tool permissions for a turn. Lifecycle
98
100
  // isolation is independent of the run-scoped behavior/permission mode.
@@ -106,7 +108,7 @@ export async function finalizeRunSuccess(args) {
106
108
  // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
107
109
  // effort: failures never touch the run result. The renderer writes the
108
110
  // title into the sidebar on receipt of the session_title stream event.
109
- {
111
+ if (profile?.disableSessionTitle !== true) {
110
112
  const messageEvents = session.transcript.getEvents("message");
111
113
  const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
112
114
  const userMsgCount = userMsgEvents.length;
@@ -113,7 +113,7 @@ export function openRunSession(args) {
113
113
  else {
114
114
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
115
115
  // lets sessionManager generate one with nanoid.
116
- session = args.sessionManager.create(args.cwd, args.llmModel, args.llmProvider, options?.sessionId, args.isSubAgent ? getCurrentSid() : undefined, args.isSubAgent ? "subagent" : args.origin, args.sessionKind);
116
+ session = args.sessionManager.create(args.cwd, args.llmModel, args.llmProvider, options?.sessionId, args.isSubAgent ? getCurrentSid() : undefined, args.isSubAgent ? "subagent" : args.origin, args.sessionKind, options?.ephemeral === true);
117
117
  const userMsg = { role: "user", content: args.userMessageContent };
118
118
  claimClientMessageId(session, options?.clientMessageId, "submit");
119
119
  if (args.parsedTask.hasImages)
@@ -32,6 +32,11 @@ export interface RunPromptComposerConfigInput {
32
32
  disabledPlugins: ComposerOptions["disabledPlugins"];
33
33
  skillAllowlist: ComposerOptions["skillAllowlist"];
34
34
  memoriesMaxAgeDays: ComposerOptions["memoriesMaxAgeDays"];
35
+ memoryCurrentProjectOnly?: ComposerOptions["memoryCurrentProjectOnly"];
36
+ disableInstructions?: ComposerOptions["disableInstructions"];
37
+ disableMemoryContext?: ComposerOptions["disableMemoryContext"];
38
+ disableCapabilityContext?: ComposerOptions["disableCapabilityContext"];
39
+ disableSourcesContext?: ComposerOptions["disableSourcesContext"];
35
40
  goalToolState: ComposerOptions["goalToolState"];
36
41
  capabilityPromptSections: ComposerOptions["capabilityPromptSections"];
37
42
  dynamicContextProviders: ComposerOptions["dynamicContextProviders"];
@@ -23,7 +23,7 @@ export function resolveRunProfileState(args) {
23
23
  }
24
24
  /** Build the prompt-composer options for a run without capturing the Engine facade. */
25
25
  export function buildPromptComposerConfig(args) {
26
- const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
26
+ const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, memoryCurrentProjectOnly, disableInstructions, disableMemoryContext, disableCapabilityContext, disableSourcesContext, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
27
27
  return {
28
28
  cwd,
29
29
  model,
@@ -49,6 +49,11 @@ export function buildPromptComposerConfig(args) {
49
49
  disabledPlugins,
50
50
  skillAllowlist,
51
51
  memoriesMaxAgeDays,
52
+ memoryCurrentProjectOnly,
53
+ disableInstructions,
54
+ disableMemoryContext,
55
+ disableCapabilityContext,
56
+ disableSourcesContext,
52
57
  goalToolState,
53
58
  capabilityPromptSections,
54
59
  dynamicContextProviders,
@@ -51,8 +51,14 @@ export function buildRunToolContext(args) {
51
51
  return reason;
52
52
  },
53
53
  },
54
+ skillAllowlist: options?.skillAllowlist !== undefined
55
+ ? [...options.skillAllowlist]
56
+ : args.base.skillAllowlist,
54
57
  };
55
- if (profile?.allowedToolNames) {
58
+ if (options?.toolAllowlist !== undefined) {
59
+ toolCtx.allowedToolNames = new Set(options.toolAllowlist);
60
+ }
61
+ else if (profile?.allowedToolNames) {
56
62
  toolCtx.allowedToolNames = profile.allowedToolNames;
57
63
  }
58
64
  if (profile?.createRunServices) {
@@ -32,6 +32,27 @@ export interface RunBehaviorProfile {
32
32
  disablePlanMode?: boolean;
33
33
  /** When true, MCP servers are neither connected nor exposed for the run. */
34
34
  disableMcp?: boolean;
35
+ /** Skip repository/user instruction discovery for this run. */
36
+ disableInstructions?: boolean;
37
+ /** Skip persistent-memory injection for this run. */
38
+ disableMemoryContext?: boolean;
39
+ /** Skip capability-owned volatile context providers for this run. */
40
+ disableCapabilityContext?: boolean;
41
+ /** Skip bound-source metadata injection for this run. */
42
+ disableSourcesContext?: boolean;
43
+ /** Ignore the project's default digital-human profile for this run. */
44
+ disableWorkspaceProfile?: boolean;
45
+ /** Skip SessionStart/UserPromptSubmit/SessionEnd hooks for this run. */
46
+ disableHooks?: boolean;
47
+ /** Skip auxiliary title generation for this run. */
48
+ disableSessionTitle?: boolean;
49
+ /**
50
+ * When true, the injected persistent-memory index is trimmed to this run's
51
+ * project: global-layer project-type / dream-scope records tied to other
52
+ * projects are dropped. Meant for manager-style profiles whose runs never
53
+ * work inside other repos.
54
+ */
55
+ memoryCurrentProjectOnly?: boolean;
35
56
  /**
36
57
  * Wrapper tag for host-provided runtime context injected at the system
37
58
  * prompt tail (e.g. "pet-world"). Injection happens only when both this tag
@@ -60,6 +81,10 @@ export interface RunBehaviorProfile {
60
81
  export declare const QUICK_CHAT_RESTRICTED_SYSTEM_PROMPT = "# Side Conversation Boundary\n\nThis is a side conversation, not the main-thread task execution environment.\n- Treat all content before this boundary as reference history only. Do not proactively continue any earlier plan, task, or modification.\n- Default to answering the user's question directly. Use lightweight read-only exploration only when needed.\n- Do not modify files, git state, configuration, or permissions unless the user explicitly asks after this boundary (for example, \"Allow you to modify files, please help me...\" or \"Please directly edit...\"). When explicitly requested, use the normally available tools subject to the current permission and approval mode.\n- Sub-agents are disabled for this side conversation. Do not create or invoke sub-agents.";
61
82
  /** The side-conversation restriction expressed as a generic behavior profile. */
62
83
  export declare const QUICK_CHAT_RESTRICTED_PROFILE: RunBehaviorProfile;
84
+ export declare const ISOLATED_TASK_BEHAVIOR_MODE: "isolatedTask";
85
+ export declare const ISOLATED_TASK_SYSTEM_PROMPT = "# Isolated Task Boundary\n\nThis is a bounded Task, not a continuation of any user conversation.\n- Use only the explicit Task input, the visible tool surface, and the selected Skill when one is available.\n- Do not infer or search for prior Session history, persistent memory, unrelated Skills, or other projects.\n- Do not create sub-agents or start unrelated work.\n- Finish the requested outcome, then return a concise result suitable for the owning application.";
86
+ /** Minimal, process-local run profile used by host-owned Tasks. */
87
+ export declare const ISOLATED_TASK_PROFILE: RunBehaviorProfile;
63
88
  export interface EngineRunOptions {
64
89
  cwd?: string;
65
90
  onStream?: StreamCallback;
@@ -76,6 +101,12 @@ export interface EngineRunOptions {
76
101
  attachments?: InputAttachmentMeta[];
77
102
  /** Named per-run behavior profile supplied by interactive product surfaces. */
78
103
  behaviorMode?: RunBehaviorMode;
104
+ /** Per-run hard tool allowlist. An empty list exposes no tools. */
105
+ toolAllowlist?: readonly string[];
106
+ /** Per-run hard Skill allowlist. An empty list exposes no Skills. */
107
+ skillAllowlist?: readonly string[];
108
+ /** Keep a fresh Session in process memory only and omit it from Session pickers. */
109
+ ephemeral?: boolean;
79
110
  /**
80
111
  * Generic per-run parameters consumed by the active behavior profile
81
112
  * (createRunServices / buildVisibilityMeta / runtime-context injection).
@@ -10,3 +10,24 @@ export const QUICK_CHAT_RESTRICTED_PROFILE = {
10
10
  id: "quickChatRestricted",
11
11
  systemPromptAppend: QUICK_CHAT_RESTRICTED_SYSTEM_PROMPT,
12
12
  };
13
+ export const ISOLATED_TASK_BEHAVIOR_MODE = "isolatedTask";
14
+ export const ISOLATED_TASK_SYSTEM_PROMPT = `# Isolated Task Boundary
15
+
16
+ This is a bounded Task, not a continuation of any user conversation.
17
+ - Use only the explicit Task input, the visible tool surface, and the selected Skill when one is available.
18
+ - Do not infer or search for prior Session history, persistent memory, unrelated Skills, or other projects.
19
+ - Do not create sub-agents or start unrelated work.
20
+ - Finish the requested outcome, then return a concise result suitable for the owning application.`;
21
+ /** Minimal, process-local run profile used by host-owned Tasks. */
22
+ export const ISOLATED_TASK_PROFILE = {
23
+ id: ISOLATED_TASK_BEHAVIOR_MODE,
24
+ systemPromptAppend: ISOLATED_TASK_SYSTEM_PROMPT,
25
+ disableMcp: true,
26
+ disableInstructions: true,
27
+ disableMemoryContext: true,
28
+ disableCapabilityContext: true,
29
+ disableSourcesContext: true,
30
+ disableWorkspaceProfile: true,
31
+ disableHooks: true,
32
+ disableSessionTitle: true,
33
+ };