toolnexus 0.19.0 → 0.21.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/README.md +40 -0
- package/dist/agents/home.js +4 -2
- package/dist/agents/runtime.d.ts +12 -3
- package/dist/agents/runtime.js +4 -3
- package/dist/batteries.d.ts +223 -0
- package/dist/batteries.js +488 -0
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +324 -46
- package/dist/classifier.d.ts +14 -0
- package/dist/classifier.js +37 -2
- package/dist/client.d.ts +35 -17
- package/dist/client.js +153 -67
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/judge.d.ts +98 -0
- package/dist/judge.js +199 -0
- package/package.json +2 -2
- package/src/agents/home.ts +4 -2
- package/src/agents/runtime.ts +12 -5
- package/src/batteries.ts +614 -0
- package/src/builtin.ts +361 -46
- package/src/classifier.ts +39 -2
- package/src/client.ts +166 -67
- package/src/index.ts +26 -0
- package/src/judge.ts +305 -0
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple judgments (SPEC.md §8B "Simple judgments — ask / gate", change `add-judge-adapters`).
|
|
3
|
+
* A thin layer over any `Classifier`: named question builders, a role-carrying `State`,
|
|
4
|
+
* `ask` (answers by name with bands) and `gate` / `Policy` (rules that escalate instead of
|
|
5
|
+
* deciding when unsure). The wire is unchanged — the builders produce exactly the §8B
|
|
6
|
+
* `evaluate(state, questions)` inputs.
|
|
7
|
+
*
|
|
8
|
+
* A gate never authorises; it only declines to decide.
|
|
9
|
+
*/
|
|
10
|
+
import { Classifier, Decision, type DecisionAnswer, type NoulCriteria, type Question, type RecordedDecision } from "./classifier.js";
|
|
11
|
+
import type { Request } from "./types.js";
|
|
12
|
+
/** One named question of an ordered question list. */
|
|
13
|
+
export interface NamedQuestion {
|
|
14
|
+
name: string;
|
|
15
|
+
question: Question;
|
|
16
|
+
}
|
|
17
|
+
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
18
|
+
export declare function noul(name: string, instructions: string, criteria?: NoulCriteria): NamedQuestion;
|
|
19
|
+
/** `choice` question with a name; `options` maps option -> what picking it MEANS. */
|
|
20
|
+
export declare function choice(name: string, instructions: string, options: Record<string, string>): NamedQuestion;
|
|
21
|
+
/** `score` question with a name; `levels` is the ordered rubric. */
|
|
22
|
+
export declare function score(name: string, instructions: string, levels: string[]): NamedQuestion;
|
|
23
|
+
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
24
|
+
export declare function questionMap(questions: readonly NamedQuestion[]): Record<string, Question>;
|
|
25
|
+
/** `role` next to the data's fields; a non-object value goes under `data`. */
|
|
26
|
+
export declare function State(role: string, data: unknown): Record<string, unknown>;
|
|
27
|
+
/** Sugar: `{context, message, ...extra}`. */
|
|
28
|
+
export declare function context(ctx: string, message: string, extra?: Record<string, unknown>): Record<string, unknown>;
|
|
29
|
+
export type Band = "yes" | "no" | "uncertain";
|
|
30
|
+
export interface Bands {
|
|
31
|
+
low: number;
|
|
32
|
+
high: number;
|
|
33
|
+
}
|
|
34
|
+
/** Default cut-points; exclusive on the confident side. */
|
|
35
|
+
export declare const DEFAULT_BANDS: Readonly<Bands>;
|
|
36
|
+
/** One answer from `ask`: the §8B answer plus `band` (noul) or `sure` (choice/score). */
|
|
37
|
+
export type Answer = DecisionAnswer & {
|
|
38
|
+
/** noul only. */
|
|
39
|
+
band?: Band;
|
|
40
|
+
/** choice / score only. */
|
|
41
|
+
sure?: boolean;
|
|
42
|
+
/** The one number: noul probability, score value, or choice confidence. */
|
|
43
|
+
value(): number;
|
|
44
|
+
/** The picked option of a choice answer ("" otherwise). JS keeps §8B's `choice` string FIELD on a
|
|
45
|
+
* choice answer (a method of the same name would shadow it), so this is `pick()`. */
|
|
46
|
+
pick(): string;
|
|
47
|
+
};
|
|
48
|
+
export type Answers = Record<string, Answer>;
|
|
49
|
+
/** Band / sure every answer of a decision. */
|
|
50
|
+
export declare function answersOf(decision: Decision, bands?: Bands): Answers;
|
|
51
|
+
/** Evaluate once; answers by name, each with a band (noul) or `sure` (choice/score). */
|
|
52
|
+
export declare function ask(classifier: Classifier, state: unknown, questions: readonly NamedQuestion[], bands?: Bands): Promise<Answers>;
|
|
53
|
+
export interface Rule {
|
|
54
|
+
question: string;
|
|
55
|
+
below?: number;
|
|
56
|
+
at_least?: number;
|
|
57
|
+
is?: string;
|
|
58
|
+
action: string;
|
|
59
|
+
target?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface GateOutcome {
|
|
62
|
+
action: string;
|
|
63
|
+
target: string;
|
|
64
|
+
escalated: boolean;
|
|
65
|
+
/** Present iff escalated: a §10 `input` Request. */
|
|
66
|
+
request?: Request;
|
|
67
|
+
answers: Answers;
|
|
68
|
+
}
|
|
69
|
+
export interface Policy {
|
|
70
|
+
rules: Rule[];
|
|
71
|
+
/** The no-rule-fired action; empty escalates with reason "no rule fired". */
|
|
72
|
+
default?: string;
|
|
73
|
+
bands?: Bands;
|
|
74
|
+
/** Skip a rule whose answer is uncertain instead of escalating on it. */
|
|
75
|
+
skipUncertain?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** Pure half of `gate`: first-match rules; an unsure or missing answer on rule i escalates and wins. */
|
|
78
|
+
export declare function applyGate(answers: Answers, rules: readonly Rule[]): GateOutcome;
|
|
79
|
+
/** Pure half of a `Policy`. */
|
|
80
|
+
export declare function applyPolicy(answers: Answers, policy: Policy): GateOutcome;
|
|
81
|
+
/** Ask, then apply rules; no rule fired -> empty action (use a `Policy` for a declared default). */
|
|
82
|
+
export declare function gate(classifier: Classifier, state: unknown, questions: readonly NamedQuestion[], rules: readonly Rule[], bands?: Bands): Promise<GateOutcome>;
|
|
83
|
+
/** Ask, then apply a `Policy` (rules, default, bands, skipUncertain). */
|
|
84
|
+
export declare function decide(classifier: Classifier, state: unknown, questions: readonly NamedQuestion[], policy: Policy): Promise<GateOutcome>;
|
|
85
|
+
/** One-line `static` classifier from recorded decisions. */
|
|
86
|
+
export declare function staticClassifier(decisions: RecordedDecision[] | RecordedDecision, model?: string): Classifier;
|
|
87
|
+
/**
|
|
88
|
+
* Records live decisions keyed by a caller-given call name, and replays them with no network.
|
|
89
|
+
* `tape.classifier(name)` returns a Classifier: recording (live given) or replaying (no live).
|
|
90
|
+
*/
|
|
91
|
+
export declare class Tape {
|
|
92
|
+
private readonly live?;
|
|
93
|
+
readonly entries: Record<string, RecordedDecision>;
|
|
94
|
+
constructor(live?: Classifier | undefined, entries?: Record<string, RecordedDecision>);
|
|
95
|
+
/** A replay-only tape from saved entries. */
|
|
96
|
+
static replay(entries: Record<string, RecordedDecision>): Tape;
|
|
97
|
+
classifier(name: string): Classifier;
|
|
98
|
+
}
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple judgments (SPEC.md §8B "Simple judgments — ask / gate", change `add-judge-adapters`).
|
|
3
|
+
* A thin layer over any `Classifier`: named question builders, a role-carrying `State`,
|
|
4
|
+
* `ask` (answers by name with bands) and `gate` / `Policy` (rules that escalate instead of
|
|
5
|
+
* deciding when unsure). The wire is unchanged — the builders produce exactly the §8B
|
|
6
|
+
* `evaluate(state, questions)` inputs.
|
|
7
|
+
*
|
|
8
|
+
* A gate never authorises; it only declines to decide.
|
|
9
|
+
*/
|
|
10
|
+
import { Classifier, noul as noulQ, choice as choiceQ, score as scoreQ, } from "./classifier.js";
|
|
11
|
+
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
12
|
+
export function noul(name, instructions, criteria) {
|
|
13
|
+
return { name, question: noulQ(instructions, criteria) };
|
|
14
|
+
}
|
|
15
|
+
/** `choice` question with a name; `options` maps option -> what picking it MEANS. */
|
|
16
|
+
export function choice(name, instructions, options) {
|
|
17
|
+
return { name, question: choiceQ(instructions, options) };
|
|
18
|
+
}
|
|
19
|
+
/** `score` question with a name; `levels` is the ordered rubric. */
|
|
20
|
+
export function score(name, instructions, levels) {
|
|
21
|
+
return { name, question: scoreQ(instructions, levels) };
|
|
22
|
+
}
|
|
23
|
+
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
24
|
+
export function questionMap(questions) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const { name, question } of questions) {
|
|
27
|
+
if (Object.prototype.hasOwnProperty.call(out, name))
|
|
28
|
+
throw new Error(`duplicate question name ${JSON.stringify(name)}`);
|
|
29
|
+
out[name] = question;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------- state
|
|
34
|
+
/** `role` next to the data's fields; a non-object value goes under `data`. */
|
|
35
|
+
export function State(role, data) {
|
|
36
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data))
|
|
37
|
+
return { role, ...data };
|
|
38
|
+
return { role, data };
|
|
39
|
+
}
|
|
40
|
+
/** Sugar: `{context, message, ...extra}`. */
|
|
41
|
+
export function context(ctx, message, extra = {}) {
|
|
42
|
+
return { context: ctx, message, ...extra };
|
|
43
|
+
}
|
|
44
|
+
/** Default cut-points; exclusive on the confident side. */
|
|
45
|
+
export const DEFAULT_BANDS = Object.freeze({ low: 0.3, high: 0.7 });
|
|
46
|
+
function noulBand(p, b) {
|
|
47
|
+
return p < b.low ? "no" : p > b.high ? "yes" : "uncertain";
|
|
48
|
+
}
|
|
49
|
+
function wrap(a, b) {
|
|
50
|
+
const extra = a.type === "noul"
|
|
51
|
+
? { band: noulBand(a.noul, b) }
|
|
52
|
+
: { sure: a.confidence > b.high && !(a.type === "choice" && a.nearUniform) };
|
|
53
|
+
const value = () => (a.type === "noul" ? a.noul : a.type === "score" ? a.score : a.confidence);
|
|
54
|
+
const pick = () => (a.type === "choice" ? a.choice : "");
|
|
55
|
+
const out = { ...a, ...extra };
|
|
56
|
+
Object.defineProperty(out, "value", { value, enumerable: false });
|
|
57
|
+
Object.defineProperty(out, "pick", { value: pick, enumerable: false });
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Band / sure every answer of a decision. */
|
|
61
|
+
export function answersOf(decision, bands = DEFAULT_BANDS) {
|
|
62
|
+
return Object.fromEntries(Object.entries(decision.answers).map(([k, a]) => [k, wrap(a, bands)]));
|
|
63
|
+
}
|
|
64
|
+
/** Evaluate once; answers by name, each with a band (noul) or `sure` (choice/score). */
|
|
65
|
+
export async function ask(classifier, state, questions, bands = DEFAULT_BANDS) {
|
|
66
|
+
return answersOf(await classifier.evaluate(state, questionMap(questions)), bands);
|
|
67
|
+
}
|
|
68
|
+
function unsure(a) {
|
|
69
|
+
return a.type === "noul" ? a.band === "uncertain" : !a.sure;
|
|
70
|
+
}
|
|
71
|
+
function escalate(answers, id, question, reason, prompt) {
|
|
72
|
+
return {
|
|
73
|
+
action: "needs_input",
|
|
74
|
+
target: "",
|
|
75
|
+
escalated: true,
|
|
76
|
+
answers,
|
|
77
|
+
request: { id, kind: "input", prompt, data: { question, reason, answers } },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function applyRules(answers, rules, skipUncertain) {
|
|
81
|
+
for (const [i, r] of rules.entries()) {
|
|
82
|
+
const a = answers[r.question];
|
|
83
|
+
if (!a || unsure(a)) {
|
|
84
|
+
if (a && skipUncertain)
|
|
85
|
+
continue;
|
|
86
|
+
const reason = `${a ? "uncertain" : "missing"} answer "${r.question}"`;
|
|
87
|
+
return escalate(answers, `gate:${i}:${r.question}`, r.question, reason, `Classifier is unsure about "${r.question}" (${reason}). Decide rule ${i} (${r.action}).`);
|
|
88
|
+
}
|
|
89
|
+
const key = JSON.stringify(r.question);
|
|
90
|
+
let fired = false;
|
|
91
|
+
let misfit = "";
|
|
92
|
+
if (r.is !== undefined) {
|
|
93
|
+
if (a.type === "choice")
|
|
94
|
+
fired = a.choice === r.is;
|
|
95
|
+
else
|
|
96
|
+
misfit = `is-rule on ${a.type} answer ${key}`;
|
|
97
|
+
}
|
|
98
|
+
else if (a.type === "choice") {
|
|
99
|
+
misfit = `numeric rule on choice answer ${key}`;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const v = a.type === "noul" ? a.noul : a.score;
|
|
103
|
+
if (r.below !== undefined)
|
|
104
|
+
fired = v < r.below;
|
|
105
|
+
else if (r.at_least !== undefined)
|
|
106
|
+
fired = v >= r.at_least;
|
|
107
|
+
else
|
|
108
|
+
misfit = `rule on ${key} has no below / at_least / is`;
|
|
109
|
+
}
|
|
110
|
+
// A rule that does not fit its answer escalates; it is never skipped.
|
|
111
|
+
if (misfit)
|
|
112
|
+
return escalate(answers, `gate:${i}:${r.question}`, r.question, misfit, `Rule ${i} (${r.action}) does not fit its answer (${misfit}). Decide.`);
|
|
113
|
+
if (fired)
|
|
114
|
+
return { action: r.action, target: r.target ?? "", escalated: false, answers };
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Pure half of `gate`: first-match rules; an unsure or missing answer on rule i escalates and wins. */
|
|
119
|
+
export function applyGate(answers, rules) {
|
|
120
|
+
return applyRules(answers, rules, false) ?? { action: "", target: "", escalated: false, answers };
|
|
121
|
+
}
|
|
122
|
+
/** Pure half of a `Policy`. */
|
|
123
|
+
export function applyPolicy(answers, policy) {
|
|
124
|
+
const hit = applyRules(answers, policy.rules, policy.skipUncertain ?? false);
|
|
125
|
+
if (hit)
|
|
126
|
+
return hit;
|
|
127
|
+
if (policy.default)
|
|
128
|
+
return { action: policy.default, target: "", escalated: false, answers };
|
|
129
|
+
return escalate(answers, "gate:default", "", "no rule fired", "No rule fired. Decide the action.");
|
|
130
|
+
}
|
|
131
|
+
/** Ask, then apply rules; no rule fired -> empty action (use a `Policy` for a declared default). */
|
|
132
|
+
export async function gate(classifier, state, questions, rules, bands = DEFAULT_BANDS) {
|
|
133
|
+
return applyGate(await ask(classifier, state, questions, bands), rules);
|
|
134
|
+
}
|
|
135
|
+
/** Ask, then apply a `Policy` (rules, default, bands, skipUncertain). */
|
|
136
|
+
export async function decide(classifier, state, questions, policy) {
|
|
137
|
+
return applyPolicy(await ask(classifier, state, questions, policy.bands ?? DEFAULT_BANDS), policy);
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------- static + tape
|
|
140
|
+
/** One-line `static` classifier from recorded decisions. */
|
|
141
|
+
export function staticClassifier(decisions, model) {
|
|
142
|
+
return new Classifier({ style: "static", decisions: Array.isArray(decisions) ? decisions : [decisions], model });
|
|
143
|
+
}
|
|
144
|
+
/** The recorded-body form of a Decision, decodable by `static`. */
|
|
145
|
+
function toRaw(d) {
|
|
146
|
+
const answers = {};
|
|
147
|
+
for (const [k, a] of Object.entries(d.answers)) {
|
|
148
|
+
if (a.type === "choice") {
|
|
149
|
+
const { nearUniform: _derived, ...rest } = a;
|
|
150
|
+
answers[k] = rest;
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
answers[k] = { ...a };
|
|
154
|
+
}
|
|
155
|
+
const usage = { input_tokens: d.usage.inputTokens, output_tokens: d.usage.outputTokens };
|
|
156
|
+
if (d.usage.cost !== undefined)
|
|
157
|
+
usage.cost = d.usage.cost;
|
|
158
|
+
return { model: d.model, answers, usage, calibrated: d.calibrated };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Records live decisions keyed by a caller-given call name, and replays them with no network.
|
|
162
|
+
* `tape.classifier(name)` returns a Classifier: recording (live given) or replaying (no live).
|
|
163
|
+
*/
|
|
164
|
+
export class Tape {
|
|
165
|
+
live;
|
|
166
|
+
entries;
|
|
167
|
+
constructor(live, entries = {}) {
|
|
168
|
+
this.live = live;
|
|
169
|
+
this.entries = { ...entries };
|
|
170
|
+
}
|
|
171
|
+
/** A replay-only tape from saved entries. */
|
|
172
|
+
static replay(entries) {
|
|
173
|
+
return new Tape(undefined, entries);
|
|
174
|
+
}
|
|
175
|
+
classifier(name) {
|
|
176
|
+
const live = this.live;
|
|
177
|
+
if (live) {
|
|
178
|
+
return new Classifier({
|
|
179
|
+
style: "custom",
|
|
180
|
+
evaluate: async (state, questions, signal) => {
|
|
181
|
+
const d = await live.evaluate(state, questions, signal);
|
|
182
|
+
this.entries[name] = { state, questions, response: toRaw(d) };
|
|
183
|
+
return d;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// Replay: resolved when evaluated (a miss fails then, naming the key, and sends nothing).
|
|
188
|
+
return new Classifier({
|
|
189
|
+
style: "custom",
|
|
190
|
+
evaluate: async (state, questions, signal) => {
|
|
191
|
+
const rec = this.entries[name];
|
|
192
|
+
if (!rec)
|
|
193
|
+
throw new Error(`tape: no recorded decision for call "${name}"`);
|
|
194
|
+
const raw = rec.response;
|
|
195
|
+
return staticClassifier(rec, raw?.model).evaluate(state, questions, signal);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
|
package/src/agents/home.ts
CHANGED
|
@@ -234,9 +234,11 @@ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions)
|
|
|
234
234
|
})
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
// keepAlive:false — a heartbeat is housekeeping and must never be the reason
|
|
238
|
+
// a host process stays up.
|
|
239
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }) // self-reschedule → one live timer
|
|
238
240
|
}
|
|
239
|
-
cancel = runtime.clock.setTimeout(beat, hb.everyMs)
|
|
241
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false })
|
|
240
242
|
|
|
241
243
|
return {
|
|
242
244
|
runtime,
|
package/src/agents/runtime.ts
CHANGED
|
@@ -39,16 +39,23 @@ import type { Answer, Request, Tool, ToolResult } from "../types.js"
|
|
|
39
39
|
export interface Clock {
|
|
40
40
|
/** Current time in ms (epoch or virtual). */
|
|
41
41
|
now(): number
|
|
42
|
-
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
43
|
-
|
|
42
|
+
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
43
|
+
*
|
|
44
|
+
* `keepAlive` defaults to TRUE, and the default is the important half: a timer
|
|
45
|
+
* that cannot hold the event loop open never fires when it is the only thing
|
|
46
|
+
* left to do — which is precisely a deadline whose promise nothing else will
|
|
47
|
+
* settle. Background housekeeping that should never hold a process open (a
|
|
48
|
+
* heartbeat) passes `false` explicitly. A host's own Clock may ignore the
|
|
49
|
+
* option; it then behaves as that host's timers always have. */
|
|
50
|
+
setTimeout(fn: () => void, ms: number, opts?: { keepAlive?: boolean }): () => void
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
/** The real clock (default).
|
|
53
|
+
/** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
|
|
47
54
|
export const systemClock: Clock = {
|
|
48
55
|
now: () => Date.now(),
|
|
49
|
-
setTimeout(fn, ms) {
|
|
56
|
+
setTimeout(fn, ms, opts) {
|
|
50
57
|
const t = setTimeout(fn, ms)
|
|
51
|
-
|
|
58
|
+
if (opts?.keepAlive === false) (t as { unref?: () => void }).unref?.()
|
|
52
59
|
return () => clearTimeout(t)
|
|
53
60
|
},
|
|
54
61
|
}
|