@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
@@ -3,7 +3,7 @@ import {
3
3
  conversationalStub,
4
4
  conversationalStubbed,
5
5
  toolResultBytes,
6
- toolResultStub,
6
+ toolResultStubText,
7
7
  toolResultStubbed,
8
8
  type ElisionState,
9
9
  } from './elision-state.js';
@@ -58,8 +58,7 @@ export function estimateContextTokens(
58
58
  for (const e of events) {
59
59
  if (isCompacted(e.seq)) continue;
60
60
  if (e.type === 'tool_result' && toolResultStubbed(e, el)) {
61
- const recalled = el.recalledCallIds.has(e.callId) || el.recalledSeqs.has(e.seq);
62
- chars += toolResultStub(e.callId, toolResultBytes(e.output), recalled).length;
61
+ chars += toolResultStubText(e, el).length;
63
62
  continue;
64
63
  }
65
64
  if ((e.type === 'user_prompt' || e.type === 'assistant_message') && conversationalStubbed(e, el)) {
@@ -116,7 +115,7 @@ function safeJsonLen(v: unknown): number {
116
115
  *
117
116
  * `config.model` is a free-form, unvalidated string, and providers happily
118
117
  * serve ids that aren't in their fixed descriptor list — a newer release
119
- * (`claude-opus-5`), a dated id, or a model registered at runtime via
118
+ * (`claude-opus-5-5`), a dated id, or a model registered at runtime via
120
119
  * provider-admin. So an exact `models.find(m => m.id === ctx.model)` often
121
120
  * MISSES, and both auto-compaction and auto-elision used to silently turn into
122
121
  * permanent no-ops for the whole session (the context then grows unbounded and
@@ -139,6 +138,19 @@ export function resolveModelContext(
139
138
  ? ctx.provider.models.find((model) => model.id === baseId)
140
139
  : undefined;
141
140
  const descriptor = exact ?? variant ?? ctx.provider.models[0];
141
+ const override = ctx.contextWindowOverride;
142
+ if (
143
+ override !== undefined && Number.isSafeInteger(override)
144
+ && override > 0 && override <= 10_000_000
145
+ ) {
146
+ const matchingDescriptor = exact ?? variant;
147
+ const reserveForOutput = matchingDescriptor?.maxOutputTokens
148
+ ?? Math.floor(override * 0.1);
149
+ return {
150
+ contextWindow: override,
151
+ reserveForOutput: Math.min(reserveForOutput, Math.floor(override * 0.5)),
152
+ };
153
+ }
142
154
  const contextWindow = descriptor?.contextWindow;
143
155
  if (!contextWindow || contextWindow <= 0) return null;
144
156
  // Signal (once) when the exact-id lookup missed and we fell back to the first
@@ -15,3 +15,14 @@ it('validates bounded control-state snapshots, never executable commands', () =>
15
15
  expect(computerControlSnapshotSchema.safeParse({...snapshot,state:'running arbitrary code'}).success).toBe(false);
16
16
  expect(computerControlSnapshotSchema.safeParse({...snapshot,windowId:'x'.repeat(200)}).success).toBe(false);
17
17
  });
18
+
19
+ it('carries the agent cursor as a fraction of the target window, and the target the human sees', () => {
20
+ const snapshot={sessionId:'s',turnId:'t',state:'background',windowId:null,
21
+ cursor:{phase:'moving',x:0.25,y:1},target:{app:'TextEdit',window:'Untitled'}};
22
+ expect(computerControlSnapshotSchema.safeParse(snapshot).success).toBe(true);
23
+ expect(computerControlSnapshotSchema.safeParse({...snapshot,cursor:{...snapshot.cursor,x:1.5}}).success).toBe(false);
24
+ expect(computerControlSnapshotSchema.safeParse({...snapshot,cursor:{...snapshot.cursor,phase:'teleporting'}}).success).toBe(false);
25
+ expect(computerControlSnapshotSchema.safeParse({...snapshot,cursor:{...snapshot.cursor,screenX:10}}).success).toBe(false);
26
+ expect(computerControlSnapshotSchema.safeParse({...snapshot,target:{app:'',window:null}}).success).toBe(false);
27
+ expect(computerControlSnapshotSchema.safeParse({...snapshot,target:{app:'TextEdit',window:'x'.repeat(300)}}).success).toBe(false);
28
+ });
@@ -6,15 +6,28 @@ export const computerControlStateSchema = z.enum([
6
6
  ]);
7
7
  const identity = z.string().min(1).max(160);
8
8
  export const computerControlOwnerSchema = z.object({ sessionId: identity, turnId: identity }).strict();
9
+ /** `takeover` pauses like `pause`, hides the agent cursor and lets go of any held key or button. */
9
10
  export const computerControlCommandSchema = computerControlOwnerSchema.extend({
10
- command: z.enum(['pause', 'resume', 'stop']),
11
+ command: z.enum(['pause', 'resume', 'stop', 'takeover']),
11
12
  }).strict();
13
+ /** Where an action is in its life; the cursor overlay and the PiP draw from it. */
14
+ export const computerCursorPhaseSchema = z.enum(['idle', 'moving', 'executing', 'delivered', 'failed']);
15
+ const fraction = z.number().min(0).max(1);
16
+ /** The agent cursor as a fraction of the target window, so any surface can draw it over that window. */
17
+ export const computerCursorSchema = z.object({ phase: computerCursorPhaseSchema, x: fraction, y: fraction }).strict();
18
+ /** The app and window being operated; the window title is application content shown to the human only. */
19
+ export const computerTargetSchema = z.object({ app: identity, window: z.string().max(200).nullable() }).strict();
12
20
  export const computerControlSnapshotSchema = computerControlOwnerSchema.extend({
13
21
  state: computerControlStateSchema,
14
22
  windowId: identity.nullable(),
23
+ /** Absent while no cursor is shown (before the first observation, after Stop or a helper failure). */
24
+ cursor: computerCursorSchema.optional(),
25
+ target: computerTargetSchema.optional(),
15
26
  }).strict();
16
27
 
17
28
  export type ComputerControlState = z.infer<typeof computerControlStateSchema>;
29
+ export type ComputerCursor = z.infer<typeof computerCursorSchema>;
30
+ export type ComputerTarget = z.infer<typeof computerTargetSchema>;
18
31
  export const computerApprovalFocusSchema = computerControlOwnerSchema.extend({
19
32
  windowId: identity, callId: identity, hostPid: z.number().int().min(1).max(2147483647),
20
33
  stage: z.enum(['begin', 'finish']), approved: z.boolean(),
@@ -28,4 +41,6 @@ export interface ComputerControlService {
28
41
  snapshot(): Promise<ReadonlyArray<ComputerControlSnapshot>>;
29
42
  /** Human control only: never starts a helper or grants tool permission. */
30
43
  control(command: ComputerControlCommand): Promise<void>;
44
+ /** Pushes this session's turns after every change, so surfaces never poll; returns the unsubscribe. */
45
+ subscribe?(listener: (turns: ReadonlyArray<ComputerControlSnapshot>) => void): () => void;
31
46
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * How Moxxy conducts herself, whatever surface or mode the conversation is on.
3
+ * Each rule answers a fault seen in a live session: live facts stated without
4
+ * checking, a choice the user settled lost on a redo, and the voice and the
5
+ * agent speaking of themselves in different grammatical genders in one chat.
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import type { ProviderRequest } from './provider.js';
9
+ import { AGENT_CONDUCT, SELF_REFERENCE_NOTE, withAgentConduct } from './conduct.js';
10
+
11
+ const request = (system?: string): ProviderRequest => ({
12
+ model: 'm',
13
+ messages: [],
14
+ ...(system !== undefined ? { system } : {}),
15
+ });
16
+
17
+ describe('SELF_REFERENCE_NOTE', () => {
18
+ it('asks for the feminine form, with Polish examples, whatever earlier messages used', () => {
19
+ expect(SELF_REFERENCE_NOTE).toMatch(/feminine/);
20
+ expect(SELF_REFERENCE_NOTE).toContain('sprawdziłam');
21
+ expect(SELF_REFERENCE_NOTE).toContain('never "sprawdziłem"');
22
+ expect(SELF_REFERENCE_NOTE).toMatch(/whatever earlier messages/);
23
+ });
24
+ });
25
+
26
+ describe('AGENT_CONDUCT', () => {
27
+ it('says live facts are only known after checking, and an unchecked one is said to be unchecked', () => {
28
+ expect(AGENT_CONDUCT).toMatch(/prices, timetables, availability/);
29
+ expect(AGENT_CONDUCT).toMatch(/Check them with a tool before stating them/);
30
+ expect(AGENT_CONDUCT).toMatch(/say plainly that you have not checked/);
31
+ });
32
+
33
+ it('carries every choice the user settled into a redo of earlier work', () => {
34
+ expect(AGENT_CONDUCT).toMatch(/carry over every choice the user already settled/);
35
+ expect(AGENT_CONDUCT).toMatch(/one-way or return/);
36
+ });
37
+
38
+ it('holds the self-reference rule the voice uses too', () => {
39
+ expect(AGENT_CONDUCT).toContain(SELF_REFERENCE_NOTE);
40
+ });
41
+ });
42
+
43
+ describe('withAgentConduct', () => {
44
+ it('sets the conduct as the system text when a request has none', () => {
45
+ expect(withAgentConduct(request()).system).toBe(AGENT_CONDUCT);
46
+ });
47
+
48
+ it('appends it after system text another plugin already added', () => {
49
+ expect(withAgentConduct(request('[user-model] likes tea')).system).toBe(`[user-model] likes tea\n\n${AGENT_CONDUCT}`);
50
+ });
51
+
52
+ it('adds it once, so a request passed through twice stays byte-identical', () => {
53
+ const once = withAgentConduct(request('x'));
54
+ expect(withAgentConduct(once)).toBe(once);
55
+ });
56
+
57
+ it('leaves everything else in the request as it was', () => {
58
+ const req: ProviderRequest = { model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], maxTokens: 9 };
59
+ const out = withAgentConduct(req);
60
+ expect(out).toMatchObject({ model: 'm', messages: req.messages, maxTokens: 9 });
61
+ });
62
+ });
package/src/conduct.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { ProviderRequest } from './provider.js';
2
+
3
+ /**
4
+ * One grammatical gender for Moxxy, shared by the agent and the voice. Both
5
+ * write into the same chat, and each tends to copy whatever form it finds
6
+ * there, so without one rule a chat drifts between "sprawdziłam" and
7
+ * "sprawdziłem".
8
+ */
9
+ export const SELF_REFERENCE_NOTE =
10
+ 'Speak of yourself in the feminine grammatical form in every language that marks it ' +
11
+ '(Polish: "sprawdziłam", "ustawiłam", "jestem gotowa"; never "sprawdziłem"), in every reply, ' +
12
+ 'whatever earlier messages in the chat used.';
13
+
14
+ const MARKER = '## How Moxxy works';
15
+
16
+ /** Appended to every agent request, in every mode and on every surface. */
17
+ export const AGENT_CONDUCT = [
18
+ MARKER,
19
+ 'You are Moxxy.',
20
+ 'Live facts — prices, timetables, availability, opening hours, news, weather, anything about the ' +
21
+ 'current state of the world — are known only after checking. Check them with a tool before stating ' +
22
+ 'them. If you cannot check, say plainly that you have not checked and that they may be out of date. ' +
23
+ 'Never present a remembered or guessed detail as current.',
24
+ 'When you redo or extend earlier work (a new search, a new tab, another attempt), carry over every ' +
25
+ 'choice the user already settled — dates, one-way or return, sizes, names, filters — unless the user ' +
26
+ 'changed it, and check that the result shows them before you report.',
27
+ SELF_REFERENCE_NOTE,
28
+ ].join('\n');
29
+
30
+ /**
31
+ * `onBeforeProviderCall` body: append the conduct to the request's system
32
+ * text. Idempotent and byte-stable, so the cached prompt prefix holds.
33
+ */
34
+ export function withAgentConduct(request: ProviderRequest): ProviderRequest {
35
+ if (request.system?.includes(MARKER)) return request;
36
+ return { ...request, system: request.system ? `${request.system}\n\n${AGENT_CONDUCT}` : AGENT_CONDUCT };
37
+ }
@@ -1,8 +1,9 @@
1
- import { mkdtemp, rm, readdir, utimes } from 'node:fs/promises';
1
+ import { mkdtemp, readdir, utimes } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path, { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
5
  import { CrossProcessFireLock } from './cross-process-lock.js';
6
+ import { removeDir } from '@moxxy/vitest-preset/fs';
6
7
 
7
8
  describe('CrossProcessFireLock', () => {
8
9
  let dir: string;
@@ -10,7 +11,7 @@ describe('CrossProcessFireLock', () => {
10
11
  dir = await mkdtemp(path.join(tmpdir(), 'moxxy-xproc-lock-'));
11
12
  });
12
13
  afterEach(async () => {
13
- await rm(dir, { recursive: true, force: true });
14
+ await removeDir(dir);
14
15
  });
15
16
 
16
17
  it('claims a key exactly once — the first caller wins, the rest lose', async () => {
package/src/define.ts CHANGED
@@ -49,6 +49,9 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
49
49
  compact?: ToolCompactPresentation;
50
50
  icon?: ToolIcon;
51
51
  isolation?: ToolIsolationSpec;
52
+ liveState?: boolean;
53
+ alwaysLoaded?: boolean;
54
+ modes?: ReadonlyArray<string>;
52
55
  }): ToolDef {
53
56
  return Object.freeze({
54
57
  name: spec.name,
@@ -62,6 +65,9 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
62
65
  compact: spec.compact,
63
66
  icon: spec.icon,
64
67
  isolation: spec.isolation,
68
+ ...(spec.liveState ? { liveState: true } : {}),
69
+ ...(spec.alwaysLoaded ? { alwaysLoaded: true } : {}),
70
+ ...(spec.modes ? { modes: Object.freeze([...spec.modes]) } : {}),
65
71
  });
66
72
  }
67
73
 
@@ -1,4 +1,5 @@
1
1
  import type { MoxxyEvent } from './events.js';
2
+ import { supersededCallIds, supersededStub } from './supersede.js';
2
3
  import { isToolDisplayResult } from './tool-display.js';
3
4
 
4
5
  /**
@@ -56,6 +57,11 @@ export interface ElisionState {
56
57
  readonly unpinnedRecallCallIds: ReadonlySet<string>;
57
58
  /** Seq of the first user_prompt (task anchor) — never elided. */
58
59
  readonly firstUserPromptSeq: number;
60
+ /**
61
+ * callIds of results a later whole result of the same key replaced (see
62
+ * `supersede.ts`). Independent of the high-water mark: stubbed at any age.
63
+ */
64
+ readonly supersededCallIds?: ReadonlySet<string>;
59
65
  }
60
66
 
61
67
  const EMPTY_STATE: ElisionState = {
@@ -118,6 +124,9 @@ export function computeElisionState(events: ReadonlyArray<MoxxyEvent>): ElisionS
118
124
  }
119
125
 
120
126
  function computeElisionStateUncached(events: ReadonlyArray<MoxxyEvent>): ElisionState {
127
+ const superseded = supersededCallIds(events);
128
+ const withSuperseded = (state: ElisionState): ElisionState =>
129
+ superseded.size > 0 ? { ...state, supersededCallIds: superseded } : state;
121
130
  let hwm = -1;
122
131
  let elideConversational = false;
123
132
  let conversationalRecallThreshold = Number.POSITIVE_INFINITY;
@@ -132,7 +141,7 @@ function computeElisionStateUncached(events: ReadonlyArray<MoxxyEvent>): Elision
132
141
  neverElide = e.neverElideTools;
133
142
  }
134
143
  }
135
- if (hwm < 0) return EMPTY_STATE;
144
+ if (hwm < 0) return withSuperseded(EMPTY_STATE);
136
145
 
137
146
  const toolNameByCall = new Map<string, string>();
138
147
  const recalledCallIds = new Set<string>();
@@ -185,7 +194,7 @@ function computeElisionStateUncached(events: ReadonlyArray<MoxxyEvent>): Elision
185
194
  }
186
195
  }
187
196
 
188
- return {
197
+ return withSuperseded({
189
198
  hwm,
190
199
  effectiveElideConversational: elideConversational && seqRecalls < conversationalRecallThreshold,
191
200
  neverElide: new Set(neverElide),
@@ -195,7 +204,7 @@ function computeElisionStateUncached(events: ReadonlyArray<MoxxyEvent>): Elision
195
204
  recallResultCallIds,
196
205
  unpinnedRecallCallIds,
197
206
  firstUserPromptSeq,
198
- };
207
+ });
199
208
  }
200
209
 
201
210
  /** Is this tool_result sent as a stub? (Shared by projection + estimate.) */
@@ -203,6 +212,7 @@ export function toolResultStubbed(
203
212
  e: Extract<MoxxyEvent, { type: 'tool_result' }>,
204
213
  state: ElisionState,
205
214
  ): boolean {
215
+ if (!e.error && state.supersededCallIds?.has(e.callId)) return true;
206
216
  if (e.seq > state.hwm || e.error) return false;
207
217
  const name = state.toolNameByCall.get(e.callId);
208
218
  if (name !== undefined && state.neverElide.has(name)) return false;
@@ -214,6 +224,17 @@ export function toolResultStubbed(
214
224
  return true;
215
225
  }
216
226
 
227
+ /** What a stubbed tool_result is sent as: the superseded marker, or the elided one. */
228
+ export function toolResultStubText(
229
+ e: Extract<MoxxyEvent, { type: 'tool_result' }>,
230
+ state: ElisionState,
231
+ ): string {
232
+ const recalled = state.recalledCallIds.has(e.callId) || state.recalledSeqs.has(e.seq);
233
+ const bytes = toolResultBytes(e.output);
234
+ if (!recalled && state.supersededCallIds?.has(e.callId)) return supersededStub(e.callId, bytes);
235
+ return toolResultStub(e.callId, bytes, recalled);
236
+ }
237
+
217
238
  /** Is this user/assistant turn collapsed to a conversational stub? */
218
239
  export function conversationalStubbed(
219
240
  e: Extract<MoxxyEvent, { type: 'user_prompt' | 'assistant_message' }>,
package/src/events.ts CHANGED
@@ -64,9 +64,13 @@ export interface UserPromptAttachment {
64
64
  * kinds it does not open a turn — renderers that treat `user_prompt` as a
65
65
  * turn boundary must skip their boundary resets for it (see chat-model's
66
66
  * pair-events).
67
+ *
68
+ * `kind: 'voice'` marks the voice conversation (GPT-Live) held while an agent
69
+ * turn ran and recorded after it as one transcript: context for later turns,
70
+ * not a request, and shown as one collapsed block.
67
71
  */
68
72
  export interface TriggerOrigin {
69
- readonly kind: 'webhook' | 'schedule' | 'workflow' | 'checkpoint';
73
+ readonly kind: 'webhook' | 'schedule' | 'workflow' | 'checkpoint' | 'voice';
70
74
  /** The trigger's name (webhook/schedule/workflow/checkpoint), for the marker label. */
71
75
  readonly name: string;
72
76
  }
@@ -137,6 +141,8 @@ export interface ToolCallApprovedEvent extends EventBase {
137
141
  readonly callId: ToolCallId;
138
142
  readonly decidedBy: 'policy' | 'resolver' | 'hook';
139
143
  readonly mode: 'allow' | 'allow_session' | 'allow_always';
144
+ /** See `PermissionDecision.decidedNow`. */
145
+ readonly decidedNow?: boolean;
140
146
  }
141
147
 
142
148
  export interface ToolCallDeniedEvent extends EventBase {
@@ -232,6 +238,12 @@ export interface CompactionEvent extends EventBase {
232
238
  readonly replacedRange: readonly [number, number];
233
239
  readonly summary: string;
234
240
  readonly tokensSaved: number;
241
+ /**
242
+ * A step of the compactor's regular bookkeeping (recording a finished turn,
243
+ * folding old records) rather than a response to a nearly full context.
244
+ * Surfaces may leave it out of the transcript; it still frees context.
245
+ */
246
+ readonly routine?: true;
235
247
  }
236
248
 
237
249
  /**
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { EXIT_AFTER_PAIR_FLAG, finishPairing } from './channel.js';
3
+
4
+ /** Records the order of the two lifecycle steps a finished pairing performs. */
5
+ function harness(flags: Record<string, string | boolean | undefined> = {}) {
6
+ const steps: string[] = [];
7
+ const ctx = {
8
+ args: { positional: [], flags },
9
+ deps: { cwd: '/tmp' },
10
+ startChannel: async (options?: Readonly<Record<string, unknown>>) => {
11
+ steps.push(`start:${JSON.stringify(options ?? {})}`);
12
+ return 7;
13
+ },
14
+ };
15
+ const stopPairingBot = async () => {
16
+ steps.push('stop-pairing-bot');
17
+ };
18
+ return { ctx, stopPairingBot, steps };
19
+ }
20
+
21
+ describe('finishPairing', () => {
22
+ it('stops the pairing bot (it runs on a provider-less probe session) and starts the channel for real', async () => {
23
+ const { ctx, stopPairingBot, steps } = harness();
24
+
25
+ const code = await finishPairing(ctx, stopPairingBot);
26
+
27
+ expect(steps).toEqual(['stop-pairing-bot', 'start:{}']);
28
+ expect(code).toBe(7);
29
+ });
30
+
31
+ it('forwards the channel start options (e.g. an allow-list chosen during setup)', async () => {
32
+ const { ctx, stopPairingBot, steps } = harness();
33
+
34
+ await finishPairing(ctx, stopPairingBot, { allowedTools: ['Read'] });
35
+
36
+ expect(steps).toEqual(['stop-pairing-bot', 'start:{"allowedTools":["Read"]}']);
37
+ });
38
+
39
+ it('hands control back without starting when an orchestrator asked to exit after pairing', async () => {
40
+ const { ctx, stopPairingBot, steps } = harness({ [EXIT_AFTER_PAIR_FLAG]: true });
41
+
42
+ expect(await finishPairing(ctx, stopPairingBot)).toBe(0);
43
+ expect(steps).toEqual(['stop-pairing-bot']);
44
+ });
45
+ });
@@ -1,8 +1,10 @@
1
1
  import { generateKeyPairSync, sign } from 'node:crypto';
2
- import { chmod, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
2
+ import { chmod, mkdir, mkdtemp, open, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { Worker } from 'node:worker_threads';
5
6
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { posixFileModes } from '@moxxy/vitest-preset/platform';
6
8
  import {
7
9
  ensurePrivateDir,
8
10
  ensurePrivateFile,
@@ -37,7 +39,7 @@ describe('writeFileAtomic', () => {
37
39
  expect(leftovers).toEqual([]);
38
40
  });
39
41
 
40
- it('enforces the requested mode past umask', async () => {
42
+ it.skipIf(!posixFileModes)('enforces the requested mode past umask', async () => {
41
43
  const target = join(dir, 'secret.json');
42
44
  await writeFileAtomic(target, 'shh', { mode: 0o600 });
43
45
  const mode = (await stat(target)).mode & 0o777;
@@ -51,6 +53,19 @@ describe('writeFileAtomic', () => {
51
53
  const read = await readFile(target);
52
54
  expect(Array.from(read)).toEqual([0, 1, 2, 255]);
53
55
  });
56
+
57
+ // Windows refuses to rename over a file another handle has open (EPERM), so a
58
+ // reader that happens to be mid-read — another moxxy process loading
59
+ // config.yaml — used to make the write fail outright.
60
+ it('lands while another reader briefly holds the file open', async () => {
61
+ const target = join(dir, 'config.yaml');
62
+ await writeFile(target, 'old');
63
+ const reader = await open(target, 'r');
64
+ const released = new Promise((resolve) => setTimeout(resolve, 100)).then(() => reader.close());
65
+ await writeFileAtomic(target, 'new');
66
+ await released;
67
+ expect(await readFile(target, 'utf8')).toBe('new');
68
+ });
54
69
  });
55
70
 
56
71
  describe('network cache writers', () => {
@@ -133,7 +148,7 @@ describe('writeFileAtomicSync', () => {
133
148
  expect(leftovers).toEqual([]);
134
149
  });
135
150
 
136
- it('enforces the requested mode past umask', async () => {
151
+ it.skipIf(!posixFileModes)('enforces the requested mode past umask', async () => {
137
152
  const target = join(dir, 'secret.json');
138
153
  writeFileAtomicSync(target, 'shh', { mode: 0o600 });
139
154
  const mode = (await stat(target)).mode & 0o777;
@@ -146,6 +161,25 @@ describe('writeFileAtomicSync', () => {
146
161
  expect(Array.from(await readFile(target))).toEqual([0, 1, 2, 255]);
147
162
  });
148
163
 
164
+ it('lands while another thread briefly holds the file open', async () => {
165
+ const target = join(dir, 'config.yaml');
166
+ await writeFile(target, 'old');
167
+ // A worker holds the handle: this thread blocks inside the sync write, so it
168
+ // could not release a handle of its own.
169
+ const holder = new Worker(
170
+ `const { openSync, closeSync } = require('node:fs');
171
+ const { parentPort, workerData } = require('node:worker_threads');
172
+ const fd = openSync(workerData, 'r');
173
+ parentPort.postMessage('open');
174
+ setTimeout(() => closeSync(fd), 100);`,
175
+ { eval: true, workerData: target },
176
+ );
177
+ await new Promise((resolve) => holder.once('message', resolve));
178
+ writeFileAtomicSync(target, 'new');
179
+ await new Promise((resolve) => holder.once('exit', resolve));
180
+ expect(await readFile(target, 'utf8')).toBe('new');
181
+ });
182
+
149
183
  it('cleans up the temp file and throws when rename fails', async () => {
150
184
  // A directory in place of the final file makes renameSync fail; the temp
151
185
  // file must be removed and the error surfaced rather than swallowed.
@@ -168,12 +202,12 @@ describe('moxxyHome / moxxyPath', () => {
168
202
  it('honors MOXXY_HOME when set', () => {
169
203
  process.env.MOXXY_HOME = '/custom/moxxy';
170
204
  expect(moxxyHome()).toBe('/custom/moxxy');
171
- expect(moxxyPath('vault.json')).toBe('/custom/moxxy/vault.json');
205
+ expect(moxxyPath('vault.json')).toBe(join('/custom/moxxy', 'vault.json'));
172
206
  });
173
207
 
174
208
  it('falls back to ~/.moxxy when unset', () => {
175
209
  delete process.env.MOXXY_HOME;
176
- expect(moxxyHome().endsWith('/.moxxy')).toBe(true);
210
+ expect(moxxyHome()).toBe(join(homedir(), '.moxxy'));
177
211
  });
178
212
  });
179
213
 
package/src/fs-utils.ts CHANGED
@@ -75,13 +75,53 @@ async function commitAtomic(
75
75
  // chmod explicitly: writeFile's mode option is masked by umask, but a
76
76
  // 0o600 secret file must be exactly 0o600 regardless of the host umask.
77
77
  if (opts.mode != null) await chmod(tmp, opts.mode);
78
- await rename(tmp, target);
78
+ await renameOverReaders(tmp, target);
79
79
  } catch (err) {
80
80
  await rm(tmp, { force: true }).catch(() => {});
81
81
  throw err;
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Windows refuses to rename over a file that any other handle has open — a
87
+ * reader mid-read in this or another process — with EPERM / EACCES / EBUSY.
88
+ * Readers hold the file for milliseconds, so the rename is retried briefly
89
+ * instead of failing the write. Elsewhere rename never blocks on readers.
90
+ */
91
+ const RENAME_RETRY_BUDGET_MS = 2000;
92
+ const MAX_RENAME_BACKOFF_MS = 50;
93
+
94
+ function isHeldByReader(err: unknown): boolean {
95
+ if (process.platform !== 'win32') return false;
96
+ const code = (err as NodeJS.ErrnoException | null)?.code;
97
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
98
+ }
99
+
100
+ async function renameOverReaders(from: string, to: string): Promise<void> {
101
+ const deadline = Date.now() + RENAME_RETRY_BUDGET_MS;
102
+ for (let backoff = 1; ; backoff = Math.min(backoff * 2, MAX_RENAME_BACKOFF_MS)) {
103
+ try {
104
+ return await rename(from, to);
105
+ } catch (err) {
106
+ if (!isHeldByReader(err) || Date.now() >= deadline) throw err;
107
+ await new Promise((resolve) => setTimeout(resolve, backoff));
108
+ }
109
+ }
110
+ }
111
+
112
+ function renameOverReadersSync(from: string, to: string): void {
113
+ const deadline = Date.now() + RENAME_RETRY_BUDGET_MS;
114
+ const pause = new Int32Array(new SharedArrayBuffer(4));
115
+ for (let backoff = 1; ; backoff = Math.min(backoff * 2, MAX_RENAME_BACKOFF_MS)) {
116
+ try {
117
+ return renameSync(from, to);
118
+ } catch (err) {
119
+ if (!isHeldByReader(err) || Date.now() >= deadline) throw err;
120
+ Atomics.wait(pause, 0, 0, backoff);
121
+ }
122
+ }
123
+ }
124
+
85
125
  /**
86
126
  * Persist an authenticated network payload as a data-only cache envelope.
87
127
  * Verification happens again at the filesystem boundary so callers cannot
@@ -166,7 +206,7 @@ function commitAtomicSync(
166
206
  // chmod explicitly: writeFileSync's mode option is masked by umask, but a
167
207
  // 0o600 secret file must be exactly 0o600 regardless of the host umask.
168
208
  if (opts.mode != null) chmodSync(tmp, opts.mode);
169
- renameSync(tmp, target);
209
+ renameOverReadersSync(tmp, target);
170
210
  } catch (err) {
171
211
  try {
172
212
  rmSync(tmp, { force: true });
package/src/index.ts CHANGED
@@ -46,9 +46,11 @@ export {
46
46
  computerControlStateSchema, computerControlOwnerSchema,
47
47
  computerControlCommandSchema, computerControlSnapshotSchema,
48
48
  computerApprovalFocusSchema, type ComputerApprovalFocus,
49
+ computerCursorPhaseSchema, computerCursorSchema, computerTargetSchema,
49
50
  } from './computer-control.js';
50
51
  export type {
51
52
  ComputerControlState, ComputerControlCommand, ComputerControlSnapshot, ComputerControlService,
53
+ ComputerCursor, ComputerTarget,
52
54
  } from './computer-control.js';
53
55
 
54
56
  // Identity. The type + pure helpers ride the main barrel; the OS resolver needs
@@ -131,6 +133,13 @@ export type {
131
133
  ClientChromeItem,
132
134
  } from './client-chrome.js';
133
135
 
136
+ export {
137
+ AUTO_APPROVE_PLUGIN_ID,
138
+ AUTO_APPROVE_SUBTYPE,
139
+ autoApproveFromEvents,
140
+ autoApproveSwitch,
141
+ } from './auto-approve.js';
142
+
134
143
  export type {
135
144
  ClientSession,
136
145
  ProvidersClientView,
@@ -196,6 +205,7 @@ export type {
196
205
  ContentBlock,
197
206
  ProviderMessage,
198
207
  ProviderRequest,
208
+ ReasoningEffort,
199
209
  ProviderEvent,
200
210
  CacheHint,
201
211
  TokenUsage,
@@ -294,6 +304,7 @@ export type {
294
304
  export type { ChannelRunStatus } from './channel-status.js';
295
305
  export type { CrossProcessFireLock, CrossProcessFireLockOptions } from './cross-process-lock.js';
296
306
  export { createMutex, type Mutex } from './mutex.js';
307
+ export { waitFor, wakeAfter, type WaitForOptions, type WaitOutcome, type WakeSource } from './wait-for.js';
297
308
  export {
298
309
  createJsonFileStore,
299
310
  type JsonFileStore,
@@ -334,6 +345,7 @@ export {
334
345
  projectMessagesFromLog,
335
346
  projectMessages,
336
347
  buildSystemPromptWithSkills,
348
+ skillsWithinReach,
337
349
  createStuckLoopDetector,
338
350
  stableHash,
339
351
  runReactLoop,
@@ -398,13 +410,26 @@ export {
398
410
  conversationalStub,
399
411
  toolResultBytes,
400
412
  toolResultStubbed,
413
+ toolResultStubText,
401
414
  conversationalStubbed,
402
415
  TINY_TURN_CHARS,
403
416
  type ElisionState,
404
417
  } from './elision-state.js';
418
+ export { supersedeOf, supersededCallIds, supersededStub, type Supersede } from './supersede.js';
419
+ export {
420
+ progressOf,
421
+ unfinishedWork,
422
+ unfinishedWorkCheckpoint,
423
+ unfinishedWorkNudge,
424
+ type Progress,
425
+ } from './progress.js';
426
+ export { AGENT_CONDUCT, SELF_REFERENCE_NOTE, withAgentConduct } from './conduct.js';
405
427
  export {
406
428
  applyLazyTools,
407
429
  buildToolIndex,
430
+ matchLoadableTools,
431
+ shouldGateTools,
432
+ LAZY_TOOLS_AUTO_THRESHOLD,
408
433
  loadedToolNames,
409
434
  ALWAYS_ON_TOOLS,
410
435
  type GatedTools,
@@ -421,6 +446,18 @@ export {
421
446
  } from './token-accounting.js';
422
447
 
423
448
  export type { Skill, SkillDef, SkillFrontmatter, SkillScope, SkillSchedule } from './skill.js';
449
+ export {
450
+ insertMention,
451
+ mentionedSkills,
452
+ mentionOptions,
453
+ mentionQueryAt,
454
+ skillAttachment,
455
+ toolPatternMatches,
456
+ withoutTools,
457
+ type MentionOption,
458
+ type MentionQuery,
459
+ } from './skill-mentions.js';
460
+ export { toolsForMode } from './tool-modes.js';
424
461
 
425
462
  export type {
426
463
  Workflow,
@@ -491,7 +528,7 @@ export type {
491
528
  ResolvedPluginManifest,
492
529
  } from './plugin.js';
493
530
 
494
- export { startChannelWith, EXIT_AFTER_PAIR_FLAG, exitAfterPairRequested } from './channel.js';
531
+ export { startChannelWith, EXIT_AFTER_PAIR_FLAG, exitAfterPairRequested, finishPairing } from './channel.js';
495
532
  export type {
496
533
  Channel,
497
534
  ChannelHandle,