@prestyj/cli 5.32.0 → 5.32.1

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 (221) hide show
  1. package/README.md +3 -2
  2. package/assets/skills/task-list/SKILL.md +39 -0
  3. package/dist/app-sidecar/app-settings.js +11 -5
  4. package/dist/app-sidecar/app-settings.js.map +1 -1
  5. package/dist/app-sidecar/auth-routes.js +10 -3
  6. package/dist/app-sidecar/auth-routes.js.map +1 -1
  7. package/dist/app-sidecar/git-target.d.ts +15 -2
  8. package/dist/app-sidecar/git-target.js +24 -10
  9. package/dist/app-sidecar/git-target.js.map +1 -1
  10. package/dist/app-sidecar/goal-routes.js +8 -0
  11. package/dist/app-sidecar/goal-routes.js.map +1 -1
  12. package/dist/app-sidecar/history-routes.js +7 -0
  13. package/dist/app-sidecar/history-routes.js.map +1 -1
  14. package/dist/app-sidecar/history.d.ts +5 -0
  15. package/dist/app-sidecar/history.js.map +1 -1
  16. package/dist/app-sidecar/integration-routes.js +4 -0
  17. package/dist/app-sidecar/integration-routes.js.map +1 -1
  18. package/dist/app-sidecar/mcp-routes.js +1 -0
  19. package/dist/app-sidecar/mcp-routes.js.map +1 -1
  20. package/dist/app-sidecar/mcp-rows.d.ts +2 -0
  21. package/dist/app-sidecar/mcp-rows.js +4 -1
  22. package/dist/app-sidecar/mcp-rows.js.map +1 -1
  23. package/dist/app-sidecar/prompt-routes.js +67 -1
  24. package/dist/app-sidecar/prompt-routes.js.map +1 -1
  25. package/dist/app-sidecar/route-context.d.ts +2 -0
  26. package/dist/app-sidecar/run-control-routes.js +43 -3
  27. package/dist/app-sidecar/run-control-routes.js.map +1 -1
  28. package/dist/app-sidecar/session-routes.js +10 -0
  29. package/dist/app-sidecar/session-routes.js.map +1 -1
  30. package/dist/app-sidecar/task-routes.js +2 -0
  31. package/dist/app-sidecar/task-routes.js.map +1 -1
  32. package/dist/app-sidecar/trophy-manager.d.ts +15 -0
  33. package/dist/app-sidecar/trophy-manager.js +71 -0
  34. package/dist/app-sidecar/trophy-manager.js.map +1 -0
  35. package/dist/app-sidecar/workspace-routes.js +1 -0
  36. package/dist/app-sidecar/workspace-routes.js.map +1 -1
  37. package/dist/app-sidecar.js +157 -30
  38. package/dist/app-sidecar.js.map +1 -1
  39. package/dist/cli/auth.js +22 -1
  40. package/dist/cli/auth.js.map +1 -1
  41. package/dist/cli/mcp.js +6 -1
  42. package/dist/cli/mcp.js.map +1 -1
  43. package/dist/cli.js +13 -9
  44. package/dist/cli.js.map +1 -1
  45. package/dist/config.js +4 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/core/action-threats.d.ts +68 -0
  48. package/dist/core/action-threats.js +894 -0
  49. package/dist/core/action-threats.js.map +1 -0
  50. package/dist/core/agent-notifications.d.ts +18 -0
  51. package/dist/core/agent-notifications.js +35 -2
  52. package/dist/core/agent-notifications.js.map +1 -1
  53. package/dist/core/agent-session/verification-tracker.d.ts +21 -0
  54. package/dist/core/agent-session/verification-tracker.js +39 -1
  55. package/dist/core/agent-session/verification-tracker.js.map +1 -1
  56. package/dist/core/agent-session.d.ts +166 -2
  57. package/dist/core/agent-session.js +478 -22
  58. package/dist/core/agent-session.js.map +1 -1
  59. package/dist/core/blocked-packages-file.d.ts +33 -0
  60. package/dist/core/blocked-packages-file.js +274 -0
  61. package/dist/core/blocked-packages-file.js.map +1 -0
  62. package/dist/core/compaction/policy.d.ts +2 -2
  63. package/dist/core/compaction/policy.js +86 -43
  64. package/dist/core/compaction/policy.js.map +1 -1
  65. package/dist/core/compaction/token-estimator.d.ts +4 -1
  66. package/dist/core/compaction/token-estimator.js +5 -2
  67. package/dist/core/compaction/token-estimator.js.map +1 -1
  68. package/dist/core/event-bus.d.ts +10 -0
  69. package/dist/core/event-bus.js +2 -0
  70. package/dist/core/event-bus.js.map +1 -1
  71. package/dist/core/idle-wake.d.ts +62 -0
  72. package/dist/core/idle-wake.js +103 -0
  73. package/dist/core/idle-wake.js.map +1 -0
  74. package/dist/core/injection-detect.d.ts +2 -1
  75. package/dist/core/injection-detect.js +13 -4
  76. package/dist/core/injection-detect.js.map +1 -1
  77. package/dist/core/installed-threats.d.ts +23 -0
  78. package/dist/core/installed-threats.js +71 -0
  79. package/dist/core/installed-threats.js.map +1 -0
  80. package/dist/core/mcp/catalog-cache.d.ts +2 -0
  81. package/dist/core/mcp/catalog-cache.js +4 -0
  82. package/dist/core/mcp/catalog-cache.js.map +1 -1
  83. package/dist/core/mcp/client.d.ts +8 -0
  84. package/dist/core/mcp/client.js +37 -3
  85. package/dist/core/mcp/client.js.map +1 -1
  86. package/dist/core/mcp/deferred-catalog.d.ts +12 -1
  87. package/dist/core/mcp/deferred-catalog.js +37 -10
  88. package/dist/core/mcp/deferred-catalog.js.map +1 -1
  89. package/dist/core/mcp/index.d.ts +2 -0
  90. package/dist/core/mcp/index.js +1 -0
  91. package/dist/core/mcp/index.js.map +1 -1
  92. package/dist/core/mcp/tool-guard.d.ts +58 -0
  93. package/dist/core/mcp/tool-guard.js +215 -0
  94. package/dist/core/mcp/tool-guard.js.map +1 -0
  95. package/dist/core/network-guard.d.ts +0 -20
  96. package/dist/core/network-guard.js +6 -0
  97. package/dist/core/network-guard.js.map +1 -1
  98. package/dist/core/package-cooldown.d.ts +71 -0
  99. package/dist/core/package-cooldown.js +301 -0
  100. package/dist/core/package-cooldown.js.map +1 -0
  101. package/dist/core/package-threats.d.ts +77 -3
  102. package/dist/core/package-threats.js +1250 -78
  103. package/dist/core/package-threats.js.map +1 -1
  104. package/dist/core/process-manager.d.ts +54 -12
  105. package/dist/core/process-manager.js +233 -56
  106. package/dist/core/process-manager.js.map +1 -1
  107. package/dist/core/prompt-commands.js +1 -0
  108. package/dist/core/prompt-commands.js.map +1 -1
  109. package/dist/core/session-manager.d.ts +3 -1
  110. package/dist/core/session-manager.js +2 -1
  111. package/dist/core/session-manager.js.map +1 -1
  112. package/dist/core/settings-manager.d.ts +1 -0
  113. package/dist/core/settings-manager.js +4 -0
  114. package/dist/core/settings-manager.js.map +1 -1
  115. package/dist/core/shell-threats.d.ts +14 -1
  116. package/dist/core/shell-threats.js +73 -24
  117. package/dist/core/shell-threats.js.map +1 -1
  118. package/dist/core/skill-scan.d.ts +112 -0
  119. package/dist/core/skill-scan.js +781 -0
  120. package/dist/core/skill-scan.js.map +1 -0
  121. package/dist/core/skills.d.ts +33 -0
  122. package/dist/core/skills.js +87 -16
  123. package/dist/core/skills.js.map +1 -1
  124. package/dist/core/slash-commands.d.ts +5 -1
  125. package/dist/core/slash-commands.js +24 -9
  126. package/dist/core/slash-commands.js.map +1 -1
  127. package/dist/core/steering.d.ts +7 -0
  128. package/dist/core/steering.js +13 -0
  129. package/dist/core/steering.js.map +1 -1
  130. package/dist/core/style-packs/index.js +1 -1
  131. package/dist/core/style-packs/index.js.map +1 -1
  132. package/dist/core/style-packs/packs.js +2 -2
  133. package/dist/core/subagent-manager.js +5 -4
  134. package/dist/core/subagent-manager.js.map +1 -1
  135. package/dist/core/threat-feed.d.ts +65 -0
  136. package/dist/core/threat-feed.js +329 -0
  137. package/dist/core/threat-feed.js.map +1 -0
  138. package/dist/core/trophies/catalog-more.d.ts +2 -0
  139. package/dist/core/trophies/catalog-more.js +857 -0
  140. package/dist/core/trophies/catalog-more.js.map +1 -0
  141. package/dist/core/trophies/catalog.d.ts +4 -0
  142. package/dist/core/trophies/catalog.js +649 -0
  143. package/dist/core/trophies/catalog.js.map +1 -0
  144. package/dist/core/trophies/engine.d.ts +70 -0
  145. package/dist/core/trophies/engine.js +351 -0
  146. package/dist/core/trophies/engine.js.map +1 -0
  147. package/dist/core/trophies/store.d.ts +17 -0
  148. package/dist/core/trophies/store.js +113 -0
  149. package/dist/core/trophies/store.js.map +1 -0
  150. package/dist/core/trophies/types.d.ts +119 -0
  151. package/dist/core/trophies/types.js +78 -0
  152. package/dist/core/trophies/types.js.map +1 -0
  153. package/dist/core/verification-evidence.d.ts +10 -6
  154. package/dist/core/verification-evidence.js +190 -11
  155. package/dist/core/verification-evidence.js.map +1 -1
  156. package/dist/goals-agent/goals-agent.js +3 -2
  157. package/dist/goals-agent/goals-agent.js.map +1 -1
  158. package/dist/interactive.js +16 -5
  159. package/dist/interactive.js.map +1 -1
  160. package/dist/modes/acp-mode.js +8 -1
  161. package/dist/modes/acp-mode.js.map +1 -1
  162. package/dist/modes/agent-home-mode.js +13 -1
  163. package/dist/modes/agent-home-mode.js.map +1 -1
  164. package/dist/modes/rpc-mode.d.ts +2 -1
  165. package/dist/modes/rpc-mode.js +15 -3
  166. package/dist/modes/rpc-mode.js.map +1 -1
  167. package/dist/modes/serve-mode.js +13 -1
  168. package/dist/modes/serve-mode.js.map +1 -1
  169. package/dist/system-prompt.d.ts +1 -0
  170. package/dist/system-prompt.js +12 -8
  171. package/dist/system-prompt.js.map +1 -1
  172. package/dist/test-support/slow-git.d.ts +1 -0
  173. package/dist/test-support/slow-git.js +21 -0
  174. package/dist/test-support/slow-git.js.map +1 -0
  175. package/dist/tools/background-watch-lint.d.ts +18 -0
  176. package/dist/tools/background-watch-lint.js +102 -0
  177. package/dist/tools/background-watch-lint.js.map +1 -0
  178. package/dist/tools/bash.d.ts +15 -4
  179. package/dist/tools/bash.js +72 -47
  180. package/dist/tools/bash.js.map +1 -1
  181. package/dist/tools/checklist.d.ts +3 -3
  182. package/dist/tools/compress.js +45 -0
  183. package/dist/tools/compress.js.map +1 -1
  184. package/dist/tools/edit.js +5 -0
  185. package/dist/tools/edit.js.map +1 -1
  186. package/dist/tools/generate-image.d.ts +29 -6
  187. package/dist/tools/generate-image.js +325 -151
  188. package/dist/tools/generate-image.js.map +1 -1
  189. package/dist/tools/grep.d.ts +14 -0
  190. package/dist/tools/grep.js +36 -2
  191. package/dist/tools/grep.js.map +1 -1
  192. package/dist/tools/index.d.ts +5 -1
  193. package/dist/tools/index.js +7 -4
  194. package/dist/tools/index.js.map +1 -1
  195. package/dist/tools/skill.d.ts +2 -2
  196. package/dist/tools/skill.js +8 -1
  197. package/dist/tools/skill.js.map +1 -1
  198. package/dist/tools/subagent-shared.js +4 -1
  199. package/dist/tools/subagent-shared.js.map +1 -1
  200. package/dist/tools/task-output.js +11 -1
  201. package/dist/tools/task-output.js.map +1 -1
  202. package/dist/tools/write.js +8 -0
  203. package/dist/tools/write.js.map +1 -1
  204. package/dist/ui/components/ModelSelector.d.ts +10 -0
  205. package/dist/ui/components/ModelSelector.js +51 -5
  206. package/dist/ui/components/ModelSelector.js.map +1 -1
  207. package/dist/ui/components/ToolExecution.js +2 -2
  208. package/dist/ui/components/ToolExecution.js.map +1 -1
  209. package/dist/ui/mcp.d.ts +2 -0
  210. package/dist/ui/mcp.js.map +1 -1
  211. package/dist/ui/terminal-history.js +2 -1
  212. package/dist/ui/terminal-history.js.map +1 -1
  213. package/dist/ui/tool-line-summary.js +3 -1
  214. package/dist/ui/tool-line-summary.js.map +1 -1
  215. package/dist/utils/git.d.ts +12 -3
  216. package/dist/utils/git.js +21 -8
  217. package/dist/utils/git.js.map +1 -1
  218. package/dist/utils/image.d.ts +14 -0
  219. package/dist/utils/image.js +23 -0
  220. package/dist/utils/image.js.map +1 -1
  221. package/package.json +4 -4
