@prestyj/cli 5.32.0 → 5.32.2

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