atom-agent 1.1.0 → 1.3.0
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.
- package/CHANGELOG.md +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +46 -8
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/context-manager.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// - How much context is available? (budget())
|
|
3
3
|
// - How much is currently used? (usage())
|
|
4
4
|
// - Should we compact? (needsCompaction())
|
|
5
|
-
// - What messages should be sent? (trimForSend())
|
|
6
5
|
//
|
|
7
6
|
// Budget derivation (no fixed-200K assumption): the history allowance comes
|
|
8
7
|
// from the model's ACTUAL verified context window:
|
|
@@ -12,20 +11,18 @@
|
|
|
12
11
|
//
|
|
13
12
|
// measured in tokens via the shared 4ch/token estimator. A model with a 1M
|
|
14
13
|
// window therefore gets ~1M of usable history instead of ~50K tokens.
|
|
14
|
+
// Models with NO verified window report no allowance (a window is never
|
|
15
|
+
// invented; auto-compact stays off for them).
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// nothing configured it never binds for known-window models. Models with NO
|
|
20
|
-
// verified window keep the legacy 200K-char / 100-message behavior exactly
|
|
21
|
-
// (a window is never invented; auto-compact stays off for them).
|
|
17
|
+
// History itself is NEVER truncated: there are no message/char caps.
|
|
18
|
+
// Compaction (manual /compact, auto at ~83% of the verified window) is the
|
|
19
|
+
// only pressure valve — Pi-style.
|
|
22
20
|
//
|
|
23
21
|
// Layering: this module owns measurement + budget math. It imports
|
|
24
|
-
// context-windows (metadata) and config (file fallback) at
|
|
25
|
-
// zen.js types ONLY (no runtime cycle — zen.ts imports this
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// re-exports from their original modules.
|
|
22
|
+
// context-windows (metadata) and config (file fallback for compactPct) at
|
|
23
|
+
// runtime, and zen.js types ONLY (no runtime cycle — zen.ts imports this
|
|
24
|
+
// module for context math). The agent loop, compaction mechanics, and
|
|
25
|
+
// providers are untouched.
|
|
29
26
|
//
|
|
30
27
|
// Prompt-caching foundation (NOT implemented): all inputs here are explicit
|
|
31
28
|
// values (system/tools/history split, measured sizes, stable options), so a
|
|
@@ -72,8 +69,10 @@ export function historyChars(history) {
|
|
|
72
69
|
total += messageChars(m);
|
|
73
70
|
return total;
|
|
74
71
|
}
|
|
75
|
-
// Load = last POST's reported
|
|
76
|
-
//
|
|
72
|
+
// Load = last POST's reported input-side tokens (prompt_tokens, normalized at
|
|
73
|
+
// parse time to include exclusive prefix-cache counters like Anthropic's
|
|
74
|
+
// cache_read/_creation) when available, else the 4ch/token estimate of the
|
|
75
|
+
// sent history chars.
|
|
77
76
|
export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
|
|
78
77
|
if (typeof lastPromptTokens === "number" &&
|
|
79
78
|
Number.isFinite(lastPromptTokens) &&
|
|
@@ -82,51 +81,6 @@ export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
|
|
|
82
81
|
}
|
|
83
82
|
return estimateTokensForChars(sentHistoryChars);
|
|
84
83
|
}
|
|
85
|
-
// ---- Safety-ceiling sources (env > atom.json > compiled default) ----
|
|
86
|
-
export const MAX_HISTORY_MESSAGES = 100;
|
|
87
|
-
export const MAX_HISTORY_CHARS = 200_000;
|
|
88
|
-
function clampInt(n, min, max) {
|
|
89
|
-
return Math.min(Math.max(Math.floor(n), min), max);
|
|
90
|
-
}
|
|
91
|
-
function envInt(raw) {
|
|
92
|
-
if (raw === undefined)
|
|
93
|
-
return undefined;
|
|
94
|
-
const text = raw.trim();
|
|
95
|
-
if (!/^\d+$/.test(text))
|
|
96
|
-
return undefined;
|
|
97
|
-
const n = Number(text);
|
|
98
|
-
return Number.isFinite(n) ? Math.floor(n) : undefined;
|
|
99
|
-
}
|
|
100
|
-
// Message-count ceiling source. `explicit` tells whether a human configured
|
|
101
|
-
// it (env or file) as opposed to the compiled default.
|
|
102
|
-
export function historyMessageSource() {
|
|
103
|
-
const env = envInt(process.env.ATOM_MAX_HISTORY_MESSAGES);
|
|
104
|
-
if (env !== undefined)
|
|
105
|
-
return { value: clampInt(env, 10, 1000), explicit: true };
|
|
106
|
-
const file = loadAtomConfig().config.maxHistoryMessages;
|
|
107
|
-
if (file !== undefined)
|
|
108
|
-
return { value: file, explicit: true };
|
|
109
|
-
return { value: MAX_HISTORY_MESSAGES, explicit: false };
|
|
110
|
-
}
|
|
111
|
-
// Char-count safety ceiling source. Same explicit contract.
|
|
112
|
-
export function historyCharSource() {
|
|
113
|
-
const env = envInt(process.env.ATOM_MAX_HISTORY_CHARS);
|
|
114
|
-
if (env !== undefined)
|
|
115
|
-
return { value: clampInt(env, 10_000, 2_000_000), explicit: true };
|
|
116
|
-
const file = loadAtomConfig().config.maxHistoryChars;
|
|
117
|
-
if (file !== undefined)
|
|
118
|
-
return { value: file, explicit: true };
|
|
119
|
-
return { value: MAX_HISTORY_CHARS, explicit: false };
|
|
120
|
-
}
|
|
121
|
-
// Legacy accessors (env → file → default). Kept for the loop's legacy path
|
|
122
|
-
// and existing callers; the manager uses the sources above so it can tell
|
|
123
|
-
// configured ceilings apart from defaults.
|
|
124
|
-
export function historyMessageBudget() {
|
|
125
|
-
return historyMessageSource().value;
|
|
126
|
-
}
|
|
127
|
-
export function historyCharBudget() {
|
|
128
|
-
return historyCharSource().value;
|
|
129
|
-
}
|
|
130
84
|
// ---- Compaction threshold (moved here: the manager owns "should compact") ----
|
|
131
85
|
export const COMPACT_PCT_DEFAULT = 0.83;
|
|
132
86
|
function clampPctPercent(n) {
|
|
@@ -159,135 +113,20 @@ export function shouldAutoCompact(load, model, pctOverride) {
|
|
|
159
113
|
: compactPct();
|
|
160
114
|
return load / window >= pct;
|
|
161
115
|
}
|
|
162
|
-
// Searchable text for todo matching: message content plus the assistant's
|
|
163
|
-
// tool_calls payload (todowrite CALLS carry the list, tool RESULTS echo it).
|
|
164
|
-
// Tool call ids are NOT searched — they are pairing keys, not goal text, so
|
|
165
|
-
// a todo that reads like an id can never false-pin a turn.
|
|
166
|
-
function todoHaystack(m) {
|
|
167
|
-
let hay = "";
|
|
168
|
-
const content = m.content;
|
|
169
|
-
if (typeof content === "string")
|
|
170
|
-
hay += content;
|
|
171
|
-
if (m.role === "assistant" && m.tool_calls !== undefined) {
|
|
172
|
-
try {
|
|
173
|
-
hay += JSON.stringify(m.tool_calls);
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
// unstringifiable payload pins nothing
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return hay;
|
|
180
|
-
}
|
|
181
|
-
function turnMentionsTodo(history, start, end, needles) {
|
|
182
|
-
for (let i = start; i < end; i++) {
|
|
183
|
-
const hay = todoHaystack(history[i]);
|
|
184
|
-
if (hay.length === 0)
|
|
185
|
-
continue;
|
|
186
|
-
for (const n of needles) {
|
|
187
|
-
if (n.length > 0 && hay.includes(n))
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
// Drop oldest user-turns until history fits BOTH caps (message count AND
|
|
194
|
-
// total chars, each plus the caller's `reserve` headroom for a message it is
|
|
195
|
-
// about to push). A user turn = the `user` message plus all following
|
|
196
|
-
// messages up to (excluding) the next `user` message, so assistant
|
|
197
|
-
// tool_calls always stay paired with their tool results across all three
|
|
198
|
-
// wire formats. NEVER drops history[0] (system prompt), the first user turn
|
|
199
|
-
// (the task prompt — the goal a long run must never forget), any turn that
|
|
200
|
-
// still quotes a CURRENT open todo, or the latest turn (the one being
|
|
201
|
-
// sent/built). Budget-aware edge: when the pinned content alone (first turn
|
|
202
|
-
// + todo turns + latest) already exceeds a cap, there is nothing left to
|
|
203
|
-
// drop — stop and still send (same never-drop-the-live-turn principle).
|
|
204
|
-
// Mutates `history` in place via splice (so caller indices captured after
|
|
205
|
-
// this call stay valid) and, when at least one turn dropped, fires ONE
|
|
206
|
-
// `notify` (the caller surfaces it dim in the TUI); silence otherwise.
|
|
207
|
-
// Returns what was dropped.
|
|
208
|
-
export function truncateHistoryWithCaps(history, caps, opts) {
|
|
209
|
-
const result = { droppedTurns: 0, droppedMessages: 0 };
|
|
210
|
-
if (history.length <= 1)
|
|
211
|
-
return result;
|
|
212
|
-
const maxMessages = caps.maxMessages;
|
|
213
|
-
const maxChars = caps.maxChars;
|
|
214
|
-
const reserve = opts?.reserve;
|
|
215
|
-
const needles = opts?.todoNeedles ?? [];
|
|
216
|
-
const roomMessages = reserve?.messages !== undefined && Number.isFinite(reserve.messages)
|
|
217
|
-
? Math.max(0, Math.floor(reserve.messages))
|
|
218
|
-
: 0;
|
|
219
|
-
const roomChars = reserve?.chars !== undefined && Number.isFinite(reserve.chars)
|
|
220
|
-
? Math.max(0, reserve.chars)
|
|
221
|
-
: 0;
|
|
222
|
-
for (;;) {
|
|
223
|
-
const over = history.length + roomMessages > maxMessages ||
|
|
224
|
-
historyChars(history) + roomChars > maxChars;
|
|
225
|
-
if (!over)
|
|
226
|
-
break;
|
|
227
|
-
// Turn boundaries over history[1..]: each turn starts at a `user`
|
|
228
|
-
// message (the oldest slice starts at 1 even when it isn't one, matching
|
|
229
|
-
// the pre-pin drop unit). Whole-turn drops keep assistant/tool pairing.
|
|
230
|
-
const starts = [1];
|
|
231
|
-
for (let i = 2; i < history.length; i++) {
|
|
232
|
-
if (history[i]?.role === "user")
|
|
233
|
-
starts.push(i);
|
|
234
|
-
}
|
|
235
|
-
// Oldest NON-pinned, non-latest turn goes first: the first turn (task
|
|
236
|
-
// prompt) and any turn still quoting a current open todo stay, and the
|
|
237
|
-
// latest turn is never dropped. No candidate means pinned content alone
|
|
238
|
-
// is over budget — stop and send it as-is (see edge above).
|
|
239
|
-
let drop = -1;
|
|
240
|
-
for (let t = 0; t < starts.length; t++) {
|
|
241
|
-
if (t === starts.length - 1)
|
|
242
|
-
continue; // latest turn
|
|
243
|
-
if (t === 0)
|
|
244
|
-
continue; // task prompt
|
|
245
|
-
const end = t + 1 < starts.length ? starts[t + 1] : history.length;
|
|
246
|
-
if (needles.length > 0 && turnMentionsTodo(history, starts[t], end, needles))
|
|
247
|
-
continue;
|
|
248
|
-
drop = t;
|
|
249
|
-
break;
|
|
250
|
-
}
|
|
251
|
-
if (drop === -1)
|
|
252
|
-
break;
|
|
253
|
-
const end = drop + 1 < starts.length ? starts[drop + 1] : history.length;
|
|
254
|
-
const removed = history.splice(starts[drop], end - starts[drop]);
|
|
255
|
-
result.droppedTurns += 1;
|
|
256
|
-
result.droppedMessages += removed.length;
|
|
257
|
-
}
|
|
258
|
-
if (result.droppedTurns > 0) {
|
|
259
|
-
try {
|
|
260
|
-
opts?.notify?.(`(history truncated: dropped ${result.droppedTurns} oldest turn(s))`);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
// observer errors never break the loop
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return result;
|
|
267
|
-
}
|
|
268
116
|
// ---- Budget derivation ----
|
|
269
117
|
// Expected completion/output reserve: one full summary-sized generation must
|
|
270
118
|
// always fit alongside history (mirrors the compaction output cap).
|
|
271
119
|
export const OUTPUT_RESERVE_TOKENS = 4096;
|
|
272
|
-
// Safety headroom below the raw window: the
|
|
120
|
+
// Safety headroom below the raw window: the budget never plans to use the
|
|
273
121
|
// last 5% (auto-compact at ~83% fires long before this matters — the margin
|
|
274
|
-
// is
|
|
122
|
+
// is informational, not a trigger).
|
|
275
123
|
export const SAFETY_MARGIN_PCT = 0.05;
|
|
276
|
-
// Default safety ceiling when nothing is configured AND no window is known
|
|
277
|
-
// (the legacy 200K-char budget, preserved byte-for-byte as fallback).
|
|
278
|
-
export const HARD_CEILING_FLOOR_CHARS = 200_000;
|
|
279
124
|
export function createContextManager(opts) {
|
|
280
125
|
const model = opts.model;
|
|
281
126
|
const toolsChars = opts.toolsChars ?? 0;
|
|
282
127
|
const reserveTokens = opts.outputReserveTokens ?? OUTPUT_RESERVE_TOKENS;
|
|
283
128
|
const marginPct = opts.safetyMarginPct ?? SAFETY_MARGIN_PCT;
|
|
284
129
|
const pct = opts.compactPct ?? compactPct();
|
|
285
|
-
function ceilingChars() {
|
|
286
|
-
if (opts.hardCeilingChars !== undefined) {
|
|
287
|
-
return { value: opts.hardCeilingChars, explicit: opts.hardCeilingExplicit ?? true };
|
|
288
|
-
}
|
|
289
|
-
return historyCharSource();
|
|
290
|
-
}
|
|
291
130
|
function budget(history) {
|
|
292
131
|
const window = contextWindowFor(model);
|
|
293
132
|
const stats = ledgerStats(history);
|
|
@@ -298,18 +137,6 @@ export function createContextManager(opts) {
|
|
|
298
137
|
? Math.max(0, window - systemTokens - toolsTokens - reserveTokens - margin)
|
|
299
138
|
: undefined;
|
|
300
139
|
const historyCharsCap = historyTokens !== undefined ? historyTokens * CHARS_PER_TOKEN : undefined;
|
|
301
|
-
const ceil = ceilingChars();
|
|
302
|
-
const ceilMsgs = opts.hardCeilingMessages ?? historyMessageSource().value;
|
|
303
|
-
// The ceiling only ever CAPS: with nothing configured the derived budget
|
|
304
|
-
// rules (large windows stay usable); an explicit ceiling still binds as
|
|
305
|
-
// the safety net it is. Unknown windows fall back to the legacy floor.
|
|
306
|
-
const effectiveMaxChars = historyCharsCap !== undefined
|
|
307
|
-
? ceil.explicit
|
|
308
|
-
? Math.min(historyCharsCap, ceil.value)
|
|
309
|
-
: historyCharsCap
|
|
310
|
-
: ceil.explicit
|
|
311
|
-
? ceil.value
|
|
312
|
-
: HARD_CEILING_FLOOR_CHARS;
|
|
313
140
|
return {
|
|
314
141
|
windowTokens: window,
|
|
315
142
|
systemTokens,
|
|
@@ -318,11 +145,6 @@ export function createContextManager(opts) {
|
|
|
318
145
|
safetyMarginTokens: margin,
|
|
319
146
|
historyTokens,
|
|
320
147
|
historyChars: historyCharsCap,
|
|
321
|
-
hardCeilingChars: ceil.value,
|
|
322
|
-
hardCeilingExplicit: ceil.explicit,
|
|
323
|
-
hardCeilingMessages: ceilMsgs,
|
|
324
|
-
effectiveMaxChars,
|
|
325
|
-
effectiveMaxMessages: ceilMsgs,
|
|
326
148
|
};
|
|
327
149
|
}
|
|
328
150
|
function usage(history, lastPromptTokens) {
|
|
@@ -340,11 +162,7 @@ export function createContextManager(opts) {
|
|
|
340
162
|
function needsCompaction(loadTokens) {
|
|
341
163
|
return shouldAutoCompact(loadTokens, model, pct);
|
|
342
164
|
}
|
|
343
|
-
|
|
344
|
-
const b = budget(history);
|
|
345
|
-
return truncateHistoryWithCaps(history, { maxMessages: b.effectiveMaxMessages, maxChars: b.effectiveMaxChars }, { notify, reserve, todoNeedles });
|
|
346
|
-
}
|
|
347
|
-
return { model, budget, usage, needsCompaction, trimForSend };
|
|
165
|
+
return { model, budget, usage, needsCompaction };
|
|
348
166
|
}
|
|
349
167
|
const ledgerByRaw = new WeakMap();
|
|
350
168
|
const ledgerByProxy = new WeakMap();
|
package/dist/context-windows.js
CHANGED
|
@@ -77,7 +77,9 @@ export function contextWindowFor(model) {
|
|
|
77
77
|
return CONTEXT_WINDOWS[model];
|
|
78
78
|
}
|
|
79
79
|
// Total session tokens: prefer usage.total_tokens when present, else
|
|
80
|
-
// prompt_tokens + completion_tokens (missing keys count as 0).
|
|
80
|
+
// prompt_tokens + completion_tokens (missing keys count as 0). prompt_tokens
|
|
81
|
+
// is normalized at parse time to total input-side tokens (exclusive
|
|
82
|
+
// prefix-cache counters folded in), so both paths count cached context.
|
|
81
83
|
export function totalTokens(usage) {
|
|
82
84
|
if (typeof usage.total_tokens === "number") {
|
|
83
85
|
return Math.max(0, Math.floor(usage.total_tokens));
|
|
@@ -90,7 +92,7 @@ export function totalTokens(usage) {
|
|
|
90
92
|
// - no usage reported yet: `token: n/a` (never estimated)
|
|
91
93
|
// - known window: `token: (P%) NK` (NK = round(total/1024) + "K" from the
|
|
92
94
|
// CUMULATIVE session spend; P = round(100*load/window) from the CURRENT
|
|
93
|
-
// context load —
|
|
95
|
+
// context load — input-side tokens of the last POST, else the 4ch/token
|
|
94
96
|
// estimate. Cumulative spend keeps growing after compaction, so it must
|
|
95
97
|
// NOT drive P; load does. Pass load explicitly; when omitted it falls
|
|
96
98
|
// back to the cumulative total for backward compat.)
|
package/dist/env-block.js
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
// timestamp.
|
|
4
4
|
//
|
|
5
5
|
// Placement: pinned to the SYSTEM message only (suffix to history[0]'s
|
|
6
|
-
// content via withEnvBlock), NEVER into user content. history[0] is the
|
|
7
|
-
//
|
|
8
|
-
// Caching: the App refreshes history[0] once per turn in submit()
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// content via withEnvBlock), NEVER into user content. history[0] is the
|
|
7
|
+
// system prompt, so the block survives every turn.
|
|
8
|
+
// Caching: the App refreshes history[0] once per turn in submit() — the
|
|
9
|
+
// loop's POSTs reuse the same history[0], so git is shelled at most once
|
|
10
|
+
// per turn.
|
|
11
11
|
// Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
|
|
12
12
|
// (cwd + node + time only), never throws, never blocks the turn. No new
|
|
13
13
|
// dependencies; one cheap `git status` invocation with a short timeout, and
|
|
14
14
|
// zero shell-outs when `.git` is absent.
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
17
18
|
import * as path from "node:path";
|
|
18
19
|
// Cap for the block itself (~500 chars per the task). The base system prompt
|
|
19
20
|
// (SYSTEM_PROMPT + AGENTS.md overlay) is untouched by this cap.
|
|
@@ -30,8 +31,9 @@ function shortCwd(cwd) {
|
|
|
30
31
|
return `…${cwd.slice(cwd.length - (CWD_DISPLAY_CAP - 1))}`;
|
|
31
32
|
}
|
|
32
33
|
// Pure formatter (no I/O): always `cwd + node + time`, plus
|
|
33
|
-
// `branch + status` only when git reported them
|
|
34
|
-
//
|
|
34
|
+
// `branch + status` only when git reported them, plus the operating context
|
|
35
|
+
// (`os + shell + user`) when provided. Capped to ENV_BLOCK_CHAR_CAP (cwd is
|
|
36
|
+
// pre-truncated so time/node survive the cap).
|
|
35
37
|
export function buildEnvBlock(parts) {
|
|
36
38
|
const cwd = shortCwd(parts.cwd);
|
|
37
39
|
const git = parts.branch !== undefined &&
|
|
@@ -39,7 +41,10 @@ export function buildEnvBlock(parts) {
|
|
|
39
41
|
parts.branch.length > 0
|
|
40
42
|
? ` branch=${parts.branch} status=${parts.status ?? "unknown"}`
|
|
41
43
|
: "";
|
|
42
|
-
const
|
|
44
|
+
const machine = parts.os !== undefined && parts.os !== null && parts.os.length > 0
|
|
45
|
+
? ` os=${parts.os} shell=${parts.shell ?? "unknown"} user=${parts.user ?? "unknown"}`
|
|
46
|
+
: "";
|
|
47
|
+
const block = `[env cwd=${cwd}${git}${machine} node=${parts.nodeVersion} time=${parts.timestamp}]`;
|
|
43
48
|
return block.length > ENV_BLOCK_CHAR_CAP
|
|
44
49
|
? `${block.slice(0, ENV_BLOCK_CHAR_CAP - 1)}]`
|
|
45
50
|
: block;
|
|
@@ -117,12 +122,45 @@ export function getEnvBlock(cwd = process.cwd()) {
|
|
|
117
122
|
catch {
|
|
118
123
|
git = null;
|
|
119
124
|
}
|
|
125
|
+
// Operating context (best-effort, never throws): tells the model which
|
|
126
|
+
// shell runs its commands and who/where it is, so it stops probing with
|
|
127
|
+
// whoami/hostname and guessing `ls` vs `dir` (measured: 2–4 wasted bash
|
|
128
|
+
// calls per task before this existed).
|
|
129
|
+
let osName = "unknown";
|
|
130
|
+
try {
|
|
131
|
+
if (typeof process.platform === "string" && process.platform.length > 0) {
|
|
132
|
+
osName = process.platform;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// keep fallback
|
|
137
|
+
}
|
|
138
|
+
let shell = "unknown";
|
|
139
|
+
try {
|
|
140
|
+
shell = process.platform === "win32" ? "cmd.exe" : "sh";
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// keep fallback
|
|
144
|
+
}
|
|
145
|
+
let user = "unknown";
|
|
146
|
+
try {
|
|
147
|
+
const name = os.userInfo?.().username;
|
|
148
|
+
if (typeof name === "string" && name.length > 0) {
|
|
149
|
+
user = name;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// keep fallback (sandboxed runtimes may forbid userInfo)
|
|
154
|
+
}
|
|
120
155
|
return buildEnvBlock({
|
|
121
156
|
cwd: dir,
|
|
122
157
|
branch: git?.branch ?? null,
|
|
123
158
|
status: git?.status ?? null,
|
|
124
159
|
nodeVersion,
|
|
125
160
|
timestamp,
|
|
161
|
+
os: osName,
|
|
162
|
+
shell,
|
|
163
|
+
user,
|
|
126
164
|
});
|
|
127
165
|
}
|
|
128
166
|
catch {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Extension slash commands (ticket 04): the runtime store behind
|
|
2
|
+
// ExtensionAPI.registerCommand. Dependency-free (no imports) like
|
|
3
|
+
// tools/custom.ts and tools/intercept.ts, so the extension host and the App
|
|
4
|
+
// slash dispatch share it with no cycle: extensions register here, the App
|
|
5
|
+
// lists/parses/runs here, nobody imports the other.
|
|
6
|
+
//
|
|
7
|
+
// A command is a real slash command (`/name args...`): it appears in the
|
|
8
|
+
// command palette and the "/" menu, takes typed free-text arguments, and
|
|
9
|
+
// runs extension code OUTSIDE the model turn loop with a generation-bound
|
|
10
|
+
// context (prompt the user, read a session snapshot, post messages).
|
|
11
|
+
//
|
|
12
|
+
// Semantics:
|
|
13
|
+
// - Names are bare in the definition ("deploy") and slash-prefixed at the
|
|
14
|
+
// seam ("/deploy"). Lowercase [a-z0-9_-] only, mirroring builtin style so
|
|
15
|
+
// menu/palette matching stays exact and case-free.
|
|
16
|
+
// - Collision decision: builtins always win. The host rejects a colliding
|
|
17
|
+
// name at activation (loud error, nothing commits) and App dispatch
|
|
18
|
+
// routes builtins first as backstop — a builtin is never shadowed, and
|
|
19
|
+
// there is no renamed form to discover. Duplicate extension names throw
|
|
20
|
+
// the same way (first registration wins, deterministically by load order).
|
|
21
|
+
// - Handlers never touch model history: say() stages messages and the
|
|
22
|
+
// runner commits them only on success. A throwing handler drops the
|
|
23
|
+
// stage and surfaces a clean `Error:` string — the session is untouched.
|
|
24
|
+
// - The context is generation-bound: every ctx call runs checkStale first,
|
|
25
|
+
// so use after a session replacement throws loudly instead of acting on
|
|
26
|
+
// the wrong session (same rule as the event API).
|
|
27
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
28
|
+
const store = new Map();
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function errorText(e) {
|
|
33
|
+
return e instanceof Error ? e.message : String(e ?? "unknown error");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate a registration shape. Throws Error on any problem (bad name,
|
|
37
|
+
* empty description, non-function handler). Duplicate and builtin-
|
|
38
|
+
* collision checks live with the callers that own those names (the store
|
|
39
|
+
* owns extension names; the host owns the builtin set).
|
|
40
|
+
*/
|
|
41
|
+
export function validateExtensionCommandDef(def) {
|
|
42
|
+
if (!isRecord(def))
|
|
43
|
+
throw new Error("extension command definition must be an object");
|
|
44
|
+
if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
|
|
45
|
+
throw new Error(`extension command has an invalid name ${JSON.stringify(def.name)} (want a bare 1-64 char a-z0-9_- name, no leading slash)`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof def.description !== "string" || def.description.trim().length === 0) {
|
|
48
|
+
throw new Error(`extension command "/${def.name}" needs a non-empty description`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof def.handler !== "function") {
|
|
51
|
+
throw new Error(`extension command "/${def.name}" needs a handler function`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Register a validated command. Throws on duplicate names. Returns an unregister function. */
|
|
55
|
+
export function registerExtensionCommand(def, owner = "(unknown)") {
|
|
56
|
+
validateExtensionCommandDef(def);
|
|
57
|
+
if (store.has(def.name)) {
|
|
58
|
+
throw new Error(`extension command "/${def.name}" is already registered`);
|
|
59
|
+
}
|
|
60
|
+
const rec = {
|
|
61
|
+
name: def.name,
|
|
62
|
+
description: def.description,
|
|
63
|
+
handler: def.handler,
|
|
64
|
+
owner,
|
|
65
|
+
};
|
|
66
|
+
store.set(def.name, rec);
|
|
67
|
+
let live = true;
|
|
68
|
+
return () => {
|
|
69
|
+
if (!live)
|
|
70
|
+
return;
|
|
71
|
+
live = false;
|
|
72
|
+
if (store.get(def.name) === rec)
|
|
73
|
+
store.delete(def.name);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function unregisterExtensionCommand(name) {
|
|
77
|
+
return store.delete(name);
|
|
78
|
+
}
|
|
79
|
+
export function getExtensionCommand(name) {
|
|
80
|
+
return store.get(name);
|
|
81
|
+
}
|
|
82
|
+
/** Live commands in registration order (deterministic menu/palette order). */
|
|
83
|
+
export function listExtensionCommands() {
|
|
84
|
+
return [...store.values()];
|
|
85
|
+
}
|
|
86
|
+
/** Test seam: drop every extension command. */
|
|
87
|
+
export function clearExtensionCommands() {
|
|
88
|
+
store.clear();
|
|
89
|
+
}
|
|
90
|
+
// Split "/name args..." into its bare name and raw args. Returns null for
|
|
91
|
+
// anything that is not a single-line slash invocation (plain text, a bare
|
|
92
|
+
// "/", multiline, namespaced skill forms like "/skill:dep" — the name must
|
|
93
|
+
// be followed by whitespace or end, so skill routing is never disturbed).
|
|
94
|
+
export function parseExtensionCommandInput(text) {
|
|
95
|
+
if (!text.startsWith("/") || text.length < 2)
|
|
96
|
+
return null;
|
|
97
|
+
if (/[\r\n]/.test(text))
|
|
98
|
+
return null;
|
|
99
|
+
const match = /^\/([A-Za-z0-9_-]+)(?:[ \t]+([\s\S]*))?$/.exec(text);
|
|
100
|
+
if (!match)
|
|
101
|
+
return null;
|
|
102
|
+
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
103
|
+
}
|
|
104
|
+
// Whitespace tokenizer for ctx.argv: double/single quotes group words, the
|
|
105
|
+
// quotes are stripped, no escape processing (documented, not shell).
|
|
106
|
+
export function splitCommandArgs(args) {
|
|
107
|
+
const out = [];
|
|
108
|
+
let cur = "";
|
|
109
|
+
let quote = null;
|
|
110
|
+
let has = false;
|
|
111
|
+
for (let i = 0; i < args.length; i++) {
|
|
112
|
+
const ch = args[i];
|
|
113
|
+
if (quote) {
|
|
114
|
+
if (ch === quote) {
|
|
115
|
+
quote = null;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
cur += ch;
|
|
119
|
+
}
|
|
120
|
+
has = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === '"' || ch === "'") {
|
|
124
|
+
quote = ch;
|
|
125
|
+
has = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === " " || ch === "\t") {
|
|
129
|
+
if (has) {
|
|
130
|
+
out.push(cur);
|
|
131
|
+
cur = "";
|
|
132
|
+
has = false;
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
cur += ch;
|
|
137
|
+
has = true;
|
|
138
|
+
}
|
|
139
|
+
if (has)
|
|
140
|
+
out.push(cur);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
// Run a registered command outside the model turn loop. Never throws for
|
|
144
|
+
// handler failures: unknown names and throwing handlers return a clean
|
|
145
|
+
// `Error:` result. say() output stages in a buffer and commits through
|
|
146
|
+
// deps.say only on success, so a throwing handler leaves the session
|
|
147
|
+
// untouched. Every context call runs checkStale first — stale use throws
|
|
148
|
+
// into the same clean-error path instead of acting on the wrong session.
|
|
149
|
+
export async function runExtensionCommand(name, rawArgs, deps) {
|
|
150
|
+
const rec = store.get(name);
|
|
151
|
+
if (!rec) {
|
|
152
|
+
return { ok: false, error: `Error: unknown extension command "/${name}" (not registered)` };
|
|
153
|
+
}
|
|
154
|
+
const stale = deps.checkStale ?? (() => undefined);
|
|
155
|
+
const staged = [];
|
|
156
|
+
const ctx = {
|
|
157
|
+
name,
|
|
158
|
+
args: rawArgs,
|
|
159
|
+
argv: splitCommandArgs(rawArgs),
|
|
160
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
161
|
+
askUser: async (question, options, allowCustom) => {
|
|
162
|
+
stale();
|
|
163
|
+
return deps.askUser(question, options, allowCustom);
|
|
164
|
+
},
|
|
165
|
+
getSession: () => {
|
|
166
|
+
stale();
|
|
167
|
+
return deps.getSession();
|
|
168
|
+
},
|
|
169
|
+
say: (message) => {
|
|
170
|
+
stale();
|
|
171
|
+
if (typeof message !== "string") {
|
|
172
|
+
throw new Error(`extension command "/${name}" say() needs a string`);
|
|
173
|
+
}
|
|
174
|
+
if (message.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
staged.push(message);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
let returned;
|
|
180
|
+
try {
|
|
181
|
+
returned = await rec.handler(ctx);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
|
|
185
|
+
}
|
|
186
|
+
if (typeof returned === "string" && returned.length > 0)
|
|
187
|
+
staged.push(returned);
|
|
188
|
+
try {
|
|
189
|
+
for (const message of staged)
|
|
190
|
+
deps.say(message);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, posted: staged.length };
|
|
196
|
+
}
|