@moxxy/sdk 0.41.2 → 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.
- package/dist/auto-approve.d.ts +14 -0
- package/dist/auto-approve.d.ts.map +1 -0
- package/dist/auto-approve.js +29 -0
- package/dist/auto-approve.js.map +1 -0
- package/dist/channel.d.ts +9 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +14 -0
- package/dist/channel.js.map +1 -1
- package/dist/client-session.d.ts +3 -0
- package/dist/client-session.d.ts.map +1 -1
- package/dist/compactor-helpers.d.ts +1 -1
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +14 -4
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/computer-control.d.ts +77 -3
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +12 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conduct.d.ts +16 -0
- package/dist/conduct.d.ts.map +1 -0
- package/dist/conduct.js +33 -0
- package/dist/conduct.js.map +1 -0
- package/dist/define.d.ts +3 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +3 -0
- package/dist/define.js.map +1 -1
- package/dist/elision-state.d.ts +9 -0
- package/dist/elision-state.d.ts.map +1 -1
- package/dist/elision-state.js +16 -3
- package/dist/elision-state.js.map +1 -1
- package/dist/events.d.ts +13 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +43 -2
- package/dist/fs-utils.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/locale.d.ts +9 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +16 -0
- package/dist/locale.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +7 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +3 -2
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +7 -0
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +26 -4
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +5 -3
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode/stuck-loop.d.ts +8 -2
- package/dist/mode/stuck-loop.d.ts.map +1 -1
- package/dist/mode/stuck-loop.js +9 -1
- package/dist/mode/stuck-loop.js.map +1 -1
- package/dist/mode-helpers.d.ts +1 -1
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +1 -1
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +10 -3
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/permission.d.ts +6 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/progress.d.ts +24 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +59 -0
- package/dist/progress.js.map +1 -0
- package/dist/provider.d.ts +11 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/resolvers.js +2 -2
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +9 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +21 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/skill-mentions.d.ts +38 -0
- package/dist/skill-mentions.d.ts.map +1 -0
- package/dist/skill-mentions.js +84 -0
- package/dist/skill-mentions.js.map +1 -0
- package/dist/skill.d.ts +6 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/supersede.d.ts +31 -0
- package/dist/supersede.d.ts.map +1 -0
- package/dist/supersede.js +34 -0
- package/dist/supersede.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +3 -2
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-gating.d.ts +9 -1
- package/dist/tool-gating.d.ts.map +1 -1
- package/dist/tool-gating.js +57 -7
- package/dist/tool-gating.js.map +1 -1
- package/dist/tool-modes.d.ts +8 -0
- package/dist/tool-modes.d.ts.map +1 -0
- package/dist/tool-modes.js +21 -0
- package/dist/tool-modes.js.map +1 -0
- package/dist/tool.d.ts +18 -0
- package/dist/tool.d.ts.map +1 -1
- package/dist/wait-for.d.ts +34 -0
- package/dist/wait-for.d.ts.map +1 -0
- package/dist/wait-for.js +67 -0
- package/dist/wait-for.js.map +1 -0
- package/package.json +11 -7
- package/src/auto-approve.test.ts +29 -0
- package/src/auto-approve.ts +29 -0
- package/src/channel-auth.test.ts +2 -1
- package/src/channel-control.test.ts +2 -1
- package/src/channel.ts +18 -0
- package/src/client-session.ts +3 -0
- package/src/compactor-helpers.test.ts +26 -0
- package/src/compactor-helpers.ts +16 -4
- package/src/computer-control.test.ts +11 -0
- package/src/computer-control.ts +16 -1
- package/src/conduct.test.ts +62 -0
- package/src/conduct.ts +37 -0
- package/src/cross-process-lock.test.ts +3 -2
- package/src/define.ts +6 -0
- package/src/elision-state.ts +24 -3
- package/src/events.ts +13 -1
- package/src/finish-pairing.test.ts +45 -0
- package/src/fs-utils.test.ts +40 -6
- package/src/fs-utils.ts +42 -2
- package/src/index.ts +38 -1
- package/src/json-file-store.test.ts +5 -3
- package/src/locale.test.ts +31 -0
- package/src/locale.ts +16 -0
- package/src/mode/checkpoint.ts +7 -0
- package/src/mode/collect-stream.ts +3 -2
- package/src/mode/project-messages.test.ts +72 -1
- package/src/mode/project-messages.ts +26 -5
- package/src/mode/react-loop.ts +6 -3
- package/src/mode/stuck-loop.ts +14 -3
- package/src/mode-helpers.ts +1 -0
- package/src/mode.ts +10 -3
- package/src/permission.ts +6 -0
- package/src/process-invocation.test.ts +3 -9
- package/src/progress.test.ts +81 -0
- package/src/progress.ts +73 -0
- package/src/provider.ts +12 -1
- package/src/resolvers.test.ts +14 -1
- package/src/resolvers.ts +2 -2
- package/src/schemas.ts +3 -0
- package/src/server.ts +2 -0
- package/src/session-like.ts +21 -1
- package/src/skill-mentions.test.ts +113 -0
- package/src/skill-mentions.ts +108 -0
- package/src/skill.ts +6 -0
- package/src/stuck-loop.test.ts +12 -0
- package/src/supersede.test.ts +115 -0
- package/src/supersede.ts +55 -0
- package/src/test-isolation.test.ts +26 -0
- package/src/token-efficiency.test.ts +7 -1
- package/src/tool-dispatch.test.ts +13 -0
- package/src/tool-dispatch.ts +3 -2
- package/src/tool-gating.test.ts +81 -0
- package/src/tool-gating.ts +53 -7
- package/src/tool-modes.test.ts +45 -0
- package/src/tool-modes.ts +23 -0
- package/src/tool.ts +18 -0
- package/src/wait-for.test.ts +132 -0
- package/src/wait-for.ts +79 -0
|
@@ -254,6 +254,28 @@ describe('runCompactionIfNeeded', () => {
|
|
|
254
254
|
expect(observedWindow).toBe(800_000);
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
it('uses a user-supplied context window for a custom model before provider fallback', async () => {
|
|
258
|
+
let observedWindow = -1;
|
|
259
|
+
const compactor: CompactorDef = {
|
|
260
|
+
name: 'inspect-custom-window',
|
|
261
|
+
shouldCompact: (_log, budget) => {
|
|
262
|
+
observedWindow = budget.contextWindow;
|
|
263
|
+
return false;
|
|
264
|
+
},
|
|
265
|
+
compact: async () => { throw new Error('should not run'); },
|
|
266
|
+
};
|
|
267
|
+
const ctx = makeCtx({
|
|
268
|
+
compactor,
|
|
269
|
+
contextWindow: 128_000,
|
|
270
|
+
ctxModelId: 'vendor/model-v2',
|
|
271
|
+
contextWindowOverride: 200_000,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await runCompactionIfNeeded(ctx);
|
|
275
|
+
|
|
276
|
+
expect(observedWindow).toBe(200_000);
|
|
277
|
+
});
|
|
278
|
+
|
|
257
279
|
it('force-compacts even when the provider exposes no usable context window', async () => {
|
|
258
280
|
// Reactive overflow recovery: the provider already said the prompt is too
|
|
259
281
|
// big, so a missing/zero window must NOT block the forced compaction.
|
|
@@ -525,6 +547,7 @@ interface MakeCtxOpts {
|
|
|
525
547
|
readonly compactor: CompactorDef | null;
|
|
526
548
|
readonly model?: string;
|
|
527
549
|
readonly contextWindow?: number;
|
|
550
|
+
readonly contextWindowOverride?: number;
|
|
528
551
|
readonly events?: ReadonlyArray<MoxxyEvent>;
|
|
529
552
|
/** Set ctx.model to an id the provider's descriptor list does NOT contain,
|
|
530
553
|
* so `models.find(...)` misses and the models[0] fallback must kick in. */
|
|
@@ -561,6 +584,9 @@ function makeCtx(opts: MakeCtxOpts): ModeContext & { emitted: EmittedEvent[] } {
|
|
|
561
584
|
// ctx.model may intentionally differ from the descriptor id to exercise the
|
|
562
585
|
// unlisted-model fallback.
|
|
563
586
|
model: opts.ctxModelId ?? opts.model ?? 'fake-model',
|
|
587
|
+
...(opts.contextWindowOverride !== undefined
|
|
588
|
+
? { contextWindowOverride: opts.contextWindowOverride }
|
|
589
|
+
: {}),
|
|
564
590
|
provider,
|
|
565
591
|
tools: { list: () => [], get: () => undefined, execute: async () => undefined },
|
|
566
592
|
skills: { list: () => [], get: () => undefined, byName: () => undefined, filterByTriggers: () => [] },
|
package/src/compactor-helpers.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
conversationalStub,
|
|
4
4
|
conversationalStubbed,
|
|
5
5
|
toolResultBytes,
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/computer-control.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
package/src/elision-state.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/fs-utils.test.ts
CHANGED
|
@@ -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
|
|
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().
|
|
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
|
|
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
|
-
|
|
209
|
+
renameOverReadersSync(tmp, target);
|
|
170
210
|
} catch (err) {
|
|
171
211
|
try {
|
|
172
212
|
rmSync(tmp, { force: true });
|