@moxxy/sdk 0.44.0 → 0.46.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 (246) hide show
  1. package/dist/agent-account-limit.d.ts +41 -0
  2. package/dist/agent-account-limit.d.ts.map +1 -0
  3. package/dist/agent-account-limit.js +64 -0
  4. package/dist/agent-account-limit.js.map +1 -0
  5. package/dist/agent-computer-use.d.ts +10 -0
  6. package/dist/agent-computer-use.d.ts.map +1 -0
  7. package/dist/agent-computer-use.js +24 -0
  8. package/dist/agent-computer-use.js.map +1 -0
  9. package/dist/agent-context-window.d.ts +22 -0
  10. package/dist/agent-context-window.d.ts.map +1 -0
  11. package/dist/agent-context-window.js +40 -0
  12. package/dist/agent-context-window.js.map +1 -0
  13. package/dist/agent-no-reply.d.ts +11 -0
  14. package/dist/agent-no-reply.d.ts.map +1 -0
  15. package/dist/agent-no-reply.js +13 -0
  16. package/dist/agent-no-reply.js.map +1 -0
  17. package/dist/agent-step.d.ts +14 -0
  18. package/dist/agent-step.d.ts.map +1 -0
  19. package/dist/agent-step.js +26 -0
  20. package/dist/agent-step.js.map +1 -0
  21. package/dist/agent-work.d.ts +34 -0
  22. package/dist/agent-work.d.ts.map +1 -0
  23. package/dist/agent-work.js +80 -0
  24. package/dist/agent-work.js.map +1 -0
  25. package/dist/attachment-payload.d.ts +1 -1
  26. package/dist/attachment-payload.js +1 -1
  27. package/dist/auto-approve.d.ts +9 -0
  28. package/dist/auto-approve.d.ts.map +1 -1
  29. package/dist/auto-approve.js +18 -0
  30. package/dist/auto-approve.js.map +1 -1
  31. package/dist/channel-auth.d.ts +3 -3
  32. package/dist/channel-auth.d.ts.map +1 -1
  33. package/dist/channel-auth.js +1 -1
  34. package/dist/channel.d.ts +1 -1
  35. package/dist/client-session.d.ts +9 -5
  36. package/dist/client-session.d.ts.map +1 -1
  37. package/dist/computer-control.d.ts +11 -11
  38. package/dist/computer-control.d.ts.map +1 -1
  39. package/dist/computer-control.js +1 -1
  40. package/dist/computer-control.js.map +1 -1
  41. package/dist/conversation-history.d.ts +50 -0
  42. package/dist/conversation-history.d.ts.map +1 -0
  43. package/dist/conversation-history.js +227 -0
  44. package/dist/conversation-history.js.map +1 -0
  45. package/dist/cron.d.ts +58 -0
  46. package/dist/cron.d.ts.map +1 -0
  47. package/dist/cron.js +320 -0
  48. package/dist/cron.js.map +1 -0
  49. package/dist/define.d.ts +2 -1
  50. package/dist/define.d.ts.map +1 -1
  51. package/dist/define.js +1 -0
  52. package/dist/define.js.map +1 -1
  53. package/dist/event-store.d.ts +1 -1
  54. package/dist/event-store.d.ts.map +1 -1
  55. package/dist/event-store.js +1 -0
  56. package/dist/event-store.js.map +1 -1
  57. package/dist/events.d.ts +17 -1
  58. package/dist/events.d.ts.map +1 -1
  59. package/dist/file-diff.d.ts +31 -0
  60. package/dist/file-diff.d.ts.map +1 -0
  61. package/dist/file-diff.js +196 -0
  62. package/dist/file-diff.js.map +1 -0
  63. package/dist/index.d.ts +22 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +16 -4
  66. package/dist/index.js.map +1 -1
  67. package/dist/mode/agent-step-loop.d.ts +13 -0
  68. package/dist/mode/agent-step-loop.d.ts.map +1 -0
  69. package/dist/mode/agent-step-loop.js +578 -0
  70. package/dist/mode/agent-step-loop.js.map +1 -0
  71. package/dist/mode/checkpoint-gate.d.ts +25 -0
  72. package/dist/mode/checkpoint-gate.d.ts.map +1 -0
  73. package/dist/mode/checkpoint-gate.js +150 -0
  74. package/dist/mode/checkpoint-gate.js.map +1 -0
  75. package/dist/mode/checkpoint.d.ts +6 -0
  76. package/dist/mode/checkpoint.d.ts.map +1 -1
  77. package/dist/mode/collect-stream.d.ts +2 -0
  78. package/dist/mode/collect-stream.d.ts.map +1 -1
  79. package/dist/mode/collect-stream.js +1 -1
  80. package/dist/mode/collect-stream.js.map +1 -1
  81. package/dist/mode/project-messages.d.ts.map +1 -1
  82. package/dist/mode/project-messages.js +9 -67
  83. package/dist/mode/project-messages.js.map +1 -1
  84. package/dist/mode/react-loop-options.d.ts +113 -0
  85. package/dist/mode/react-loop-options.d.ts.map +1 -0
  86. package/dist/mode/react-loop-options.js +2 -0
  87. package/dist/mode/react-loop-options.js.map +1 -0
  88. package/dist/mode/react-loop.d.ts +2 -99
  89. package/dist/mode/react-loop.d.ts.map +1 -1
  90. package/dist/mode/react-loop.js +7 -145
  91. package/dist/mode/react-loop.js.map +1 -1
  92. package/dist/mode.d.ts +19 -7
  93. package/dist/mode.d.ts.map +1 -1
  94. package/dist/mode.js +1 -1
  95. package/dist/mode.js.map +1 -1
  96. package/dist/npm-registry.d.ts +22 -0
  97. package/dist/npm-registry.d.ts.map +1 -0
  98. package/dist/npm-registry.js +54 -0
  99. package/dist/npm-registry.js.map +1 -0
  100. package/dist/permission.d.ts +31 -0
  101. package/dist/permission.d.ts.map +1 -1
  102. package/dist/process-invocation.d.ts +2 -0
  103. package/dist/process-invocation.d.ts.map +1 -1
  104. package/dist/process-invocation.js +2 -2
  105. package/dist/process-invocation.js.map +1 -1
  106. package/dist/process-tree.d.ts +9 -0
  107. package/dist/process-tree.d.ts.map +1 -0
  108. package/dist/process-tree.js +40 -0
  109. package/dist/process-tree.js.map +1 -0
  110. package/dist/provider-login-bridge.d.ts +1 -2
  111. package/dist/provider-login-bridge.d.ts.map +1 -1
  112. package/dist/provider-login-bridge.js +2 -3
  113. package/dist/provider-login-bridge.js.map +1 -1
  114. package/dist/provider.d.ts +409 -1
  115. package/dist/provider.d.ts.map +1 -1
  116. package/dist/question-draft.d.ts +19 -0
  117. package/dist/question-draft.d.ts.map +1 -0
  118. package/dist/question-draft.js +30 -0
  119. package/dist/question-draft.js.map +1 -0
  120. package/dist/question.d.ts +204 -0
  121. package/dist/question.d.ts.map +1 -0
  122. package/dist/question.js +141 -0
  123. package/dist/question.js.map +1 -0
  124. package/dist/questions.d.ts +7 -0
  125. package/dist/questions.d.ts.map +1 -0
  126. package/dist/questions.js +7 -0
  127. package/dist/questions.js.map +1 -0
  128. package/dist/resolvers.d.ts +14 -1
  129. package/dist/resolvers.d.ts.map +1 -1
  130. package/dist/resolvers.js +33 -21
  131. package/dist/resolvers.js.map +1 -1
  132. package/dist/schemas.d.ts +53 -53
  133. package/dist/schemas.d.ts.map +1 -1
  134. package/dist/schemas.js +3 -2
  135. package/dist/schemas.js.map +1 -1
  136. package/dist/server.d.ts +2 -0
  137. package/dist/server.d.ts.map +1 -1
  138. package/dist/server.js +3 -0
  139. package/dist/server.js.map +1 -1
  140. package/dist/session-like.d.ts +19 -1
  141. package/dist/session-like.d.ts.map +1 -1
  142. package/dist/starting-model.d.ts +12 -0
  143. package/dist/starting-model.d.ts.map +1 -0
  144. package/dist/starting-model.js +9 -0
  145. package/dist/starting-model.js.map +1 -0
  146. package/dist/tool-dispatch.d.ts.map +1 -1
  147. package/dist/tool-dispatch.js +10 -4
  148. package/dist/tool-dispatch.js.map +1 -1
  149. package/dist/tool-display.d.ts +79 -3
  150. package/dist/tool-display.d.ts.map +1 -1
  151. package/dist/tool-display.js +117 -3
  152. package/dist/tool-display.js.map +1 -1
  153. package/dist/tool-modes.d.ts +1 -1
  154. package/dist/tool-modes.d.ts.map +1 -1
  155. package/dist/tool-modes.js +8 -2
  156. package/dist/tool-modes.js.map +1 -1
  157. package/dist/tool-output.d.ts +15 -0
  158. package/dist/tool-output.d.ts.map +1 -0
  159. package/dist/tool-output.js +39 -0
  160. package/dist/tool-output.js.map +1 -0
  161. package/dist/tool.d.ts +14 -1
  162. package/dist/tool.d.ts.map +1 -1
  163. package/dist/tunnel.d.ts +3 -3
  164. package/dist/tunnel.d.ts.map +1 -1
  165. package/dist/tunnel.js +2 -2
  166. package/dist/workflow-approval.d.ts +9 -9
  167. package/dist/workflow-approval.d.ts.map +1 -1
  168. package/dist/workflow-approval.js +1 -1
  169. package/dist/workflow-approval.js.map +1 -1
  170. package/package.json +37 -5
  171. package/src/agent-account-limit.test.ts +87 -0
  172. package/src/agent-account-limit.ts +89 -0
  173. package/src/agent-computer-use.test.ts +26 -0
  174. package/src/agent-computer-use.ts +29 -0
  175. package/src/agent-context-window.ts +51 -0
  176. package/src/agent-no-reply.ts +16 -0
  177. package/src/agent-step.ts +26 -0
  178. package/src/agent-work.ts +102 -0
  179. package/src/attachment-payload.ts +1 -1
  180. package/src/auto-approve.test.ts +13 -1
  181. package/src/auto-approve.ts +19 -0
  182. package/src/channel-auth.ts +3 -3
  183. package/src/channel.ts +1 -1
  184. package/src/client-session.ts +9 -2
  185. package/src/computer-control.ts +1 -1
  186. package/src/conversation-history.test.ts +184 -0
  187. package/src/conversation-history.ts +272 -0
  188. package/src/cron.test.ts +280 -0
  189. package/src/cron.ts +355 -0
  190. package/src/define.test.ts +1 -1
  191. package/src/define.ts +3 -1
  192. package/src/event-store.ts +2 -1
  193. package/src/events.ts +17 -1
  194. package/src/file-diff.test.ts +103 -0
  195. package/src/file-diff.ts +208 -0
  196. package/src/index.ts +121 -1
  197. package/src/json-file-store.test.ts +1 -1
  198. package/src/loop-helpers.test.ts +15 -0
  199. package/src/mode/agent-step-loop.ts +645 -0
  200. package/src/mode/checkpoint-gate.ts +198 -0
  201. package/src/mode/checkpoint.ts +6 -0
  202. package/src/mode/collect-stream.ts +1 -1
  203. package/src/mode/project-messages.test.ts +11 -0
  204. package/src/mode/project-messages.ts +8 -61
  205. package/src/mode/react-loop-options.ts +124 -0
  206. package/src/mode/react-loop.ts +10 -296
  207. package/src/mode.ts +19 -7
  208. package/src/npm-registry.test.ts +95 -0
  209. package/src/npm-registry.ts +57 -0
  210. package/src/package-root.test.ts +2 -2
  211. package/src/permission.ts +30 -0
  212. package/src/process-invocation.test.ts +25 -0
  213. package/src/process-invocation.ts +8 -3
  214. package/src/process-tree.ts +37 -0
  215. package/src/provider-login-bridge.ts +2 -3
  216. package/src/provider-schema-constraints.test.ts +1 -1
  217. package/src/provider-utils.test.ts +1 -1
  218. package/src/provider.ts +347 -1
  219. package/src/question-draft.test.ts +57 -0
  220. package/src/question-draft.ts +47 -0
  221. package/src/question.test.ts +130 -0
  222. package/src/question.ts +229 -0
  223. package/src/questions.ts +6 -0
  224. package/src/redact-subpath.test.ts +10 -0
  225. package/src/resolvers.test.ts +84 -1
  226. package/src/resolvers.ts +40 -27
  227. package/src/schemas.test.ts +9 -4
  228. package/src/schemas.ts +3 -2
  229. package/src/server.ts +3 -0
  230. package/src/session-like.ts +19 -1
  231. package/src/skill-mentions.test.ts +1 -1
  232. package/src/starting-model.test.ts +19 -0
  233. package/src/starting-model.ts +11 -0
  234. package/src/token-efficiency.test.ts +1 -1
  235. package/src/tool-dispatch.ts +10 -4
  236. package/src/tool-display.test.ts +119 -0
  237. package/src/tool-display.ts +162 -4
  238. package/src/tool-gating.test.ts +1 -1
  239. package/src/tool-icon.test.ts +1 -1
  240. package/src/tool-modes.test.ts +18 -1
  241. package/src/tool-modes.ts +6 -2
  242. package/src/tool-output.test.ts +46 -0
  243. package/src/tool-output.ts +46 -0
  244. package/src/tool.ts +14 -1
  245. package/src/tunnel.ts +3 -3
  246. package/src/workflow-approval.ts +1 -1
