@particle-academy/prism-harness 0.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. package/package.json +34 -0
@@ -0,0 +1,198 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isJsonObject } from './json.js';
3
+ import { StoreTaskSource } from './store-task-source.js';
4
+ import { Thread } from './thread.js';
5
+ /**
6
+ * One participant's live runtime, reconstructed per turn.
7
+ *
8
+ * RESOLVED, NEVER HELD. Nothing survives in memory between turns, so a fresh
9
+ * worker resolving the same address has to see the same active mode, the same
10
+ * model and the same conversation as whatever set them. Everything here reads
11
+ * through a store for that reason, rather than being a property that happens to
12
+ * be populated.
13
+ *
14
+ * State is split deliberately:
15
+ *
16
+ * - mode, model, provider and run bookkeeping are EPHEMERAL. Lose them and the
17
+ * next turn falls back to a default, which is a shrug.
18
+ * - the thread and stored capabilities are DURABLE.
19
+ */
20
+ export class Session {
21
+ scope;
22
+ participant;
23
+ #ephemeral;
24
+ #durable;
25
+ #ttlSeconds;
26
+ #cachedState = null;
27
+ constructor(options) {
28
+ this.participant = options.participant;
29
+ this.scope = options.scope;
30
+ this.#ephemeral = options.ephemeral;
31
+ this.#durable = options.durable;
32
+ this.#ttlSeconds = options.ttlSeconds ?? null;
33
+ }
34
+ /**
35
+ * The address this session is resolved by.
36
+ *
37
+ * Participant AND scope, because one participant holds several unrelated
38
+ * conversations at once and they must not collide.
39
+ *
40
+ * The type is HASHED rather than interpolated, and with sha1 truncated to 12
41
+ * characters — byte for byte what the PHP reference produces. A class name
42
+ * contains backslashes there, which make for awkward store keys and leak the
43
+ * application's namespace layout into something visible in tooling. Matching
44
+ * the reference exactly is what lets a PHP app and a TypeScript agent share
45
+ * ONE store and resolve the same session.
46
+ */
47
+ key() {
48
+ const digest = createHash('sha1').update(this.participant.type).digest('hex').slice(0, 12);
49
+ return `session:${digest}:${String(this.participant.id)}:${this.scope}`;
50
+ }
51
+ // -- ephemeral half --------------------------------------------------------
52
+ async mode() {
53
+ return this.#readString('mode');
54
+ }
55
+ async usingMode(mode) {
56
+ return this.#write('mode', mode);
57
+ }
58
+ async model() {
59
+ return this.#readString('model');
60
+ }
61
+ async usingModel(model) {
62
+ return this.#write('model', model);
63
+ }
64
+ async provider() {
65
+ return this.#readString('provider');
66
+ }
67
+ async usingProvider(provider) {
68
+ return this.#write('provider', provider);
69
+ }
70
+ async state() {
71
+ this.#cachedState ??= (await this.#ephemeral.get(this.#ephemeralKey())) ?? {};
72
+ return this.#cachedState;
73
+ }
74
+ /** Drop the ephemeral half. THE CONVERSATION IS UNTOUCHED. */
75
+ async forget() {
76
+ await this.#ephemeral.forget(this.#ephemeralKey());
77
+ this.#cachedState = null;
78
+ return this;
79
+ }
80
+ // -- durable half ----------------------------------------------------------
81
+ /** The stored conversation this session is bound to. */
82
+ thread() {
83
+ return new Thread(this.#durable, `${this.key()}:thread`);
84
+ }
85
+ /**
86
+ * The agent task list this session is bound to.
87
+ *
88
+ * The DURABLE half, alongside the thread, for the same reason: a
89
+ * half-finished task list that vanishes on a deploy is indistinguishable from
90
+ * a finished one. A volatile durable slot has already been refused by the
91
+ * store manager before this can be reached, and `StoreTaskSource` refuses one
92
+ * again on its own -- the second check is what makes a source built directly
93
+ * against a store as safe as one reached through here.
94
+ */
95
+ tasks(options = {}) {
96
+ return new StoreTaskSource(this.#durable, `${this.key()}:tasks`, options);
97
+ }
98
+ async capability(name) {
99
+ const stored = (await this.#durable.get(this.#durableKey())) ?? {};
100
+ const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
101
+ const capability = capabilities[name];
102
+ return isJsonObject(capability) ? capability : null;
103
+ }
104
+ async usingCapability(name, state) {
105
+ return this.#writeCapabilities((capabilities) => ({ ...capabilities, [name]: state }));
106
+ }
107
+ async forgetCapability(name) {
108
+ return this.#writeCapabilities(({ [name]: _dropped, ...rest }) => rest);
109
+ }
110
+ // -- runs ------------------------------------------------------------------
111
+ async run() {
112
+ const run = (await this.state()).run;
113
+ return isJsonObject(run) && typeof run.id === 'string' ? run : null;
114
+ }
115
+ async beginRun(id, mode, provider, model) {
116
+ return this.#write('run', {
117
+ id,
118
+ status: 'running',
119
+ mode,
120
+ provider,
121
+ model,
122
+ started_at: new Date().toISOString(),
123
+ });
124
+ }
125
+ /**
126
+ * @param toolCalls the NAMES of the tools this run invoked, in order.
127
+ *
128
+ * NAMES ONLY, and that boundary is deliberate. "Which tools did this run
129
+ * reach for" is what an operator needs to audit a guardrail, and a tool name
130
+ * is not PII. ARGUMENTS are — `prism-opentelemetry` already carries them
131
+ * behind an opt-in capture gate with a length cap, and recording them a
132
+ * second time here, ungated, would quietly undo that decision for everyone
133
+ * who installed both.
134
+ */
135
+ async completeRun(id, finishReason, toolCalls = []) {
136
+ return this.#finishRun(id, 'completed', {
137
+ finish_reason: finishReason,
138
+ tool_calls: [...toolCalls],
139
+ });
140
+ }
141
+ async failRun(id, failure) {
142
+ return this.#finishRun(id, 'failed', { failure });
143
+ }
144
+ /**
145
+ * Run something that MUST NOT HAPPEN TWICE.
146
+ *
147
+ * Two workers can hold the same session at the same moment: a queued job
148
+ * finishing a run while the user sends another message is ordinary. Advance a
149
+ * run inside this, not outside it.
150
+ */
151
+ async lock(callback, ttlSeconds = 10, waitSeconds = 5) {
152
+ return this.#ephemeral.withLock(this.key(), async () => {
153
+ // Re-read inside the lock. State written by whoever held it before us
154
+ // is otherwise invisible to this instance, and acting on a stale read
155
+ // is the thing the lock exists to prevent.
156
+ this.#cachedState = null;
157
+ return callback(this);
158
+ }, ttlSeconds, waitSeconds);
159
+ }
160
+ // -- internals -------------------------------------------------------------
161
+ #ephemeralKey() {
162
+ return `${this.key()}:ephemeral`;
163
+ }
164
+ #durableKey() {
165
+ return `${this.key()}:durable`;
166
+ }
167
+ async #readString(key) {
168
+ const value = (await this.state())[key];
169
+ return typeof value === 'string' ? value : null;
170
+ }
171
+ async #write(key, value) {
172
+ const state = { ...(await this.state()), [key]: value };
173
+ await this.#ephemeral.put(this.#ephemeralKey(), state, this.#ttlSeconds);
174
+ this.#cachedState = state;
175
+ return this;
176
+ }
177
+ async #writeCapabilities(mutate) {
178
+ const stored = (await this.#durable.get(this.#durableKey())) ?? {};
179
+ const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
180
+ await this.#durable.put(this.#durableKey(), { ...stored, capabilities: mutate(capabilities) });
181
+ return this;
182
+ }
183
+ async #finishRun(id, status, extra) {
184
+ const current = await this.run();
185
+ // A run that is not the one in flight does not overwrite it. A late worker
186
+ // reporting on a superseded run would otherwise mark the live one finished.
187
+ if (current !== null && current.id !== id) {
188
+ return this;
189
+ }
190
+ return this.#write('run', {
191
+ ...(current ?? { id }),
192
+ id,
193
+ status,
194
+ finished_at: new Date().toISOString(),
195
+ ...extra,
196
+ });
197
+ }
198
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Skills a mode can load into its system prompt, read from disk.
3
+ *
4
+ * ## The read is GUARDED, and that is the whole of it
5
+ *
6
+ * A skill name and a path both arrive from configuration or, worse, from a
7
+ * model that was told it may fetch a referenced file. Reading either without
8
+ * checking is a path traversal with extra steps, and the file it would reach is
9
+ * on the machine running the agent.
10
+ *
11
+ * Three checks, in this order, each closing something the others do not:
12
+ *
13
+ * 1. the NAME matches a strict pattern — this is what stops `../` in the
14
+ * name itself, before it is ever joined to anything;
15
+ * 2. the relative PATH is rejected lexically if it is absolute or contains a
16
+ * `..` segment;
17
+ * 3. the resolved REAL path must still sit inside the skill's own real root,
18
+ * which is what catches a symlink pointing out of it — the one thing the
19
+ * first two checks cannot see.
20
+ *
21
+ * The third is not redundant. A lexically innocent `notes/link.md` that is a
22
+ * symlink to `/etc/passwd` passes both earlier checks.
23
+ */
24
+ export declare class SkillRegistry {
25
+ #private;
26
+ constructor(root: string);
27
+ /**
28
+ * The system prompt with each named skill appended as a tagged section.
29
+ *
30
+ * Returns the prompt UNCHANGED when no skills are named, rather than
31
+ * appending an empty preamble that would tell the model skills are available
32
+ * when none are.
33
+ */
34
+ augmentPrompt(systemPrompt: string, names: readonly string[]): string;
35
+ /** Read one file from inside a skill. See the class docblock for the guard. */
36
+ read(name: string, path: string): string;
37
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,73 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { HarnessError } from './errors.js';
4
+ const SKILL_NAME = /^[a-z0-9][a-z0-9_-]*$/;
5
+ const TRAVERSAL = /(^|\/)\.\.(\/|$)/;
6
+ /**
7
+ * Skills a mode can load into its system prompt, read from disk.
8
+ *
9
+ * ## The read is GUARDED, and that is the whole of it
10
+ *
11
+ * A skill name and a path both arrive from configuration or, worse, from a
12
+ * model that was told it may fetch a referenced file. Reading either without
13
+ * checking is a path traversal with extra steps, and the file it would reach is
14
+ * on the machine running the agent.
15
+ *
16
+ * Three checks, in this order, each closing something the others do not:
17
+ *
18
+ * 1. the NAME matches a strict pattern — this is what stops `../` in the
19
+ * name itself, before it is ever joined to anything;
20
+ * 2. the relative PATH is rejected lexically if it is absolute or contains a
21
+ * `..` segment;
22
+ * 3. the resolved REAL path must still sit inside the skill's own real root,
23
+ * which is what catches a symlink pointing out of it — the one thing the
24
+ * first two checks cannot see.
25
+ *
26
+ * The third is not redundant. A lexically innocent `notes/link.md` that is a
27
+ * symlink to `/etc/passwd` passes both earlier checks.
28
+ */
29
+ export class SkillRegistry {
30
+ #root;
31
+ constructor(root) {
32
+ this.#root = resolve(root);
33
+ }
34
+ /**
35
+ * The system prompt with each named skill appended as a tagged section.
36
+ *
37
+ * Returns the prompt UNCHANGED when no skills are named, rather than
38
+ * appending an empty preamble that would tell the model skills are available
39
+ * when none are.
40
+ */
41
+ augmentPrompt(systemPrompt, names) {
42
+ const sections = names.map((name) => `<skill name="${name}">\n${this.read(name, 'SKILL.md')}\n</skill>`);
43
+ if (sections.length === 0) {
44
+ return systemPrompt;
45
+ }
46
+ return `${systemPrompt}\n\nThe following Harness-owned skills are available. Follow their routing instructions and use skill_read for referenced files. Do not copy skill files into the project workspace.\n\n${sections.join('\n\n')}`.trim();
47
+ }
48
+ /** Read one file from inside a skill. See the class docblock for the guard. */
49
+ read(name, path) {
50
+ if (!SKILL_NAME.test(name)) {
51
+ throw HarnessError.skillPathRefused(`the skill name [${name}] is not a valid name`);
52
+ }
53
+ const relative = path.replaceAll('\\', '/').trim();
54
+ if (relative === '' || relative.startsWith('/') || TRAVERSAL.test(relative)) {
55
+ throw HarnessError.skillPathRefused(`[${path}] must stay inside the skill`);
56
+ }
57
+ let skillRoot;
58
+ let file;
59
+ try {
60
+ skillRoot = realpathSync.native(join(this.#root, name));
61
+ file = realpathSync.native(join(this.#root, name, ...relative.split('/')));
62
+ }
63
+ catch {
64
+ throw HarnessError.skillPathRefused(`[${name}/${relative}] was not found`);
65
+ }
66
+ // The check the lexical ones cannot make: a symlink inside the skill that
67
+ // points out of it is lexically innocent and resolves elsewhere.
68
+ if (!file.startsWith(skillRoot + sep) || !statSync(file).isFile()) {
69
+ throw HarnessError.skillPathRefused(`[${name}/${relative}] resolves outside the skill`);
70
+ }
71
+ return readFileSync(file, 'utf8');
72
+ }
73
+ }
@@ -0,0 +1,169 @@
1
+ import type { Durability, SessionStore } from './stores/session-store.js';
2
+ import type { RunBudget, RunLedger } from './subagents.js';
3
+ import type { AgentTask, AgentTaskRecord, AgentTaskSource, LeasedAgentTask, TaskOutcome, TaskState } from './tasks.js';
4
+ /** Integer Unix seconds. Injectable so a lease can be expired in a test without waiting for one. */
5
+ export type Clock = () => number;
6
+ export interface StoreTaskSourceOptions {
7
+ /** Defaults to the system clock, truncated to whole seconds. */
8
+ now?: Clock;
9
+ }
10
+ /**
11
+ * A task whose state was read at a point in time.
12
+ *
13
+ * A SNAPSHOT, and it has to be: `AgentTask.state()` is synchronous, and the
14
+ * only honest way to make it live would be to hit the store, which cannot be
15
+ * done without returning a promise. Ask `StoreTaskSource.find()` for a fresh
16
+ * read; do not treat a task object held across an await as current.
17
+ */
18
+ export declare class StoredAgentTask implements LeasedAgentTask {
19
+ #private;
20
+ constructor(record: AgentTaskRecord);
21
+ id(): string;
22
+ instruction(): string;
23
+ state(): TaskState;
24
+ claimedBy(): string | null;
25
+ claimedUntil(): number | null;
26
+ toRecord(): AgentTaskRecord;
27
+ }
28
+ /**
29
+ * The default task source: the list lives in the harness's own durable store.
30
+ *
31
+ * No schema, no migration, works on install. A consumer with an existing table
32
+ * adapts it instead (`asAgentTask` / `withAgentTask`); both satisfy one
33
+ * contract, and neither is the afterthought.
34
+ *
35
+ * ## Durable or nothing
36
+ *
37
+ * REFUSED AT CONSTRUCTION against a store that reports itself volatile. The
38
+ * package already draws this line for the durable slot and this inherits the
39
+ * rule rather than restating it: a half-finished task list that vanishes on a
40
+ * deploy is indistinguishable from a finished one, so losing it is a
41
+ * correctness failure and not a degradation to a default.
42
+ *
43
+ * ## The claim is one operation
44
+ *
45
+ * `claim()` does the whole read-expire-select-write inside ONE store lock.
46
+ * Reading the next task and then marking it taken is two operations with a
47
+ * window between them, and two workers arriving in that window both get the
48
+ * same task. That race is the reason this class exists, so the lock wraps
49
+ * everything rather than just the write.
50
+ *
51
+ * **The guarantee is the STORE'S, not this class's.** "Never the same task
52
+ * twice" holds exactly as far as `SessionStore.withLock` does:
53
+ * `MemorySessionStore`'s lock is process-local and `FileSessionStore`'s is one
54
+ * machine. Two workers on different machines over a network filesystem are not
55
+ * excluded by either, and no file lock can promise that -- point the durable
56
+ * slot at a database or Redis there. Saying so is the point: a claim that looks
57
+ * atomic and is not would hand the same expensive task to two agents.
58
+ *
59
+ * ## Size
60
+ *
61
+ * The whole list is one stored value, read and rewritten on every claim. That
62
+ * is right for the tens-of-tasks a bounded agent run produces and wrong for
63
+ * thousands: a consumer with a real backlog wants their own table and the
64
+ * application-model adapter, which is why that adapter is a first-class shape
65
+ * rather than an afterthought.
66
+ *
67
+ * ## Ordering
68
+ *
69
+ * Tasks are held in an array and `claim()` takes the first claimable one, so
70
+ * the order out is the order in. Nothing here sorts, prioritises or shuffles.
71
+ * Ordering is the divergence nothing reports: the agent just does the work in a
72
+ * different sequence and produces a different result.
73
+ */
74
+ export declare class StoreTaskSource implements AgentTaskSource {
75
+ #private;
76
+ constructor(store: SessionStore, key: string, options?: StoreTaskSourceOptions);
77
+ /** What the underlying store says about itself. Always `durable` -- see the constructor. */
78
+ durability(): Durability;
79
+ /**
80
+ * Append a task, in `todo`.
81
+ *
82
+ * The id may be supplied -- a consumer seeding a list from their own domain
83
+ * keys wants their keys -- and is generated as `t-<n>` when it is not. A
84
+ * supplied id that is already in the list is REFUSED rather than merged: two
85
+ * units of work sharing an id would share a claim.
86
+ *
87
+ * An id of `""` is refused too, compared against the empty string EXACTLY.
88
+ * Nothing is trimmed first, in any of the three languages: `trim`, `strip`
89
+ * and `String.prototype.trim` each strip a different set of codepoints, so a
90
+ * trim here would give three different answers to the same input. A single
91
+ * space is a legal id, and that is the correct behaviour.
92
+ */
93
+ add(instruction: string, id?: string): Promise<StoredAgentTask>;
94
+ /** Append several tasks at once, in the order given, with generated ids. */
95
+ addMany(instructions: readonly string[]): Promise<StoredAgentTask[]>;
96
+ /**
97
+ * Take the next claimable task, atomically, or null when there is none.
98
+ *
99
+ * The claim is WRITTEN BEFORE this returns, which is the point: the caller
100
+ * cannot begin work until the store says the task is theirs, so a worker that
101
+ * dies mid-task leaves a `claimed` row with an expiry rather than a `todo`
102
+ * row that looks like it was never attempted.
103
+ */
104
+ claim(worker: string, leaseSeconds?: number): Promise<StoredAgentTask | null>;
105
+ /**
106
+ * Record what happened, as the worker HOLDING the task. Both outcomes are TERMINAL.
107
+ *
108
+ * Refuses a task that is already terminal rather than ignoring it: two
109
+ * callers believing they own one outcome is worth reporting, and the second
110
+ * write silently winning would leave nothing recording the disagreement.
111
+ *
112
+ * Refuses a task this worker is not holding -- nobody holding it, someone
113
+ * else holding it, or a lease that has since expired all reach the same
114
+ * refusal, because they have the same answer. The state machine has no edge
115
+ * from `todo` to `done`, and a lease that expired mid-flight has already put
116
+ * the task back where anyone can take it, so the worker that finally finished
117
+ * may no longer be the one whose result counts.
118
+ *
119
+ * And refuses an OUTCOME that is not one of the two, BEFORE anything else --
120
+ * see `assertOutcome`, and the ordering note there.
121
+ */
122
+ release(task: AgentTask, worker: string, outcome: TaskOutcome): Promise<void>;
123
+ /**
124
+ * How many tasks a `claim()` could still hand out.
125
+ *
126
+ * Counts `todo` AND any `claimed` task whose lease has run out, because an
127
+ * expired lease is claimable by anyone -- a loop that stopped while such a
128
+ * task sat there would stop with work left.
129
+ *
130
+ * Reads without taking the lock. A store `get` is a single atomic read of one
131
+ * key, so the count is consistent with some moment; taking the lock would
132
+ * make it consistent with a moment that has already passed by the time the
133
+ * caller reads it.
134
+ */
135
+ pending(): Promise<number>;
136
+ /** A fresh read of one task, with any expired lease already applied, or null. */
137
+ find(id: string): Promise<StoredAgentTask | null>;
138
+ /**
139
+ * Push this worker's lease out, and ONLY while it still holds it.
140
+ *
141
+ * Bounded by the RUN, which is the whole argument. Unbounded self-extension
142
+ * is how a wedged worker holds a task forever, and a second timeout invented
143
+ * here would be one limit spelled two ways across an ecosystem -- which is
144
+ * how a limit ends up set in the place that is not enforced. The `RunBudget`
145
+ * is already the stop condition, so extension stops when the run's own
146
+ * allowance does. Nothing new to enforce and nothing new to forget.
147
+ *
148
+ * TWO checks, not one. `exhaustion()` refuses outright -- cancelled, out of
149
+ * steps, out of money or out of time -- and `remainingSeconds()` then bounds
150
+ * how much is granted. Reading the spec's "remaining wall-clock budget"
151
+ * literally and checking only the second left a cancelled run able to keep
152
+ * extending; the reference settled it the other way.
153
+ *
154
+ * Granted seconds are `min(requested, remaining)`. A budget with no
155
+ * wall-clock cap (`maxSeconds` null) grants what was asked, matching
156
+ * `RunLedger.exhaustion()`, which also has nothing to enforce in that case.
157
+ *
158
+ * The new expiry is `now + granted` EVEN WHEN THAT SHORTENS the lease. A
159
+ * first draft here took the later of the two, on the reasoning that a call
160
+ * named "extend" should never take time away. That is the wrong reasoning:
161
+ * the granted figure is what the RUN can still afford, so keeping a longer
162
+ * lease would hold a task past the budget that is supposed to bound it, and
163
+ * it would also make the result depend on the order calls happened to arrive
164
+ * in. One rule, one answer.
165
+ *
166
+ * @returns the new expiry, an integer Unix timestamp.
167
+ */
168
+ extendLease(task: AgentTask, worker: string, seconds: number, ledger: RunLedger, budget: RunBudget): Promise<number>;
169
+ }