@@ -1,7 +1,11 @@
1
1
  import { agentLoop, isAbortError, isUsageLimitError, repairToolPairingAdjacent, } from "@prestyj/agent";
2
- import { ProviderError, stream, } from "@prestyj/ai";
2
+ import { ProviderError, resolveToolSchema, stream, } from "@prestyj/ai";
3
3
  import { EventBus } from "./event-bus.js";
4
4
  import { flagUntrustedToolResult } from "./injection-detect.js";
5
+ import { sameStamp, scanProjectLockfiles, stampProjectLockfiles, } from "./installed-threats.js";
6
+ import { onThreatFeedUpdated } from "./threat-feed.js";
7
+ import { parseSafetyBlock } from "./action-threats.js";
8
+ import { recordBlockedPackages } from "./blocked-packages-file.js";
5
9
  import { COMPLETION_REVIEW_STATE_KIND, } from "./completion-review.js";
6
10
  import { SlashCommandRegistry, createBuiltinCommands, } from "./slash-commands.js";
7
11
  import { PROMPT_COMMANDS, getPromptCommand } from "./prompt-commands.js";
@@ -19,7 +23,7 @@ import { shouldCompact, compact, } from "./compaction/compactor.js";
19
23
  import { getHistoryMessageVisibility } from "./session-history.js";
