aegis-desktop 0.8.19 → 0.8.21

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/vendor/aegis.js CHANGED
@@ -66,46 +66,292 @@ function envIdleMs(name) {
66
66
  }
67
67
 
68
68
  /**
69
- * DeepSeek's chat template wraps tool calls and turn boundaries in special
70
- * tokens — fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁
71
- * (U+2581) as its word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|,
72
- * |Assistant|, |begin▁of▁sentence|. These are chat-template internals, never
73
- * user-facing text; when the provider's streaming tool-call path leaks them
74
- * into `delta.content` they used to reach the screen verbatim — reported as
75
- * the pasted "||DSML||" envelope on Windows/macOS. Returns a per-stream
76
- * stripper closure with a short lookback buffer so a token split across two
77
- * SSE chunks isn't half-rendered before its closing pipe arrives.
69
+ * DeepSeek emits its tool calls as DSML — an XML-ish dialect of its chat
70
+ * template that the OpenAI-compatible wire format does NOT structure for us:
71
+ *
72
+ * <|DSML|tool_calls>
73
+ * <|DSML|invoke name="write">
74
+ * <|DSML|parameter name="path" string="true">src/app.js</|DSML|parameter>
75
+ * <|DSML|parameter name="content" string="true">…the file body…</|DSML|parameter>
76
+ * </|DSML|invoke>
77
+ * </|DSML|tool_calls>
78
+ *
79
+ * It arrives as plain `delta.content` text, and that produces two failures:
80
+ * the markup rendering verbatim (reported as the pasted "||DSML||" envelope),
81
+ * and the call itself never running because text is not a tool call. Ported
82
+ * from aegiscodex-dev's src/backend.js, which carries the full history of this
83
+ * grammar's quirks (provider/template/model revision disagree about the exact
84
+ * identifier, angle brackets sometimes absent, tag names drift under
85
+ * sampling) — every part of the pattern below is deliberately optional and
86
+ * spacing-insensitive for that reason. `string="false"` marks a JSON
87
+ * parameter; `string="true"` (or an absent attribute) is the literal value,
88
+ * kept verbatim so a multi-line `content` is never mangled by a JSON
89
+ * round-trip.
90
+ */
91
+ // `\b` after each tag-name alternative matters: without it, "call" matches as
92
+ // a bare PREFIX of a drifted wrapper tag like "calls" (observed verbatim —
93
+ // `<|| calls>`, no "tool_"/"function_" prefix at all), swallowing everything
94
+ // up to the next real `</invoke>` as that spurious match's unnamed body and
95
+ // silently discarding a whole tool call with it. "call" and "calls" are both
96
+ // word characters, so plain alternation order cannot tell them apart.
97
+ const DSML_RE_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*>/g;
98
+ const DSML_RE_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/g;
99
+ const DSML_RE_LEADER = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:(?:tool|function)[_▁ ]*calls?|reasoning)[_▁ ]*(?:begin|end)?\s*>/gi;
100
+ const DSML_RE_ANY_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
101
+ const DSML_RE_SKELETON = /<\s*\/?\s*(?:tool_calls|function_calls|invoke|parameter|param|reasoning)\b[^>]{0,120}>/gi;
102
+ const DSML_RE_JSON_CALLS = /|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*begin[ \t]*|([\s\S]*?)|[ \t]*(?:tool|function)[_▁ ]*calls?[_▁ ]*end[ \t]*|/gi;
103
+ const DSML_RE_ENVELOPE_TOKEN = /|[ \t]?[A-Za-z0-9_▁]*[ \t]?|/g;
104
+ const DSML_RE_DRIFT_INVOKE = /<\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call)\b\s*([^>]*?)\s*>/gi;
105
+ const DSML_RE_DRIFT_PARAM = /<\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*([^>]*?)\s*>/gi;
106
+ const DSML_RE_PARAM_CLOSE = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:parameter|param|arg)\b\s*>/gi;
107
+ const DSML_RE_ATTR = /([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
108
+ const DSML_RE_STRUCT_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
109
+ const DSML_RE_STRUCT_CLOSER = /<\s*\/\s*|\s*(?:DSML\s*)?|\s*(?:invoke|call|tool_call|parameter|param|arg|reasoning|(?:tool|function)[_▁ ]*calls?)\b/gi;
110
+ /**
111
+ * A COMPLETE (its own '>' has arrived) wrapper/leader tag that carries no
112
+ * call of its own — observed verbatim: a bare `<||calls>`, with neither a
113
+ * "tool_"/"function_" prefix (so DSML_RE_LEADER above misses it) nor a JSON
114
+ * envelope pair. Safe to drop the moment it closes, UNLIKE invoke/parameter —
115
+ * excluded here via negative lookahead so a *partial* invoke/parameter head is
116
+ * never erased before its own close has a chance to pair it up with
117
+ * DSML_RE_INVOKE/DSML_RE_PARAM on a later call once more of it has arrived.
118
+ */
119
+ const DSML_RE_WRAPPER_TAG = /<\s*\/?\s*|\s*(?:DSML\s*)?|\s*(?!(?:invoke|call|tool_call|parameter|param|arg)\b)[A-Za-z_][A-Za-z0-9_]*[^>]{0,120}>/g;
120
+ /** A '<' (or '</') at the tail: the head of a tag whose pipe is still in flight. */
121
+ const DSML_RE_TRAILING_LT = /<\s*\/?\s*$/;
122
+ /** An in-flight identifier token: a pipe whose partner has not arrived yet. */
123
+ const DSML_RE_OPEN_TOKEN = /^|[ \t]?[A-Za-z0-9_▁]*[ \t]?$/;
124
+ /** Past this much buffered markup, a held tail is released as-is. */
125
+ const DSML_HOLD_MAX = 4 * 1024 * 1024;
126
+
127
+ /** The attributes of one tag head, lower-cased: name="write" → { name: 'write' }. */
128
+ function dsmlAttrs(text) {
129
+ const out = {};
130
+ if (!text) return out;
131
+ DSML_RE_ATTR.lastIndex = 0;
132
+ let m;
133
+ while ((m = DSML_RE_ATTR.exec(text))) out[m[1].toLowerCase()] = m[2] != null ? m[2] : m[3];
134
+ return out;
135
+ }
136
+
137
+ /** A JSON parameter — or, when it is not JSON after all, the text it came as. */
138
+ function dsmlJson(raw) {
139
+ const s = String(raw == null ? '' : raw).trim();
140
+ try {
141
+ const v = JSON.parse(s);
142
+ return v === undefined ? s : v;
143
+ } catch {
144
+ return s;
145
+ }
146
+ }
147
+
148
+ /** The `parameter` tags of one `invoke` body, as a tool call's args object. */
149
+ function dsmlArgs(body) {
150
+ const args = {};
151
+ DSML_RE_PARAM.lastIndex = 0;
152
+ let p;
153
+ while ((p = DSML_RE_PARAM.exec(body))) {
154
+ const a = dsmlAttrs(p[1]);
155
+ const key = a.name || a.key || `arg${Object.keys(args).length}`;
156
+ const isJson = String(a.string || '').toLowerCase() === 'false' || String(a.json || '').toLowerCase() === 'true';
157
+ args[key] = isJson ? dsmlJson(p[2]) : p[2];
158
+ }
159
+ return args;
160
+ }
161
+
162
+ /**
163
+ * Decode every DSML tool call in `text` and return the two things a caller
164
+ * needs: what may be shown, and what must be executed — `{ text, calls }`.
165
+ * The generic `|…|` tokens are deliberately left in place here: an `invoke`
166
+ * block still arriving is identified by its delimiters on the next pass, and
167
+ * eating them early would lose the call's name and arguments. They are
168
+ * removed from the *released* text instead (see dsmlResidue).
169
+ */
170
+ function decodeDeepSeekDSML(input) {
171
+ if (typeof input !== 'string' || !input || input.indexOf('|') === -1) {
172
+ return { text: input || '', calls: [] };
173
+ }
174
+ const calls = [];
175
+ let text = input;
176
+
177
+ // 1. One `invoke` block per call, the well-formed spelling.
178
+ DSML_RE_INVOKE.lastIndex = 0;
179
+ const spans = [];
180
+ let m;
181
+ while ((m = DSML_RE_INVOKE.exec(text))) {
182
+ const a = dsmlAttrs(m[1]);
183
+ const name = a.name || a.function || a.tool || '';
184
+ if (name) calls.push({ name, args: dsmlArgs(m[2]) });
185
+ spans.push([m.index, m.index + m[0].length]);
186
+ }
187
+ for (let i = spans.length - 1; i >= 0; i--) {
188
+ text = text.slice(0, spans[i][0]) + text.slice(spans[i][1]);
189
+ }
190
+
191
+ // 2. The older spelling: a JSON array wrapped in envelope tokens.
192
+ text = text.replace(DSML_RE_JSON_CALLS, (full, body) => {
193
+ const parsed = dsmlJson(body);
194
+ const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []);
195
+ for (const c of list) {
196
+ const fn = (c && c.function) || c || {};
197
+ const name = fn.name || (c && c.name) || '';
198
+ if (!name) continue;
199
+ let args = fn.arguments != null ? fn.arguments : (c && c.arguments);
200
+ if (typeof args === 'string') args = dsmlJson(args);
201
+ calls.push({ name, args: args && typeof args === 'object' ? args : {} });
202
+ }
203
+ return '';
204
+ });
205
+
206
+ // 3. The wrapper tags carry no call of their own.
207
+ text = text.replace(DSML_RE_LEADER, '');
208
+ return { text, calls };
209
+ }
210
+
211
+ /**
212
+ * Last-resort decode for markup that drifted out of well-formedness: a missing
213
+ * closing tag, or a stream cut mid-call. The call still names a tool and its
214
+ * parameters are still readable, so it is decoded rather than deleted — a call
215
+ * that reached the wire must not vanish because its closing tag did.
216
+ */
217
+ function decodeDeepSeekDSMLDrift(input) {
218
+ if (typeof input !== 'string' || !input || input.indexOf('|') === -1) return { text: input || '', calls: [] };
219
+ const opens = [];
220
+ DSML_RE_DRIFT_INVOKE.lastIndex = 0;
221
+ let m;
222
+ while ((m = DSML_RE_DRIFT_INVOKE.exec(input))) {
223
+ opens.push({ at: m.index, attrs: m[1], body: DSML_RE_DRIFT_INVOKE.lastIndex });
224
+ }
225
+ if (!opens.length) return { text: input, calls: [] };
226
+ const calls = [];
227
+ let text = '';
228
+ let cursor = 0;
229
+ for (let i = 0; i < opens.length; i++) {
230
+ // One call runs to the next `invoke` head, or to the end of the text.
231
+ const end = i + 1 < opens.length ? opens[i + 1].at : input.length;
232
+ const body = input.slice(opens[i].body, end);
233
+ const a = dsmlAttrs(opens[i].attrs);
234
+ const name = a.name || a.function || a.tool || '';
235
+ const args = {};
236
+ const params = [];
237
+ DSML_RE_DRIFT_PARAM.lastIndex = 0;
238
+ let pm;
239
+ while ((pm = DSML_RE_DRIFT_PARAM.exec(body))) {
240
+ params.push({ at: pm.index, attrs: pm[1], from: DSML_RE_DRIFT_PARAM.lastIndex });
241
+ }
242
+ for (let k = 0; k < params.length; k++) {
243
+ // A value runs to the next parameter tag — its closing tag may be the
244
+ // thing that never arrived.
245
+ const stop = k + 1 < params.length ? params[k + 1].at : body.length;
246
+ const raw = body.slice(params[k].from, stop).replace(DSML_RE_PARAM_CLOSE, '');
247
+ const pa = dsmlAttrs(params[k].attrs);
248
+ const key = pa.name || pa.key || `arg${Object.keys(args).length}`;
249
+ const isJson = String(pa.string || '').toLowerCase() === 'false';
250
+ args[key] = isJson ? dsmlJson(raw) : raw;
251
+ }
252
+ text += input.slice(cursor, opens[i].at);
253
+ cursor = end;
254
+ if (name) calls.push({ name, args });
255
+ }
256
+ text += input.slice(cursor);
257
+ return { text, calls };
258
+ }
259
+
260
+ /**
261
+ * Residue removal for text that is about to be shown. Two spellings have to go:
262
+ * a leftover envelope token (`|DSML|`, `|tool▁calls▁begin|`, `|Assistant|`),
263
+ * and the skeleton an earlier release left behind by stripping the delimiters
264
+ * out of a DSML block — `<tool_calls> <invoke name="write"> <parameter …>`,
265
+ * which is exactly what a user pasted back at us.
266
+ */
267
+ function dsmlResidue(text) {
268
+ if (!text) return text;
269
+ if (text.indexOf('|') === -1) return text.replace(DSML_RE_SKELETON, '');
270
+ return text.replace(DSML_RE_ANY_TAG, '').replace(DSML_RE_SKELETON, '').replace(DSML_RE_ENVELOPE_TOKEN, '');
271
+ }
272
+
273
+ /**
274
+ * Is the buffer, read from the first `|` in it, a construct that is still
275
+ * arriving? Only two shapes may be held back: a pipe whose partner has not
276
+ * arrived, and a DSML opener whose matching close has not landed. Everything
277
+ * else is prose — a `|` in an answer is a character, not a promise of markup,
278
+ * and holding the rest of the answer behind it would swallow the reply.
279
+ */
280
+ function dsmlUnterminated(s) {
281
+ if (DSML_RE_OPEN_TOKEN.test(s)) return true;
282
+ const openers = (s.match(DSML_RE_STRUCT_TAG) || []).length;
283
+ const closers = (s.match(DSML_RE_STRUCT_CLOSER) || []).length;
284
+ return openers > closers;
285
+ }
286
+
287
+ /**
288
+ * Where, if anywhere, an in-flight DSML construct still needs the rest of the
289
+ * buffer held back from view. By the time this runs, decodeDeepSeekDSML() has
290
+ * already removed every COMPLETE invoke/parameter span and the caller has
291
+ * already removed every complete non-invoke wrapper tag (DSML_RE_WRAPPER_TAG),
292
+ * so whatever is left can only be: clean prose, or the start of exactly ONE
293
+ * incomplete construct running to the end of the buffer — an opener whose own
294
+ * '|' has arrived but not its '>' (`<\s*\/?\s*|`, found wherever it starts,
295
+ * not just at the tail: a Write's `content` can be arbitrarily long, so the
296
+ * unresolved opener is usually far behind the cursor, not at it), a '<' that
297
+ * arrived with no '|' yet (the chunk boundary landed first), or a bare pipe
298
+ * token still accumulating its identifier (the plain envelope-leak case, e.g.
299
+ * '|tool▁cal'). Returns -1 when none apply — safe to release in full.
300
+ */
301
+ function dsmlHoldFrom(buf) {
302
+ const tagStart = buf.search(/<\s*\/?\s*|/);
303
+ if (tagStart !== -1) return tagStart;
304
+ const lt = DSML_RE_TRAILING_LT.exec(buf);
305
+ if (lt) return lt.index;
306
+ const openIdx = buf.indexOf('|');
307
+ if (openIdx !== -1 && dsmlUnterminated(buf.slice(openIdx))) return openIdx;
308
+ return -1;
309
+ }
310
+
311
+ /**
312
+ * The per-stream stripper the transports share. Feed it each `delta.content`
313
+ * chunk: it returns the text that is safe to show, holds back whatever is
314
+ * still arriving (a Write's `content` parameter is a whole file — it belongs
315
+ * to the call, not to the screen), and collects the tool calls the model made
316
+ * in text form. `flush()` releases the tail once the stream is drained, and
317
+ * `calls()` returns everything decoded so far, which the caller merges into
318
+ * the turn's tool calls so a Write that arrived as DSML is executed like any
319
+ * other.
78
320
  */
79
- const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
80
321
  function makeDeepSeekEnvelopeStripper() {
81
322
  let buf = '';
323
+ const calls = [];
82
324
  const strip = (chunk) => {
83
- buf += chunk;
84
- buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
85
- const openIdx = buf.lastIndexOf('|');
86
- if (openIdx !== -1) {
87
- const tail = buf.slice(openIdx);
88
- // Held back only while the tail still looks like an in-progress token
89
- // (no closing pipe yet); a bare '|' or genuine prose flushes normally.
90
- if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
91
- const out = buf.slice(0, openIdx);
92
- buf = tail;
93
- return out;
94
- }
325
+ buf += typeof chunk === 'string' ? chunk : '';
326
+ const dec = decodeDeepSeekDSML(buf);
327
+ buf = dec.text;
328
+ for (const c of dec.calls) calls.push(c);
329
+ buf = buf.replace(DSML_RE_WRAPPER_TAG, '');
330
+ const hold = dsmlHoldFrom(buf);
331
+ let out;
332
+ if (hold !== -1 && buf.length - hold <= DSML_HOLD_MAX) {
333
+ out = buf.slice(0, hold);
334
+ buf = buf.slice(hold);
335
+ } else {
336
+ out = buf;
337
+ buf = '';
95
338
  }
96
- const out = buf;
97
- buf = '';
98
- return out;
339
+ return dsmlResidue(out);
99
340
  };
100
341
  // Unconditional release of whatever is left buffered — called once the
101
- // stream is fully drained, when a held-back '|…' is known to never close
102
- // (dropped connection, or it was real prose all along) rather than an
342
+ // stream is fully drained, when a held-back construct is known to never
343
+ // close (dropped connection, or it was real prose all along) rather than an
103
344
  // in-progress token.
104
345
  strip.flush = () => {
105
- const out = buf;
346
+ const dec = decodeDeepSeekDSML(buf);
106
347
  buf = '';
107
- return out;
348
+ for (const c of dec.calls) calls.push(c);
349
+ const drift = decodeDeepSeekDSMLDrift(dec.text);
350
+ for (const c of drift.calls) calls.push(c);
351
+ return dsmlResidue(drift.text);
108
352
  };
353
+ /** Every tool call decoded from text so far, as `{ name, args }`. */
354
+ strip.calls = () => calls.slice();
109
355
  return strip;
110
356
  }
111
357
 
@@ -678,7 +924,7 @@ function createClient(opts = {}) {
678
924
  else if (mode) body.mode = mode;
679
925
  const headers = authHeaders(agentKey ? { 'X-AEGIS-Agent-Key': agentKey } : undefined);
680
926
  if (!stream || typeof onStream !== 'function') {
681
- return apiPost('/api/v1/chat/completions', { ...body, stream: false }, headers);
927
+ return decodeDsmlInResult(await apiPost('/api/v1/chat/completions', { ...body, stream: false }, headers));
682
928
  }
683
929
  // An OpenAI-compatible SSE response carries no `usage` unless the caller
684
930
  // asks for it (`stream_options.include_usage`). Without this the streamed
@@ -784,7 +1030,7 @@ function createClient(opts = {}) {
784
1030
  // billable traffic instead of a free ride.
785
1031
  if (apiKey) headers['X-AEGIS-Key'] = apiKey;
786
1032
  if (!stream || typeof onStream !== 'function') {
787
- return apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers);
1033
+ return decodeDsmlInResult(await apiPost('/api/v1/byok/chat/completions', { ...body, stream: false }, headers));
788
1034
  }
789
1035
  return postStream('/api/v1/byok/chat/completions', body, headers, onStream, signal);
790
1036
  }
@@ -796,6 +1042,31 @@ function createClient(opts = {}) {
796
1042
  return typeof content === 'string' ? content : '';
797
1043
  }
798
1044
 
1045
+ /**
1046
+ * One-shot DSML decode for a non-streamed completion: the whole answer
1047
+ * already arrived in one JSON payload, so there is no partial-token hold-
1048
+ * back to do — just decode, drift-decode what the well-formed pass missed,
1049
+ * and strip whatever residue would otherwise render as garble. Mutates and
1050
+ * returns `data` so every non-stream return site in this module can wrap
1051
+ * its result the same way the streaming path's `message.content` is clean.
1052
+ */
1053
+ function decodeDsmlInResult(data) {
1054
+ const choice = data && data.choices && data.choices[0];
1055
+ const content = choice && choice.message && choice.message.content;
1056
+ if (typeof content !== 'string' || content.indexOf('|') === -1) return data;
1057
+ const once = decodeDeepSeekDSML(content);
1058
+ const drift = decodeDeepSeekDSMLDrift(once.text);
1059
+ choice.message.content = dsmlResidue(drift.text);
1060
+ const calls = [...once.calls, ...drift.calls];
1061
+ if (calls.length) {
1062
+ data.toolCalls = [
1063
+ ...(Array.isArray(data.toolCalls) ? data.toolCalls : []),
1064
+ ...calls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
1065
+ ];
1066
+ }
1067
+ return data;
1068
+ }
1069
+
799
1070
  /**
800
1071
  * POST body with `stream: true` and forward SSE deltas to onStream.
801
1072
  * Shared by the AEGIS pool and the BYOK relay so every host parses exactly
@@ -881,7 +1152,7 @@ function createClient(opts = {}) {
881
1152
  // request with plain JSON. Dropping the signal here meant an aborted turn
882
1153
  // kept waiting on a request nobody was listening for any more — the
883
1154
  // caller had already moved on, and the host held the socket.
884
- const data = await apiPost(path, { ...cleanBody, stream: false }, headers, { signal });
1155
+ const data = decodeDsmlInResult(await apiPost(path, { ...cleanBody, stream: false }, headers, { signal }));
885
1156
  const fullText = textOf(data);
886
1157
  if (fullText) onStream({ delta: fullText });
887
1158
  return data;
@@ -890,7 +1161,7 @@ function createClient(opts = {}) {
890
1161
  const contentType = res.headers.get('content-type') || '';
891
1162
  if (!contentType.includes('text/event-stream')) {
892
1163
  // Server ignored the stream flag and answered with plain JSON.
893
- const data = await parseResponse(res);
1164
+ const data = decodeDsmlInResult(await parseResponse(res));
894
1165
  const fullText = textOf(data);
895
1166
  if (fullText) {
896
1167
  onStream({ delta: fullText });
@@ -1146,6 +1417,19 @@ function createClient(opts = {}) {
1146
1417
  ],
1147
1418
  };
1148
1419
  if (usage) result.usage = usage;
1420
+ // DSML calls arrive as plain text, decoded by stripEnvelope rather than
1421
+ // accumulated by index like structured delta.tool_calls — merged into one
1422
+ // normalised `{ id, name, args }` list so a tool call that arrived as DSML
1423
+ // (no provider-native delta.tool_calls at all) is executed like any other.
1424
+ // engine.js's extractToolCalls() reads this field first, ahead of
1425
+ // message.tool_calls.
1426
+ const dsmlCalls = stripEnvelope.calls();
1427
+ if (toolCalls.length || dsmlCalls.length) {
1428
+ result.toolCalls = [
1429
+ ...toolCalls.map((c) => ({ id: c.id, name: c.function.name, args: c.function.arguments })),
1430
+ ...dsmlCalls.map((c) => ({ id: '', name: c.name, args: c.args || {} })),
1431
+ ];
1432
+ }
1149
1433
  return result;
1150
1434
  }
1151
1435
 
@@ -121,6 +121,32 @@ const KEY_FILE_MODE = 0o600;
121
121
  const DIR_MODE = 0o700;
122
122
  const GROUP_OTHER_BITS = 0o077;
123
123
 
124
+ /**
125
+ * Do `fs.stat().mode` permission bits mean anything on this host?
126
+ *
127
+ * Windows: no. libuv has no NTFS ACL to report, so it synthesises the mode from
128
+ * the single read-only attribute and returns `0o666` for every writable file —
129
+ * the agent key included. `(mode & GROUP_OTHER_BITS) !== 0` is therefore ALWAYS
130
+ * true there, so the check below refused the agent key unconditionally: on
131
+ * Windows a correctly locked-down key at 0600 was reported as "readable by other
132
+ * users, refusing to treat it as a secret", and no chmod could ever clear it.
133
+ * `writeAgentKey` then threw on its own post-write assert for the same reason,
134
+ * so the gate could not even be initialised there.
135
+ *
136
+ * Where this returns false the boundary is the DIRECTORY's ACL — a per-user
137
+ * profile directory is already inaccessible to other non-admin accounts — which
138
+ * is what client/authorship-key.js has always done for the key file. The right
139
+ * behaviour is to skip the judgement, never to assume the worst.
140
+ *
141
+ * Duplicated from client/platform.js `hasPosixModes()` rather than imported: this
142
+ * file is deliberately standalone so it still runs from a copied hook, a staged
143
+ * tarball, or a vendored tools/ directory that has no sibling `client/`. Same
144
+ * reason cli/src/securefs.js carries its own copy.
145
+ */
146
+ function hasPosixModes(platform) {
147
+ return (platform || process.platform) !== 'win32';
148
+ }
149
+
124
150
  // ── derivations ─────────────────────────────────────────────────────────────
125
151
 
126
152
  /** @param {string} key @returns {string} hex sha256 under the gate domain */
@@ -259,10 +285,27 @@ const WRITEISH_BASH = [
259
285
  /(^|[^<])>>?\s*\S/, // > file, >> file
260
286
  /\btee\b/, /\bsed\s+-i/, /\btruncate\b/, /\bdd\b/,
261
287
  /\b(rm|mv|cp|install|touch|mkdir|rmdir|chmod|chown|ln)\b/,
262
- /\b(patch|applypatch)\b/, /\bpython[0-9.]*\s+-c\b.*\bopen\s*\(/, /\bperl\s+-i\b/,
288
+ /\b(patch|applypatch)\b/, /\bperl\s+-i\b/,
263
289
  /\bgit\s+(checkout|restore|reset|clean|stash|apply|revert|cherry-pick|merge|rebase)\b/,
264
290
  /\bnpm\s+(install|ci|uninstall|update)\b/, /\byarn\s+(add|remove)\b/, /\bpip[0-9.]*\s+install\b/,
265
291
  /\bAEGIS_GATE_SKIP\b/,
292
+
293
+ // `find` is a writer the moment it mutates: -delete, -exec/-execdir, and the
294
+ // -fprint/-fls family all write without ever containing `rm` or a redirect.
295
+ // Found by probing this gate keylessly on 2026-10-02 (`find <gated-tree>
296
+ // -name guard.json -delete` was reported as a read). Kept in step with
297
+ // aegiscodex-dev/src/repoguard.js, which carries the same list.
298
+ /\bfind\b[^|;]*\s-(delete|exec|execdir|fprint|fprint0|fls)\b/,
299
+ /\bxargs\b/,
300
+
301
+ // An inline-eval interpreter is an opaque program: `node -e`, `python -c`,
302
+ // `perl -e`, `sh -c` can write anywhere without naming a path in the command
303
+ // text. The old python rule looked for a literal `open(` and so missed
304
+ // `open(String.fromCharCode(...))`; the interpreter itself is the honest
305
+ // marker, and path-less write-ish commands fall back to cwd (fail-closed).
306
+ // `node --test` is deliberately NOT a hit.
307
+ /\b(python[0-9.]*|perl)\s+-c\b/, /\b(python[0-9.]*|perl)\s+-e\b/,
308
+ /\bnode\b[^|;]*\s(?:-e|--eval)\b/, /\b(sh|bash|zsh|dash)\s+-c\b/,
266
309
  ];
267
310
 
268
311
  /** Path-shaped tokens in a command line. Deliberately over-eager: see bashTargets. */
@@ -333,7 +376,7 @@ function protectedUnder(manifest, absPaths) {
333
376
  *
334
377
  * @returns {{ key: string, source: string, error: string|null }}
335
378
  */
336
- export function resolveAgentKey({ env = process.env, manifestRoot = null, key = null } = {}) {
379
+ export function resolveAgentKey({ env = process.env, manifestRoot = null, key = null, platform = null } = {}) {
337
380
  if (key) return { key: String(key).trim(), source: 'argument', error: null };
338
381
 
339
382
  const fromEnv = env.AEGIS_AGENT_KEY;
@@ -350,7 +393,9 @@ export function resolveAgentKey({ env = process.env, manifestRoot = null, key =
350
393
  }
351
394
  let mode = null;
352
395
  try { mode = st.mode & 0o777; } catch { /* non-POSIX */ }
353
- if (mode !== null && (mode & GROUP_OTHER_BITS)) {
396
+ // Skipped where the mode is synthesised (see hasPosixModes): the test is true
397
+ // for every file on Windows, so it refused the key on every read there.
398
+ if (hasPosixModes(platform) && mode !== null && (mode & GROUP_OTHER_BITS)) {
354
399
  return {
355
400
  key: '',
356
401
  source: file,
@@ -368,13 +413,17 @@ export function resolveAgentKey({ env = process.env, manifestRoot = null, key =
368
413
  return { key: value, source: file, error: null };
369
414
  }
370
415
 
371
- export function writeAgentKey(key, env = process.env) {
416
+ export function writeAgentKey(key, env = process.env, platform = null) {
372
417
  const file = path.join(aegisHome(env), KEY_FILE_REL);
373
418
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
374
419
  fs.writeFileSync(file, `${key}\n`, { mode: KEY_FILE_MODE });
375
420
  fs.chmodSync(file, KEY_FILE_MODE); // writeFileSync's mode is masked by umask
376
- const after = fs.statSync(file).mode & 0o777;
377
- if (after !== KEY_FILE_MODE) throw new Error(`key file landed at mode 0${after.toString(8)}, expected 0600`);
421
+ // The re-read assert only means something where stat reports what chmod wrote;
422
+ // on Windows it reports 0666 for this 0600 file, so this threw on every init.
423
+ if (hasPosixModes(platform)) {
424
+ const after = fs.statSync(file).mode & 0o777;
425
+ if (after !== KEY_FILE_MODE) throw new Error(`key file landed at mode 0${after.toString(8)}, expected 0600`);
426
+ }
378
427
  return file;
379
428
  }
380
429
 
@@ -87,6 +87,11 @@
87
87
  const fs = require('node:fs');
88
88
  const path = require('node:path');
89
89
  const credentials = require('./credentials.js');
90
+ // `hasPosixModes()` — on Windows fs.stat's mode is synthesised from the read-only
91
+ // attribute, so 0o666 comes back for every file and the group/other test below is
92
+ // always true. Reporting a Windows user's .env as world-readable on every single
93
+ // load is a false alarm they cannot act on.
94
+ const platform = require('./platform.js');
90
95
 
91
96
  const ENV_FILE = '.env';
92
97
 
@@ -339,8 +344,9 @@ function loadEnvFile(o = {}) {
339
344
  const st = fs.statSync(file);
340
345
  result.mode = st.mode & 0o777;
341
346
  // Group- or world-readable: worth one line to the user, never a rewrite of
342
- // a file we did not create.
343
- result.loose = (st.mode & 0o077) !== 0;
347
+ // a file we did not create. Only asked where the answer can be true or false
348
+ // (see the platform require above).
349
+ result.loose = platform.hasPosixModes() && (st.mode & 0o077) !== 0;
344
350
  } catch { /* stat is advisory only */ }
345
351
 
346
352
  const parsed = parseEnvText(text);
@@ -406,7 +412,7 @@ function loadForeignSecrets(o = {}) {
406
412
  try {
407
413
  const st = fs.statSync(file);
408
414
  result.mode = st.mode & 0o777;
409
- result.loose = (st.mode & 0o077) !== 0;
415
+ result.loose = platform.hasPosixModes() && (st.mode & 0o077) !== 0;
410
416
  } catch { /* advisory */ }
411
417
 
412
418
  const parsed = parseEnvText(text);
@@ -708,7 +714,8 @@ function setEnvValue(name, value, o = {}) {
708
714
  out.changed = true;
709
715
  out.replaced = replaced;
710
716
  out.mode = SECRET_FILE_MODE;
711
- out.tightened = !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
717
+ out.tightened =
718
+ platform.hasPosixModes() && !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
712
719
  if (o.apply !== false) {
713
720
  env[name] = secret;
714
721
  // Just written to the file and applied here: file-sourced (see above).