newmark-agent 0.3.11 → 0.3.12

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 (39) hide show
  1. package/config.example.json +6 -0
  2. package/dist/cli-commands.d.ts +7 -0
  3. package/dist/cli-commands.js +206 -15
  4. package/dist/cli-discovery.d.ts +15 -0
  5. package/dist/cli-discovery.js +182 -0
  6. package/dist/cli-help.d.ts +2 -0
  7. package/dist/cli-help.js +25 -1
  8. package/dist/conversation-utility-host.bundle.cjs +357 -84
  9. package/dist/core/agent.d.ts +18 -3
  10. package/dist/core/agent.js +214 -30
  11. package/dist/core/agentKernelRunner.js +53 -8
  12. package/dist/core/config.d.ts +7 -2
  13. package/dist/core/config.js +24 -6
  14. package/dist/core/conversationKernel.js +1 -1
  15. package/dist/core/electronUtilityRuntimePool.d.ts +11 -0
  16. package/dist/core/electronUtilityRuntimePool.js +62 -0
  17. package/dist/core/flow-runner.js +1 -1
  18. package/dist/core/modelValidationStore.d.ts +4 -1
  19. package/dist/core/modelValidationStore.js +7 -1
  20. package/dist/core/workspace.d.ts +6 -0
  21. package/dist/core/workspace.js +14 -0
  22. package/dist/core/wslAgentRuntimePool.d.ts +4 -0
  23. package/dist/core/wslAgentRuntimePool.js +56 -0
  24. package/dist/launcher.js +40 -11
  25. package/dist/llm/provider.d.ts +8 -5
  26. package/dist/llm/provider.js +85 -33
  27. package/dist/main.js +167 -45
  28. package/dist/preload.js +6 -0
  29. package/dist/providers/chat-completions.adapter.js +1 -5
  30. package/dist/providers/provider-events.d.ts +7 -0
  31. package/dist/providers/provider-events.js +44 -0
  32. package/dist/providers/responses.adapter.js +1 -3
  33. package/dist/tui/src/adapters/core-runtime-adapter.js +40 -3
  34. package/dist/tui/src/app.js +47 -13
  35. package/dist/tui/src/render.js +23 -7
  36. package/dist/tui/src/state.js +61 -9
  37. package/dist/ui/index.html +250 -84
  38. package/dist/wsl-agent-host.bundle.cjs +357 -84
  39. package/package.json +14 -5