20
24
  import { sourceFingerprint as computeSourceFingerprint } from "./session-compaction.js";
21
25
  import { getAuthStorageKeys, getContextWindow, getModel, MODELS } from "./model-registry.js";
22
- import { discoverSkills } from "./skills.js";
26
+ import { discoverSkillsWithBlocked } from "./skills.js";
23
27
  import { ensureAppDirs } from "../config.js";
24
28
  import { buildSubAgentSystemPrompt, buildSystemPrompt, } from "../system-prompt.js";
25
29
  import { createTools, createWebSearchTool, } from "../tools/index.js";
@@ -54,7 +58,7 @@ import { buildLoopBreakMessage } from "./loop-breaker.js";
54
58
  import { buildRegroundingMessage, requestTextForRegrounding } from "./regrounding.js";
55
59
  import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, parseSemanticLoopVerdict, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
56
60
  import { buildEnvDeltaMessage, buildLanguageUpdateMessage } from "./env-delta.js";
57
- import { wrapSteeringText, buildNotificationSteeringText, STEERING_PREFIX } from "./steering.js";
61
+ import { wrapSteeringText, buildNotificationSteeringText, buildIdleWakeNotificationText, STEERING_PREFIX, } from "./steering.js";
58
62
  import { AgentNotificationQueue } from "./agent-notifications.js";
59
63
  import { VerificationGate, VERIFICATION_STATE_KIND } from "./verification-gate.js";
60
64
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
@@ -69,8 +73,101 @@ import { RunLoopMonitor } from "./agent-session/run-loop-monitor.js";
69
73
  import { VerificationTracker } from "./agent-session/verification-tracker.js";
70
74
  import { McpToolRegistry } from "./agent-session/mcp-tools.js";
71
75
  import { TranscriptMarkers } from "./agent-session/transcript-markers.js";
76
+ // ── Output cap ─────────────────────────────────────────────
77
+ /**
78
+ * The output cap to send on a request. A model's output ceiling can equal its
79
+ * whole window (Kimi K2.7 Code: 262,144 of 262,144), and providers that count
80
+ * prompt + max_tokens against the window reject every request with a real
81
+ * prompt. Cap it at the room the model's TOTAL window leaves once the context
82
+ * reaches this session's compaction trigger — the largest prompt it lets build
83
+ * up. The total window, not the route's: the ChatGPT/Codex route's 272K
84
+ * window is an input budget, and GPT-6's 128K output sits outside it.
85
+ */
86
+ export function outputCapForRequest(options) {
87
+ const totalWindow = getContextWindow(options.model, { provider: options.provider });
88
+ const { targetTokens } = resolveCompactionPolicy({
89
+ provider: options.provider,
90
+ model: options.model,
91
+ contextWindow: getContextWindow(options.model, {
92
+ provider: options.provider,
93
+ accountId: options.accountId,
94
+ }),
95
+ threshold: options.threshold,
96
+ accountId: options.accountId,
97
+ });
98
+ const room = totalWindow - targetTokens;
99
+ return room > 0 ? Math.min(options.maxTokens, room) : options.maxTokens;
100
+ }
101
+ // ── Paid models ────────────────────────────────────────────
102
+ /**
103
+ * Reply for chat bridges (Telegram, agent home) that have no confirmation step.
104
+ * Generic on purpose: every paid model (Claude fast mode, MiMo UltraSpeed) is
105
+ * refused here, so the reason must not name one of them.
106
+ */
107
+ export function paidModelRefusal(modelName) {
108
+ return `${modelName} spends real money, so it can't be switched to from chat. Switch to it in the EZ Coder app or CLI, where you confirm it.`;
109
+ }
110
+ /**
111
+ * Thrown by {@link AgentSession.switchModel} when the target model spends real
112
+ * money and the caller didn't pass the user's explicit confirmation.
113
+ */
114
+ export class PaidModelConfirmationError extends Error {
115
+ modelName;
116
+ detail;
117
+ constructor(modelName, detail) {
118
+ super(`${modelName} spends real money. ${detail} Confirm before switching to it.`);
119
+ this.name = "PaidModelConfirmationError";
120
+ this.modelName = modelName;
121
+ this.detail = detail;
122
+ }
123
+ }
72
124
  export { resolveSessionToolResultCharLimit, resolveSessionTurnToolResultCharLimit, } from "./agent-session/helpers.js";
73
125
  // ── Agent Session ──────────────────────────────────────────