@@ -1,10 +1,7 @@
1
1
  import { isContextOverflowError, runCompactionIfNeeded } from '../compactor-helpers.js';
2
2
  import { runElisionIfNeeded } from '../elision-helpers.js';
3
3
  import type { MoxxyEvent } from '../events.js';
4
- import { asPluginId } from '../ids.js';
5
4
  import type { ModeContext } from '../mode.js';
6
- import type { TokenUsage } from '../provider.js';
7
- import type { StopReason } from '../provider-utils.js';
8
5
  import { usageEventFields } from '../token-accounting.js';
9
6
  import {
10
7
  emitRequestsAndDetectStuck,
@@ -14,8 +11,9 @@ import {
14
11
  type StuckTripInfo,
15
12
  } from '../tool-dispatch.js';
16
13
  import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
17
- import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
18
- import { collectProviderStream, type CollectedToolUse } from './collect-stream.js';
14
+ import { emitAbort, emitError, runCheckpointGate } from './checkpoint-gate.js';
15
+ import { runAgentStepLoop } from './agent-step-loop.js';
16
+ import { collectProviderStream } from './collect-stream.js';
19
17
  import { buildSystemPromptWithSkills, projectMessages, skillsWithinReach } from './project-messages.js';
20
18
  import { createStuckLoopDetector } from './stuck-loop.js';
21
19
 
@@ -44,8 +42,6 @@ const DEFAULT_MAX_ITERATIONS = 500;
44
42
  const MAX_REACTIVE_COMPACTIONS = 2;
45
43
  const DEFAULT_MAX_INJECTIONS = 3;
46
44
  const DEFAULT_MAX_INJECT_CHARS = 16_384;
47
- const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
48
- const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
49
45
 
50
46
  // Abort-aware sleep, injectable for tests so back-off paths run instantly and
51
47
  // deterministically. Production delegates to sleepWithAbort: a real timer that
@@ -70,115 +66,18 @@ export function __setRetrySleepForTests(
70
66
  };
71
67
  }
