@moxxy/sdk 0.41.3 → 0.42.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.
Files changed (172) hide show
  1. package/dist/auto-approve.d.ts +14 -0
  2. package/dist/auto-approve.d.ts.map +1 -0
  3. package/dist/auto-approve.js +29 -0
  4. package/dist/auto-approve.js.map +1 -0
  5. package/dist/channel.d.ts +9 -0
  6. package/dist/channel.d.ts.map +1 -1
  7. package/dist/channel.js +14 -0
  8. package/dist/channel.js.map +1 -1
  9. package/dist/client-session.d.ts +3 -0
  10. package/dist/client-session.d.ts.map +1 -1
  11. package/dist/compactor-helpers.d.ts +1 -1
  12. package/dist/compactor-helpers.d.ts.map +1 -1
  13. package/dist/compactor-helpers.js +14 -4
  14. package/dist/compactor-helpers.js.map +1 -1
  15. package/dist/computer-control.d.ts +77 -3
  16. package/dist/computer-control.d.ts.map +1 -1
  17. package/dist/computer-control.js +12 -1
  18. package/dist/computer-control.js.map +1 -1
  19. package/dist/conduct.d.ts +16 -0
  20. package/dist/conduct.d.ts.map +1 -0
  21. package/dist/conduct.js +33 -0
  22. package/dist/conduct.js.map +1 -0
  23. package/dist/define.d.ts +3 -0
  24. package/dist/define.d.ts.map +1 -1
  25. package/dist/define.js +3 -0
  26. package/dist/define.js.map +1 -1
  27. package/dist/elision-state.d.ts +9 -0
  28. package/dist/elision-state.d.ts.map +1 -1
  29. package/dist/elision-state.js +16 -3
  30. package/dist/elision-state.js.map +1 -1
  31. package/dist/events.d.ts +13 -1
  32. package/dist/events.d.ts.map +1 -1
  33. package/dist/fs-utils.d.ts.map +1 -1
  34. package/dist/fs-utils.js +43 -2
  35. package/dist/fs-utils.js.map +1 -1
  36. package/dist/index.d.ts +14 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +12 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/locale.d.ts +9 -0
  41. package/dist/locale.d.ts.map +1 -0
  42. package/dist/locale.js +16 -0
  43. package/dist/locale.js.map +1 -0
  44. package/dist/mode/checkpoint.d.ts +7 -0
  45. package/dist/mode/checkpoint.d.ts.map +1 -1
  46. package/dist/mode/collect-stream.d.ts.map +1 -1
  47. package/dist/mode/collect-stream.js +3 -2
  48. package/dist/mode/collect-stream.js.map +1 -1
  49. package/dist/mode/project-messages.d.ts +7 -0
  50. package/dist/mode/project-messages.d.ts.map +1 -1
  51. package/dist/mode/project-messages.js +26 -4
  52. package/dist/mode/project-messages.js.map +1 -1
  53. package/dist/mode/react-loop.d.ts.map +1 -1
  54. package/dist/mode/react-loop.js +5 -3
  55. package/dist/mode/react-loop.js.map +1 -1
  56. package/dist/mode/stuck-loop.d.ts +8 -2
  57. package/dist/mode/stuck-loop.d.ts.map +1 -1
  58. package/dist/mode/stuck-loop.js +9 -1
  59. package/dist/mode/stuck-loop.js.map +1 -1
  60. package/dist/mode-helpers.d.ts +1 -1
  61. package/dist/mode-helpers.d.ts.map +1 -1
  62. package/dist/mode-helpers.js +1 -1
  63. package/dist/mode-helpers.js.map +1 -1
  64. package/dist/mode.d.ts +10 -3
  65. package/dist/mode.d.ts.map +1 -1
  66. package/dist/mode.js.map +1 -1
  67. package/dist/permission.d.ts +6 -0
  68. package/dist/permission.d.ts.map +1 -1
  69. package/dist/progress.d.ts +24 -0
  70. package/dist/progress.d.ts.map +1 -0
  71. package/dist/progress.js +59 -0
  72. package/dist/progress.js.map +1 -0
  73. package/dist/provider.d.ts +11 -1
  74. package/dist/provider.d.ts.map +1 -1
  75. package/dist/resolvers.js +2 -2
  76. package/dist/resolvers.js.map +1 -1
  77. package/dist/schemas.d.ts +9 -0
  78. package/dist/schemas.d.ts.map +1 -1
  79. package/dist/schemas.js +3 -0
  80. package/dist/schemas.js.map +1 -1
  81. package/dist/server.d.ts +1 -0
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +2 -0
  84. package/dist/server.js.map +1 -1
  85. package/dist/session-like.d.ts +21 -1
  86. package/dist/session-like.d.ts.map +1 -1
  87. package/dist/skill-mentions.d.ts +38 -0
  88. package/dist/skill-mentions.d.ts.map +1 -0
  89. package/dist/skill-mentions.js +84 -0
  90. package/dist/skill-mentions.js.map +1 -0
  91. package/dist/skill.d.ts +6 -0
  92. package/dist/skill.d.ts.map +1 -1
  93. package/dist/supersede.d.ts +31 -0
  94. package/dist/supersede.d.ts.map +1 -0
  95. package/dist/supersede.js +34 -0
  96. package/dist/supersede.js.map +1 -0
  97. package/dist/tool-dispatch.d.ts.map +1 -1
  98. package/dist/tool-dispatch.js +3 -2
  99. package/dist/tool-dispatch.js.map +1 -1
  100. package/dist/tool-gating.d.ts +9 -1
  101. package/dist/tool-gating.d.ts.map +1 -1
  102. package/dist/tool-gating.js +57 -7
  103. package/dist/tool-gating.js.map +1 -1
  104. package/dist/tool-modes.d.ts +8 -0
  105. package/dist/tool-modes.d.ts.map +1 -0
  106. package/dist/tool-modes.js +21 -0
  107. package/dist/tool-modes.js.map +1 -0
  108. package/dist/tool.d.ts +18 -0
  109. package/dist/tool.d.ts.map +1 -1
  110. package/dist/wait-for.d.ts +34 -0
  111. package/dist/wait-for.d.ts.map +1 -0
  112. package/dist/wait-for.js +67 -0
  113. package/dist/wait-for.js.map +1 -0
  114. package/package.json +9 -5
  115. package/src/auto-approve.test.ts +29 -0
  116. package/src/auto-approve.ts +29 -0
  117. package/src/channel-auth.test.ts +2 -1
  118. package/src/channel-control.test.ts +2 -1
  119. package/src/channel.ts +18 -0
  120. package/src/client-session.ts +3 -0
  121. package/src/compactor-helpers.test.ts +26 -0
  122. package/src/compactor-helpers.ts +16 -4
  123. package/src/computer-control.test.ts +11 -0
  124. package/src/computer-control.ts +16 -1
  125. package/src/conduct.test.ts +62 -0
  126. package/src/conduct.ts +37 -0
  127. package/src/cross-process-lock.test.ts +3 -2
  128. package/src/define.ts +6 -0
  129. package/src/elision-state.ts +24 -3
  130. package/src/events.ts +13 -1
  131. package/src/finish-pairing.test.ts +45 -0
  132. package/src/fs-utils.test.ts +40 -6
  133. package/src/fs-utils.ts +42 -2
  134. package/src/index.ts +38 -1
  135. package/src/json-file-store.test.ts +5 -3
  136. package/src/locale.test.ts +31 -0
  137. package/src/locale.ts +16 -0
  138. package/src/mode/checkpoint.ts +7 -0
  139. package/src/mode/collect-stream.ts +3 -2
  140. package/src/mode/project-messages.test.ts +72 -1
  141. package/src/mode/project-messages.ts +26 -5
  142. package/src/mode/react-loop.ts +6 -3
  143. package/src/mode/stuck-loop.ts +14 -3
  144. package/src/mode-helpers.ts +1 -0
  145. package/src/mode.ts +10 -3
  146. package/src/permission.ts +6 -0
  147. package/src/process-invocation.test.ts +3 -9
  148. package/src/progress.test.ts +81 -0
  149. package/src/progress.ts +73 -0
  150. package/src/provider.ts +12 -1
  151. package/src/resolvers.test.ts +14 -1
  152. package/src/resolvers.ts +2 -2
  153. package/src/schemas.ts +3 -0
  154. package/src/server.ts +2 -0
  155. package/src/session-like.ts +21 -1
  156. package/src/skill-mentions.test.ts +113 -0
  157. package/src/skill-mentions.ts +108 -0
  158. package/src/skill.ts +6 -0
  159. package/src/stuck-loop.test.ts +12 -0
  160. package/src/supersede.test.ts +115 -0
  161. package/src/supersede.ts +55 -0
  162. package/src/test-isolation.test.ts +26 -0
  163. package/src/token-efficiency.test.ts +7 -1
  164. package/src/tool-dispatch.test.ts +13 -0
  165. package/src/tool-dispatch.ts +3 -2
  166. package/src/tool-gating.test.ts +81 -0
  167. package/src/tool-gating.ts +53 -7
  168. package/src/tool-modes.test.ts +45 -0
  169. package/src/tool-modes.ts +23 -0
  170. package/src/tool.ts +18 -0
  171. package/src/wait-for.test.ts +132 -0
  172. package/src/wait-for.ts +79 -0