126
+ /** A prompt that asks for the live web (see AgentSession.attachWebSearchIfAsked). */
127
+ const WEB_INTENT = /\b(?:web|internet|online|google|googling|website|look (?:it |this |that )?up|(?:latest|newest|current) (?:version|release|news|docs?|documentation)|release notes|news)\b/i;
128
+ /** Distinct security rows one run may add to the chat (and session file). */
129
+ const MAX_SECURITY_ROWS_PER_RUN = 20;
130
+ /**
131
+ * A chat row's target: first line, no control or bidi-override characters
132
+ * (they could make a command look like another), at most 120 chars.
133
+ */
134
+ export function securityRowTarget(raw) {
135
+ const first = (raw.slice(0, 1024).split(/\r?\n/, 1)[0] ?? "")
136
+ // eslint-disable-next-line no-control-regex -- stripping control characters is the point
137
+ .replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
138
+ .trim();
139
+ return first.length > 120 ? `${first.slice(0, 119)}\u2026` : first;
140
+ }
141
+ /** Not the home folder or a filesystem root (those are never a project). */
142
+ export function isScannableProjectDir(dir, home = os.homedir()) {
143
+ const resolved = path.resolve(dir);
144
+ if (path.parse(resolved).root === resolved)
145
+ return false;
146
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
147
+ return !home || norm(path.resolve(home)) !== norm(resolved);
148
+ }
149
+ /**
150
+ * Windows on the same project in this process share one in-flight scan
151
+ * (session start and a feed update otherwise fire N identical scans).
152
+ */
153
+ const inflightLockfileScans = new Map();
154
+ function sharedLockfileScan(cwd, options) {
155
+ const run = async () => {
156
+ const stamp = await stampProjectLockfiles(cwd);
157
+ const found = stamp.size === 0 ? [] : await scanProjectLockfiles(cwd, options);
158
+ return { stamp, found };
159
+ };
160
+ // Test-specific check options are never shared.
161
+ if (options)
162
+ return run();
163
+ const key = path.resolve(cwd);
164
+ const existing = inflightLockfileScans.get(key);
165
+ if (existing)
166
+ return existing;
167
+ const promise = run().finally(() => inflightLockfileScans.delete(key));
168
+ inflightLockfileScans.set(key, promise);
169
+ return promise;
170
+ }
74
171
  export class AgentSession {
75
172
  eventBus = new EventBus();
76
173
  slashCommands = new SlashCommandRegistry();
@@ -97,6 +194,18 @@ export class AgentSession {
97
194
  clearReadTracker;
98
195
  recordBashReads;
99
196
  skills = [];
197
+ /** Skills discovery refused to load (safety scan); shown to the user, named by the skill tool. */
198
+ blockedSkills = [];
199
+ /** Lockfile entries now known to be malware (background check), for the app/CLI to show. */
200
+ installedThreats = [];
201
+ installedWatchStop;
202
+ installedStamp;
203
+ installedScanRunning = false;
204
+ installedWatchStopped = false;
205
+ /** rule\0target of the security rows already shown this run. */
206
+ securityRowsThisRun = new Set();
207
+ /** Project session (code mode): the only kind that writes the "don't install" list. */
208
+ projectSession = false;
100
209
  cacheKeyLogged = false;
101
210
  // ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
102
211
  // Reset at the start of every run; observed from the event stream; read by
@@ -158,6 +267,8 @@ export class AgentSession {
158
267
  deferredCompactionPending = false;
159
268
  /** Latest provider count, anchored to the assistant response it measured. */
160
269
  providerContext = null;
270
+ /** See {@link toolDefinitionChars}. */
271
+ toolCharsCache = null;
161
272
  originalRequest = "";
162
273
  // Messages queued by the user while a run is in flight. Drained at the
163
274
  // mid-loop steering boundary (user steering wins over the hooks), mirroring
@@ -169,6 +280,8 @@ export class AgentSession {
169
280
  // different message (or past the end) by the time the cancel arrives.
170
281
  userQueue = [];
171
282
  queueSeq = 0;
283
+ /** Monotonic count of human-originated prompts; idle-wake budgets reset on it. */
284
+ humanPromptSeq = 0;
172
285
  /** Instant interrupt: the running loop's preempt listeners, fired on queueMessage. */
173
286
  steeringListeners = new Set();
174
287
  processManager;
@@ -299,9 +412,25 @@ export class AgentSession {
299
412
  }
300
413
  return modelInfo?.maxOutputTokens ?? 16384;
301
414
  }
415
+ /** {@link outputCapForRequest} for this session's live model and settings. */
416
+ requestMaxTokens(accountId) {
417
+ return outputCapForRequest({
418
+ provider: this.provider,
419
+ model: this.model,
420
+ maxTokens: this.maxTokens,
421
+ threshold: this.settingsManager.get("compactThreshold"),
422
+ accountId,
423
+ });
424
+ }
302
425
  async initialize() {
303
426
  // Set model for accurate token estimation
304
427
  setEstimatorModel(this.model);
428
+ // The Anthropic transport needs the current Claude Code version for its
429
+ // User-Agent before the first request. Without a fresh on-disk cache that
430
+ // is an npm round-trip (~0.3–3s) on the user's first prompt; start it now
431
+ // so it overlaps session setup and the user's typing instead.
432
+ if (this.provider === "anthropic")
433
+ void getClaudeCliUserAgent().catch(() => { });
305
434
  const paths = await ensureAppDirs();
306
435
  // Load settings & auth
307
436
  this.settingsManager = new SettingsManager(paths.settingsFile);
@@ -312,6 +441,12 @@ export class AgentSession {
312
441
  // Session manager. Agent-specific roots keep chat and coder histories isolated.
313
442
  this.sessionManager = new SessionManager(this.opts.sessionRootDir ?? paths.sessionsDir);
314
443
  const projectCustomization = this.opts.projectCustomization !== false;
444
+ // A real project folder: not Motion (own skills), not GG Goals (goal notes
445
+ // folder, agentContext "none"), not chat (no project customization).
446
+ this.projectSession =
447
+ projectCustomization &&
448
+ !this.opts.skills &&
449
+ (this.opts.agentContext ?? "project") === "project";
315
450
  // A sub-agent worker never creates them: the session that owns the
316
451
  // project does. EZ Goals' workers work in the user's attached folders,
317
452
  // which mustn't gain empty .ezcoder folders as a side effect.
@@ -326,10 +461,13 @@ export class AgentSession {
326
461
  this.skills = [...this.opts.skills];
327
462
  }
328
463
  else if (projectCustomization) {
329
- this.skills = await discoverSkills({
464
+ const discovery = await discoverSkillsWithBlocked({
330
465
  globalSkillsDir: paths.skillsDir,
331
466
  projectDir: this.cwd,
332
467
  });
468
+ this.skills = discovery.skills;
469
+ this.blockedSkills = discovery.blocked;
470
+ this.startInstalledPackageWatch();
333
471
  }
334
472
  else {
335
473
  this.skills = [];
@@ -351,6 +489,7 @@ export class AgentSession {
351
489
  shareWorkspaceRoots: this.opts.shareRootsWithSubAgents,
352
490
  defaultSubAgent: this.opts.defaultSubAgent,
353
491
  skills: this.skills,
492
+ blockedSkills: this.blockedSkills,
354
493
  contextLimits: this.contextLimits,
355
494
  provider: this.provider,
356
495
  model: this.model,
@@ -360,6 +499,7 @@ export class AgentSession {
360
499
  allowOutsideWorkspaceWrites: this.settingsManager.get("allowOutsideWorkspaceWrites"),
361
500
  additionalRoots: this.additionalRoots,
362
501
  }),
502
+ getPackageCooldownDays: () => this.settingsManager.get("packageCooldownDays"),
363
503
  getNetworkPolicy: () => ({
364
504
  mode: this.settingsManager.get("networkMode"),
365
505
  allow: this.settingsManager.get("networkAllow"),
@@ -718,6 +858,45 @@ export class AgentSession {
718
858
  this.wrapToPromote("spawn_agent", ["wait_agent"]);
719
859
  this.wrapToPromote("bash", ["task_output", "task_send", "task_stop"], (args) => args.run_in_background === true);
720
860
  }
861
+ /**
862
+ * Whether provider-native web search rides along with this request.
863
+ *
864
+ * Anthropic's `web_search_20250305` server tool costs ~2.5k prefix tokens on
865
+ * every request (count_tokens: 6,628 vs 4,098 for the GG prefix without it),
866
+ * yet web search is reached in ~6% of sessions — the reason the client tool
867
+ * is deferred everywhere else. So on a tiered Anthropic session it is only
868
+ * attached once the model has promoted `web_search` (tool_search or a direct
869
+ * call). The server tool is appended last, so the tool block stays
870
+ * append-only. Untiered sessions and other providers keep it always on.
871
+ */
872
+ nativeWebSearchOn() {
873
+ if (this.provider !== "anthropic")
874
+ return true;
875
+ if (!this.deferredBuiltinToolNames.includes("web_search"))
876
+ return true;
877
+ return this.tools.some((t) => t.name === "web_search");
878
+ }
879
+ /**
880
+ * A human prompt that plainly asks for the web gets native web search from
881
+ * its first request. Without this, an on-demand web_search costs an extra
882
+ * round trip (tool_search or a client search) before the native tool is
883
+ * attached — slower than the always-on tool it replaced for exactly the
884
+ * sessions that need it. Conservative on purpose: a miss only costs that
885
+ * one discovery turn, a false hit only the ~2.5k-token schema.
886
+ */
887
+ attachWebSearchIfAsked(content) {
888
+ if (this.provider !== "anthropic" || this.nativeWebSearchOn())
889
+ return;
890
+ const text = typeof content === "string"
891
+ ? content
892
+ : Array.isArray(content)
893
+ ? content
894
+ .map((part) => part && typeof part === "object" && "text" in part ? String(part.text) : "")
895
+ .join(" ")
896
+ : "";
897
+ if (WEB_INTENT.test(text))
898
+ this.promoteDeferredBuiltin("web_search");
899
+ }
721
900
  /** Append a deferred built-in to the live tool list (append-only). */
722
901
  promoteDeferredBuiltin(name) {
723
902
  if (!this.deferredBuiltinToolNames.includes(name))
@@ -810,6 +989,8 @@ export class AgentSession {
810
989
  visibility: "transcript",
811
990
  }, options = {}) {
812
991
  this.prewarmController?.abort();
992
+ if (provenance.source === "human")
993
+ this.humanPromptSeq += 1;
813
994
  await this.settlePostTurnCompaction();
814
995
  await this.adoptDeferredCheckpointBeforePrompt();
815
996
  const slash = await this.resolveSlashInput(content);
@@ -837,6 +1018,8 @@ export class AgentSession {
837
1018
  this.messages.push(userMessage);
838
1019
  await this.persistMessage(userMessage);
839
1020
  this.lastPersistedIndex = this.messages.length;
1021
+ if (provenance.source === "human")
1022
+ this.attachWebSearchIfAsked(content);
840
1023
  await this.runLoop(options);
841
1024
  }
842
1025
  /**
@@ -848,11 +1031,13 @@ export class AgentSession {
848
1031
  */
849
1032
  async promptWithAttachments(text, attachments) {
850
1033
  this.prewarmController?.abort();
1034
+ this.humanPromptSeq += 1;
851
1035
  await this.settlePostTurnCompaction();
852
1036
  await this.adoptDeferredCheckpointBeforePrompt();
853
1037
  const parts = this.buildAttachmentParts(text, attachments);
854
1038
  if (parts.length === 0)
855
1039
  return;
1040
+ this.attachWebSearchIfAsked(text);
856
1041
  const userMessage = {
857
1042
  role: "user",
858
1043
  content: parts,
@@ -948,6 +1133,7 @@ export class AgentSession {
948
1133
  this.opts.completionReview?.begin(originalRequest);
949
1134
  this.lspManager?.clearPendingDiagnostics();
950
1135
  this.loopMonitor.reset();
1136
+ this.securityRowsThisRun.clear();
951
1137
  this.verificationTracker.resetRun();
952
1138
  this.regroundingInjected = false;
953
1139
  this.runStartedAt = Date.now();
@@ -991,6 +1177,9 @@ export class AgentSession {
991
1177
  const call = this.verificationTracker.getToolCall(event.toolCallId);
992
1178
  const name = call?.name ?? "";
993
1179
  const args = call?.args;
1180
+ const safetyRule = parseSafetyBlock(event.result);
1181
+ if (safetyRule)
1182
+ this.recordSafetyBlock(safetyRule, name, args, event.details);
994
1183
  this.loopMonitor.recordToolEnd(name, args, event.result, event.isError);
995
1184
  const verificationChanged = await this.verificationTracker.recordToolCallEnd(event, call, this.planModeRef.current);
996
1185
  if (verificationChanged)
@@ -1432,6 +1621,11 @@ export class AgentSession {
1432
1621
  });
1433
1622
  return [...diagnosticMessages, ...processFollowUp];
1434
1623
  }
1624
+ // A check whose exit status could not count (`npm test | tail; node -e …`)
1625
+ // is re-run on its own before the gate decides, so a passing run is not
1626
+ // demanded again. Diagnostics errors take precedence over the re-run.
1627
+ if (diagnosticMessages.length === 0)
1628
+ await this.rerunRefusedCheck();
1435
1629
  // Verification gate: code was edited but nothing verified since the last
1436
1630
  // edit. Off via the `verificationGateEnabled` setting.
1437
1631
  if (this.verificationGateActive()) {
@@ -1506,6 +1700,50 @@ export class AgentSession {
1506
1700
  }
1507
1701
  return null;
1508
1702
  }
1703
+ /**
1704
+ * The model often runs its tests inside a longer command (`npm test 2>&1 |
1705
+ * tail; node -e '…'`) whose exit status belongs to a later part, so the
1706
+ * tests can pass without counting. When the turn would end unverified that
1707
+ * way, run that check once more on its own, through the same bash tool, and
1708
+ * record its real exit status. No model turn and nothing in the transcript:
1709
+ * only the run's verification status changes.
1710
+ */
1711
+ async rerunRefusedCheck() {
1712
+ if (this.planModeRef.current || this.opts.signal?.aborted)
1713
+ return;
1714
+ const check = this.verificationTracker.takeRefusedCheck();
1715
+ if (!check)
1716
+ return;
1717
+ const bash = this.tools.find((tool) => tool.name === "bash");
1718
+ // An explicit timeout stops a stuck run instead of moving it to the
1719
+ // background, which would leave a process for the next turn to collect.
1720
+ const params = bash?.parameters.safeParse({ command: check.command, timeout: 120_000 });
1721
+ if (!bash || !params?.success)
1722
+ return;
1723
+ const startedAt = Date.now();
1724
+ let passed = false;
1725
+ try {
1726
+ const result = await bash.execute(params.data, {
1727
+ signal: this.opts.signal ?? new AbortController().signal,
1728
+ toolCallId: `ez-recheck-${startedAt}`,
1729
+ });
1730
+ const text = typeof result === "string" ? result : result.content;
1731
+ passed = typeof text === "string" && /^Exit code:\s*0(?:\s|$)/.test(text.trim());
1732
+ }
1733
+ catch (error) {
1734
+ if (isAbortError(error) || this.opts.signal?.aborted)
1735
+ return;
1736
+ log("WARN", "verification", `Re-running a refused check failed: ${String(error)}`);
1737
+ }
1738
+ if (this.opts.signal?.aborted)
1739
+ return;
1740
+ this.verificationTracker.recordRefusedCheckRerun(check, passed);
1741
+ log("INFO", "verification", "Re-ran a check whose original exit status could not count", {
1742
+ passed: String(passed),
1743
+ durationMs: String(Date.now() - startedAt),
1744
+ });
1745
+ await this.persistVerificationState();
1746
+ }
1509
1747
  /** Wraps the real run: cancels an in-flight cache prewarm and tracks run
1510
1748
  * activity / last real request time for {@link prewarm}. */
1511
1749
  async runLoop(options = {}) {
@@ -1551,7 +1789,10 @@ export class AgentSession {
1551
1789
  }
1552
1790
  // Reset self-correction hook state for this run; pin the latest user message
1553
1791
  // as the verbatim original request for post-compaction re-grounding.
1554
- const lastUser = [...this.messages].reverse().find((m) => m.role === "user");
1792
+ // A status update that started an idle-wake run is not the user's request.
1793
+ const lastUser = [...this.messages]
1794
+ .reverse()
1795
+ .find((m) => m.role === "user" && m.provenance?.kind !== "notification");
1555
1796
  this.resetHookState(requestTextForRegrounding(lastUser));
1556
1797
  // Resolve OAuth credentials and run agent loop.
1557
1798
  // On 401, force-refresh the token and retry once — the provider may have
@@ -1646,8 +1887,8 @@ export class AgentSession {
1646
1887
  provider: this.provider,
1647
1888
  model: this.model,
1648
1889
  tools: options.disableTools ? [] : this.tools,
1649
- webSearch: !options.disableTools,
1650
- maxTokens: this.maxTokens,
1890
+ webSearch: options.disableTools ? false : () => this.nativeWebSearchOn(),
1891
+ maxTokens: this.requestMaxTokens(accountId),
1651
1892
  maxTurns: this.opts.maxTurns,
1652
1893
  maxTurnExtensions: this.opts.maxTurnExtensions,
1653
1894
  // Plan mode caps effort at medium (Codex `plan_mode_reasoning_effort`
@@ -1703,7 +1944,7 @@ export class AgentSession {
1703
1944
  thinking: this.planModeRef.current || options.capThinking
1704
1945
  ? clampThinkingForPlanMode(this.thinkingLevel)
1705
1946
  : this.thinkingLevel,
1706
- webSearch: !options.disableTools,
1947
+ webSearch: !options.disableTools && this.nativeWebSearchOn(),
1707
1948
  supportsImages: modelInfo?.supportsImages,
1708
1949
  promptCacheKey: this.getPromptCacheKey(),
1709
1950
  },
@@ -1753,8 +1994,12 @@ export class AgentSession {
1753
1994
  if (anchor?.role === "assistant") {
1754
1995
  this.providerContext = { usage: { ...transformOpts.usage }, anchor };
1755
1996
  // Feed the authoritative usage back into the token estimator so
1756
- // char-based estimates track this session's real tokenizer.
1757
- calibrateEstimatorFromUsage(messages.slice(0, anchorIndex), transformOpts.usage);
1997
+ // char-based estimates track this session's real tokenizer. The
1998
+ // provider's count includes the tool definitions sent with the
1999
+ // request, so their characters must be counted too — without
2000
+ // them a short history reads as ~2 chars/token and drags the
2001
+ // shared ratio down for every session in the process.
2002
+ calibrateEstimatorFromUsage(messages.slice(0, anchorIndex), transformOpts.usage, options.disableTools ? 0 : this.toolDefinitionChars());
1758
2003
  }
1759
2004
  }
1760
2005
  const force = transformOpts.force === true;
@@ -1805,12 +2050,13 @@ export class AgentSession {
1805
2050
  compactedToolCalls: String(pruneResult.compactedToolCalls),
1806
2051
  freedTokens: String(pruneResult.freedTokens),
1807
2052
  });
1808
- // The turn's own usage also counted the pruned content — estimate
1809
- // the (now smaller) history so the freed tokens actually defer
1810
- // the LLM compaction.
1811
- activeTokens = calculateActiveContextTokens(messages, {
1812
- pendingMessages: transformOpts.pendingMessages,
1813
- });
2053
+ // The measured size counted the pruned content, so take the
2054
+ // freed tokens off it. Never swap the provider's count for a
2055
+ // whole-history estimate here: that estimate rides a shared
2056
+ // chars-per-token ratio, and it once read a 234K-token MiMo
2057
+ // context (23% of 1M) as ~400K and compacted it against a 340K
2058
+ // trigger.
2059
+ activeTokens = Math.max(0, activeTokens - pruneResult.freedTokens);
1814
2060
  }
1815
2061
  }
1816
2062
  if (Date.now() < this.compactionRetryAfter)
@@ -1870,6 +2116,12 @@ export class AgentSession {
1870
2116
  }
1871
2117
  finally {
1872
2118
  this.activeLoopMessages = null;
2119
+ // An in-flight compaction rebinds `this.messages` to the compacted array
2120
+ // while the loop keeps appending to `loopMessages`. Resync on every exit
2121
+ // — a failed or aborted run included — or the session silently drops
2122
+ // this run's post-compaction messages (they are still on disk) and the
2123
+ // next prompt replays a history the provider may reject.
2124
+ this.messages = loopMessages;
1873
2125
  }
1874
2126
  };
1875
2127
  const clearInvalidStaticApiKey = async (error) => {
@@ -1994,7 +2246,17 @@ export class AgentSession {
1994
2246
  // prompt. Detached by design; the pre-run path above remains the backstop.
1995
2247
  this.maybeCompactPostTurn(creds);
1996
2248
  }
1997
- async switchModel(provider, model) {
2249
+ /**
2250
+ * Switch the session's model. A model that spends real money (Claude fast
2251
+ * mode bills usage credits) is refused unless `confirmPaid` carries the
2252
+ * user's explicit yes, so no caller can switch onto one by accident: every
2253
+ * path (app, CLI, editors, chat bridges, RPC) fails closed by default.
2254
+ */
2255
+ async switchModel(provider, model, options = {}) {
2256
+ const target = getModel(model);
2257
+ if (target?.billing?.paid && options.confirmPaid !== true && model !== this.model) {
2258
+ throw new PaidModelConfirmationError(target.name, target.billing.detail);
2259
+ }
1998
2260
  // The model-switch note is appended to `this.messages`; settle any
1999
2261
  // background compaction first so the note cannot be swapped out.
2000
2262
  await this.settlePostTurnCompaction();
@@ -2058,7 +2320,13 @@ export class AgentSession {
2058
2320
  // Add/remove client-side web_search tool based on provider.
2059
2321
  // Anthropic has native server-side web search; all other providers need the client tool.
2060
2322
  const hasWebSearch = this.tools.some((t) => t.name === "web_search");
2061
- if (this.provider === "anthropic" && hasWebSearch) {
2323
+ if (this.deferredBuiltinToolNames.includes("web_search")) {
2324
+ // Tiered: the client tool lives in the catalog for every provider and,
2325
+ // once promoted, is swapped for the native server tool by the Anthropic
2326
+ // transport. Nothing to add or remove — doing so would either load the
2327
+ // schema eagerly or take away a capability the model already has.
2328
+ }
2329
+ else if (this.provider === "anthropic" && hasWebSearch) {
2062
2330
  // Switching TO anthropic — remove client-side web_search (server-side handles it)
2063
2331
  this.tools = this.tools.filter((t) => t.name !== "web_search");
2064
2332
  }
@@ -2648,6 +2916,117 @@ export class AgentSession {
2648
2916
  this.setSessionPath(loaded.path);
2649
2917
  return this.sessionManager.listBranches(loaded.entries);
2650
2918
  }
2919
+ /** Skills the safety scan kept out of this session, for the app/CLI to show. */
2920
+ getBlockedSkills() {
2921
+ return this.blockedSkills;
2922
+ }
2923
+ /** Installed packages (project lockfiles) now known to be malware. */
2924
+ getInstalledThreats() {
2925
+ return this.installedThreats;
2926
+ }
2927
+ /**
2928
+ * Background check of the project's lockfiles for packages flagged as
2929
+ * malware after they were installed: shortly after start, after each daily
2930
+ * threat-feed update, and after a run that changed a lockfile. Never awaited
2931
+ * by anything; a new hit is shown to the user once (security_notice) and told
2932
+ * to the agent at its next turn. Off under vitest unless a test opts in.
2933
+ */
2934
+ startInstalledPackageWatch() {
2935
+ const enabled = this.opts.installedPackageWatch ?? !process.env.VITEST;
2936
+ // Workers share their parent's project: the parent session watches it.
2937
+ if (!enabled || !this.projectSession || this.opts.subagentWorker || this.installedWatchStop) {
2938
+ return;
2939
+ }
2940
+ // The home folder or a filesystem root is not a project: never scan it.
2941
+ if (!isScannableProjectDir(this.cwd))
2942
+ return;
2943
+ const delay = this.opts.installedPackageWatchDelayMs ?? 5_000;
2944
+ const timer = setTimeout(() => void this.scanInstalledPackages(), delay);
2945
+ timer.unref?.();
2946
+ const offFeed = onThreatFeedUpdated(() => void this.scanInstalledPackages());
2947
+ const offDone = this.eventBus.on("agent_done", () => void this.rescanIfLockfilesChanged());
2948
+ this.installedWatchStop = () => {
2949
+ this.installedWatchStopped = true;
2950
+ clearTimeout(timer);
2951
+ offFeed();
2952
+ offDone();
2953
+ };
2954
+ }
2955
+ async rescanIfLockfilesChanged() {
2956
+ if (!this.installedStamp || this.installedWatchStopped)
2957
+ return;
2958
+ const stamp = await stampProjectLockfiles(this.cwd);
2959
+ if (!sameStamp(stamp, this.installedStamp))
2960
+ await this.scanInstalledPackages();
2961
+ }
2962
+ /**
2963
+ * A guard refused a tool call: a lasting chat row (live event + persisted
2964
+ * marker, so a reopened session shows it too) and, for packages, an entry in
2965
+ * the project's "don't install" list. All fire-and-forget; never blocks.
2966
+ */
2967
+ recordSafetyBlock(rule, toolName, args, details) {
2968
+ const a = (args ?? {});
2969
+ const raw = String(a.command ?? a.input ?? a.file_path ?? a.path ?? a.url ?? toolName);
2970
+ this.emitSecurityRow(rule, securityRowTarget(raw));
2971
+ const packages = details
2972
+ ?.safetyBlock?.packages;
2973
+ if (packages?.length)
2974
+ this.recordBlockedList(packages);
2975
+ }
2976
+ /**
2977
+ * One row per distinct block per run: an agent retrying the same blocked
2978
+ * install 30 times leaves one row, and a run hitting 100 blocks leaves at
2979
+ * most MAX_SECURITY_ROWS_PER_RUN (the session file stays small).
2980
+ */
2981
+ emitSecurityRow(rule, target) {
2982
+ const key = `${rule}\0${target}`;
2983
+ if (this.securityRowsThisRun.has(key))
2984
+ return;
2985
+ if (this.securityRowsThisRun.size >= MAX_SECURITY_ROWS_PER_RUN)
2986
+ return;
2987
+ this.securityRowsThisRun.add(key);
2988
+ this.eventBus.emit("security_block", { rule, target });
2989
+ void this.persistAppMarker("security", { rule, target }).catch(() => { });
2990
+ }
2991
+ recordBlockedList(packages) {
2992
+ const enabled = this.opts.blockedPackagesFile ?? !process.env.VITEST;
2993
+ if (!enabled || !this.projectSession)
2994
+ return;
2995
+ void recordBlockedPackages(this.cwd, packages).then((file) => {
2996
+ if (file)
2997
+ log("INFO", "blocked-packages", `recorded blocked packages in ${file}`);
2998
+ }, (err) => log("WARN", "blocked-packages", `could not record blocked packages: ${String(err)}`));
2999
+ }
3000
+ /** Exposed for tests; safe to call any time (one scan at a time, never throws). */
3001
+ async scanInstalledPackages() {
3002
+ if (this.installedScanRunning || this.installedWatchStopped)
3003
+ return;
3004
+ this.installedScanRunning = true;
3005
+ try {
3006
+ const { stamp, found } = await sharedLockfileScan(this.cwd, this.opts.installedPackageCheck);
3007
+ // Disposed mid-scan: no late events.
3008
+ if (this.installedWatchStopped)
3009
+ return;
3010
+ this.installedStamp = stamp;
3011
+ const known = new Set(this.installedThreats.map((t) => t.key));
3012
+ this.installedThreats = found;
3013
+ for (const threat of found) {
3014
+ if (known.has(threat.key))
3015
+ continue;
3016
+ log("WARN", "installed-threats", threat.message);
3017
+ this.notify(`installed-threat:${threat.file}`, threat.agentText);
3018
+ this.eventBus.emit("security_notice", { key: threat.key, message: threat.message });
3019
+ this.emitSecurityRow("installed-malware", securityRowTarget(`${threat.packages.join(", ")} in ${path.relative(this.cwd, threat.file) || threat.file}`));
3020
+ this.recordBlockedList(threat.blocked);
3021
+ }
3022
+ }
3023
+ catch (err) {
3024
+ log("WARN", "installed-threats", `installed-package check failed: ${String(err)}`);
3025
+ }
3026
+ finally {
3027
+ this.installedScanRunning = false;
3028
+ }
3029
+ }
2651
3030
  getState() {
2652
3031
  return {
2653
3032
  provider: this.provider,
@@ -2781,6 +3160,74 @@ export class AgentSession {
2781
3160
  notify(id, text) {
2782
3161
  this.notifications.enqueue("goal", id, text, { terminal: true });
2783
3162
  }
3163
+ /**
3164
+ * Observe urgent background alerts — a background task failed or exited
3165
+ * non-zero, or a child agent finished — as they are queued. Hosts use this to
3166
+ * wake an idle session (see {@link continueFromNotifications}); a live run
3167
+ * already receives them at its next steering boundary. Returns unsubscribe.
3168
+ */
3169
+ onBackgroundAlert(listener) {
3170
+ return this.notifications.onUrgent(() => listener());
3171
+ }
3172
+ /**
3173
+ * Characters of the tool definitions sent with every request (name,
3174
+ * description, JSON schema), for estimator calibration. Cached by tool count:
3175
+ * the tool array is append-only.
3176
+ */
3177
+ toolDefinitionChars() {
3178
+ if (this.toolCharsCache?.count === this.tools.length)
3179
+ return this.toolCharsCache.chars;
3180
+ let chars = 0;
3181
+ for (const tool of this.tools) {
3182
+ chars += tool.name.length + tool.description.length;
3183
+ try {
3184
+ chars += JSON.stringify(resolveToolSchema(tool)).length;
3185
+ }
3186
+ catch {
3187
+ // A schema that cannot be serialized is not sent either.
3188
+ }
3189
+ }
3190
+ this.toolCharsCache = { count: this.tools.length, chars };
3191
+ return chars;
3192
+ }
3193
+ /** True while an urgent background alert is waiting to be delivered. */
3194
+ hasPendingBackgroundAlert() {
3195
+ return this.notifications.hasUrgent();
3196
+ }
3197
+ /** Monotonic count of human-originated prompts this session has run. */
3198
+ getHumanPromptSeq() {
3199
+ return this.humanPromptSeq;
3200
+ }
3201
+ /**
3202
+ * Start a run from pending background notifications, for a session that had
3203
+ * already finished its turn. The notifications become one hidden runtime
3204
+ * message (never shown as something the user typed), and the agent decides
3205
+ * whether they need action. Returns false, starting nothing, when no urgent
3206
+ * alert is pending — a live run may have delivered it in the meantime.
3207
+ */
3208
+ async continueFromNotifications() {
3209
+ if (!this.notifications.hasUrgent())
3210
+ return false;
3211
+ this.prewarmController?.abort();
3212
+ await this.settlePostTurnCompaction();
3213
+ await this.adoptDeferredCheckpointBeforePrompt();
3214
+ const notified = this.notifications.drain();
3215
+ if (notified.length === 0)
3216
+ return false;
3217
+ const message = {
3218
+ role: "user",
3219
+ content: buildIdleWakeNotificationText(notified.map((entry) => entry.text)),
3220
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" },
3221
+ };
3222
+ log("INFO", "notifications", "Starting a run from background alerts", {
3223
+ count: String(notified.length),
3224
+ });
3225
+ this.messages.push(message);
3226
+ await this.persistMessage(message);
3227
+ this.lastPersistedIndex = this.messages.length;
3228
+ await this.runLoop();
3229
+ return true;
3230
+ }
2784
3231
  /** This session's sub-agents (workers), newest-state, including ones restored after a restart. */
2785
3232
  listSubAgents() {
2786
3233
  return this.subAgentManager?.list() ?? [];
@@ -3310,10 +3757,18 @@ export class AgentSession {
3310
3757
  return { enhanced: text, segments: [{ kind: "text", text }] };
3311
3758
  // Keep credentials and settings on the model selected at click time, even
3312
3759
  // if the user switches models while credential resolution is pending.
3313
- const { provider, model, thinkingLevel, maxTokens, baseUrl } = this;
3760
+ const { provider, model, thinkingLevel, baseUrl } = this;
3761
+ const threshold = this.settingsManager.get("compactThreshold");
3314
3762
  const creds = await this.authStorage.resolveCredentials(provider, {
3315
3763
  storageKeys: this.currentAuthStorageKeys(),
3316
3764
  });
3765
+ const maxTokens = outputCapForRequest({
3766
+ provider,
3767
+ model,
3768
+ maxTokens: this.maxTokens,
3769
+ threshold,
3770
+ accountId: creds.accountId,
3771
+ });
3317
3772
  // Cheap, best-effort stack detection from the project root so terminology is
3318
3773
  // idiomatic to the user's stack. Never throws (returns "" on any failure).
3319
3774
  let stack = "";
@@ -3430,8 +3885,8 @@ export class AgentSession {
3430
3885
  model: this.model,
3431
3886
  messages,
3432
3887
  tools: this.tools,
3433
- webSearch: true,
3434
- maxTokens: this.maxTokens,
3888
+ webSearch: this.nativeWebSearchOn(),
3889
+ maxTokens: this.requestMaxTokens(creds.accountId),
3435
3890
  thinking: this.planModeRef.current
3436
3891
  ? clampThinkingForPlanMode(this.thinkingLevel)
3437
3892
  : this.thinkingLevel,
@@ -3526,6 +3981,7 @@ export class AgentSession {
3526
3981
  // First and synchronous: nothing below may delay this, or a hung teardown
3527
3982
  // step leaves background commands running after the app has quit.
3528
3983
  this.stopBackgroundProcesses();
3984
+ this.installedWatchStop?.();
3529
3985
  // Quiesce any in-flight post-turn compaction BEFORE tearing down state:
3530
3986
  // the background compact() snapshots and replaces `this.messages`, so
3531
3987
  // letting it run past this point would checkpoint a near-empty history
@@ -3755,7 +4211,7 @@ export class AgentSession {
3755
4211
  }
3756
4212
  createSlashCommandContext() {
3757
4213
  return {
3758
- switchModel: (provider, model) => this.switchModel(provider, model),
4214
+ switchModel: (provider, model, options) => this.switchModel(provider, model, options),
3759
4215
  compact: (focus) => this.compact(undefined, "manual", focus),
3760
4216
  newSession: () => this.newSession(),
3761
4217
  listSessions: async () => {