72
68
 
73
- /** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
74
- export interface ProviderSuccessInfo {
75
- readonly text: string;
76
- readonly stopReason: StopReason;
77
- readonly toolUses: ReadonlyArray<CollectedToolUse>;
78
- readonly usage?: TokenUsage;
79
- readonly iteration: number;
80
- }
81
-
82
- /** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
83
- export interface ToolBatchInfo {
84
- readonly toolUses: ReadonlyArray<CollectedToolUse>;
85
- readonly iteration: number;
86
- }
87
-
88
- /** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
89
- export interface StopDirective {
90
- readonly action: 'stop';
91
- }
92
-
93
- export interface ReactLoopOptions {
94
- /** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
95
- readonly strategyName: string;
96
- /**
97
- * Iteration cap when the context doesn't supply one. Default 500. May be
98
- * `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
99
- * ends only via a terminal signal (checkpoint/hook stop, abort, fatal
100
- * error) — an explicit `ctx.maxIterations` still takes precedence.
101
- */
102
- readonly defaultMaxIterations?: number;
103
- /** Prefix for provider-error messages (goal mode uses `'goal: '`). */
104
- readonly errorPrefix?: string;
105
- /**
106
- * When set, an already-aborted signal is reported (with this reason) BEFORE
107
- * the first iteration — goal/collab preflight. When omitted the first
108
- * iteration's own abort check covers it with the generic reason.
109
- */
110
- readonly preflightAbortReason?: string;
111
- /** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
112
- readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
113
- /**
114
- * Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
115
- * consecutive gate rounds with no tool work between them; the count resets
116
- * whenever a tool batch executes (default 3). When exhausted the turn ends
117
- * with the model's answer as-is plus a visible warning — a
118
- * permanently-failing gate degrades loudly instead of looping forever.
119
- */
120
- readonly maxInjections?: number;
121
- /** Clamp on injected feedback length in characters (default 16_384). */
122
- readonly maxInjectChars?: number;
123
- /**
124
- * Stuck-loop policy + wording overrides; sensible defaults from
125
- * `strategyName`. `action` picks what a detector trip does:
126
- *
127
- * - `'abort'` (default): fail the batch and end the turn with a fatal
128
- * error — the historical behavior, right for attended modes where the
129
- * user is present to redirect.
130
- * - `'nudge'`: never stop. The batch still executes (repeated calls are
131
- * usually legitimate work — re-running a failing build between edits),
132
- * a visible warning + `extraOnStuck` events are emitted, the detector
133
- * resets, and `nudgeText` (or a default) rides the next provider call
134
- * as a volatile steer. For unattended modes (goal) where a heuristic
135
- * must never kill the run.
136
- */
137
- readonly stuck?: Partial<StuckLoopReport> & {
138
- readonly action?: 'abort' | 'nudge';
139
- /** Volatile steer for `'nudge'` trips; default wording when omitted. */
140
- readonly nudgeText?: (info: StuckTripInfo) => string;
141
- };
142
- /**
143
- * Runs after compaction/elision, before the provider call. May return a
144
- * volatile user message to ride ONLY the next call (collab's inbox/pause
145
- * awareness) — never appended to the log; the cache strategy is told via
146
- * `volatileTailCount` so its tail breakpoint stays ahead of it.
147
- */
148
- readonly onIterationStart?: (
149
- ctx: ModeContext,
150
- iteration: number,
151
- ) => Promise<{ readonly volatileUserText?: string } | undefined>;
152
- /**
153
- * Runs after every clean provider call (reasoning already logged, tools not
154
- * yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
155
- * its own wrap-up events first (goal mode's token-budget backstop).
156
- */
157
- readonly onProviderSuccess?: (
158
- ctx: ModeContext,
159
- info: ProviderSuccessInfo,
160
- ) => Promise<StopDirective | undefined>;
161
- /**
162
- * Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
163
- * end the turn (goal/collab terminal-tool detection: `goal_complete`,
164
- * `collab_done`); the hook emits its own completion events first.
165
- */
166
- readonly onToolBatchEnd?: (
167
- ctx: ModeContext,
168
- info: ToolBatchInfo,
169
- ) => Promise<StopDirective | undefined>;
170
- /**
171
- * Replaces the default "exceeded maxIterations" fatal error — the hook
172
- * emits its own cap-reached events (goal mode adds a plugin event and its
173
- * own wording). The loop returns right after.
174
- */
175
- readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
176
- }
69
+ export type { ProviderSuccessInfo, ReactLoopOptions, StopDirective, ToolBatchInfo } from './react-loop-options.js';
70
+ import type { ReactLoopOptions } from './react-loop-options.js';
177
71
 