@@ -1,9 +1,11 @@
1
- import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { posixFileModes } from '@moxxy/vitest-preset/platform';
5
6
  import { z } from 'zod';
6
7
  import { createJsonFileStore } from './json-file-store.js';
8
+ import { removeDir } from '@moxxy/vitest-preset/fs';
7
9
 
8
10
  interface Item {
9
11
  id: string;
@@ -34,7 +36,7 @@ describe('createJsonFileStore', () => {
34
36
  });
35
37
 
36
38
  afterEach(async () => {
37
- await rm(dir, { recursive: true, force: true });
39
+ await removeDir(dir);
38
40
  });
39
41
 
40
42
  function store() {
@@ -230,7 +232,7 @@ describe('createJsonFileStore', () => {
230
232
  // versionless, name-key `rows`, compact, trailing newline
231
233
  expect(raw).toBe('{"rows":[{"id":"a","n":1}]}\n');
232
234
  const { stat } = await import('node:fs/promises');
233
- expect((await stat(f)).mode & 0o777).toBe(0o600);
235
+ if (posixFileModes) expect((await stat(f)).mode & 0o777).toBe(0o600);
234
236
  });
235
237
 
236
238
  it('invalidate() forces a re-read from disk', async () => {
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { utf8Locale } from './locale.js';
3
+
4
+ describe('utf8Locale', () => {
5
+ it('gives a GUI-launched runner on macOS a UTF-8 character type, so pbcopy keeps Polish letters', () => {
6
+ expect(utf8Locale({}, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
7
+ });
8
+
9
+ it('uses the C.UTF-8 locale on Linux, where en_US may not be installed', () => {
10
+ expect(utf8Locale({ LANG: '' }, 'linux')).toEqual({ LC_CTYPE: 'C.UTF-8' });
11
+ });
12
+
13
+ it('treats the C and POSIX locales as unset', () => {
14
+ expect(utf8Locale({ LANG: 'C' }, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
15
+ expect(utf8Locale({ LC_CTYPE: 'POSIX' }, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
16
+ });
17
+
18
+ it('leaves a locale that already speaks UTF-8 alone', () => {
19
+ expect(utf8Locale({ LANG: 'pl_PL.UTF-8' }, 'darwin')).toEqual({});
20
+ expect(utf8Locale({ LC_CTYPE: 'UTF-8' }, 'darwin')).toEqual({});
21
+ expect(utf8Locale({ LC_ALL: 'en_US.utf8' }, 'linux')).toEqual({});
22
+ });
23
+
24
+ it('respects a non-UTF-8 locale the user chose on purpose', () => {
25
+ expect(utf8Locale({ LANG: 'pl_PL.ISO8859-2' }, 'darwin')).toEqual({});
26
+ });
27
+
28
+ it('changes nothing on Windows, which has no POSIX locale variables', () => {
29
+ expect(utf8Locale({}, 'win32')).toEqual({});
30
+ });
31
+ });
package/src/locale.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A process started from the Dock or Finder gets no `LANG`, so every command
3
+ * the agent runs falls back to the legacy encoding: `pbcopy` then reads UTF-8
4
+ * as Mac Central European and "pamięć" lands in the clipboard as "pamińôńá".
5
+ * Returns the variables to add so child processes read and write UTF-8; only
6
+ * the character type is set, so messages keep the user's language.
7
+ */
8
+ export function utf8Locale(
9
+ env: Readonly<Record<string, string | undefined>>,
10
+ platform: NodeJS.Platform,
11
+ ): Record<string, string> {
12
+ if (platform === 'win32') return {};
13
+ const effective = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '';
14
+ if (effective !== '' && effective !== 'C' && effective !== 'POSIX') return {};
15
+ return { LC_CTYPE: platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8' };
16
+ }
@@ -101,5 +101,12 @@ export interface TurnCheckpoint {
101
101
  * (goal/collab-style stall handling wants these too).
102
102
  */
103
103
  readonly gateOn?: 'end_turn' | 'idle';
104
+ /**
105
+ * A cheap test of whether this turn needs the checkpoint at all. When it
106
+ * says no, the checkpoint is skipped without a trace — no lifecycle events —
107
+ * so a check that rarely applies can sit on every turn without adding to
108
+ * every conversation's log.
109
+ */
110
+ applies?(ctx: ModeContext): boolean;
104
111
  run(check: CheckpointContext, ctx: ModeContext): Promise<CheckpointResult>;
105
112
  }
@@ -1,7 +1,7 @@
1
1
  import type { ProviderEvent, ProviderMessage, TokenUsage } from '../provider.js';
2
2
  import type { ModeContext } from '../mode.js';
3
3
  import type { StopReason } from '../provider-utils.js';
4
- import { applyLazyTools } from '../tool-gating.js';
4
+ import { applyLazyTools, shouldGateTools } from '../tool-gating.js';
5
5
  import type { ProviderCallTiming } from '../events.js';
6
6
  import { providerTiming } from './provider-timing.js';
7
7
 
@@ -110,7 +110,7 @@ export async function collectProviderStream(
110
110
  opts.includeTools === false || descriptor?.supportsTools === false
111
111
  ? undefined
112
112
  : ctx.tools.list();
113
- if (ctx.lazyTools && toolList) {
113
+ if (toolList && shouldGateTools(ctx.lazyTools, toolList.length)) {
114
114
  const gated = applyLazyTools(messages, toolList, ctx.log);
115
115
  effectiveMessages = gated.messages;
116
116
  toolList = gated.tools;
@@ -151,6 +151,7 @@ export async function collectProviderStream(
151
151
  ...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
152
152
  ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
153
153
  ...(reqReasoning ? { reasoning: reqReasoning } : {}),
154
+ ...(ctx.fast && descriptor?.supportsFast ? { fast: true } : {}),
154
155
  signal: ctx.signal,
155
156
  };
156
157
  const prepared=performance.now();
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet } from './project-messages.js';
2
+ import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet, skillsWithinReach } from './project-messages.js';
3
+ import type { Skill } from '../skill.js';
3
4
  import { computeElisionState } from '../elision-state.js';
4
5
  import { asEventId, asSessionId, asTurnId } from '../ids.js';
5
6
  import type { EventLogReader } from '../log.js';
@@ -181,3 +182,73 @@ describe('projectMessagesFromLog tool_result stringify hardening', () => {
181
182
  expect(toolResultText(logWith('plain text'))).toBe('plain text');
182
183
  });
183
184
  });
185
+
186
+ describe('skillsWithinReach', () => {
187
+ const skill = (name: string, tools?: string[]): Skill => ({
188
+ id: name as Skill['id'], path: `/skills/${name}.md`, scope: 'user', body: '',
189
+ frontmatter: { name, description: name, ...(tools ? { 'allowed-tools': tools } : {}) },
190
+ });
191
+ const names = (skills: ReadonlyArray<Skill>) => skills.map((entry) => entry.frontmatter.name);
192
+ const tools = ['Bash', 'Read', 'mcp__gmail__send', 'computer_click'];
193
+
194
+ it('keeps a skill that names no tools or at least one tool of the session', () => {
195
+ const kept = skillsWithinReach([skill('prose'), skill('empty', []), skill('mixed', ['Bash', 'web-research']), skill('mail', ['mcp__gmail__*'])], tools);
196
+ expect(names(kept)).toEqual(['prose', 'empty', 'mixed', 'mail']);
197
+ });
198
+
199
+ it('drops a skill when none of the tools it is written for exists', () => {
200
+ const kept = skillsWithinReach([skill('charts', ['mcp__tradingview__tv_quote']), skill('zoho', ['mcp__zoho__*']), skill('old', ['AppleScript', 'computer'])], tools);
201
+ expect(kept).toEqual([]);
202
+ });
203
+ });
204
+
205
+ describe('projectMessagesFromLog tool pairing across a compaction boundary', () => {
206
+ const t2 = asTurnId('t2');
207
+ const call = (seq: number, callId: string): MoxxyEvent =>
208
+ event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'terminal', input: {} });
209
+ const result = (seq: number, callId: string): MoxxyEvent =>
210
+ event(seq, { type: 'tool_result', turnId: t1, source: 'tool', callId, ok: true, output: 'installed' });
211
+ const compaction = (seq: number, replacedRange: [number, number]): MoxxyEvent =>
212
+ event(seq, {
213
+ type: 'compaction',
214
+ turnId: t2,
215
+ source: 'compactor',
216
+ compactor: 'segments',
217
+ replacedRange,
218
+ summary: 'earlier work',
219
+ tokensSaved: 10,
220
+ });
221
+
222
+ const blocksOf = (events: ReadonlyArray<MoxxyEvent>) =>
223
+ projectMessagesFromLog({ log: reader(events) }).flatMap((m) => m.content);
224
+
225
+ it('drops a late tool_result whose tool call was summarized away', () => {
226
+ const blocks = blocksOf([
227
+ userPrompt(0, { text: 'install it' }),
228
+ call(1, 'late'),
229
+ userPrompt(2, { text: 'anything new?', turnId: t2 }),
230
+ result(3, 'late'),
231
+ compaction(4, [0, 1]),
232
+ ]);
233
+ expect(blocks.filter((b) => b.type === 'tool_result')).toEqual([]);
234
+ expect(blocks.filter((b) => b.type === 'tool_use')).toEqual([]);
235
+ });
236
+
237
+ it('answers a visible tool call whose result was summarized away', () => {
238
+ const messages = projectMessagesFromLog({
239
+ log: reader([
240
+ userPrompt(0, { text: 'install it' }),
241
+ call(1, 'cut'),
242
+ userPrompt(2, { text: 'anything new?', turnId: t2 }),
243
+ result(3, 'cut'),
244
+ compaction(4, [2, 3]),
245
+ ]),
246
+ });
247
+ const useIndex = messages.findIndex((m) => m.content.some((b) => b.type === 'tool_use' && b.id === 'cut'));
248
+ expect(useIndex).toBeGreaterThanOrEqual(0);
249
+ expect(messages[useIndex + 1]).toMatchObject({
250
+ role: 'tool_result',
251
+ content: [{ type: 'tool_result', toolUseId: 'cut', isError: true }],
252
+ });
253
+ });
254
+ });
@@ -1,6 +1,7 @@
1
1
  import type { ContentBlock, ProviderMessage } from '../provider.js';
2
2
  import type { ModeContext } from '../mode.js';
3
3
  import type { Skill } from '../skill.js';
4
+ import { toolPatternMatches } from '../skill-mentions.js';
4
5
  import type { MoxxyEvent, UserPromptEvent } from '../events.js';
5
6
  import {
6
7
  activeCompactionRanges,
@@ -11,8 +12,7 @@ import {
11
12
  computeElisionState,
12
13
  conversationalStub,
13
14
  conversationalStubbed,
14
- toolResultBytes,
15
- toolResultStub,
15
+ toolResultStubText,
16
16
  toolResultStubbed,
17
17
  type ElisionState,
18
18
  } from '../elision-state.js';
@@ -70,6 +70,20 @@ export const ELISION_SYSTEM_NOTE =
70
70
  'the full text before relying on any detail from an elided turn. Recent turns are always ' +
71
71
  'shown verbatim.';
72
72
 
73
+ /**
74
+ * Skills worth advertising: one that lists `allowed-tools` is written for those
75
+ * tools, so it is left out when none of them exists in the session (an MCP
76
+ * server that is not connected, a plugin that was replaced). Lists are loose in
77
+ * practice, so one tool that exists is enough; a trailing `*` matches a prefix.
78
+ */
79
+ export function skillsWithinReach(skills: ReadonlyArray<Skill>, toolNames: ReadonlyArray<string>): ReadonlyArray<Skill> {
80
+ const exists = (pattern: string) => toolNames.some((name) => toolPatternMatches(pattern, name));
81
+ return skills.filter((skill) => {
82
+ const wanted = skill.frontmatter['allowed-tools'] ?? [];
83
+ return wanted.length === 0 || wanted.some(exists);
84
+ });
85
+ }
86
+
73
87
  /**
74
88
  * Compose a model-facing system prompt that includes any base prompt
75
89
  * plus a COMPACT skill index (name + description + triggers only).
@@ -260,7 +274,13 @@ export function projectMessages(
260
274
  // (or tool_call_denied) somewhere in the log. Used to synthesize a
261
275
  // fallback `[interrupted]` tool_result for orphan tool_use blocks
262
276
  // when the assistant message gets flushed.
263
- const resolvedCallIds = resolvedCallIdSet(allEvents);
277
+ // Only VISIBLE results count: one swallowed by a compaction is never sent, so
278
+ // its still-visible tool_use needs the fallback just like a missing one.
279
+ const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
280
+ // callIds whose tool_use block is projected. A tool_result outside this set
281
+ // answers a call a compaction summarized away (the result landed after later
282
+ // turns began); providers reject a result with no matching call.
283
+ const projectedCallIds = new Set<string>();
264
284
 
265
285
  let pendingAssistant: ProviderMessage | null = null;
266
286
  let pendingAssistantMaxSeq = -1;
@@ -391,6 +411,7 @@ export function projectMessages(
391
411
  }
392
412
  }
393
413
  pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, e.seq);
414
+ projectedCallIds.add(e.callId);
394
415
  (pendingAssistant.content as Array<ProviderMessage['content'][number]>).push({
395
416
  type: 'tool_use',
396
417
  id: e.callId,
@@ -400,6 +421,7 @@ export function projectMessages(
400
421
  break;
401
422
  }
402
423
  case 'tool_result': {
424
+ if (!projectedCallIds.has(e.callId)) break;
403
425
  flush();
404
426
  // Stub bulky old tool output to a recall-able marker (decision shared
405
427
  // with estimateContextTokens via toolResultStubbed).
@@ -409,8 +431,7 @@ export function projectMessages(
409
431
  // result isn't stubbed (elided) or an error and the call succeeded.
410
432
  let image: Extract<ContentBlock, { type: 'image' }> | null = null;
411
433
  if (toolResultStubbed(e, el)) {
412
- const recalled = el.recalledCallIds.has(e.callId) || el.recalledSeqs.has(e.seq);
413
- text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
434
+ text = toolResultStubText(e, el);
414
435
  } else if (e.error) {
415
436
  text = `[error:${e.error.kind}] ${e.error.message}`;
416
437
  } else if (isToolDisplayResult(e.output)) {
@@ -16,7 +16,7 @@ import {
16
16
  import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
17
17
  import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
18
18
  import { collectProviderStream, type CollectedToolUse } from './collect-stream.js';
19
- import { buildSystemPromptWithSkills, projectMessages } from './project-messages.js';
19
+ import { buildSystemPromptWithSkills, projectMessages, skillsWithinReach } from './project-messages.js';
20
20
  import { createStuckLoopDetector } from './stuck-loop.js';
21
21
 
22
22
  /**
@@ -269,7 +269,9 @@ export async function* runReactLoop(
269
269
  // conservative capability fallback.
270
270
  const projectionStarted=performance.now();
271
271
  const descriptor = ctx.provider.models.find((candidate) => candidate.id === ctx.model);
272
- const availableSkills = descriptor?.supportsTools === false ? [] : ctx.skills.list();
272
+ const availableSkills = descriptor?.supportsTools === false
273
+ ? []
274
+ : skillsWithinReach(ctx.skills.list(), ctx.tools.list().map((tool) => tool.name));
273
275
  const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, availableSkills);
274
276
  const { messages, stablePrefixIndex } = projectMessages(ctx, {
275
277
  ...(systemPrompt ? { systemPrompt } : {}),
@@ -518,7 +520,8 @@ async function* runCheckpointGate(
518
520
  // only face the idle-tolerant ones — reviewing a half-sentence as if it
519
521
  // were a completion claim wastes a checker run and confuses the model.
520
522
  const eligible = checkpoints.filter(
521
- (cp) => round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle',
523
+ (cp) =>
524
+ (round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
522
525
  );
523
526
  if (eligible.length === 0) return { kind: 'end' };
524
527
 
@@ -30,8 +30,12 @@ export interface StuckSignal {
30
30
  export interface StuckLoopDetector {
31
31
  readonly windowSize: number;
32
32
  readonly repeatThreshold: number;
33
- /** Record the call and report whether the loop guard should trip. */
34
- record(toolName: string, input: unknown): StuckSignal;
33
+ /**
34
+ * Record the call and report whether the loop guard should trip. A
35
+ * `liveState` tool (see `ToolDef.liveState`) is expected to repeat between
36
+ * other calls, so only a back-to-back run of the same call counts for it.
37
+ */
38
+ record(toolName: string, input: unknown, opts?: { readonly liveState?: boolean }): StuckSignal;
35
39
  /**
36
40
  * Clear both sliding windows. Used by the nudge (steer-don't-stop) stuck
37
41
  * policy: after a trip is surfaced to the model, the detector starts a fresh
@@ -86,13 +90,18 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
86
90
  const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
87
91
  const recent: string[] = [];
88
92
  const recentNear: string[] = [];
93
+ let last: string | null = null;
94
+ let streak = 0;
89
95
  return {
90
96
  windowSize,
91
97
  repeatThreshold,
92
- record(toolName, input): StuckSignal {
98
+ record(toolName, input, opts): StuckSignal {
93
99
  // Disabled → never trip (rely on the maxIterations cap alone).
94
100
  if (!enabled) return { stuck: false, count: 0, kind: 'exact' };
95
101
  const key = `${toolName}|${stableHash(input)}`;
102
+ streak = key === last ? streak + 1 : 1;
103
+ last = key;
104
+ if (opts?.liveState) return { stuck: streak >= repeatThreshold, count: streak, kind: 'exact' };
96
105
  recent.push(key);
97
106
  if (recent.length > windowSize) recent.shift();
98
107
  const exactCount = recent.filter((k) => k === key).length;
@@ -112,6 +121,8 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
112
121
  reset(): void {
113
122
  recent.length = 0;
114
123
  recentNear.length = 0;
124
+ last = null;
125
+ streak = 0;
115
126
  },
116
127
  };
117
128
  }
@@ -16,6 +16,7 @@
16
16
  export {
17
17
  ELISION_SYSTEM_NOTE,
18
18
  buildSystemPromptWithSkills,
19
+ skillsWithinReach,
19
20
  projectMessagesFromLog,
20
21
  projectMessages,
21
22
  type ProjectMessagesOptions,
package/src/mode.ts CHANGED
@@ -6,7 +6,7 @@ import type { ServiceRegistry } from './services.js';
6
6
  import type { SessionId, TurnId } from './ids.js';
7
7
  import type { EventLogReader } from './log.js';
8
8
  import type { PermissionResolver } from './permission.js';
9
- import type { LLMProvider } from './provider.js';
9
+ import type { LLMProvider, ReasoningEffort } from './provider.js';
10
10
  import type { LoopGuardSettings } from './mode/stuck-loop.js';
11
11
  import type { Skill } from './skill.js';
12
12
  import type { SubagentSpawner } from './subagent.js';
@@ -84,6 +84,8 @@ export interface ModeContext {
84
84
  readonly cwd: string;
85
85
  readonly env: Readonly<Record<string, string | undefined>>;
86
86
  readonly model: string;
87
+ /** User-supplied context-window estimate for a custom model, if known. */
88
+ readonly contextWindowOverride?: number;
87
89
  readonly systemPrompt?: string;
88
90
  readonly provider: LLMProvider;
89
91
  readonly tools: ToolRegistry;
@@ -94,7 +96,10 @@ export interface ModeContext {
94
96
  readonly cacheStrategy: CacheStrategyDef | null;
95
97
  /** Elision (context-on-demand) settings; undefined → defaults apply. */
96
98
  readonly elision?: ElisionSettings;
97
- /** When true, send only always-on + loaded tool schemas; index the rest. */
99
+ /**
100
+ * Send only always-on + loaded tool schemas and index the rest. Unset means
101
+ * automatic: on once the registry holds more than `LAZY_TOOLS_AUTO_THRESHOLD` tools.
102
+ */
98
103
  readonly lazyTools?: boolean;
99
104
  /**
100
105
  * Per-provider reasoning/thinking preference, resolved from the active
@@ -102,7 +107,9 @@ export interface ModeContext {
102
107
  * `ProviderRequest.reasoning` by {@link collectProviderStream}, gated on the
103
108
  * active model's `supportsReasoning`. Absent/false → reasoning off.
104
109
  */
105
- readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
110
+ readonly reasoning?: { readonly effort?: ReasoningEffort } | boolean;
111
+ /** Ask for the provider's faster tier; forwarded only to a model with `supportsFast`. */
112
+ readonly fast?: boolean;
106
113
  readonly permissions: PermissionResolver;
107
114
  /**
108
115
  * Optional generic "ask the user a question" gate. Any loop strategy can
package/src/permission.ts CHANGED
@@ -7,6 +7,12 @@ export interface PermissionDecision {
7
7
  readonly reason?: string;
8
8
  /** Narrow an `allow_session` grant to calls with matching input values. */
9
9
  readonly sessionScope?: { readonly inputKeys: ReadonlyArray<string> };
10
+ /**
11
+ * This call was decided now: a prompt answered for it, or the run's own
12
+ * allow-list or auto-approve. Left unset by a standing rule and by an earlier
13
+ * answer reused, so a tool can tell consent to this call from a blanket one.
14
+ */
15
+ readonly decidedNow?: boolean;
10
16
  }
11
17
 
12
18
  export interface PermissionRule {
@@ -1,12 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import {
3
- chmodSync,
4
- mkdtempSync,
5
- mkdirSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- } from 'node:fs';
2
+ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
3
  import { tmpdir } from 'node:os';
11
4
  import * as path from 'node:path';
12
5
 
@@ -19,6 +12,7 @@ import {
19
12
  resolveExecutableTarget,
20
13
  spawnExecutableTarget,
21
14
  } from './process-invocation.js';
15
+ import { removeDirSync } from '@moxxy/vitest-preset/fs';
22
16
 
23
17
  let root: string;
24
18
 
@@ -27,7 +21,7 @@ beforeEach(() => {
27
21
  });
28
22
 
29
23
  afterEach(() => {
30
- rmSync(root, { recursive: true, force: true });
24
+ removeDirSync(root);
31
25
  });
32
26
 
33
27
  function put(relative: string, body = ''): string {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asEventId, asSessionId, asTurnId } from './ids.js';
3
+ import type { MoxxyEvent } from './events.js';
4
+ import { progressOf, unfinishedWork } from './progress.js';
5
+
6
+ /**
7
+ * What a tool last saw of something may be work that has not finished yet: a
8
+ * page still busy, a job still running. In the Coolify task the agent pressed
9
+ * Deploy and reported the address as down from a check made before it — the
10
+ * service came up minutes later. A result that names what it looked at
11
+ * (`progress.key`) and says whether that was still under way
12
+ * (`progress.pending`) lets the loop tell finished from not-yet.
13
+ */
14
+ const sid = asSessionId('s1');
15
+ const t1 = asTurnId('t1');
16
+
17
+ function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
18
+ return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
19
+ }
20
+
21
+ function look(seq: number, callId: string, output: unknown, ok = true): MoxxyEvent[] {
22
+ return [
23
+ event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'look', input: {} }),
24
+ event(seq + 1, { type: 'tool_result', turnId: t1, source: 'tool', callId, ok, output }),
25
+ ];
26
+ }
27
+
28
+ describe('progressOf', () => {
29
+ it('reads what a result says it looked at and whether that was still under way', () => {
30
+ expect(progressOf({ text: 'x', progress: { key: 'browser:t1', pending: 'uploading' } })).toEqual({
31
+ key: 'browser:t1',
32
+ pending: 'uploading',
33
+ });
34
+ expect(progressOf({ progress: { key: 'browser:t1', pending: null } })).toEqual({ key: 'browser:t1', pending: null });
35
+ });
36
+
37
+ it('is null for a result that says nothing about it', () => {
38
+ expect(progressOf('plain text')).toBeNull();
39
+ expect(progressOf({ text: 'x' })).toBeNull();
40
+ expect(progressOf({ progress: { pending: 'no key' } })).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe('unfinishedWork', () => {
45
+ it('names what was still under way the last time it was looked at', () => {
46
+ const events = [
47
+ ...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'progressbar "Deploying"' } }),
48
+ ...look(2, 'c2', { text: 'something else entirely' }),
49
+ ];
50
+
51
+ expect(unfinishedWork(events)).toEqual(['progressbar "Deploying"']);
52
+ });
53
+
54
+ it('lets a later look that found it finished settle it', () => {
55
+ const events = [
56
+ ...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
57
+ ...look(2, 'c2', { progress: { key: 'browser:t1', pending: null } }),
58
+ ];
59
+
60
+ expect(unfinishedWork(events)).toEqual([]);
61
+ });
62
+
63
+ it('keeps one thing still running while another finished', () => {
64
+ const events = [
65
+ ...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
66
+ ...look(2, 'c2', { progress: { key: 'browser:t2', pending: 'uploading' } }),
67
+ ...look(4, 'c3', { progress: { key: 'browser:t1', pending: null } }),
68
+ ];
69
+
70
+ expect(unfinishedWork(events)).toEqual(['uploading']);
71
+ });
72
+
73
+ it('does not count a failed call as a look', () => {
74
+ const events = [
75
+ ...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
76
+ ...look(2, 'c2', { progress: { key: 'browser:t1', pending: null } }, false),
77
+ ];
78
+
79
+ expect(unfinishedWork(events)).toEqual(['busy']);
80
+ });
81
+ });
@@ -0,0 +1,73 @@
1
+ import type { MoxxyEvent } from './events.js';
2
+ import type { ModeContext } from './mode.js';
3
+ import type { TurnCheckpoint } from './mode/checkpoint.js';
4
+
5
+ /**
6
+ * What a tool result says about the thing it looked at: `key` names it (a
7
+ * browser tab, a job), `pending` says what was still under way there, or null
8
+ * when nothing was. A result that knows nothing about it leaves `progress` out.
9
+ */
10
+ export interface Progress {
11
+ readonly key: string;
12
+ readonly pending: string | null;
13
+ }
14
+
15
+ export function progressOf(output: unknown): Progress | null {
16
+ if (typeof output !== 'object' || output === null) return null;
17
+ const progress = (output as { progress?: unknown }).progress;
18
+ if (typeof progress !== 'object' || progress === null) return null;
19
+ const { key, pending } = progress as { key?: unknown; pending?: unknown };
20
+ if (typeof key !== 'string') return null;
21
+ return { key, pending: typeof pending === 'string' ? pending : null };
22
+ }
23
+
24
+ /** What was still under way the last time each thing was looked at, in `events`. */
25
+ export function unfinishedWork(events: ReadonlyArray<MoxxyEvent>): string[] {
26
+ const latest = new Map<string, string | null>();
27
+ for (const e of events) {
28
+ if (e.type !== 'tool_result' || !e.ok) continue;
29
+ const progress = progressOf(e.output);
30
+ if (progress) latest.set(progress.key, progress.pending);
31
+ }
32
+ return [...latest.values()].filter((pending): pending is string => pending !== null);
33
+ }
34
+
35
+ export function unfinishedWorkNudge(pending: ReadonlyArray<string>): string {
36
+ return (
37
+ `Before you report: the last time you looked, this was still in progress — ${pending.join('; ')}. ` +
38
+ 'What it leads to is not known yet, and anything you checked before it may no longer hold. ' +
39
+ 'Wait for it to finish (with a tool’s own wait, if it has one), look again, and report what you see then. ' +
40
+ 'If it does not finish, say plainly that it has not.'
41
+ );
42
+ }
43
+
44
+ const turnOf = (ctx: ModeContext): string => `${ctx.sessionId}/${ctx.turnId}`;
45
+
46
+ /** Turns already asked, so one turn is asked at most once. Bounded: oldest go first. */
47
+ const MAX_REMEMBERED_TURNS = 256;
48
+
49
+ /**
50
+ * At the end of a turn, one reminder when the last look at something found
51
+ * work still under way. The report is then based on a state that has not
52
+ * settled — the agent pressed Deploy and reported the address from a check
53
+ * made before it. Asked once per turn: the agent may well have good reason to
54
+ * report anyway, and it is told how to say so.
55
+ */
56
+ export function unfinishedWorkCheckpoint(): TurnCheckpoint {
57
+ const asked = new Set<string>();
58
+ return {
59
+ name: 'unfinished-work',
60
+ applies: (ctx: ModeContext) =>
61
+ !asked.has(turnOf(ctx)) && unfinishedWork(ctx.log.byTurn(ctx.turnId)).length > 0,
62
+ async run(_check, ctx: ModeContext) {
63
+ const pending = unfinishedWork(ctx.log.byTurn(ctx.turnId));
64
+ if (pending.length === 0) return { action: 'pass' };
65
+ asked.add(turnOf(ctx));
66
+ for (const oldest of asked) {
67
+ if (asked.size <= MAX_REMEMBERED_TURNS) break;
68
+ asked.delete(oldest);
69
+ }
70
+ return { action: 'inject', text: unfinishedWorkNudge(pending), volatile: true };
71
+ },
72
+ };
73
+ }
package/src/provider.ts CHANGED
@@ -53,6 +53,9 @@ export interface CacheHint {
53
53
  readonly target: 'tools' | 'system' | { readonly messageIndex: number };
54
54
  }
55
55
 
56
+ /** How deeply a reasoning model should think; `xhigh` is the deepest. */
57
+ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
58
+
56
59
  export interface ProviderRequest {
57
60
  readonly model: string;
58
61
  /**
@@ -81,7 +84,13 @@ export interface ProviderRequest {
81
84
  * each provider's native knob (Anthropic thinking budget, OpenAI/Codex
82
85
  * `reasoning.effort`).
83
86
  */
84
- readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
87
+ readonly reasoning?: { readonly effort?: ReasoningEffort } | boolean;
88
+ /**
89
+ * Serve this request on the provider's faster, pricier tier (OpenAI
90
+ * `service_tier: "priority"`). Set by the loop only for a model whose
91
+ * descriptor has `supportsFast`.
92
+ */
93
+ readonly fast?: boolean;
85
94
  }
86
95
 
87
96
  export type ProviderEvent =
@@ -148,6 +157,8 @@ export interface ModelDescriptor {
148
157
  * for this model. When false, `ProviderRequest.reasoning` is ignored.
149
158
  */
150
159
  readonly supportsReasoning?: boolean;
160
+ /** Whether the provider can serve this model on a faster tier (see `ProviderRequest.fast`). */
161
+ readonly supportsFast?: boolean;
151
162
  /**
152
163
  * Tools executed by the provider on behalf of this model (for example
153
164
  * OpenAI Responses or Anthropic server tools). A matching client ToolDef