@@ -33,6 +33,7 @@ export interface AgentRuntimeOptions {
33
33
  actorId?: string;
34
34
  conversationId?: string;
35
35
  runtimeLifecycleRole?: RuntimeLifecycleRole;
36
+ readOnlyConfig?: boolean;
36
37
  linkedPlanAccess?: {
37
38
  get(conversationId?: string): LinkedPlanState;
38
39
  update(markdown: string, expectedRevision: number, actorId: string, conversationId?: string): LinkedPlanState;
@@ -487,7 +488,7 @@ export declare class Agent {
487
488
  private normalizeConversationChatMessages;
488
489
  persistGuideMessage(clientMessageId: string, content: string, runId?: string, historyContent?: unknown, attachments?: ConversationImageAttachment[], guideId?: string): boolean;
489
490
  setConversationWorkRunExpanded(runId: string, expanded: boolean): boolean;
490
- finishConversationWorkRun(runId: string, status: Exclude<ConversationWorkRunStatus, 'running'>, endedAt?: string): boolean;
491
+ finishConversationWorkRun(runId: string, status: Exclude<ConversationWorkRunStatus, 'running'>, endedAt?: string, errorMessage?: string): boolean;
491
492
  private ensureCompletedWorkRunFinalResult;
492
493
  emitWorkEvent(input: Omit<AgentWorkEvent, 'id' | 'conversationId' | 'mode' | 'model' | 'timestamp'> & Partial<Pick<AgentWorkEvent, 'conversationId' | 'mode' | 'model' | 'timestamp'>>): AgentWorkEvent;
493
494
  appendWorkflowMessage(content: string, toolName?: string, toolArgs?: string, persist?: boolean): void;
@@ -598,6 +599,7 @@ export declare class Agent {
598
599
  private applyWorkspaceContext;
599
600
  selectWorkspace(id: string): WorkspaceInfo | null;
600
601
  selectWorkspaceFromStorage(id: string): WorkspaceInfo | null;
602
+ refreshWorkspaceRegistryFromStorage(): WorkspaceInfo | null;
601
603
  setConversation(id: string): string;
602
604
  setConversationFromStorage(id: string): string;
603
605
  persistActiveConversationSelection(id: string, ws?: WorkspaceInfo | null): string;
@@ -688,9 +690,20 @@ export declare class Agent {
688
690
  claimGoalContinuationMessage(options?: {
689
691
  force?: boolean;
690
692
  }): AgentPromptMessage | null;
693
+ private buildSessionArchive;
691
694
  private writeSessionArchive;
695
+ private writeSessionArchiveAsync;
692
696
  archiveSession(): string;
693
697
  archiveConversation(conversationId: string): string | null;
698
+ /**
699
+ * Non-blocking archive writer used by the desktop IPC path. The conversation
700
+ * state merge remains synchronous and lock-protected, but the potentially
701
+ * large markdown payload and manifest use promise-based filesystem I/O so
702
+ * independent workspaces can archive in parallel without freezing Electron.
703
+ */
704
+ archiveConversationAsync(conversationId: string): Promise<string | null>;
705
+ private archiveConversationAsyncUnlocked;
706
+ private finalizeAsyncConversationArchive;
694
707
  private listStoredConversationIds;
695
708
  listArchives(scope?: 'workspace' | 'all'): Array<{
696
709
  id: string;
@@ -743,7 +756,9 @@ export declare class Agent {
743
756
  switchToFallbackModel(errorText?: string): string | null;
744
757
  isLlmErrorText(text: string): boolean;
745
758
  private scopedSwitchModels;
746
- validateModels(selectedNames?: string[]): Promise<ModelValidationResult[]>;
759
+ validateModels(selectedNames?: string[], options?: {
760
+ persist?: boolean;
761
+ }): Promise<ModelValidationResult[]>;
747
762
  isModelValidationRunning(): boolean;
748
763
  modelValidationStatus(): ModelValidationProgress;
749
764
  private runModelValidation;
@@ -825,7 +840,7 @@ export declare class Agent {
825
840
  handleSkillDownload(args: string): Promise<void>;
826
841
  refreshSkills(): void;
827
842
  private processOpencode;
828
- maybeCompress(msgs: Array<Record<string, unknown>>, provider?: LLMProvider | null, signal?: AbortSignal, compressionModel?: string, force?: boolean): Promise<void>;
843
+ maybeCompress(msgs: Array<Record<string, unknown>>, provider?: LLMProvider | null, signal?: AbortSignal, compressionModel?: string, force?: boolean): Promise<boolean>;
829
844
  private buildCompressionSummary;
830
845
  private localCompressionSummary;
831
846
  private latestUserHistoryText;
@@ -292,7 +292,7 @@ class Agent {
292
292
  this.subagentName = options.subagentName || '';
293
293
  this.subagentPrompt = options.subagentPrompt || '';
294
294
  this.linkedPlanAccess = options.linkedPlanAccess;
295
- this.config = new config_1.ConfigManager(rootPath);
295
+ this.config = new config_1.ConfigManager(rootPath, { readOnly: options.readOnlyConfig === true });
296
296
  this.compressionHistoryArchive = new compressionHistoryArchive_1.CompressionHistoryArchive(rootPath);
297
297
  this.contextV2 = new agent_context_manager_1.AgentContextManager(rootPath, this.config);
298
298
  this.agentRunService = this.config.contextFlag('agent_runtime_v2')
@@ -763,7 +763,12 @@ class Agent {
763
763
  }
764
764
  const previousAuto = this.model === 'auto' ? this.resolvedDeployment : null;
765
765
  const qualified = parseDeploymentSelectionValue(requested);
766
- const current = qualified ? this.config.findDeployment(qualified) : (requested ? this.config.findModel(requested) : undefined);
766
+ const legacyQualified = requested.includes('/')
767
+ ? this.config.allModels().filter(model => `${model.provider_id}/${model.name}` === requested || `${model.provider}/${model.name}` === requested)
768
+ : [];
769
+ const current = qualified
770
+ ? this.config.findDeployment(qualified)
771
+ : (legacyQualified.length === 1 ? legacyQualified[0] : (requested ? this.config.findModel(requested) : undefined));
767
772
  this.model = current?.name || requested;
768
773
  this.fixedDeployment = current ? this.deploymentRef(current) : qualified;
769
774
  this.resolvedDeployment = null;
@@ -2062,7 +2067,7 @@ class Agent {
2062
2067
  this.saveWorkspaceConversationState(true);
2063
2068
  return true;
2064
2069
  }
2065
- finishConversationWorkRun(runId, status, endedAt = this.nowIso()) {
2070
+ finishConversationWorkRun(runId, status, endedAt = this.nowIso(), errorMessage = '') {
2066
2071
  const run = this.workRuns.find(item => item.runId === String(runId || ''));
2067
2072
  if (!run)
2068
2073
  return false;
@@ -2110,7 +2115,9 @@ class Agent {
2110
2115
  this.enforceGoalTerminalInvariant(status, goalAudit);
2111
2116
  this.emitWorkEvent({
2112
2117
  type: status === 'completed' ? 'done' : status === 'error' ? 'error' : 'status',
2113
- content: status === 'force_interrupted' ? 'Force interrupted.' : status === 'interrupted' ? 'Interrupted.' : 'Response complete.',
2118
+ content: status === 'error'
2119
+ ? (String(errorMessage || '').trim() || 'Agent run failed.')
2120
+ : status === 'force_interrupted' ? 'Force interrupted.' : status === 'interrupted' ? 'Interrupted.' : 'Response complete.',
2114
2121
  status,
2115
2122
  runId: run.runId,
2116
2123
  conversationId: run.target.conversationId,
@@ -2281,6 +2288,12 @@ class Agent {
2281
2288
  this.awaitingAgentKernelRuntime = false;
2282
2289
  if (!runtime)
2283
2290
  return;
2291
+ // A user stop can arrive while the Native Kernel is still being loaded or
2292
+ // assembling its first context. In that handoff window
2293
+ // abortActiveKernelRun() can only abort the outer process signal; make a
2294
+ // runtime that attaches afterwards observe the already-aborted state too.
2295
+ if (this.activeProcessAbortController?.signal.aborted)
2296
+ runtime.abort?.();
2284
2297
  const queued = this.pendingAgentKernelQueue.splice(0);
2285
2298
  for (const item of queued) {
2286
2299
  const accepted = this.forwardAgentKernelQueueMessage(item.content, item.queueMode, item.clientMessageId, item.runId, item.images, item.hiddenUserInput);
@@ -3595,6 +3608,28 @@ class Agent {
3595
3608
  this.loadWorkspaceConversationState();
3596
3609
  return selected;
3597
3610
  }
3611
+ refreshWorkspaceRegistryFromStorage() {
3612
+ const before = JSON.stringify({
3613
+ internal: this.workspace.internal,
3614
+ external: this.workspace.external,
3615
+ current: this.workspace.current,
3616
+ });
3617
+ const selected = this.workspace.reloadFromStorage();
3618
+ const after = JSON.stringify({
3619
+ internal: this.workspace.internal,
3620
+ external: this.workspace.external,
3621
+ current: this.workspace.current,
3622
+ });
3623
+ if (before === after)
3624
+ return selected;
3625
+ if (selected)
3626
+ this.config.loadWorkspaceConfig(selected.path);
3627
+ else
3628
+ this.config.clearWorkspaceOverrides();
3629
+ this.workspaceConversations.clear();
3630
+ this.loadWorkspaceConversationState();
3631
+ return selected;
3632
+ }
3598
3633
  setConversation(id) {
3599
3634
  const clean = this.safeConversationId(id || 'default');
3600
3635
  // Conversation runners may bind a target workspace directly before their
@@ -4663,29 +4698,52 @@ class Agent {
4663
4698
  this.saveWorkspaceConversationState(true);
4664
4699
  return { text, hiddenUserInput: true, goalContinuation: true };
4665
4700
  }
4666
- writeSessionArchive(messages, mode, model) {
4701
+ buildSessionArchive(messages, mode, model, archiveDir) {
4667
4702
  const stamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '_').replace('Z', '');
4668
- const archiveDir = this.archiveDir();
4669
- fs.mkdirSync(archiveDir, { recursive: true });
4670
- const filename = `session_${stamp}.md`;
4671
- const outPath = path.join(archiveDir, filename);
4672
- let md = `# Newmark Session — ${stamp}\n\n`;
4673
- md += `**Mode**: ${mode}\n**Model**: ${model}\n`;
4674
- md += `**Messages**: ${messages.length}\n\n---\n\n`;
4703
+ // Millisecond-only names collide when a user clicks several archive
4704
+ // buttons in one event-loop turn. Keep the readable timestamp and add a
4705
+ // cryptographic suffix so every request owns an independent file.
4706
+ const filename = `session_${stamp}_${crypto.randomUUID().slice(0, 8)}.md`;
4707
+ let markdown = `# Newmark Session — ${stamp}\n\n`;
4708
+ markdown += `**Mode**: ${mode}\n**Model**: ${model}\n`;
4709
+ markdown += `**Messages**: ${messages.length}\n\n---\n\n`;
4675
4710
  if (this.goal)
4676
- md += `**Goal**: ${this.goal.objective}\n\n`;
4711
+ markdown += `**Goal**: ${this.goal.objective}\n\n`;
4677
4712
  for (const msg of messages) {
4678
- md += `**[${msg.role}] ${msg.timestamp}**\n\n${msg.content}\n\n`;
4713
+ markdown += `**[${msg.role}] ${msg.timestamp}**\n\n${msg.content}\n\n`;
4679
4714
  for (const attachment of (0, conversationAttachments_1.hydrateConversationImageAttachments)(this.rootPath, msg.attachments)) {
4680
4715
  const archived = (0, conversationAttachments_1.archiveConversationImageAttachment)(this.rootPath, archiveDir, attachment);
4681
4716
  if (!archived)
4682
4717
  continue;
4683
4718
  const alt = archived.name.replace(/[\]\r\n]/g, ' ').trim() || 'Submitted image';
4684
- md += `![${alt}](${archived.relativePath})\n\n`;
4719
+ markdown += `![${alt}](${archived.relativePath})\n\n`;
4685
4720
  }
4686
4721
  }
4687
- fs.writeFileSync(outPath, md, 'utf-8');
4688
- return filename;
4722
+ return { filename, markdown };
4723
+ }
4724
+ writeSessionArchive(messages, mode, model) {
4725
+ const archiveDir = this.archiveDir();
4726
+ fs.mkdirSync(archiveDir, { recursive: true });
4727
+ const archive = this.buildSessionArchive(messages, mode, model, archiveDir);
4728
+ fs.writeFileSync(path.join(archiveDir, archive.filename), archive.markdown, 'utf-8');
4729
+ return archive.filename;
4730
+ }
4731
+ async writeSessionArchiveAsync(messages, mode, model, archiveDir = this.archiveDir()) {
4732
+ const archive = this.buildSessionArchive(messages, mode, model, archiveDir);
4733
+ await fs.promises.mkdir(archiveDir, { recursive: true });
4734
+ const outPath = path.join(archiveDir, archive.filename);
4735
+ const tempPath = `${outPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
4736
+ try {
4737
+ await fs.promises.writeFile(tempPath, archive.markdown, 'utf-8');
4738
+ await fs.promises.rename(tempPath, outPath);
4739
+ }
4740
+ finally {
4741
+ try {
4742
+ await fs.promises.unlink(tempPath);
4743
+ }
4744
+ catch { }
4745
+ }
4746
+ return archive.filename;
4689
4747
  }
4690
4748
  archiveSession() {
4691
4749
  return this.writeSessionArchive(this.chatMessages, this.modeName(), this.model);
@@ -4759,6 +4817,111 @@ class Agent {
4759
4817
  }
4760
4818
  return filename;
4761
4819
  }
4820
+ /**
4821
+ * Non-blocking archive writer used by the desktop IPC path. The conversation
4822
+ * state merge remains synchronous and lock-protected, but the potentially
4823
+ * large markdown payload and manifest use promise-based filesystem I/O so
4824
+ * independent workspaces can archive in parallel without freezing Electron.
4825
+ */
4826
+ async archiveConversationAsync(conversationId) {
4827
+ // Archive payloads intentionally start in parallel. The final state
4828
+ // mutation below operates on the latest locked disk snapshot, so no
4829
+ // JavaScript queue is needed for independent targets in one workspace.
4830
+ return await this.archiveConversationAsyncUnlocked(conversationId);
4831
+ }
4832
+ async archiveConversationAsyncUnlocked(conversationId) {
4833
+ const ws = this.workspace.current;
4834
+ if (!ws)
4835
+ return null;
4836
+ const clean = this.safeConversationId(conversationId || 'default');
4837
+ const stateKey = this.workspaceConversationStateKey(clean);
4838
+ if (!stateKey)
4839
+ return null;
4840
+ const memoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
4841
+ const archiveDir = path.join(ws.path, 'archive');
4842
+ const workspacePrefix = this.workspaceConversationPrefix() || '';
4843
+ const archiveMode = this.modeName();
4844
+ const archiveModel = this.model;
4845
+ // readStoredConversationState returns a cache object. Clone it before any
4846
+ // asynchronous gap so concurrent archive requests cannot mutate one
4847
+ // another's source snapshot.
4848
+ const cachedStored = this.readStoredConversationState(ws);
4849
+ const stored = JSON.parse(JSON.stringify(cachedStored || {}));
4850
+ const persisted = stored.conversations?.[stateKey];
4851
+ if (persisted)
4852
+ this.normalizeConversationTree(persisted);
4853
+ const memory = this.workspaceConversations.get(memoryKey);
4854
+ const persistedMessagesAvailable = persisted?.chatMessages !== undefined;
4855
+ const sourceMessages = persisted?.chatMessages ?? memory?.chatMessages ?? [];
4856
+ const sourceHistory = persistedMessagesAvailable
4857
+ ? (persisted?.history ?? [])
4858
+ : (memory?.history ?? persisted?.history ?? []);
4859
+ const messages = this.normalizeConversationChatMessages(sourceMessages, sourceHistory);
4860
+ const filename = await this.writeSessionArchiveAsync(messages, archiveMode, archiveModel, archiveDir);
4861
+ const archiveEntry = persisted ? JSON.parse(JSON.stringify(persisted)) : {
4862
+ title: this.titleFromMessages(messages, clean),
4863
+ chatMessages: messages,
4864
+ history: sourceHistory,
4865
+ plan: memory?.plan,
4866
+ linkedPlan: memory?.linkedPlan,
4867
+ subagentState: memory?.subagentState,
4868
+ workRuns: memory?.workRuns,
4869
+ continuations: memory?.continuations,
4870
+ updatedAt: new Date().toISOString(),
4871
+ };
4872
+ const manifest = {
4873
+ version: 2,
4874
+ kind: 'newmark-conversation-archive',
4875
+ archivedAt: new Date().toISOString(),
4876
+ conversationId: clean,
4877
+ workspaceId: ws.id,
4878
+ workspaceName: ws.name,
4879
+ workspacePath: ws.path,
4880
+ workspaceInternal: ws.isInternal,
4881
+ statePrefix: workspacePrefix,
4882
+ entry: this.conversationEntryForDisk(archiveEntry),
4883
+ };
4884
+ const manifestPath = this.archiveManifestPath(path.join(archiveDir, filename));
4885
+ const manifestTempPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
4886
+ try {
4887
+ await fs.promises.writeFile(manifestTempPath, JSON.stringify(manifest, null, 2), 'utf-8');
4888
+ await fs.promises.rename(manifestTempPath, manifestPath);
4889
+ }
4890
+ finally {
4891
+ try {
4892
+ await fs.promises.unlink(manifestTempPath);
4893
+ }
4894
+ catch { }
4895
+ }
4896
+ this.finalizeAsyncConversationArchive(clean, stateKey, memoryKey, ws);
4897
+ return filename;
4898
+ }
4899
+ finalizeAsyncConversationArchive(clean, stateKey, memoryKey, ws) {
4900
+ let nextActiveId = '';
4901
+ this.mutateStoredConversationState(ws, latest => {
4902
+ latest.conversations = latest.conversations || {};
4903
+ delete latest.conversations[stateKey];
4904
+ // Derive the target prefix from the captured state key rather than the
4905
+ // Agent's possibly changed foreground workspace.
4906
+ const prefix = stateKey.slice(0, Math.max(0, stateKey.length - clean.length - 1)) + '-';
4907
+ const remaining = Object.keys(latest.conversations)
4908
+ .filter(key => !prefix || key.startsWith(prefix))
4909
+ .map(key => key.slice(prefix.length))
4910
+ .filter(Boolean);
4911
+ const currentActiveId = this.safeConversationId(latest.activeConversationId || this.activeConversationId || 'default');
4912
+ if (clean === currentActiveId)
4913
+ latest.activeConversationId = remaining[0] || 'default';
4914
+ nextActiveId = latest.activeConversationId || remaining[0] || 'default';
4915
+ return latest;
4916
+ });
4917
+ this.workspaceConversations.delete(memoryKey);
4918
+ const duplicateMemoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
4919
+ this.workspaceConversations.delete(duplicateMemoryKey);
4920
+ if (clean === this.safeConversationId(this.activeConversationId || 'default')) {
4921
+ this.activeConversationId = nextActiveId || 'default';
4922
+ this.loadWorkspaceConversationState();
4923
+ }
4924
+ }
4762
4925
  listStoredConversationIds(stored) {
4763
4926
  const prefix = `${this.workspaceConversationPrefix() || ''}-`;
4764
4927
  return Object.keys(stored.conversations || {})
@@ -5393,10 +5556,10 @@ class Agent {
5393
5556
  const provider = this.config.findProvider(providerId);
5394
5557
  return all.filter(m => m.provider_id === (provider?.id || providerId));
5395
5558
  }
5396
- async validateModels(selectedNames) {
5559
+ async validateModels(selectedNames, options = {}) {
5397
5560
  if (this.modelValidationPromise)
5398
5561
  return this.modelValidationPromise;
5399
- const validation = this.runModelValidation(selectedNames);
5562
+ const validation = this.runModelValidation(selectedNames, options.persist !== false);
5400
5563
  this.modelValidationPromise = validation;
5401
5564
  try {
5402
5565
  return await validation;
@@ -5416,7 +5579,7 @@ class Agent {
5416
5579
  recentChecks: this.modelValidationProgress.recentChecks.map(item => ({ ...item })),
5417
5580
  };
5418
5581
  }
5419
- async runModelValidation(selectedNames) {
5582
+ async runModelValidation(selectedNames, persist = true) {
5420
5583
  const selectedModels = this.config.modelsForSelections(selectedNames);
5421
5584
  if (!selectedModels.length) {
5422
5585
  this.modelValidationProgress = {
@@ -5427,7 +5590,10 @@ class Agent {
5427
5590
  }
5428
5591
  const results = [];
5429
5592
  const catalogByProvider = new Map();
5430
- const cache = new modelValidationStore_1.FileModelValidationCache(this.rootPath);
5593
+ // Read-only CLI validation may reuse already persisted, redacted evidence
5594
+ // without rewriting it. This keeps the no-mutation contract while making
5595
+ // repeated release/user checks local when the seven-day record is fresh.
5596
+ const cache = new modelValidationStore_1.FileModelValidationCache(this.rootPath, { readOnly: !persist });
5431
5597
  const checksPerModel = 11;
5432
5598
  let currentModel = '';
5433
5599
  let currentModelChecks = 0;
@@ -5568,7 +5734,8 @@ class Agent {
5568
5734
  completedModels: this.modelValidationProgress.completedModels + 1,
5569
5735
  };
5570
5736
  }
5571
- this.config.save();
5737
+ if (persist)
5738
+ this.config.save();
5572
5739
  this.modelValidationProgress = {
5573
5740
  ...this.modelValidationProgress,
5574
5741
  running: false,
@@ -5851,7 +6018,14 @@ class Agent {
5851
6018
  const text = typeof input === 'string' ? input : String(input.text || '');
5852
6019
  const inputEnvelope = typeof input === 'string' ? null : input;
5853
6020
  const hiddenUserInput = inputEnvelope?.hiddenUserInput === true;
5854
- this.ensureUsableModelSelection();
6021
+ // An empty selection may be repaired to the configured default, but an
6022
+ // explicitly requested fixed model must remain visible to the
6023
+ // fail-closed availability check below. Otherwise a typo such as
6024
+ // provider-that-does-not-exist/missing-model silently becomes the first
6025
+ // configured fixture/default deployment.
6026
+ const explicitFixedModel = this.model !== '' && this.model !== 'auto';
6027
+ if (!explicitFixedModel)
6028
+ this.ensureUsableModelSelection();
5855
6029
  const clientMessageId = String(inputEnvelope?.clientMessageId || '').trim();
5856
6030
  const inputRunId = String(inputEnvelope?.runId || this.activeWorkRunId || '').trim();
5857
6031
  const rawImages = typeof input === 'string' ? [] : (Array.isArray(input.images) ? input.images : []);
@@ -5949,7 +6123,16 @@ class Agent {
5949
6123
  await this.evaluateAndSwitch(displayText, inputEnvelope?.routePolicy);
5950
6124
  }
5951
6125
  if (this.model && this.modelIsUnavailable(this.model)) {
6126
+ const requestedModel = this.model;
5952
6127
  this.switchToFallbackModel();
6128
+ if (this.modelIsUnavailable(this.model)) {
6129
+ const message = `[Error] Model '${requestedModel || 'unknown'}' is unavailable or not configured. Select a configured model or enable a valid provider before sending.`;
6130
+ this.status = 'error';
6131
+ // Do not return an error token as if it were a successful assistant
6132
+ // response. The normal catch/finalizer path must publish an error
6133
+ // terminal event and keep the work run out of completed state.
6134
+ throw new Error(message);
6135
+ }
5953
6136
  }
5954
6137
  // Use external opencode CLI engine
5955
6138
  if (this.engine === 'opencode') {
@@ -7059,13 +7242,13 @@ class Agent {
7059
7242
  }
7060
7243
  async maybeCompress(msgs, provider, signal, compressionModel, force = false) {
7061
7244
  if (signal?.aborted)
7062
- return;
7245
+ return false;
7063
7246
  if (!this.config.getBool('context', 'auto_compress'))
7064
- return;
7247
+ return false;
7065
7248
  const total = msgs.reduce((sum, m) => sum + (typeof m.content === 'string' ? m.content.length : JSON.stringify(m.content || '').length), 0);
7066
7249
  const budget = this.compressionBudget(msgs);
7067
7250
  if (budget.estimatedTokens < budget.triggerTokens && !force)
7068
- return;
7251
+ return false;
7069
7252
  if (!force && this.lastCompression && String(msgs[0]?.content || '').includes(this.lastCompression.summary)) {
7070
7253
  const baselineChars = Math.max(0, Number(this.lastCompression.compressedChars || 0));
7071
7254
  const baselineTokens = Math.max(0, Number(this.lastCompression.compressedTokens || 0));
@@ -7074,12 +7257,12 @@ class Agent {
7074
7257
  const minCharGrowth = Math.max(12_000, Math.floor(baselineChars * 0.25));
7075
7258
  const minTokenGrowth = Math.max(1_024, Math.floor(budget.triggerTokens * 0.2));
7076
7259
  if (charGrowth < minCharGrowth && tokenGrowth < minTokenGrowth)
7077
- return;
7260
+ return false;
7078
7261
  }
7079
7262
  const originalMessageCount = msgs.length;
7080
7263
  const configuredKeepLast = this.config.getNum('context', 'keep_recent_messages') || 10;
7081
7264
  if (msgs.length <= 1)
7082
- return;
7265
+ return false;
7083
7266
  // Reserve room for the one-time post-compression continuation anchor so
7084
7267
  // adding it cannot push a near-limit request back over the target budget.
7085
7268
  const continuationAnchorTokens = this.estimateContextTokens([this.postCompressionContinuationMessage()]);
@@ -7087,7 +7270,7 @@ class Agent {
7087
7270
  const recent = this.recentContextSuffix(msgs, configuredKeepLast, recentBudget);
7088
7271
  const recentStart = Math.max(0, msgs.length - recent.length);
7089
7272
  if (recentStart <= 0)
7090
- return;
7273
+ return false;
7091
7274
  // The first history item is usually the first user task, not foundational
7092
7275
  // context. Keeping it forever makes an old task more salient after every
7093
7276
  // compaction. Foundational rules are rebuilt by buildSystemPrompt(); the
@@ -7096,7 +7279,7 @@ class Agent {
7096
7279
  const currentInstruction = this.latestUserHistoryText(recent);
7097
7280
  const compression = await this.buildCompressionSummary(middle, total, budget, provider, signal, compressionModel || this.activeModelName(), currentInstruction);
7098
7281
  if (signal?.aborted)
7099
- return;
7282
+ return false;
7100
7283
  const compressed = [{
7101
7284
  role: 'system',
7102
7285
  content: compression.summary,
@@ -7123,6 +7306,7 @@ class Agent {
7123
7306
  };
7124
7307
  this.pushCompressionCacheEntry(compression.summary, middle, compression.model, compression.fallback);
7125
7308
  this.persistCompressedHistory(compression.summary, recent.length, msgs);
7309
+ return true;
7126
7310
  }
7127
7311
  async buildCompressionSummary(middle, totalChars, budget, provider, signal, compressionModel, currentInstruction = '') {
7128
7312
  const workspacePath = this.workspace.current?.path || this.rootPath;
@@ -258,17 +258,40 @@ function normalizePublicProviderError(error, secrets = []) {
258
258
  }
259
259
  return raw.slice(0, 1_200);
260
260
  }
261
+ function throwIfKernelAborted(signal) {
262
+ if (!signal?.aborted)
263
+ return;
264
+ const reason = signal.reason;
265
+ if (reason instanceof Error) {
266
+ reason.name = 'AbortError';
267
+ throw reason;
268
+ }
269
+ const error = new Error(reason ? String(reason) : 'Agent run aborted');
270
+ error.name = 'AbortError';
271
+ throw error;
272
+ }
261
273
  async function runAgentKernel(agent) {
262
274
  const stopContextTimer = (0, performanceDiagnostics_1.performanceTimer)('context_prepare', { conversationId: agent.activeConversationId });
275
+ const processSignal = agent.activeProcessSignal();
276
+ if (processSignal?.aborted) {
277
+ stopContextTimer();
278
+ throwIfKernelAborted(processSignal);
279
+ }
263
280
  if (!agent.engineModel()) {
281
+ const message = 'No LLM configured. Add provider in Settings > Models.';
264
282
  agent.status = 'error';
265
283
  agent.saveWorkspaceConversationState();
266
- return [{ type: 'text', text: '[Error] No LLM configured. Add provider in Settings > Models.' }];
284
+ // A missing provider is a terminal run failure, not a visible assistant
285
+ // response. Throwing here lets Agent.process and ConversationKernel share
286
+ // the normal error finalization path, so GUI/TUI/CLI cannot turn this into
287
+ // a synthetic successful Build with an empty final summary.
288
+ throw new Error(message);
267
289
  }
268
290
  const [{ Agent: NativeAgent }, KernelStreamCompat] = await Promise.all([
269
291
  import('./agentKernel/index.js'),
270
292
  import('./agentKernel/stream-types.js'),
271
293
  ]);
294
+ throwIfKernelAborted(processSignal);
272
295
  const toolProvisioning = new ToolProvisionSession([], []);
273
296
  let activeToolSurfaceIdentity = '';
274
297
  let activeToolSurfaceNotice = '';
@@ -300,6 +323,7 @@ async function runAgentKernel(agent) {
300
323
  const initialToolSurface = refreshToolSurface(true);
301
324
  const assembledContext = agent.assembleContextV2(initialToolSurface.systemPromptNotice);
302
325
  const systemPrompt = assembledContext.text;
326
+ throwIfKernelAborted(processSignal);
303
327
  let providerRequestCount = 0;
304
328
  let bootstrappedCompressionAt = agent.lastCompression?.at || '';
305
329
  stopContextTimer();
@@ -320,6 +344,28 @@ async function runAgentKernel(agent) {
320
344
  kernel.state.tools = toKernelTools(agent, initialToolSurface.definitions, toolProvisioning);
321
345
  kernel.state.messages = toKernelMessages(agent);
322
346
  agent.attachAgentKernelRuntime(kernel);
347
+ let detachProcessAbort = () => { };
348
+ if (processSignal) {
349
+ const abortKernel = () => kernel.abort();
350
+ if (processSignal.aborted) {
351
+ kernel.abort();
352
+ }
353
+ else {
354
+ processSignal.addEventListener('abort', abortKernel, { once: true });
355
+ detachProcessAbort = () => processSignal.removeEventListener('abort', abortKernel);
356
+ }
357
+ }
358
+ try {
359
+ // abort() cannot cancel a NativeAgent before its internal run exists. The
360
+ // explicit check closes the remaining handoff window between attaching the
361
+ // kernel and entering kernel.prompt().
362
+ throwIfKernelAborted(processSignal);
363
+ }
364
+ catch (error) {
365
+ detachProcessAbort();
366
+ agent.attachAgentKernelRuntime(null);
367
+ throw error;
368
+ }
323
369
  const tokens = [];
324
370
  const runOnce = async (promptMessages, appendPromptToAgentHistory) => {
325
371
  let lastAssistant = null;
@@ -433,6 +479,7 @@ async function runAgentKernel(agent) {
433
479
  }
434
480
  }
435
481
  finally {
482
+ detachProcessAbort();
436
483
  agent.attachAgentKernelRuntime(null);
437
484
  }
438
485
  agent.status = 'idle';
@@ -614,14 +661,12 @@ async function transformContext(agent, messages, signal) {
614
661
  // projection so the internal broker call/result and its compact catalog can
615
662
  // never be written into conversation state or revived after a reload.
616
663
  const newmarkMessages = publicHistoryFromKernelMessages(messages);
617
- const beforeCompression = JSON.stringify(newmarkMessages);
618
664
  const compressionAt = agent.lastCompression?.at || '';
619
- await agent.maybeCompress(newmarkMessages, provider, processSignal, compressionModel);
665
+ let compressed = await agent.maybeCompress(newmarkMessages, provider, processSignal, compressionModel);
620
666
  if (processSignal?.aborted)
621
667
  return messages;
622
- const primaryCompressed = JSON.stringify(newmarkMessages) !== beforeCompression;
623
- if (primaryCompressed && agent.estimateContextTokens(newmarkMessages) >= Math.floor(agent.contextWindow(compressionModel).maxTokens * 0.82)) {
624
- await agent.maybeCompress(newmarkMessages, null, processSignal, compressionModel, true);
668
+ if (compressed && agent.estimateContextTokens(newmarkMessages) >= Math.floor(agent.contextWindow(compressionModel).maxTokens * 0.82)) {
669
+ compressed = (await agent.maybeCompress(newmarkMessages, null, processSignal, compressionModel, true)) || compressed;
625
670
  }
626
671
  // Hard safety net: even a conservative worst-case token estimate must never
627
672
  // leave a request that could exceed the model's context window. The improved
@@ -631,9 +676,9 @@ async function transformContext(agent, messages, signal) {
631
676
  const windowMax = agent.contextWindow(compressionModel).maxTokens;
632
677
  const conservativeTokens = agent.estimateContextTokens(newmarkMessages);
633
678
  if (conservativeTokens >= Math.floor(windowMax * 0.9) && !processSignal?.aborted) {
634
- await agent.maybeCompress(newmarkMessages, null, processSignal, compressionModel, true);
679
+ compressed = (await agent.maybeCompress(newmarkMessages, null, processSignal, compressionModel, true)) || compressed;
635
680
  }
636
- if (JSON.stringify(newmarkMessages) === beforeCompression)
681
+ if (!compressed)
637
682
  return messages;
638
683
  const durableMessages = toKernelMessagesFromHistory(newmarkMessages, agent);
639
684
  if (agent.lastCompression?.at && agent.lastCompression.at !== compressionAt) {
@@ -81,7 +81,10 @@ export declare class ConfigManager {
81
81
  rootPath: string;
82
82
  private config;
83
83
  private workspaceOverrides;
84
- constructor(rootPath: string);
84
+ private readonly readOnly;
85
+ constructor(rootPath: string, options?: {
86
+ readOnly?: boolean;
87
+ });
85
88
  reload(): void;
86
89
  private load;
87
90
  get<T = unknown>(section: string, key: string): T | undefined;
@@ -153,7 +156,9 @@ export interface ModelValidationSummary {
153
156
  message: string;
154
157
  };
155
158
  }
156
- export declare function ensureRootConfig(rootPath: string): void;
159
+ export declare function ensureRootConfig(rootPath: string, options?: {
160
+ readOnly?: boolean;
161
+ }): void;
157
162
  export declare function inferProviderProtocol(name: string, baseUrl: string): ProviderProtocol;
158
163
  export declare function normalizeProviderProtocol(value: unknown, name: string, baseUrl: string): ProviderProtocol;
159
164
  export declare function defaultProviderBaseUrl(protocol: ProviderProtocol): string;