178
72
  export async function* runReactLoop(
179
73
  ctx: ModeContext,
180
74
  opts: ReactLoopOptions,
181
75
  ): AsyncIterable<MoxxyEvent> {
76
+ // An agent connection: the vendor's agent does each round, not this loop.
77
+ if (ctx.provider.agentStep) {
78
+ yield* runAgentStepLoop(ctx, opts);
79
+ return;
80
+ }
182
81
  if (opts.preflightAbortReason && ctx.signal.aborted) {
183
82
  yield await emitAbort(ctx, opts.preflightAbortReason);
184
83
  return;
@@ -499,145 +398,6 @@ export async function* runReactLoop(
499
398
  );
500
399
  }
501
400
 
502
- /** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
503
- type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
504
-
505
- interface GateRound {
506
- readonly candidateText: string;
507
- readonly stopReason: StopReason;
508
- readonly iteration: number;
509
- readonly consecutiveIdle: number;
510
- readonly injectionsUsed: number;
511
- readonly injectionBudget: number;
512
- readonly maxInjectChars: number;
513
- }
514
-
515
- async function* runCheckpointGate(
516
- ctx: ModeContext,
517
- checkpoints: ReadonlyArray<TurnCheckpoint>,
518
- round: GateRound,
519
- ): AsyncGenerator<MoxxyEvent, GateVerdict> {
520
- // Natural completions face every checkpoint; truncated/errored candidates
521
- // only face the idle-tolerant ones — reviewing a half-sentence as if it
522
- // were a completion claim wastes a checker run and confuses the model.
523
- const eligible = checkpoints.filter(
524
- (cp) =>
525
- (round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
526
- );
527
- if (eligible.length === 0) return { kind: 'end' };
528
-
529
- // Budget exhausted → the answer ships as-is, but LOUDLY: silent
530
- // degradation would let the user believe a gated answer passed its gates.
531
- if (round.injectionsUsed >= round.injectionBudget) {
532
- yield await emitError(
533
- ctx,
534
- 'retryable',
535
- `checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
536
- `ending the turn with unresolved checkpoint feedback; verify the result manually.`,
537
- );
538
- return { kind: 'end' };
539
- }
540
-
541
- for (const cp of eligible) {
542
- // User cancelled mid-gate: end the turn WITHOUT retracting the
543
- // already-logged answer — an abort must never un-say what was said.
544
- if (ctx.signal.aborted) return { kind: 'end' };
545
-
546
- yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
547
- name: cp.name,
548
- iteration: round.iteration,
549
- });
550
-
551
- const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
552
- const timeoutCtl = new AbortController();
553
- const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
554
- timer.unref?.();
555
- let result: CheckpointResult;
556
- try {
557
- result = await cp.run(
558
- {
559
- candidateText: round.candidateText,
560
- stopReason: round.stopReason,
561
- iteration: round.iteration,
562
- consecutiveIdle: round.consecutiveIdle,
563
- injectionsUsed: round.injectionsUsed,
564
- injectionBudget: round.injectionBudget,
565
- signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
566
- },
567
- ctx,
568
- );
569
- } catch (err) {
570
- if (ctx.signal.aborted) return { kind: 'end' };
571
- // Fail OPEN, visibly. A crashed or timed-out checker downgrades the
572
- // gate to a warning — it must never wedge the turn (fail-closed is a
573
- // turn that can never end) and never crash it (the answer is already
574
- // logged).
575
- const why = timeoutCtl.signal.aborted
576
- ? `timed out after ${timeoutMs}ms`
577
- : `failed: ${err instanceof Error ? err.message : String(err)}`;
578
- yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
579
- continue;
580
- } finally {
581
- clearTimeout(timer);
582
- }
583
-
584
- switch (result.action) {
585
- case 'pass': {
586
- yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
587
- continue;
588
- }
589
- case 'stop': {
590
- // The checkpoint already emitted its own wrap-up events.
591
- yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
592
- return { kind: 'end' };
593
- }
594
- case 'retry': {
595
- yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
596
- return { kind: 'loop' };
597
- }
598
- case 'inject': {
599
- // Guard the guard: an inject with blank text would loop the turn
600
- // with no new signal for the model — a checker bug; fail open.
601
- const feedback = result.text?.trim();
602
- if (!feedback) {
603
- yield await emitError(
604
- ctx,
605
- 'retryable',
606
- `checkpoint "${cp.name}" injected empty feedback — ignored`,
607
- );
608
- continue;
609
- }
610
- const clamped = clampChars(feedback, round.maxInjectChars);
611
- if (result.volatile) {
612
- yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
613
- name: cp.name,
614
- volatile: true,
615
- });
616
- return { kind: 'loop', volatileText: clamped };
617
- }
618
- // Persistent: a checkpoint-origin user prompt. Projected as a
619
- // user-role message by the existing projection case, marked via the
620
- // same `origin` machinery trigger prompts use, cache-safe because it
621
- // is an ordinary append at the log tail.
622
- yield await ctx.emit({
623
- type: 'user_prompt',
624
- sessionId: ctx.sessionId,
625
- turnId: ctx.turnId,
626
- source: 'system',
627
- text: clamped,
628
- origin: { kind: 'checkpoint', name: cp.name },
629
- });
630
- yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
631
- name: cp.name,
632
- volatile: false,
633
- });
634
- return { kind: 'loop' };
635
- }
636
- }
637
- }
638
- return { kind: 'end' };
639
- }
640
-
641
401
  function defaultStuckNudge(trip: StuckTripInfo): string {
642
402
  return (
643
403
  `You have called the tool \`${trip.toolName}\` ${trip.count} times ${trip.how}. ` +
@@ -663,49 +423,3 @@ function buildStuckReport(opts: ReactLoopOptions): StuckLoopReport {
663
423
  ...(opts.stuck?.extraOnStuck ? { extraOnStuck: opts.stuck.extraOnStuck } : {}),
664
424
  };
665
425
  }
666
-
667
- function clampChars(text: string, max: number): string {
668
- if (text.length <= max) return text;
669
- return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
670
- }
671
-
672
- async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
673
- return ctx.emit({
674
- type: 'abort',
675
- sessionId: ctx.sessionId,
676
- turnId: ctx.turnId,
677
- source: 'system',
678
- reason,
679
- });
680
- }
681
-
682
- async function emitError(
683
- ctx: ModeContext,
684
- kind: 'retryable' | 'fatal',
685
- message: string,
686
- ): Promise<MoxxyEvent> {
687
- return ctx.emit({
688
- type: 'error',
689
- sessionId: ctx.sessionId,
690
- turnId: ctx.turnId,
691
- source: 'system',
692
- kind,
693
- message,
694
- });
695
- }
696
-
697
- async function emitCheckpointEvent(
698
- ctx: ModeContext,
699
- subtype: string,
700
- payload: unknown,
701
- ): Promise<MoxxyEvent> {
702
- return ctx.emit({
703
- type: 'plugin_event',
704
- sessionId: ctx.sessionId,
705
- turnId: ctx.turnId,
706
- source: 'system',
707
- pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
708
- subtype,
709
- payload,
710
- });
711
- }
package/src/mode.ts CHANGED
@@ -8,6 +8,7 @@ import type { EventLogReader } from './log.js';
8
8
  import type { PermissionResolver } from './permission.js';
9
9
  import type { LLMProvider, ReasoningEffort } from './provider.js';
10
10
  import type { LoopGuardSettings } from './mode/stuck-loop.js';
11
+ import type { QuestionAskOptions, QuestionItem, QuestionOutcome } from './question.js';
11
12
  import type { Skill } from './skill.js';
12
13
  import type { SubagentSpawner } from './subagent.js';
13
14
  import type { ToolDef } from './tool.js';
@@ -89,6 +90,13 @@ export interface ModeContext {
89
90
  readonly systemPrompt?: string;
90
91
  readonly provider: LLMProvider;
91
92
  readonly tools: ToolRegistry;
93
+ /**
94
+ * Every tool of the session, in any mode (`tools` is this turn's mode's,
95
+ * plus any it adds for itself). An agent connection lists these to the
96
+ * vendor agent, which reads its tool list once, and refuses a call to one
97
+ * this mode does not offer.
98
+ */
99
+ readonly allTools?: ToolRegistry;
92
100
  readonly skills: SkillRegistry;
93
101
  readonly log: EventLogReader;
94
102
  readonly compactor: CompactorDef | null;
@@ -125,6 +133,11 @@ export interface ModeContext {
125
133
  * picked the default option, or fail closed depending on the strategy.
126
134
  */
127
135
  readonly approval?: ApprovalResolver;
136
+ /**
137
+ * Ask the person a Question in this turn (the conversation's Questions).
138
+ * Absent where nobody can be asked (subagents, synthetic test contexts).
139
+ */
140
+ readonly ask?: (questions: ReadonlyArray<QuestionItem>, opts?: QuestionAskOptions) => Promise<QuestionOutcome>;
128
141
  readonly hooks: HookDispatcher;
129
142
  readonly pluginHost: PluginHostHandle;
130
143
  /** Inter-plugin service registry (mirrors {@link AppContext.services}). */
@@ -246,14 +259,13 @@ export interface ModeDef {
246
259
  readonly badge?: ModeBadge;
247
260
  /**
248
261
  * Marks a "special" mode — one the user does NOT pick from `/mode`. Set this
249
- * and the mode is, uniformly across every surface (TUI, mobile, desktop):
262
+ * and the mode is, uniformly across every surface (TUI, desktop):
250
263
  * - **hidden** from every mode list / picker (presence), and
251
264
  * - **refused** by the normal mode-switch by name (execution) — it can only
252
265
  * be entered through its own invocation (a slash command, a spawned
253
266
  * agent's `MOXXY_MODE`, …) via the raw registry `setActive`.
254
267
  * The mode stays registered + runnable; it's just never *offered* or
255
- * name-switched. Extensible: any number of special modes (collaborative today,
256
- * more later) opt in the same way. See {@link isSelectableMode}.
268
+ * name-switched. Extensible: any number of special modes opt in the same way. See {@link isSelectableMode}.
257
269
  */
258
270
  readonly special?: ModeSpecial;
259
271
  /**
@@ -277,16 +289,16 @@ export interface ModeDef {
277
289
  /** Descriptor for a {@link ModeDef.special} mode — see that field. */
278
290
  export interface ModeSpecial {
279
291
  /**
280
- * Human-facing entry point that enters this mode, e.g. `'collab'` (the
281
- * `/collab` command). Surfaced when the user tries to pick/switch it ("entered
282
- * via /collab") so the UI can point at the right action.
292
+ * Human-facing entry point that enters this mode, e.g. `'goal'` (the
293
+ * `/goal` command). Surfaced when the user tries to pick/switch it ("entered
294
+ * via /goal") so the UI can point at the right action.
283
295
  */
284
296
  readonly invokedBy?: string;
285
297
  }
286
298
 
287
299
  /**
288
300
  * Whether a mode is user-selectable — i.e. NOT a {@link ModeDef.special} mode.
289
- * The single predicate every surface (TUI/mobile/desktop mode lists + the
301
+ * The single predicate every surface (TUI/desktop mode lists + the
290
302
  * by-name mode-switch) uses to filter/refuse special modes uniformly. The
291
303
  * parameter is intentionally loose (`{ special? }`) so it also applies to the
292
304
  * minimal `{ name }` rows the swap-UI registries expose — non-mode defs simply
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { fetchNpmLatest as fetchLatest } from './npm-registry.js';
3
+
4
+ /** A fetch stub returning a JSON body with the given status. */
5
+ function jsonResponse(status: number, body: unknown): typeof fetch {
6
+ return vi.fn(async () => ({
7
+ ok: status >= 200 && status < 300,
8
+ status,
9
+ json: async () => body,
10
+ })) as unknown as typeof fetch;
11
+ }
12
+
13
+ describe('fetchNpmLatest', () => {
14
+ it('returns the version string for a 200 with {version}', async () => {
15
+ const fetchImpl = jsonResponse(200, { version: '1.2.3' });
16
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBe('1.2.3');
17
+ });
18
+
19
+ it('returns null for a 200 whose body has no string version', async () => {
20
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, {}) })).resolves.toBeNull();
21
+ await expect(
22
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: 42 }) }),
23
+ ).resolves.toBeNull();
24
+ });
25
+
26
+ it('rejects non-semver and prerelease dist tags before they reach the cache', async () => {
27
+ await expect(
28
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '../../payload' }) }),
29
+ ).resolves.toBeNull();
30
+ await expect(
31
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '1.2.3-beta.1' }) }),
32
+ ).resolves.toBeNull();
33
+ });
34
+
35
+ it('returns null for a 404 (and never reads the body)', async () => {
36
+ const json = vi.fn(async () => ({ version: 'should-not-be-read' }));
37
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 404, json })) as unknown as typeof fetch;
38
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
39
+ expect(json).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('returns null for a 500', async () => {
43
+ await expect(
44
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(500, {}) }),
45
+ ).resolves.toBeNull();
46
+ });
47
+
48
+ it('returns null when the fetch implementation rejects (offline)', async () => {
49
+ const fetchImpl = vi.fn(async () => {
50
+ throw new Error('network down');
51
+ }) as unknown as typeof fetch;
52
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
53
+ });
54
+
55
+ it('encodes the package name so a hostile pkg cannot escape the registry path', async () => {
56
+ let seenUrl = '';
57
+ const fetchImpl = vi.fn(async (url: string) => {
58
+ seenUrl = url;
59
+ return { ok: true, status: 200, json: async () => ({ version: '1.0.0' }) };
60
+ }) as unknown as typeof fetch;
61
+ await fetchLatest('@moxxy/cli', { fetchImpl });
62
+ expect(seenUrl).toBe('https://registry.npmjs.org/%40moxxy%2Fcli/latest');
63
+
64
+ // A name containing traversal / a host segment must stay inside the path:
65
+ // encodeURIComponent escapes the separators (`/` → %2F, npm's own scoped-
66
+ // package encoding) and the query (`?` → %3F), so the hostile name becomes a
67
+ // single inert path segment. Bare dots are harmless — what must NOT survive
68
+ // is an unescaped `../` traversal or a raw `?` query injection.
69
+ await fetchLatest('../../evil?x=1', { fetchImpl });
70
+ expect(seenUrl).toBe('https://registry.npmjs.org/..%2F..%2Fevil%3Fx%3D1/latest');
71
+ expect(seenUrl.startsWith('https://registry.npmjs.org/')).toBe(true);
72
+ expect(seenUrl.endsWith('/latest')).toBe(true);
73
+ expect(seenUrl).not.toContain('../');
74
+ expect(seenUrl).not.toContain('?x=1');
75
+ expect(seenUrl).toContain('%2F');
76
+ });
77
+
78
+ it('aborts and resolves null when the request outlives the timeout', async () => {
79
+ let abortSeen = false;
80
+ // A fetch that never resolves on its own — it only settles when the
81
+ // AbortController fires, proving the timeout path clears nothing it
82
+ // shouldn't and returns null fail-soft.
83
+ const fetchImpl = vi.fn((_url: string, init?: { signal?: AbortSignal }) => {
84
+ return new Promise((_resolve, reject) => {
85
+ init?.signal?.addEventListener('abort', () => {
86
+ abortSeen = true;
87
+ reject(new Error('aborted'));
88
+ });
89
+ });
90
+ }) as unknown as typeof fetch;
91
+
92
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl, timeoutMs: 1 })).resolves.toBeNull();
93
+ expect(abortSeen).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Ask the npm registry what version a package's `latest` dist-tag points at.
3
+ * Pure transport over global `fetch` — no `npm` dependency, no child process —
4
+ * so `moxxy update`, the TUI's "new version" banner and the agent CLI checks
5
+ * share one cheap call.
6
+ *
7
+ * Fail-soft by contract: every error (offline, timeout, 404, malformed body)
8
+ * resolves to `null`, never throws. A version check must never break a caller.
9
+ */
10
+
11
+ const REGISTRY = 'https://registry.npmjs.org';
12
+ const DEFAULT_TIMEOUT_MS = 4000;
13
+ const STABLE_SEMVER_RE = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
14
+
15
+ function normalizeStableSemver(value: unknown): string | null {
16
+ if (typeof value !== 'string') return null;
17
+ const match = STABLE_SEMVER_RE.exec(value);
18
+ if (!match) return null;
19
+ const [, major, minor, patch] = match;
20
+ if (major === undefined || minor === undefined || patch === undefined) return null;
21
+ return `${Number(major)}.${Number(minor)}.${Number(patch)}`;
22
+ }
23
+
24
+ export interface FetchNpmLatestOptions {
25
+ /** Override the fetch implementation (tests inject a stub). */
26
+ fetchImpl?: typeof fetch;
27
+ /** Abort the request after this many ms (default 4000). */
28
+ timeoutMs?: number;
29
+ }
30
+
31
+ /**
32
+ * The version under the `latest` dist-tag for `pkg`, or `null` if it can't be
33
+ * resolved. Uses the per-tag manifest endpoint
34
+ * (`/<pkg>/latest`) so we transfer a tiny document, not the full packument.
35
+ */
36
+ export async function fetchNpmLatest(pkg: string, opts: FetchNpmLatestOptions = {}): Promise<string | null> {
37
+ const fetchImpl = opts.fetchImpl ?? fetch;
38
+ // Encode the whole package name (incl. the scope slash) so an arbitrary or
39
+ // attacker-influenced `pkg` can't inject path traversal / a query string /
40
+ // another host segment into the registry URL. npm accepts `@scope%2Fname`.
41
+ const url = `${REGISTRY}/${encodeURIComponent(pkg)}/latest`;
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
44
+ try {
45
+ const res = await fetchImpl(url, {
46
+ signal: controller.signal,
47
+ headers: { accept: 'application/json' },
48
+ });
49
+ if (!res.ok) return null;
50
+ const json = (await res.json()) as { version?: unknown };
51
+ return normalizeStableSemver(json.version);
52
+ } catch {
53
+ return null;
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
@@ -21,7 +21,7 @@ describe('@moxxy/sdk package root', () => {
21
21
  it('exports requirement types through package root declarations', async () => {
22
22
  const requirement: import('@moxxy/sdk').MoxxyRequirement = {
23
23
  kind: 'runtime',
24
- name: 'auth:provider:openai-codex',
24
+ name: 'auth:provider:codex-agent',
25
25
  state: 'ready',
26
26
  };
27
27
  const check: import('@moxxy/sdk').RequirementCheck = {
@@ -35,7 +35,7 @@ describe('@moxxy/sdk package root', () => {
35
35
  ],
36
36
  };
37
37
 
38
- expect(check.issues[0]?.requirement.name).toBe('auth:provider:openai-codex');
38
+ expect(check.issues[0]?.requirement.name).toBe('auth:provider:codex-agent');
39
39
  });
40
40
 
41
41
  // The main barrel is the browser/RN-safe surface: a value import of any of
package/src/permission.ts CHANGED
@@ -30,6 +30,13 @@ export interface PermissionRule {
30
30
  }>;
31
31
  };
32
32
  readonly reason?: string;
33
+ /**
34
+ * How far an "allow for this session" answer reaches for this tool: only
35
+ * later calls with the same values under these input keys. For a tool whose
36
+ * approval grants something by itself (access to an app), where a grant of
37
+ * the whole tool would let every later, wider request through unasked.
38
+ */
39
+ readonly sessionScope?: { readonly inputKeys: ReadonlyArray<string> };
33
40
  }
34
41
 
35
42
  export interface PendingToolCall {
@@ -44,9 +51,32 @@ export interface PendingToolCall {
44
51
  readonly requestedAtSeq?: number;
45
52
  }
46
53
 
54
+ /**
55
+ * A standing "allow" rule a channel saves to the person's permissions.json:
56
+ * the whole tool, or with `inputEquals` only calls whose inputs have exactly
57
+ * these values (`null` for a left-out one).
58
+ */
59
+ export interface StandingAllowRule {
60
+ readonly name: string;
61
+ readonly reason?: string;
62
+ readonly inputEquals?: Readonly<Record<string, unknown>>;
63
+ }
64
+
47
65
  export interface PermissionContext {
48
66
  readonly turnId?: string;
49
67
  readonly toolDescription?: string;
68
+ /**
69
+ * The rule declared by the tool this turn offers under the call's name. A
70
+ * mode may offer tools the session registry does not hold (plan mode's
71
+ * `plan_complete`), so the resolver needs it from the call site.
72
+ */
73
+ readonly toolPermission?: PermissionRule;
74
+ /**
75
+ * The person answers even while auto-approve is on (standing rules still
76
+ * decide first): the agent's own setting would have asked them, and they
77
+ * never chose auto-approve for it.
78
+ */
79
+ readonly askPerson?: boolean;
50
80
  readonly skillContext?: string;
51
81
  readonly sessionId: string;
52
82
  }
@@ -272,4 +272,29 @@ describe('spawnExecutableTarget', () => {
272
272
 
273
273
  expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual(args.slice(1));
274
274
  });
275
+
276
+ it("gives a native program the environment its target carries, over the caller's", () => {
277
+ const child = spawnExecutableTarget(
278
+ { kind: 'direct', command: process.execPath, env: { MOXXY_TARGET_ENV: 'from target' } },
279
+ ['-e', 'process.stdout.write(String(process.env.MOXXY_TARGET_ENV))'],
280
+ { stdio: ['ignore', 'pipe', 'pipe'], env: { MOXXY_TARGET_ENV: 'from caller' } },
281
+ );
282
+ let stdout = '';
283
+ child.stdout?.setEncoding('utf8');
284
+ child.stdout?.on('data', (chunk: string) => {
285
+ stdout += chunk;
286
+ });
287
+
288
+ return new Promise<void>((resolve, reject) => {
289
+ child.once('error', reject);
290
+ child.once('close', () => {
291
+ try {
292
+ expect(stdout).toBe('from target');
293
+ resolve();
294
+ } catch (error) {
295
+ reject(error);
296
+ }
297
+ });
298
+ });
299
+ });
275
300
  });