@xl0/pi-lovely-agents 0.1.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { watch } from "node:fs"
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises"
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
5
|
+
import { type Static, Type } from "typebox"
|
|
6
|
+
import { Value } from "typebox/value"
|
|
7
|
+
import { getAgentCoordinator, getBashCoordinator } from "./coordinator.js"
|
|
8
|
+
import { publishTaskUpdate } from "./updates.js"
|
|
9
|
+
|
|
10
|
+
export const TASK_METADATA_VERSION = 3
|
|
11
|
+
export const TASK_REFERENCE_PATTERN = /^[ab]_[0-9a-f]{8}$/
|
|
12
|
+
export const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
|
|
13
|
+
export const STORAGE_GITIGNORE = "*\n"
|
|
14
|
+
export const MAX_AGENT_INPUT_BYTES = 64 * 1024
|
|
15
|
+
export const MAX_AGENT_LABEL_BYTES = 80
|
|
16
|
+
export const MAX_QUEUED_FOLLOWUPS = 32
|
|
17
|
+
export const MAX_TASK_NOTIFICATIONS = 128
|
|
18
|
+
export const MAX_NOTIFICATION_CONTENT_BYTES = 8 * 1024
|
|
19
|
+
export const PARENT_LEASE_VERSION = 1
|
|
20
|
+
export const RETAINED_OUTPUT_MAX_LINES = 2_000
|
|
21
|
+
export const RETAINED_OUTPUT_MAX_BYTES = 50 * 1024
|
|
22
|
+
export const RETAINED_OUTPUT_MAX_WAIT_MS = 10 * 60 * 1_000
|
|
23
|
+
|
|
24
|
+
const DIRECTORY_MODE = 0o700
|
|
25
|
+
const FILE_MODE = 0o600
|
|
26
|
+
const MAX_TASK_REFERENCE_ATTEMPTS = 100
|
|
27
|
+
const MAX_LEASE_ACQUIRE_ATTEMPTS = 10
|
|
28
|
+
const DEFINITION_NAME_PATTERN = "^[a-z0-9][a-z0-9_-]{0,63}$"
|
|
29
|
+
const LEASE_STATE_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/parent-leases/v1")
|
|
30
|
+
const TASK_QUEUE_STATE_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/task-queues/v1")
|
|
31
|
+
|
|
32
|
+
const RunId = Type.String({ pattern: "^r_[0-9a-f]{16}$" })
|
|
33
|
+
const Timestamp = Type.Integer({ minimum: 0 })
|
|
34
|
+
const TaskState = Type.Union([
|
|
35
|
+
Type.Literal("idle"),
|
|
36
|
+
Type.Literal("queued"),
|
|
37
|
+
Type.Literal("running"),
|
|
38
|
+
Type.Literal("suspended"),
|
|
39
|
+
Type.Literal("interrupted")
|
|
40
|
+
])
|
|
41
|
+
const RunOutcome = Type.Union([Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("stopped"), Type.Literal("interrupted")])
|
|
42
|
+
const ThinkingLevel = Type.Union([
|
|
43
|
+
Type.Literal("off"),
|
|
44
|
+
Type.Literal("minimal"),
|
|
45
|
+
Type.Literal("low"),
|
|
46
|
+
Type.Literal("medium"),
|
|
47
|
+
Type.Literal("high"),
|
|
48
|
+
Type.Literal("xhigh"),
|
|
49
|
+
Type.Literal("max")
|
|
50
|
+
])
|
|
51
|
+
const ActiveRun = Type.Object(
|
|
52
|
+
{
|
|
53
|
+
id: RunId,
|
|
54
|
+
sequence: Type.Integer({ minimum: 1 }),
|
|
55
|
+
acceptanceOrder: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
56
|
+
// Acceptance policy, not current config. Missing means foreground.
|
|
57
|
+
background: Type.Optional(Type.Boolean()),
|
|
58
|
+
kind: Type.Union([Type.Literal("initial"), Type.Literal("followup")]),
|
|
59
|
+
state: Type.Union([Type.Literal("queued"), Type.Literal("running"), Type.Literal("suspended")]),
|
|
60
|
+
input: Type.String(),
|
|
61
|
+
acceptedAt: Timestamp,
|
|
62
|
+
startedAt: Type.Optional(Timestamp),
|
|
63
|
+
detachedAt: Type.Optional(Timestamp)
|
|
64
|
+
},
|
|
65
|
+
{ additionalProperties: false }
|
|
66
|
+
)
|
|
67
|
+
const QueuedFollowUp = Type.Object(
|
|
68
|
+
{
|
|
69
|
+
id: RunId,
|
|
70
|
+
sequence: Type.Integer({ minimum: 1 }),
|
|
71
|
+
acceptanceOrder: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
72
|
+
background: Type.Optional(Type.Boolean()),
|
|
73
|
+
content: Type.String(),
|
|
74
|
+
acceptedAt: Timestamp
|
|
75
|
+
},
|
|
76
|
+
{ additionalProperties: false }
|
|
77
|
+
)
|
|
78
|
+
const Notification = Type.Object(
|
|
79
|
+
{
|
|
80
|
+
id: Type.String({ minLength: 1 }),
|
|
81
|
+
type: Type.Union([Type.Literal("completion"), Type.Literal("suspension"), Type.Literal("interruption")]),
|
|
82
|
+
runId: RunId,
|
|
83
|
+
content: Type.String({ minLength: 1 }),
|
|
84
|
+
createdAt: Timestamp,
|
|
85
|
+
deliveredAt: Type.Optional(Timestamp)
|
|
86
|
+
},
|
|
87
|
+
{ additionalProperties: false }
|
|
88
|
+
)
|
|
89
|
+
const ParentLeaseFileSchema = Type.Object(
|
|
90
|
+
{
|
|
91
|
+
version: Type.Literal(PARENT_LEASE_VERSION),
|
|
92
|
+
pid: Type.Integer({ minimum: 1 }),
|
|
93
|
+
token: Type.String({ pattern: "^[0-9a-f]{32}$" }),
|
|
94
|
+
createdAt: Timestamp
|
|
95
|
+
},
|
|
96
|
+
{ additionalProperties: false }
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
/** Durable current snapshot for one agent session. */
|
|
100
|
+
export const AgentTaskMetadataSchema = Type.Object(
|
|
101
|
+
{
|
|
102
|
+
version: Type.Literal(TASK_METADATA_VERSION),
|
|
103
|
+
kind: Type.Literal("agent"),
|
|
104
|
+
taskRef: Type.String({ pattern: "^a_[0-9a-f]{8}$" }),
|
|
105
|
+
parentSessionId: Type.String({ pattern: SESSION_ID_PATTERN.source }),
|
|
106
|
+
childSessionId: Type.String({ pattern: SESSION_ID_PATTERN.source }),
|
|
107
|
+
definitionName: Type.String({ pattern: DEFINITION_NAME_PATTERN }),
|
|
108
|
+
label: Type.String({ minLength: 1 }),
|
|
109
|
+
// Bounded current/last run input for human task panels; survives settlement.
|
|
110
|
+
inputPreview: Type.Optional(Type.String({ maxLength: 512 })),
|
|
111
|
+
model: Type.Object(
|
|
112
|
+
{
|
|
113
|
+
provider: Type.String({ minLength: 1 }),
|
|
114
|
+
id: Type.String({ minLength: 1 })
|
|
115
|
+
},
|
|
116
|
+
{ additionalProperties: false }
|
|
117
|
+
),
|
|
118
|
+
thinking: ThinkingLevel,
|
|
119
|
+
depth: Type.Integer({ minimum: 1 }),
|
|
120
|
+
allowAgents: Type.Boolean(),
|
|
121
|
+
sessionConfig: Type.Object(
|
|
122
|
+
{
|
|
123
|
+
systemPrompt: Type.String({ minLength: 1 }),
|
|
124
|
+
tools: Type.Union([Type.Array(Type.String({ minLength: 1 })), Type.Null()]),
|
|
125
|
+
excludeAgentsMd: Type.Boolean(),
|
|
126
|
+
scopedModels: Type.Array(
|
|
127
|
+
Type.Object(
|
|
128
|
+
{
|
|
129
|
+
provider: Type.String({ minLength: 1 }),
|
|
130
|
+
id: Type.String({ minLength: 1 }),
|
|
131
|
+
thinkingLevel: Type.Optional(ThinkingLevel)
|
|
132
|
+
},
|
|
133
|
+
{ additionalProperties: false }
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
},
|
|
137
|
+
{ additionalProperties: false }
|
|
138
|
+
),
|
|
139
|
+
state: TaskState,
|
|
140
|
+
latestOutcome: Type.Union([RunOutcome, Type.Null()]),
|
|
141
|
+
latestReply: Type.Union([
|
|
142
|
+
Type.Object({ text: Type.String(), streaming: Type.Boolean() }, { additionalProperties: false }),
|
|
143
|
+
Type.Null()
|
|
144
|
+
]),
|
|
145
|
+
lastActivity: Type.Optional(
|
|
146
|
+
Type.Object(
|
|
147
|
+
{
|
|
148
|
+
at: Timestamp,
|
|
149
|
+
action: Type.String({ minLength: 1, maxLength: 200 })
|
|
150
|
+
},
|
|
151
|
+
{ additionalProperties: false }
|
|
152
|
+
)
|
|
153
|
+
),
|
|
154
|
+
// Pi's composed prompt at agent start, distinct from the immutable Definition recipe.
|
|
155
|
+
effectiveSystemPrompt: Type.Optional(Type.String()),
|
|
156
|
+
lastRunSequence: Type.Integer({ minimum: 0 }),
|
|
157
|
+
activeRun: Type.Union([ActiveRun, Type.Null()]),
|
|
158
|
+
queuedFollowUps: Type.Array(QueuedFollowUp, { maxItems: MAX_QUEUED_FOLLOWUPS }),
|
|
159
|
+
notifications: Type.Array(Notification, { maxItems: MAX_TASK_NOTIFICATIONS }),
|
|
160
|
+
discardedAt: Type.Union([Timestamp, Type.Null()]),
|
|
161
|
+
createdAt: Timestamp,
|
|
162
|
+
updatedAt: Timestamp
|
|
163
|
+
},
|
|
164
|
+
{ additionalProperties: false }
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
/** One shell invocation, without a Pi session or a reusable agent recipe. */
|
|
168
|
+
export const BashTaskMetadataSchema = Type.Object(
|
|
169
|
+
{
|
|
170
|
+
...Type.Pick(AgentTaskMetadataSchema, [
|
|
171
|
+
"version",
|
|
172
|
+
"parentSessionId",
|
|
173
|
+
"label",
|
|
174
|
+
"inputPreview",
|
|
175
|
+
"state",
|
|
176
|
+
"latestOutcome",
|
|
177
|
+
"latestReply",
|
|
178
|
+
"lastActivity",
|
|
179
|
+
"lastRunSequence",
|
|
180
|
+
"activeRun",
|
|
181
|
+
"queuedFollowUps",
|
|
182
|
+
"notifications",
|
|
183
|
+
"discardedAt",
|
|
184
|
+
"createdAt",
|
|
185
|
+
"updatedAt"
|
|
186
|
+
]).properties,
|
|
187
|
+
kind: Type.Literal("bash"),
|
|
188
|
+
latestReply: Type.Union([
|
|
189
|
+
Type.Object(
|
|
190
|
+
{ text: Type.String(), streaming: Type.Boolean(), truncated: Type.Optional(Type.Boolean()) },
|
|
191
|
+
{ additionalProperties: false }
|
|
192
|
+
),
|
|
193
|
+
Type.Null()
|
|
194
|
+
]),
|
|
195
|
+
taskRef: Type.String({ pattern: "^b_[0-9a-f]{8}$" }),
|
|
196
|
+
command: Type.String({ minLength: 1 }),
|
|
197
|
+
cwd: Type.String({ minLength: 1 }),
|
|
198
|
+
exitCode: Type.Union([Type.Integer(), Type.Null()]),
|
|
199
|
+
signal: Type.Union([Type.String({ minLength: 1 }), Type.Null()])
|
|
200
|
+
},
|
|
201
|
+
{ additionalProperties: false }
|
|
202
|
+
)
|
|
203
|
+
export const TaskMetadataSchema = Type.Union([AgentTaskMetadataSchema, BashTaskMetadataSchema])
|
|
204
|
+
export type AgentTaskMetadata = Static<typeof AgentTaskMetadataSchema>
|
|
205
|
+
export type BashTaskMetadata = Static<typeof BashTaskMetadataSchema>
|
|
206
|
+
export type TaskMetadata = AgentTaskMetadata | BashTaskMetadata
|
|
207
|
+
type ParentLeaseFile = Static<typeof ParentLeaseFileSchema>
|
|
208
|
+
|
|
209
|
+
/** Filesystem locations owned by one parent Pi session. */
|
|
210
|
+
export type ParentStoragePaths = {
|
|
211
|
+
workspace: string
|
|
212
|
+
root: string
|
|
213
|
+
parentSessionId: string
|
|
214
|
+
parentDirectory: string
|
|
215
|
+
lease: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Filesystem locations retained for one task. */
|
|
219
|
+
export type TaskStoragePaths = ParentStoragePaths & {
|
|
220
|
+
taskRef: string
|
|
221
|
+
taskDirectory: string
|
|
222
|
+
metadata: string
|
|
223
|
+
session: string
|
|
224
|
+
history: string
|
|
225
|
+
output: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Process-global ownership proof for one parent partition. */
|
|
229
|
+
export type ParentLease = Readonly<ParentLeaseFile & { paths: Readonly<ParentStoragePaths> }>
|
|
230
|
+
|
|
231
|
+
/** Stable model-visible paths for retained task artifacts. */
|
|
232
|
+
export type RetainedPaths = {
|
|
233
|
+
history: string
|
|
234
|
+
session?: string
|
|
235
|
+
output?: string
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Chronological inputs, replies, compact tool summaries, and run outcomes. */
|
|
239
|
+
export type HistoryEntry =
|
|
240
|
+
| { type: "run-start"; sequence: number; kind: "initial" | "followup"; timestamp: number }
|
|
241
|
+
| { type: "input"; delivery: "initial" | "followup" | "steer" | "stdin"; timestamp: number; content: string }
|
|
242
|
+
| { type: "assistant"; content: string }
|
|
243
|
+
| { type: "output"; content: string }
|
|
244
|
+
| { type: "stdin"; content: string; timestamp: number; eof?: boolean }
|
|
245
|
+
| { type: "run-end"; sequence: number; outcome: Static<typeof RunOutcome>; timestamp: number; summary?: string }
|
|
246
|
+
| { type: "tool"; tool: string; arguments: string; result: string; isError: boolean }
|
|
247
|
+
|
|
248
|
+
export type RetainedOutputReadOptions = {
|
|
249
|
+
waitMs?: number
|
|
250
|
+
signal?: AbortSignal
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Latest agent reply or Bash output tail; run status is independent of streaming. */
|
|
254
|
+
export type RetainedOutputRead = {
|
|
255
|
+
text: string
|
|
256
|
+
totalLines: number
|
|
257
|
+
truncated: boolean
|
|
258
|
+
timedOut: boolean
|
|
259
|
+
state: Static<typeof TaskState>
|
|
260
|
+
latestOutcome: Static<typeof RunOutcome> | null
|
|
261
|
+
streaming: boolean
|
|
262
|
+
queuedFollowUps: number
|
|
263
|
+
lastActivity: NonNullable<TaskMetadata["lastActivity"]> | null
|
|
264
|
+
queueReason: "capacity" | "provider-limit" | "starting" | null
|
|
265
|
+
/** Held process-wide execution permits, not the number of tasks in running state. */
|
|
266
|
+
capacity: { active: number; limit: number }
|
|
267
|
+
exitCode?: number | null
|
|
268
|
+
signal?: string | null
|
|
269
|
+
paths: RetainedPaths
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type MetadataDiagnostic = {
|
|
273
|
+
code: "unreadable" | "invalid-json" | "unsupported-version" | "invalid-metadata"
|
|
274
|
+
message: string
|
|
275
|
+
path: string
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type MetadataLoadResult =
|
|
279
|
+
| { status: "ok"; metadata: TaskMetadata }
|
|
280
|
+
| { status: "missing" }
|
|
281
|
+
| { status: "invalid"; diagnostic: MetadataDiagnostic }
|
|
282
|
+
|
|
283
|
+
type ParentLeaseLoadResult = { status: "ok"; lease: ParentLeaseFile } | { status: "missing" } | { status: "invalid"; message: string }
|
|
284
|
+
|
|
285
|
+
type ParentLeaseState = {
|
|
286
|
+
version: typeof PARENT_LEASE_VERSION
|
|
287
|
+
leases: Map<string, ParentLease>
|
|
288
|
+
queues: Map<string, Promise<void>>
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type TaskQueueState = {
|
|
292
|
+
version: 1
|
|
293
|
+
metadata: Map<string, Promise<void>>
|
|
294
|
+
logs: Map<string, Promise<void>>
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class InvalidTaskMetadataError extends Error {
|
|
298
|
+
constructor(message: string) {
|
|
299
|
+
super(message)
|
|
300
|
+
this.name = "InvalidTaskMetadataError"
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export class ParentLeaseError extends Error {
|
|
305
|
+
readonly code: string = "LOVELY_AGENTS_PARENT_LEASE"
|
|
306
|
+
|
|
307
|
+
constructor(message: string) {
|
|
308
|
+
super(message)
|
|
309
|
+
this.name = "ParentLeaseError"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export class ParentLeaseConflictError extends ParentLeaseError {
|
|
314
|
+
override readonly code = "LOVELY_AGENTS_PARENT_LEASE_CONFLICT"
|
|
315
|
+
readonly ownerPid: number
|
|
316
|
+
|
|
317
|
+
constructor(path: string, ownerPid: number) {
|
|
318
|
+
super(`Lovely Agents parent partition is owned by live process ${ownerPid}: ${path}`)
|
|
319
|
+
this.name = "ParentLeaseConflictError"
|
|
320
|
+
this.ownerPid = ownerPid
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function parentStoragePaths(cwd: string, parentSessionId: string): ParentStoragePaths {
|
|
325
|
+
assertSessionId(parentSessionId)
|
|
326
|
+
const workspace = resolve(cwd)
|
|
327
|
+
const root = join(workspace, ".pi", "lovely-agents")
|
|
328
|
+
const parentDirectory = join(root, parentSessionId)
|
|
329
|
+
return { workspace, root, parentSessionId, parentDirectory, lease: join(parentDirectory, ".lease") }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function taskStoragePaths(parent: ParentStoragePaths, taskRef: string): TaskStoragePaths {
|
|
333
|
+
assertTaskReference(taskRef)
|
|
334
|
+
const taskDirectory = join(parent.parentDirectory, taskRef)
|
|
335
|
+
return {
|
|
336
|
+
...parent,
|
|
337
|
+
taskRef,
|
|
338
|
+
taskDirectory,
|
|
339
|
+
metadata: join(taskDirectory, "metadata.json"),
|
|
340
|
+
session: join(taskDirectory, "session.jsonl"),
|
|
341
|
+
history: join(taskDirectory, "history.md"),
|
|
342
|
+
output: join(taskDirectory, "output.log")
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function archivedTaskStoragePaths(paths: TaskStoragePaths): TaskStoragePaths {
|
|
347
|
+
return taskStoragePaths({ ...paths, parentDirectory: join(paths.root, "archive", paths.parentSessionId) }, paths.taskRef)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Only ownership is decoded across versions. Unsupported execution recipes stay unreadable. */
|
|
351
|
+
export async function readTaskIdentity(
|
|
352
|
+
paths: TaskStoragePaths
|
|
353
|
+
): Promise<{ kind: "agent"; childSessionId: string } | { kind: "bash" } | undefined> {
|
|
354
|
+
try {
|
|
355
|
+
await assertRegularDirectory(dirname(paths.parentDirectory))
|
|
356
|
+
await assertRegularDirectory(paths.parentDirectory)
|
|
357
|
+
await assertRegularDirectory(paths.taskDirectory)
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (hasCode(error, "ENOENT")) return undefined
|
|
360
|
+
throw error
|
|
361
|
+
}
|
|
362
|
+
const stats = await lstat(paths.metadata)
|
|
363
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`Task metadata is not a regular file: ${paths.metadata}`)
|
|
364
|
+
const value: unknown = JSON.parse(await readFile(paths.metadata, "utf8"))
|
|
365
|
+
if (!isRecord(value) || property(value, "taskRef") !== paths.taskRef || property(value, "parentSessionId") !== paths.parentSessionId) {
|
|
366
|
+
throw new Error("Metadata identity does not match its parent/task path")
|
|
367
|
+
}
|
|
368
|
+
const kind = property(value, "kind")
|
|
369
|
+
if (kind === "bash" && paths.taskRef.startsWith("b_")) {
|
|
370
|
+
if (property(value, "childSessionId") !== undefined) throw new Error("Bash task cannot own a child session")
|
|
371
|
+
return { kind }
|
|
372
|
+
}
|
|
373
|
+
// Older agent identities did not require kind; decode ownership only, never their recipe.
|
|
374
|
+
const legacyAgent =
|
|
375
|
+
kind === undefined && typeof property(value, "version") === "number" && property(value, "version") !== TASK_METADATA_VERSION
|
|
376
|
+
if ((kind !== "agent" && !legacyAgent) || !paths.taskRef.startsWith("a_")) throw new Error("Task kind does not match its reference")
|
|
377
|
+
const childSessionId = property(value, "childSessionId")
|
|
378
|
+
if (typeof childSessionId !== "string" || !SESSION_ID_PATTERN.test(childSessionId) || childSessionId === paths.parentSessionId) {
|
|
379
|
+
throw new Error("Invalid child session identity")
|
|
380
|
+
}
|
|
381
|
+
return { kind: "agent", childSessionId }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Moves stopped work under archive without rewriting its retained metadata. */
|
|
385
|
+
export async function archiveTaskStorage(paths: TaskStoragePaths): Promise<void> {
|
|
386
|
+
await serializeMetadataMutation(paths.metadata, async () => {
|
|
387
|
+
const archived = archivedTaskStoragePaths(paths)
|
|
388
|
+
await assertRegularDirectory(paths.taskDirectory)
|
|
389
|
+
for (const directory of [dirname(archived.parentDirectory), archived.parentDirectory]) {
|
|
390
|
+
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE })
|
|
391
|
+
await assertRegularDirectory(directory)
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await lstat(archived.taskDirectory)
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (!hasCode(error, "ENOENT")) throw error
|
|
397
|
+
await rename(paths.taskDirectory, archived.taskDirectory)
|
|
398
|
+
await Promise.all([syncDirectory(paths.parentDirectory), syncDirectory(archived.parentDirectory)])
|
|
399
|
+
publishTaskUpdate(paths.workspace, paths.parentSessionId)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
throw new Error(`Archive already exists: ${archived.taskDirectory}`)
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function createTaskReference(kind: TaskMetadata["kind"] = "agent"): string {
|
|
407
|
+
return `${kind === "bash" ? "b" : "a"}_${randomBytes(4).toString("hex")}`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Creates and verifies the private root and exact parent partition. */
|
|
411
|
+
export async function ensureParentStorage(cwd: string, parentSessionId: string): Promise<ParentStoragePaths> {
|
|
412
|
+
const paths = parentStoragePaths(cwd, parentSessionId)
|
|
413
|
+
const configDirectory = dirname(paths.root)
|
|
414
|
+
await mkdir(configDirectory, { recursive: true })
|
|
415
|
+
await assertRegularDirectory(configDirectory)
|
|
416
|
+
const [realWorkspace, realConfigDirectory] = await Promise.all([realpath(paths.workspace), realpath(configDirectory)])
|
|
417
|
+
if (dirname(realConfigDirectory) !== realWorkspace) {
|
|
418
|
+
throw new Error(`Lovely Agents config directory escapes the workspace: ${configDirectory}`)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
await mkdir(paths.root, { mode: DIRECTORY_MODE })
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
425
|
+
}
|
|
426
|
+
await assertRegularDirectory(paths.root)
|
|
427
|
+
const realRoot = await realpath(paths.root)
|
|
428
|
+
if (dirname(realRoot) !== realConfigDirectory) throw new Error(`Lovely Agents storage escapes the workspace: ${paths.root}`)
|
|
429
|
+
await chmod(paths.root, DIRECTORY_MODE)
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
await mkdir(paths.parentDirectory, { mode: DIRECTORY_MODE })
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
435
|
+
}
|
|
436
|
+
await assertRegularDirectory(paths.parentDirectory)
|
|
437
|
+
if (dirname(await realpath(paths.parentDirectory)) !== realRoot) {
|
|
438
|
+
throw new Error(`Lovely Agents parent partition escapes its storage root: ${paths.parentDirectory}`)
|
|
439
|
+
}
|
|
440
|
+
await chmod(paths.parentDirectory, DIRECTORY_MODE)
|
|
441
|
+
await ensureStorageGitignore(paths.root)
|
|
442
|
+
return paths
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Acquires one durable parent-partition lease. Duplicate calls in this process
|
|
447
|
+
* return the same lease, including across extension runtime reloads.
|
|
448
|
+
*/
|
|
449
|
+
export async function acquireParentLease(cwd: string, parentSessionId: string): Promise<ParentLease> {
|
|
450
|
+
const paths = await ensureParentStorage(cwd, parentSessionId)
|
|
451
|
+
const state = parentLeaseState()
|
|
452
|
+
return serializeOperation(state.queues, paths.lease, async () => {
|
|
453
|
+
const existing = state.leases.get(paths.lease)
|
|
454
|
+
if (existing) {
|
|
455
|
+
const loaded = await loadParentLease(paths.lease)
|
|
456
|
+
if (loaded.status === "ok" && loaded.lease.pid === existing.pid && loaded.lease.token === existing.token) {
|
|
457
|
+
return existing
|
|
458
|
+
}
|
|
459
|
+
state.leases.delete(paths.lease)
|
|
460
|
+
throw new ParentLeaseError(`Process-global lease ownership no longer matches ${paths.lease}`)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const leaseFile: ParentLeaseFile = {
|
|
464
|
+
version: PARENT_LEASE_VERSION,
|
|
465
|
+
pid: process.pid,
|
|
466
|
+
token: randomBytes(16).toString("hex"),
|
|
467
|
+
createdAt: Date.now()
|
|
468
|
+
}
|
|
469
|
+
const candidate = `${paths.lease}.${process.pid}.${leaseFile.token}.tmp`
|
|
470
|
+
await writePrivateFile(candidate, `${JSON.stringify(leaseFile)}\n`)
|
|
471
|
+
try {
|
|
472
|
+
for (let attempt = 0; attempt < MAX_LEASE_ACQUIRE_ATTEMPTS; attempt++) {
|
|
473
|
+
try {
|
|
474
|
+
await link(candidate, paths.lease)
|
|
475
|
+
try {
|
|
476
|
+
await syncDirectory(paths.parentDirectory)
|
|
477
|
+
} catch (error) {
|
|
478
|
+
await removeIfPresent(paths.lease)
|
|
479
|
+
throw error
|
|
480
|
+
}
|
|
481
|
+
const lease = Object.freeze({ ...leaseFile, paths: Object.freeze({ ...paths }) })
|
|
482
|
+
state.leases.set(paths.lease, lease)
|
|
483
|
+
return lease
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const loaded = await loadParentLease(paths.lease)
|
|
489
|
+
if (loaded.status === "missing") continue
|
|
490
|
+
if (loaded.status === "invalid") {
|
|
491
|
+
throw new ParentLeaseError(`Cannot acquire invalid parent lease ${paths.lease}: ${loaded.message}`)
|
|
492
|
+
}
|
|
493
|
+
if (processIsAlive(loaded.lease.pid)) {
|
|
494
|
+
throw new ParentLeaseConflictError(paths.lease, loaded.lease.pid)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const confirmed = await loadParentLease(paths.lease)
|
|
498
|
+
if (confirmed.status !== "ok" || confirmed.lease.pid !== loaded.lease.pid || confirmed.lease.token !== loaded.lease.token) {
|
|
499
|
+
continue
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
await unlink(paths.lease)
|
|
503
|
+
await syncDirectory(paths.parentDirectory)
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (!hasCode(error, "ENOENT")) throw error
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
throw new ParentLeaseError(`Unable to acquire changing parent lease: ${paths.lease}`)
|
|
509
|
+
} finally {
|
|
510
|
+
await removeIfPresent(candidate)
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Releases only the matching process-global lease; repeated release is safe. */
|
|
516
|
+
export async function releaseParentLease(lease: ParentLease): Promise<void> {
|
|
517
|
+
const state = parentLeaseState()
|
|
518
|
+
await serializeOperation(state.queues, lease.paths.lease, async () => {
|
|
519
|
+
if (state.leases.get(lease.paths.lease) !== lease) return
|
|
520
|
+
|
|
521
|
+
const loaded = await loadParentLease(lease.paths.lease)
|
|
522
|
+
if (loaded.status === "missing") {
|
|
523
|
+
state.leases.delete(lease.paths.lease)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (loaded.status === "invalid" || loaded.lease.pid !== lease.pid || loaded.lease.token !== lease.token) {
|
|
527
|
+
state.leases.delete(lease.paths.lease)
|
|
528
|
+
throw new ParentLeaseError(`Refusing to release a parent lease no longer owned by this process: ${lease.paths.lease}`)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await unlink(lease.paths.lease)
|
|
532
|
+
state.leases.delete(lease.paths.lease)
|
|
533
|
+
await syncDirectory(lease.paths.parentDirectory)
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Releases a registered lease by identity after a semantic parent close. */
|
|
538
|
+
export async function releaseParentLeaseFor(cwd: string, parentSessionId: string): Promise<boolean> {
|
|
539
|
+
const path = parentStoragePaths(cwd, parentSessionId).lease
|
|
540
|
+
const lease = parentLeaseState().leases.get(path)
|
|
541
|
+
if (!lease) return false
|
|
542
|
+
await releaseParentLease(lease)
|
|
543
|
+
return true
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Creates missing logs without overwriting retained content. */
|
|
547
|
+
export async function initializeRetainedLogs(paths: TaskStoragePaths): Promise<void> {
|
|
548
|
+
await ensurePrivateLogFile(paths.history)
|
|
549
|
+
await ensurePrivateLogFile(paths.taskRef.startsWith("b_") ? paths.output : paths.session)
|
|
550
|
+
await syncDirectory(paths.taskDirectory)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function appendHistoryLog(paths: TaskStoragePaths, entry: HistoryEntry): Promise<void> {
|
|
554
|
+
await appendRetainedLog(paths.history, renderHistoryEntry(entry))
|
|
555
|
+
publishTaskUpdate(paths.workspace, paths.parentSessionId)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Coalesced observed work; late events cannot overwrite a newer or settled run. */
|
|
559
|
+
export async function writeTaskProgress(
|
|
560
|
+
paths: TaskStoragePaths,
|
|
561
|
+
runId: string,
|
|
562
|
+
progress: Partial<Pick<TaskMetadata, "latestReply" | "lastActivity">> & { effectiveSystemPrompt?: string }
|
|
563
|
+
): Promise<void> {
|
|
564
|
+
await mutateTaskMetadata(paths, metadata => {
|
|
565
|
+
if (metadata.discardedAt !== null || metadata.activeRun?.id !== runId || metadata.state !== "running") return metadata
|
|
566
|
+
if (metadata.kind === "bash" && progress.effectiveSystemPrompt !== undefined) {
|
|
567
|
+
throw new Error("Bash tasks do not have a system prompt")
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
...metadata,
|
|
571
|
+
...progress,
|
|
572
|
+
updatedAt: Date.now()
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Explain queued work without pretending to know its ETA or FIFO position. */
|
|
578
|
+
export function taskSchedulingStatus(
|
|
579
|
+
metadata: Pick<AgentTaskMetadata, "kind" | "state" | "model"> | Pick<BashTaskMetadata, "kind" | "state">
|
|
580
|
+
): Pick<RetainedOutputRead, "queueReason" | "capacity"> {
|
|
581
|
+
const coordinator = metadata.kind === "bash" ? getBashCoordinator() : getAgentCoordinator()
|
|
582
|
+
const capacity = { active: coordinator.activeCount, limit: coordinator.maxConcurrency }
|
|
583
|
+
const queueReason =
|
|
584
|
+
metadata.state !== "queued"
|
|
585
|
+
? null
|
|
586
|
+
: metadata.kind === "agent" && !coordinator.isTupleOpen({ provider: metadata.model.provider, model: metadata.model.id })
|
|
587
|
+
? "provider-limit"
|
|
588
|
+
: capacity.active >= capacity.limit
|
|
589
|
+
? "capacity"
|
|
590
|
+
: "starting"
|
|
591
|
+
return { queueReason, capacity }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function retainedPaths(paths: TaskStoragePaths): RetainedPaths {
|
|
595
|
+
return {
|
|
596
|
+
history: displayWorkspacePath(paths.workspace, paths.history),
|
|
597
|
+
...(paths.taskRef.startsWith("b_")
|
|
598
|
+
? { output: displayWorkspacePath(paths.workspace, paths.output) }
|
|
599
|
+
: { session: displayWorkspacePath(paths.workspace, paths.session) })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function countRetainedOutputLines(paths: TaskStoragePaths): Promise<number> {
|
|
604
|
+
return splitCompleteLines((await requireTaskMetadata(paths)).latestReply?.text ?? "").length
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Returns a snapshot, never transcript pages. With waitMs, wait for the current
|
|
609
|
+
* run to end or suspend, not for partial output, activity, or capacity changes.
|
|
610
|
+
*/
|
|
611
|
+
export async function readRetainedOutput(paths: TaskStoragePaths, options: RetainedOutputReadOptions = {}): Promise<RetainedOutputRead> {
|
|
612
|
+
const waitMs = options.waitMs ?? 0
|
|
613
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > RETAINED_OUTPUT_MAX_WAIT_MS) {
|
|
614
|
+
throw new Error(`waitMs must be an integer from 0 to ${RETAINED_OUTPUT_MAX_WAIT_MS}`)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (options.signal?.aborted) throw abortReason(options.signal)
|
|
618
|
+
let metadata = await requireTaskMetadata(paths)
|
|
619
|
+
let timedOut = false
|
|
620
|
+
if (waitMs > 0 && metadata.activeRun && metadata.state !== "suspended") {
|
|
621
|
+
const settled = await waitForRunEnd(paths, metadata.activeRun.id, waitMs, options.signal)
|
|
622
|
+
timedOut = settled === undefined
|
|
623
|
+
metadata = settled ?? (await requireTaskMetadata(paths))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return retainedOutputSnapshot(paths, metadata, timedOut)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Formats an immutable run result before a later run can replace its reply. */
|
|
630
|
+
export function retainedOutputSnapshot(paths: TaskStoragePaths, metadata: TaskMetadata, timedOut = false): RetainedOutputRead {
|
|
631
|
+
const fullText = metadata.latestReply?.text ?? ""
|
|
632
|
+
const lines = splitCompleteLines(fullText)
|
|
633
|
+
const text = truncateUtf8(lines.slice(0, RETAINED_OUTPUT_MAX_LINES).join("\n"), RETAINED_OUTPUT_MAX_BYTES)
|
|
634
|
+
const snapshotTruncated = lines.length > RETAINED_OUTPUT_MAX_LINES || Buffer.byteLength(fullText) > RETAINED_OUTPUT_MAX_BYTES
|
|
635
|
+
const truncated = snapshotTruncated || (metadata.kind === "bash" && metadata.latestReply?.truncated === true)
|
|
636
|
+
return {
|
|
637
|
+
text:
|
|
638
|
+
metadata.kind === "bash"
|
|
639
|
+
? `${snapshotTruncated ? text : fullText}\n\n[${truncated ? "Output truncated; showing tail. " : ""}Full output: ${retainedPaths(paths).output}]`
|
|
640
|
+
: truncated
|
|
641
|
+
? `${text}\n\n[Reply truncated. Full replies: ${retainedPaths(paths).history}]`
|
|
642
|
+
: fullText,
|
|
643
|
+
totalLines: lines.length,
|
|
644
|
+
truncated,
|
|
645
|
+
timedOut,
|
|
646
|
+
state: metadata.state,
|
|
647
|
+
latestOutcome: metadata.activeRun ? null : metadata.latestOutcome,
|
|
648
|
+
streaming: metadata.state === "running" && (metadata.latestReply?.streaming ?? false),
|
|
649
|
+
queuedFollowUps: metadata.queuedFollowUps.length,
|
|
650
|
+
lastActivity: metadata.lastActivity ?? null,
|
|
651
|
+
...(metadata.kind === "bash" ? { exitCode: metadata.exitCode, signal: metadata.signal } : {}),
|
|
652
|
+
...taskSchedulingStatus(metadata),
|
|
653
|
+
paths: retainedPaths(paths)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Atomically reserves a fresh task directory; existing names are collisions. */
|
|
658
|
+
export async function reserveTaskStorage(
|
|
659
|
+
parent: ParentStoragePaths,
|
|
660
|
+
nextReference: () => string = createTaskReference
|
|
661
|
+
): Promise<TaskStoragePaths> {
|
|
662
|
+
for (let attempt = 0; attempt < MAX_TASK_REFERENCE_ATTEMPTS; attempt++) {
|
|
663
|
+
const paths = taskStoragePaths(parent, nextReference())
|
|
664
|
+
try {
|
|
665
|
+
const reserved = await serializeMetadataMutation(paths.metadata, async () => {
|
|
666
|
+
try {
|
|
667
|
+
await lstat(archivedTaskStoragePaths(paths).taskDirectory)
|
|
668
|
+
return false
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (!hasCode(error, "ENOENT")) throw error
|
|
671
|
+
}
|
|
672
|
+
await mkdir(paths.taskDirectory, { mode: DIRECTORY_MODE })
|
|
673
|
+
return true
|
|
674
|
+
})
|
|
675
|
+
if (reserved) return paths
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
throw new Error(`Unable to reserve a unique Task Reference after ${MAX_TASK_REFERENCE_ATTEMPTS} attempts`)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function readTaskMetadata(paths: TaskStoragePaths): Promise<MetadataLoadResult> {
|
|
684
|
+
let source: string
|
|
685
|
+
try {
|
|
686
|
+
const stats = await lstat(paths.metadata)
|
|
687
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
688
|
+
return invalidMetadata(paths.metadata, "unreadable", "metadata.json is not a regular file")
|
|
689
|
+
}
|
|
690
|
+
source = await readFile(paths.metadata, "utf8")
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (hasCode(error, "ENOENT")) {
|
|
693
|
+
try {
|
|
694
|
+
if (await readTaskIdentity(archivedTaskStoragePaths(paths))) {
|
|
695
|
+
return invalidMetadata(paths.metadata, "unreadable", `Task ${paths.taskRef} has been discarded`)
|
|
696
|
+
}
|
|
697
|
+
return { status: "missing" }
|
|
698
|
+
} catch (archiveError) {
|
|
699
|
+
return invalidMetadata(paths.metadata, "unreadable", errorMessage(archiveError))
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return invalidMetadata(paths.metadata, "unreadable", errorMessage(error))
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let value: unknown
|
|
706
|
+
try {
|
|
707
|
+
value = JSON.parse(source)
|
|
708
|
+
} catch (error) {
|
|
709
|
+
return invalidMetadata(paths.metadata, "invalid-json", errorMessage(error))
|
|
710
|
+
}
|
|
711
|
+
const version = isRecord(value) ? property(value, "version") : undefined
|
|
712
|
+
if (typeof version === "number" && version !== TASK_METADATA_VERSION) {
|
|
713
|
+
return invalidMetadata(paths.metadata, "unsupported-version", `Unsupported metadata version ${version}`)
|
|
714
|
+
}
|
|
715
|
+
const validated = validateTaskMetadata(value)
|
|
716
|
+
if (!validated.ok) return invalidMetadata(paths.metadata, "invalid-metadata", validated.message)
|
|
717
|
+
if (validated.value.taskRef !== paths.taskRef || validated.value.parentSessionId !== paths.parentSessionId) {
|
|
718
|
+
return invalidMetadata(paths.metadata, "invalid-metadata", "Metadata identity does not match its parent/task path")
|
|
719
|
+
}
|
|
720
|
+
return { status: "ok", metadata: validated.value }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Replaces metadata through a same-directory, fsynced temporary file. */
|
|
724
|
+
export function writeTaskMetadata(paths: TaskStoragePaths, metadata: TaskMetadata): Promise<void> {
|
|
725
|
+
return serializeMetadataMutation(paths.metadata, async () => {
|
|
726
|
+
assertMetadataForPath(paths, metadata)
|
|
727
|
+
const snapshot = metadata.activeRun ? { ...metadata, inputPreview: historyPreview(metadata.activeRun.input, 512) } : metadata
|
|
728
|
+
await atomicWriteMetadata(paths.metadata, snapshot)
|
|
729
|
+
publishTaskUpdate(paths.workspace, paths.parentSessionId)
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Reads, transforms, validates, and durably writes one task under a per-task queue. */
|
|
734
|
+
export function mutateTaskMetadata(
|
|
735
|
+
paths: TaskStoragePaths,
|
|
736
|
+
mutate: (metadata: TaskMetadata) => TaskMetadata | Promise<TaskMetadata>
|
|
737
|
+
): Promise<TaskMetadata> {
|
|
738
|
+
return serializeMetadataMutation(paths.metadata, async () => {
|
|
739
|
+
const loaded = await readTaskMetadata(paths)
|
|
740
|
+
if (loaded.status !== "ok") throw new InvalidTaskMetadataError(metadataLoadError(loaded, paths.metadata))
|
|
741
|
+
const updated = await mutate(structuredClone(loaded.metadata))
|
|
742
|
+
if (updated.activeRun && updated.activeRun.id !== loaded.metadata.activeRun?.id) {
|
|
743
|
+
updated.latestReply = null
|
|
744
|
+
updated.lastActivity = { at: updated.updatedAt, action: updated.state }
|
|
745
|
+
updated.inputPreview = historyPreview(updated.activeRun.input, 512)
|
|
746
|
+
if (updated.kind === "agent") delete updated.effectiveSystemPrompt
|
|
747
|
+
}
|
|
748
|
+
if (updated.state !== "running" && updated.latestReply) updated.latestReply.streaming = false
|
|
749
|
+
assertMetadataForPath(paths, updated)
|
|
750
|
+
await atomicWriteMetadata(paths.metadata, updated)
|
|
751
|
+
publishTaskUpdate(paths.workspace, paths.parentSessionId)
|
|
752
|
+
return updated
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function assertTaskMetadata(value: unknown): asserts value is TaskMetadata {
|
|
757
|
+
const validated = validateTaskMetadata(value)
|
|
758
|
+
if (!validated.ok) throw new InvalidTaskMetadataError(validated.message)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function validateTaskMetadata(value: unknown): { ok: true; value: TaskMetadata } | { ok: false; message: string } {
|
|
762
|
+
if (!Value.Check(TaskMetadataSchema, value)) {
|
|
763
|
+
const error = Value.Errors(TaskMetadataSchema, value)[0]
|
|
764
|
+
return { ok: false, message: error ? `${error.instancePath || "/"} ${error.message}` : "Invalid task metadata" }
|
|
765
|
+
}
|
|
766
|
+
const semanticError = taskMetadataSemanticError(value)
|
|
767
|
+
return semanticError ? { ok: false, message: semanticError } : { ok: true, value }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function taskMetadataSemanticError(value: TaskMetadata): string | undefined {
|
|
771
|
+
if (value.kind === "bash") {
|
|
772
|
+
const commandError = inputValidationError(value.command, "/command")
|
|
773
|
+
if (commandError) return commandError
|
|
774
|
+
if (!isAbsolute(value.cwd)) return "/cwd must be absolute"
|
|
775
|
+
if (value.state === "suspended") return "Bash tasks cannot be suspended"
|
|
776
|
+
if (value.queuedFollowUps.length > 0) return "Bash tasks cannot queue Follow-ups"
|
|
777
|
+
if (value.lastRunSequence > 1 || (value.activeRun && (value.activeRun.kind !== "initial" || value.activeRun.sequence !== 1))) {
|
|
778
|
+
return "Bash tasks support only an initial run"
|
|
779
|
+
}
|
|
780
|
+
if (value.notifications.some(notification => notification.type === "suspension")) return "Bash tasks cannot suspend"
|
|
781
|
+
}
|
|
782
|
+
if (!value.label.trim()) return "/label must be nonblank"
|
|
783
|
+
if (Buffer.byteLength(value.label, "utf8") > MAX_AGENT_LABEL_BYTES) {
|
|
784
|
+
return `/label must be at most ${MAX_AGENT_LABEL_BYTES} UTF-8 bytes`
|
|
785
|
+
}
|
|
786
|
+
if (value.updatedAt < value.createdAt) return "/updatedAt must not precede /createdAt"
|
|
787
|
+
if (value.discardedAt !== null && value.discardedAt < value.createdAt) return "/discardedAt must not precede /createdAt"
|
|
788
|
+
if ((value.activeRun === null) !== (value.state === "idle" || value.state === "interrupted")) {
|
|
789
|
+
return "/activeRun must exist exactly while state is queued, running, or suspended"
|
|
790
|
+
}
|
|
791
|
+
if (value.activeRun) {
|
|
792
|
+
if (value.activeRun.state !== value.state) return "/activeRun/state must match /state"
|
|
793
|
+
if (value.activeRun.sequence > value.lastRunSequence) return "/activeRun/sequence exceeds /lastRunSequence"
|
|
794
|
+
if (value.activeRun.state !== "queued" && value.activeRun.startedAt === undefined) {
|
|
795
|
+
return "/activeRun/startedAt is required after leaving queued state"
|
|
796
|
+
}
|
|
797
|
+
const inputError = inputValidationError(value.activeRun.input, "/activeRun/input")
|
|
798
|
+
if (inputError) return inputError
|
|
799
|
+
}
|
|
800
|
+
let priorSequence = value.activeRun?.sequence ?? 0
|
|
801
|
+
for (let index = 0; index < value.queuedFollowUps.length; index++) {
|
|
802
|
+
const followUp = value.queuedFollowUps[index]
|
|
803
|
+
if (!followUp) continue
|
|
804
|
+
if (followUp.sequence <= priorSequence) return `/queuedFollowUps/${index}/sequence must be strictly increasing`
|
|
805
|
+
if (followUp.sequence > value.lastRunSequence) return `/queuedFollowUps/${index}/sequence exceeds /lastRunSequence`
|
|
806
|
+
const contentError = inputValidationError(followUp.content, `/queuedFollowUps/${index}/content`)
|
|
807
|
+
if (contentError) return contentError
|
|
808
|
+
priorSequence = followUp.sequence
|
|
809
|
+
}
|
|
810
|
+
for (let index = 0; index < value.notifications.length; index++) {
|
|
811
|
+
if (Buffer.byteLength(value.notifications[index]?.content ?? "", "utf8") > MAX_NOTIFICATION_CONTENT_BYTES) {
|
|
812
|
+
return `/notifications/${index}/content must be at most ${MAX_NOTIFICATION_CONTENT_BYTES} UTF-8 bytes`
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return undefined
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function inputValidationError(value: string, path: string): string | undefined {
|
|
819
|
+
if (!value.trim()) return `${path} must be nonblank`
|
|
820
|
+
if (Buffer.byteLength(value, "utf8") > MAX_AGENT_INPUT_BYTES) return `${path} must be at most ${MAX_AGENT_INPUT_BYTES} UTF-8 bytes`
|
|
821
|
+
return undefined
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function assertMetadataForPath(paths: TaskStoragePaths, metadata: unknown): asserts metadata is TaskMetadata {
|
|
825
|
+
assertTaskMetadata(metadata)
|
|
826
|
+
if (metadata.taskRef !== paths.taskRef || metadata.parentSessionId !== paths.parentSessionId) {
|
|
827
|
+
throw new InvalidTaskMetadataError("Metadata identity does not match its parent/task path")
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function renderHistoryEntry(entry: HistoryEntry): string {
|
|
832
|
+
switch (entry.type) {
|
|
833
|
+
case "run-start":
|
|
834
|
+
return `<run ${entry.sequence} ${entry.kind}>\n`
|
|
835
|
+
case "input":
|
|
836
|
+
return taggedBlockEntry(
|
|
837
|
+
entry.delivery === "stdin" ? "stdin" : entry.delivery === "steer" ? "steer" : "user",
|
|
838
|
+
entry.content,
|
|
839
|
+
entry.delivery === "stdin"
|
|
840
|
+
)
|
|
841
|
+
case "assistant":
|
|
842
|
+
return taggedBlockEntry("agent", entry.content)
|
|
843
|
+
case "output":
|
|
844
|
+
return taggedBlockEntry("output", entry.content)
|
|
845
|
+
case "stdin":
|
|
846
|
+
return `${taggedBlockEntry("stdin", entry.content, true)}${entry.eof ? "<stdin EOF>\n" : ""}`
|
|
847
|
+
case "run-end":
|
|
848
|
+
return `<outcome ${entry.outcome}>\n${entry.summary ? taggedBlockEntry("summary", entry.summary) : ""}\n`
|
|
849
|
+
case "tool":
|
|
850
|
+
return `<tool ${historyPreview(entry.tool, 80)} ${entry.isError ? "error" : "ok"}>\n${historyPreview(entry.arguments, 160)} → ${historyPreview(entry.result, 240)}\n`
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function taggedBlockEntry(tag: string, content: string, literal = false): string {
|
|
855
|
+
const body = literal ? content : content.replace(/\r\n?/g, "\n").replace(/\n+$/, "")
|
|
856
|
+
return `<${tag}>\n${body}${body.endsWith("\n") ? "" : "\n"}`
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function historyPreview(content: string, maximumBytes: number): string {
|
|
860
|
+
const singleLine = content.replace(/\s+/g, " ").trim()
|
|
861
|
+
return truncateUtf8(singleLine, maximumBytes)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function truncateUtf8(content: string, maximumBytes: number): string {
|
|
865
|
+
const bytes = Buffer.from(content)
|
|
866
|
+
if (bytes.length <= maximumBytes) return content
|
|
867
|
+
let end = maximumBytes - 3
|
|
868
|
+
while (end > 0 && isUtf8Continuation(bytes[end])) end--
|
|
869
|
+
return `${bytes.subarray(0, end).toString("utf8")}...`
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function requireTaskMetadata(paths: TaskStoragePaths): Promise<TaskMetadata> {
|
|
873
|
+
const loaded = await readTaskMetadata(paths)
|
|
874
|
+
if (loaded.status !== "ok") throw new InvalidTaskMetadataError(metadataLoadError(loaded, paths.metadata))
|
|
875
|
+
if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${paths.taskRef} has been discarded`)
|
|
876
|
+
return loaded.metadata
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function waitForRunEnd(
|
|
880
|
+
paths: TaskStoragePaths,
|
|
881
|
+
runId: string,
|
|
882
|
+
waitMs: number,
|
|
883
|
+
signal?: AbortSignal
|
|
884
|
+
): Promise<TaskMetadata | undefined> {
|
|
885
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
886
|
+
return new Promise<TaskMetadata | undefined>((resolvePromise, rejectPromise) => {
|
|
887
|
+
let settled = false
|
|
888
|
+
let checking = false
|
|
889
|
+
let checkPending = false
|
|
890
|
+
const watcher = watch(paths.taskDirectory, { persistent: false }, () => {
|
|
891
|
+
requestCheck()
|
|
892
|
+
})
|
|
893
|
+
const timer = setTimeout(() => finish(undefined), waitMs)
|
|
894
|
+
const onAbort = () => fail(abortReason(signal))
|
|
895
|
+
const cleanup = () => {
|
|
896
|
+
clearTimeout(timer)
|
|
897
|
+
watcher.close()
|
|
898
|
+
signal?.removeEventListener("abort", onAbort)
|
|
899
|
+
}
|
|
900
|
+
const finish = (metadata: TaskMetadata | undefined) => {
|
|
901
|
+
if (settled) return
|
|
902
|
+
settled = true
|
|
903
|
+
cleanup()
|
|
904
|
+
resolvePromise(metadata)
|
|
905
|
+
}
|
|
906
|
+
const fail = (error: unknown) => {
|
|
907
|
+
if (settled) return
|
|
908
|
+
settled = true
|
|
909
|
+
cleanup()
|
|
910
|
+
rejectPromise(error)
|
|
911
|
+
}
|
|
912
|
+
const requestCheck = () => {
|
|
913
|
+
if (checking) {
|
|
914
|
+
checkPending = true
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
void check()
|
|
918
|
+
}
|
|
919
|
+
const check = async () => {
|
|
920
|
+
if (settled) return
|
|
921
|
+
checking = true
|
|
922
|
+
try {
|
|
923
|
+
const current = await requireTaskMetadata(paths)
|
|
924
|
+
// A later Follow-up must not extend a wait for the run we observed.
|
|
925
|
+
if (current.activeRun?.id !== runId || current.state === "suspended") finish(current)
|
|
926
|
+
} catch (error) {
|
|
927
|
+
fail(error)
|
|
928
|
+
} finally {
|
|
929
|
+
checking = false
|
|
930
|
+
if (checkPending) {
|
|
931
|
+
checkPending = false
|
|
932
|
+
requestCheck()
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
watcher.once("error", fail)
|
|
938
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
939
|
+
requestCheck()
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function splitCompleteLines(content: string): string[] {
|
|
944
|
+
if (!content) return []
|
|
945
|
+
const lines = content.split("\n")
|
|
946
|
+
if (content.endsWith("\n")) lines.pop()
|
|
947
|
+
return lines
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export function displayWorkspacePath(workspace: string, path: string): string {
|
|
951
|
+
const display = relative(workspace, path)
|
|
952
|
+
if (display === "" || display === ".." || display.startsWith(`..${sep}`) || isAbsolute(display)) return path
|
|
953
|
+
return display.split(sep).join("/")
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function isUtf8Continuation(byte: number | undefined): boolean {
|
|
957
|
+
return byte !== undefined && (byte & 0xc0) === 0x80
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function abortReason(signal?: AbortSignal): unknown {
|
|
961
|
+
return signal?.reason ?? new Error("Operation aborted")
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function ensureStorageGitignore(root: string): Promise<void> {
|
|
965
|
+
const path = join(root, ".gitignore")
|
|
966
|
+
try {
|
|
967
|
+
await writePrivateFile(path, STORAGE_GITIGNORE)
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (hasCode(error, "EEXIST")) return
|
|
970
|
+
throw error
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function assertRegularDirectory(path: string): Promise<void> {
|
|
975
|
+
const stats = await lstat(path)
|
|
976
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error(`Lovely Agents storage path is not a regular directory: ${path}`)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function ensurePrivateLogFile(path: string): Promise<void> {
|
|
980
|
+
try {
|
|
981
|
+
await writePrivateFile(path, "")
|
|
982
|
+
return
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
985
|
+
}
|
|
986
|
+
const stats = await lstat(path)
|
|
987
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`Retained log is not a regular file: ${path}`)
|
|
988
|
+
await chmod(path, FILE_MODE)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function appendRetainedLog(path: string, content: string): Promise<void> {
|
|
992
|
+
return serializeOperation(taskQueueState().logs, path, async () => {
|
|
993
|
+
await ensurePrivateLogFile(path)
|
|
994
|
+
const handle = await open(path, "a", FILE_MODE)
|
|
995
|
+
try {
|
|
996
|
+
await handle.writeFile(content, "utf8")
|
|
997
|
+
await handle.sync()
|
|
998
|
+
} finally {
|
|
999
|
+
await handle.close()
|
|
1000
|
+
}
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function loadParentLease(path: string): Promise<ParentLeaseLoadResult> {
|
|
1005
|
+
let source: string
|
|
1006
|
+
try {
|
|
1007
|
+
const stats = await lstat(path)
|
|
1008
|
+
if (!stats.isFile() || stats.isSymbolicLink()) return { status: "invalid", message: "lease is not a regular file" }
|
|
1009
|
+
source = await readFile(path, "utf8")
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
if (hasCode(error, "ENOENT")) return { status: "missing" }
|
|
1012
|
+
return { status: "invalid", message: errorMessage(error) }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let value: unknown
|
|
1016
|
+
try {
|
|
1017
|
+
value = JSON.parse(source)
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
return { status: "invalid", message: errorMessage(error) }
|
|
1020
|
+
}
|
|
1021
|
+
if (!Value.Check(ParentLeaseFileSchema, value)) {
|
|
1022
|
+
const error = Value.Errors(ParentLeaseFileSchema, value)[0]
|
|
1023
|
+
return { status: "invalid", message: error ? `${error.instancePath || "/"} ${error.message}` : "invalid lease data" }
|
|
1024
|
+
}
|
|
1025
|
+
return { status: "ok", lease: value }
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function writePrivateFile(path: string, content: string): Promise<void> {
|
|
1029
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
1030
|
+
let complete = false
|
|
1031
|
+
try {
|
|
1032
|
+
handle = await open(path, "wx", FILE_MODE)
|
|
1033
|
+
await handle.writeFile(content, "utf8")
|
|
1034
|
+
await handle.sync()
|
|
1035
|
+
complete = true
|
|
1036
|
+
} finally {
|
|
1037
|
+
await handle?.close()
|
|
1038
|
+
if (handle && !complete) await removeIfPresent(path)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function atomicWriteMetadata(path: string, metadata: TaskMetadata): Promise<void> {
|
|
1043
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`)
|
|
1044
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined
|
|
1045
|
+
let renamed = false
|
|
1046
|
+
try {
|
|
1047
|
+
handle = await open(temporary, "wx", FILE_MODE)
|
|
1048
|
+
await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, "utf8")
|
|
1049
|
+
await handle.sync()
|
|
1050
|
+
await handle.close()
|
|
1051
|
+
handle = undefined
|
|
1052
|
+
await rename(temporary, path)
|
|
1053
|
+
renamed = true
|
|
1054
|
+
await syncDirectory(dirname(path))
|
|
1055
|
+
} finally {
|
|
1056
|
+
await handle?.close()
|
|
1057
|
+
if (!renamed) await removeIfPresent(temporary)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function syncDirectory(path: string): Promise<void> {
|
|
1062
|
+
if (process.platform === "win32") return
|
|
1063
|
+
const handle = await open(path, "r")
|
|
1064
|
+
try {
|
|
1065
|
+
await handle.sync()
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (!hasCode(error, "EINVAL") && !hasCode(error, "ENOTSUP")) throw error
|
|
1068
|
+
} finally {
|
|
1069
|
+
await handle.close()
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function serializeMetadataMutation<T>(path: string, operation: () => Promise<T>): Promise<T> {
|
|
1074
|
+
return serializeOperation(taskQueueState().metadata, path, operation)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function serializeOperation<T>(queues: Map<string, Promise<void>>, path: string, operation: () => Promise<T>): Promise<T> {
|
|
1078
|
+
const preceding = queues.get(path) ?? Promise.resolve()
|
|
1079
|
+
const result = preceding.then(operation)
|
|
1080
|
+
const tail = result.then(
|
|
1081
|
+
() => undefined,
|
|
1082
|
+
() => undefined
|
|
1083
|
+
)
|
|
1084
|
+
queues.set(path, tail)
|
|
1085
|
+
return result.finally(() => {
|
|
1086
|
+
if (queues.get(path) === tail) queues.delete(path)
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function parentLeaseState(): ParentLeaseState {
|
|
1091
|
+
const globals = globalThis as unknown as { [key: symbol]: unknown }
|
|
1092
|
+
const existing = globals[LEASE_STATE_SYMBOL]
|
|
1093
|
+
if (existing !== undefined) {
|
|
1094
|
+
if (!isParentLeaseState(existing)) throw new ParentLeaseError("Incompatible process-global Lovely Agents lease state")
|
|
1095
|
+
return existing
|
|
1096
|
+
}
|
|
1097
|
+
const state: ParentLeaseState = {
|
|
1098
|
+
version: PARENT_LEASE_VERSION,
|
|
1099
|
+
leases: new Map(),
|
|
1100
|
+
queues: new Map()
|
|
1101
|
+
}
|
|
1102
|
+
globals[LEASE_STATE_SYMBOL] = state
|
|
1103
|
+
return state
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function taskQueueState(): TaskQueueState {
|
|
1107
|
+
const globals = globalThis as unknown as { [key: symbol]: unknown }
|
|
1108
|
+
const existing = globals[TASK_QUEUE_STATE_SYMBOL]
|
|
1109
|
+
if (existing !== undefined) {
|
|
1110
|
+
const candidate = existing as Partial<TaskQueueState>
|
|
1111
|
+
if (candidate.version !== 1 || !(candidate.metadata instanceof Map) || !(candidate.logs instanceof Map)) {
|
|
1112
|
+
throw new Error("Incompatible process-global Lovely Agents task queue state")
|
|
1113
|
+
}
|
|
1114
|
+
return candidate as TaskQueueState
|
|
1115
|
+
}
|
|
1116
|
+
const created: TaskQueueState = { version: 1, metadata: new Map(), logs: new Map() }
|
|
1117
|
+
globals[TASK_QUEUE_STATE_SYMBOL] = created
|
|
1118
|
+
return created
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function isParentLeaseState(value: unknown): value is ParentLeaseState {
|
|
1122
|
+
return (
|
|
1123
|
+
isRecord(value) &&
|
|
1124
|
+
property(value, "version") === PARENT_LEASE_VERSION &&
|
|
1125
|
+
property(value, "leases") instanceof Map &&
|
|
1126
|
+
property(value, "queues") instanceof Map
|
|
1127
|
+
)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function processIsAlive(pid: number): boolean {
|
|
1131
|
+
try {
|
|
1132
|
+
process.kill(pid, 0)
|
|
1133
|
+
return true
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
if (hasCode(error, "ESRCH")) return false
|
|
1136
|
+
if (hasCode(error, "EPERM")) return true
|
|
1137
|
+
throw error
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function invalidMetadata(path: string, code: MetadataDiagnostic["code"], message: string): MetadataLoadResult {
|
|
1142
|
+
return { status: "invalid", diagnostic: { code, message, path } }
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function metadataLoadError(result: Exclude<MetadataLoadResult, { status: "ok" }>, path: string): string {
|
|
1146
|
+
return result.status === "missing" ? `Missing metadata: ${path}` : `${result.diagnostic.message}: ${result.diagnostic.path}`
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function assertSessionId(value: string): void {
|
|
1150
|
+
if (!SESSION_ID_PATTERN.test(value)) throw new Error(`Invalid parent session ID: ${value}`)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function assertTaskReference(value: string): void {
|
|
1154
|
+
if (!TASK_REFERENCE_PATTERN.test(value)) throw new Error(`Invalid Task Reference: ${value}`)
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function removeIfPresent(path: string): Promise<void> {
|
|
1158
|
+
try {
|
|
1159
|
+
await unlink(path)
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
if (!hasCode(error, "ENOENT")) throw error
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
1166
|
+
return isRecord(error) && property(error, "code") === code
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1170
|
+
return typeof value === "object" && value !== null
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function property(record: Record<string, unknown>, key: string): unknown {
|
|
1174
|
+
return record[key]
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function errorMessage(error: unknown): string {
|
|
1178
|
+
return error instanceof Error ? error.message : String(error)
|
|
1179
|
+
}
|