@xl0/pi-lovely-agents 0.1.0 → 0.1.2

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.
@@ -4,9 +4,8 @@ import { appendTaskNotification, prepareTaskNotification } from "./notifications
4
4
  import {
5
5
  acquireParentLease,
6
6
  appendHistoryLog,
7
- archivedTaskStoragePaths,
8
- archiveTaskStorage,
9
7
  mutateTaskMetadata,
8
+ type ParentLease,
10
9
  parentStoragePaths,
11
10
  readTaskIdentity,
12
11
  readTaskMetadata,
@@ -16,6 +15,7 @@ import {
16
15
  type TaskStoragePaths,
17
16
  taskStoragePaths
18
17
  } from "./state.js"
18
+ import { readTaskDiscardMarker, rebuildActiveTaskLinks, syncActiveTaskLink, writeTaskDiscardMarker } from "./storage.js"
19
19
 
20
20
  export type ReconciliationResult = {
21
21
  interrupted: number
@@ -29,7 +29,7 @@ export type RecoveryResult = {
29
29
 
30
30
  const DISCARD_OPERATIONS = Symbol.for("@xl0/pi-lovely-agents/discards/v1")
31
31
 
32
- /** Stops and archives an owned subtree, decoding only identity for unsupported versions. */
32
+ /** Stops and tombstones an owned subtree without changing its canonical paths. */
33
33
  export function discardTask(paths: TaskStoragePaths, visited = new Set([paths.parentSessionId])): Promise<void> {
34
34
  const global = globalThis as typeof globalThis & { [DISCARD_OPERATIONS]?: Map<string, Promise<void>> }
35
35
  global[DISCARD_OPERATIONS] ??= new Map()
@@ -38,11 +38,9 @@ export function discardTask(paths: TaskStoragePaths, visited = new Set([paths.pa
38
38
  if (existing) return existing
39
39
  const pending = (async () => {
40
40
  const identity = await readTaskIdentity(paths)
41
- if (!identity) {
42
- if (await readTaskIdentity(archivedTaskStoragePaths(paths))) return
43
- throw new Error(`Unknown Task Reference: ${paths.taskRef}`)
44
- }
41
+ if (!identity) throw new Error(`Unknown Task Reference: ${paths.taskRef}`)
45
42
  if (identity.kind === "agent" && visited.has(identity.childSessionId)) throw new Error("Cyclic task ownership")
43
+ const marked = await readTaskDiscardMarker(paths)
46
44
  const loaded = await readTaskMetadata(paths)
47
45
  if (loaded.status === "ok") {
48
46
  await stopTask(paths)
@@ -54,23 +52,25 @@ export function discardTask(paths: TaskStoragePaths, visited = new Set([paths.pa
54
52
  }))
55
53
  // Fence input that raced the first stop before the tombstone committed.
56
54
  await stopTask(paths)
57
- } else if (loaded.status === "invalid" && loaded.diagnostic.code === "unsupported-version") {
58
- await getAgentCoordinator().getResident(paths.taskDirectory)?.stop()
55
+ } else if (marked || (loaded.status === "invalid" && loaded.diagnostic.code === "unsupported-version")) {
56
+ if (!marked) {
57
+ await getAgentCoordinator().getResident(paths.taskDirectory)?.stop()
58
+ await writeTaskDiscardMarker(paths)
59
+ }
59
60
  } else {
60
61
  throw new Error(loaded.status === "invalid" ? loaded.diagnostic.message : `Missing metadata: ${paths.metadata}`)
61
62
  }
62
63
  if (identity.kind === "agent") {
63
64
  const descendants = new Set([...visited, identity.childSessionId])
64
65
  const lease = await acquireParentLease(paths.workspace, identity.childSessionId)
65
- try {
66
- for (const child of await directTaskPaths(paths.workspace, identity.childSessionId)) {
67
- await discardTask(child, descendants)
68
- }
69
- } finally {
70
- await releaseParentLease(lease)
66
+ for (const child of await directTaskPaths(paths.workspace, identity.childSessionId, true)) {
67
+ await discardTask(child, descendants)
71
68
  }
69
+ // Failed cleanup may leave residents using this borrowed partition lease.
70
+ await releaseParentLease(lease)
72
71
  }
73
- await archiveTaskStorage(paths)
72
+ const warning = await syncActiveTaskLink(paths, true)
73
+ if (warning) throw new Error(warning)
74
74
  })().finally(() => {
75
75
  if (operations.get(paths.taskDirectory) === pending) operations.delete(paths.taskDirectory)
76
76
  })
@@ -85,13 +85,27 @@ export async function reconcileParentTasks(cwd: string, parentSessionId: string)
85
85
  await acquireParentLease(cwd, parentSessionId)
86
86
 
87
87
  const result: ReconciliationResult = { interrupted: 0, diagnostics: [] }
88
+ try {
89
+ result.diagnostics.push(...(await rebuildActiveTaskLinks(parent)))
90
+ } catch (error) {
91
+ result.diagnostics.push(`Active task index: ${errorMessage(error)}`)
92
+ }
88
93
  for (const paths of await directTaskPaths(cwd, parentSessionId)) {
94
+ try {
95
+ if (await readTaskDiscardMarker(paths)) continue
96
+ } catch (error) {
97
+ result.diagnostics.push(`${paths.taskDirectory}: ${errorMessage(error)}`)
98
+ continue
99
+ }
89
100
  const loaded = await readTaskMetadata(paths)
90
101
  if (loaded.status !== "ok") {
91
- if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
102
+ if (loaded.status === "invalid") {
103
+ const diagnostic = `${paths.taskDirectory}: ${loaded.diagnostic.message}`
104
+ if (!result.diagnostics.includes(diagnostic)) result.diagnostics.push(diagnostic)
105
+ }
92
106
  continue
93
107
  }
94
- if (getAgentCoordinator().getResident(paths.taskDirectory)) continue
108
+ if (loaded.metadata.discardedAt !== null || getAgentCoordinator().getResident(paths.taskDirectory)) continue
95
109
  if (loaded.metadata.state === "idle" || loaded.metadata.state === "interrupted") {
96
110
  if (loaded.metadata.queuedFollowUps.length > 0) {
97
111
  await mutateTaskMetadata(paths, metadata => ({ ...metadata, queuedFollowUps: [], updatedAt: Date.now() }))
@@ -135,7 +149,10 @@ export async function reconcileParentTasks(cwd: string, parentSessionId: string)
135
149
 
136
150
  /** Stops active work and clears queued input throughout an owned task tree. */
137
151
  export async function stopOwnedTaskTree(cwd: string, parentSessionId: string): Promise<void> {
138
- await stopPartition(cwd, parentSessionId, new Set())
152
+ const leases: ParentLease[] = []
153
+ await stopPartition(cwd, parentSessionId, new Set(), leases)
154
+ // A failed owner can remain live even when stopping its descendants succeeded.
155
+ for (const lease of leases.reverse()) await releaseParentLease(lease)
139
156
  }
140
157
 
141
158
  /** Requeues resident suspended work in the exact owned descendant tree. */
@@ -152,31 +169,32 @@ export async function stopTask(paths: TaskStoragePaths): Promise<void> {
152
169
  else await settleStopped(paths)
153
170
  }
154
171
 
155
- async function stopPartition(cwd: string, parentSessionId: string, visited: Set<string>): Promise<void> {
172
+ async function stopPartition(cwd: string, parentSessionId: string, visited: Set<string>, leases: ParentLease[]): Promise<void> {
156
173
  if (visited.has(parentSessionId)) return
157
174
  visited.add(parentSessionId)
158
175
  const parent = parentStoragePaths(cwd, parentSessionId)
159
176
  if (!(await isDirectory(parent.parentDirectory))) return
160
- const lease = await acquireParentLease(cwd, parentSessionId)
177
+ leases.push(await acquireParentLease(cwd, parentSessionId))
161
178
  const errors: unknown[] = []
162
179
 
163
- try {
164
- for (const paths of await directTaskPaths(cwd, parentSessionId)) {
165
- const loaded = await readTaskMetadata(paths)
166
- if (loaded.status !== "ok") continue
167
- try {
168
- await stopTask(paths)
169
- } catch (error) {
170
- errors.push(error)
171
- }
172
- try {
173
- if (loaded.metadata.kind === "agent") await stopPartition(cwd, loaded.metadata.childSessionId, visited)
174
- } catch (error) {
175
- errors.push(error)
180
+ for (const paths of await directTaskPaths(cwd, parentSessionId)) {
181
+ const loaded = await readTaskMetadata(paths)
182
+ if (loaded.status !== "ok") {
183
+ if (getAgentCoordinator().getResident(paths.taskDirectory)) {
184
+ errors.push(new Error(`Cannot validate live task ownership: ${paths.metadata}`))
176
185
  }
186
+ continue
187
+ }
188
+ try {
189
+ await stopTask(paths)
190
+ } catch (error) {
191
+ errors.push(error)
192
+ }
193
+ try {
194
+ if (loaded.metadata.kind === "agent") await stopPartition(cwd, loaded.metadata.childSessionId, visited, leases)
195
+ } catch (error) {
196
+ errors.push(error)
177
197
  }
178
- } finally {
179
- await releaseParentLease(lease)
180
198
  }
181
199
  if (errors.length > 0) throw new AggregateError(errors, `Failed to stop ${errors.length} owned task operation(s)`)
182
200
  }
@@ -188,13 +206,19 @@ async function recoverPartition(cwd: string, parentSessionId: string, visited: S
188
206
  if (!(await isDirectory(parent.parentDirectory))) return
189
207
 
190
208
  for (const paths of await directTaskPaths(cwd, parentSessionId)) {
209
+ try {
210
+ if (await readTaskDiscardMarker(paths)) continue
211
+ } catch (error) {
212
+ result.diagnostics.push(`${paths.taskDirectory}: ${errorMessage(error)}`)
213
+ continue
214
+ }
191
215
  const loaded = await readTaskMetadata(paths)
192
216
  if (loaded.status !== "ok") {
193
217
  if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
194
218
  continue
195
219
  }
196
- if (loaded.metadata.kind !== "agent") continue
197
- if (loaded.metadata.discardedAt === null && loaded.metadata.state === "suspended" && loaded.metadata.activeRun?.background) {
220
+ if (loaded.metadata.discardedAt !== null || loaded.metadata.kind !== "agent") continue
221
+ if (loaded.metadata.state === "suspended" && loaded.metadata.activeRun?.background) {
198
222
  const resident = getAgentCoordinator().getResident(paths.taskDirectory)
199
223
  if (!resident?.recover) result.diagnostics.push(`${paths.taskDirectory}: suspended task has no recoverable resident`)
200
224
  else if (await resident.recover()) result.resumed++
@@ -223,9 +247,16 @@ async function settleStopped(paths: TaskStoragePaths): Promise<void> {
223
247
  }
224
248
  }
225
249
 
226
- async function directTaskPaths(cwd: string, parentSessionId: string): Promise<TaskStoragePaths[]> {
250
+ async function directTaskPaths(cwd: string, parentSessionId: string, strict = false): Promise<TaskStoragePaths[]> {
227
251
  const parent = parentStoragePaths(cwd, parentSessionId)
228
252
  const entries = await readdir(parent.parentDirectory, { withFileTypes: true })
253
+ if (strict) {
254
+ for (const entry of entries) {
255
+ if (TASK_REFERENCE_PATTERN.test(entry.name) && (!entry.isDirectory() || entry.isSymbolicLink())) {
256
+ throw new Error(`Unsafe descendant task path: ${parent.parentDirectory}/${entry.name}`)
257
+ }
258
+ }
259
+ }
229
260
  return entries
230
261
  .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && TASK_REFERENCE_PATTERN.test(entry.name))
231
262
  .map(entry => taskStoragePaths(parent, entry.name))
@@ -276,7 +276,7 @@ async function manageTask(ctx: ExtensionContext, id: string, options: Management
276
276
  { value: "steer", label: "Steer", description: "Redirect running work; otherwise becomes a Follow-up" }
277
277
  ]),
278
278
  { value: "stop", label: "Stop", description: "Stop work and preserve retained files" },
279
- { value: "discard", label: "Discard", description: "Stop and archive this task and its descendants" }
279
+ { value: "discard", label: "Discard", description: "Stop and remove from active work; keep files in place" }
280
280
  ])
281
281
  if (!choice) return
282
282
  if (choice === "details") await showText(ctx, task.label, renderTask(task))
@@ -302,7 +302,9 @@ async function manageTask(ctx: ExtensionContext, id: string, options: Management
302
302
  ) {
303
303
  await options.controlTask(task.id, "stop")
304
304
  }
305
- } else if (await ctx.ui.confirm(`Discard ${task.id}?`, "Files move to the archive. Later model I/O is rejected.")) {
305
+ } else if (
306
+ await ctx.ui.confirm(`Discard ${task.id}?`, "Files stay at their original paths. Results remain readable; further input is rejected.")
307
+ ) {
306
308
  await options.controlTask(task.id, "discard")
307
309
  return
308
310
  }
@@ -344,6 +346,10 @@ async function showLiveTaskOutput(ctx: ExtensionContext, task: TaskListRow): Pro
344
346
  let closed = false
345
347
  let loading = false
346
348
  let refreshAgain = false
349
+ let offset = 0
350
+ let pageSize = 1
351
+ let total = 0
352
+ let follow = task.kind === "bash"
347
353
  const refresh = async () => {
348
354
  if (closed) return
349
355
  refreshAgain = true
@@ -364,26 +370,58 @@ async function showLiveTaskOutput(ctx: ExtensionContext, task: TaskListRow): Pro
364
370
  const unbind = bindTaskUpdateRoute(ctx.cwd, ctx.sessionManager.getSessionId(), refresh)
365
371
  return {
366
372
  render(width: number) {
373
+ const exitStatus =
374
+ output.exitCode !== undefined ? ` · Exit code: ${output.exitCode ?? "unknown"} · Signal: ${output.signal ?? "none"}` : ""
367
375
  const heading = theme.fg(
368
376
  "accent",
369
377
  theme.bold(
370
- `${task.id} · ${output.state}${output.latestOutcome ? `/${output.latestOutcome}` : ""}${output.streaming ? " · streaming" : ""}`
378
+ `${task.id} · ${output.state}${output.latestOutcome ? `/${output.latestOutcome}` : ""}${output.streaming ? " · streaming" : ""}${exitStatus}`
371
379
  )
372
380
  )
373
- const progress = [
381
+ const progressLines = [
374
382
  `Capacity: ${output.capacity.active}/${output.capacity.limit} execution permits`,
383
+ ...(output.progress ? [`Progress: ${JSON.stringify(output.progress)}`] : []),
375
384
  ...(output.queueReason ? [`Waiting: ${output.queueReason}`] : []),
376
385
  ...(output.lastActivity ? [`${output.lastActivity.action} · ${relativeTime(output.lastActivity.at, Date.now())}`] : [])
377
- ].join("\n")
378
- return new Text(
379
- `${heading}\n${progress}\n\n${output.text || "(no output)"}\n\n${theme.fg("dim", "Esc or Enter to go back")}`,
380
- 1,
381
- 0
382
- ).render(width)
386
+ ]
387
+ const bodyLines = new Text(output.text || "(no output)", 0, 0).render(width)
388
+ total = bodyLines.length
389
+ pageSize = Math.max(1, Math.floor(tui.terminal.rows * 0.6) - (1 + progressLines.length + 2))
390
+ const maxOffset = Math.max(0, total - pageSize)
391
+ if (follow) offset = maxOffset
392
+ else offset = Math.max(0, Math.min(offset, maxOffset))
393
+ const position = `${Math.min(offset + 1, total)}-${Math.min(offset + pageSize, total)}/${total}${follow ? " · follow" : ""}`
394
+ return [
395
+ truncateToWidth(heading, width),
396
+ ...progressLines.map(line => truncateToWidth(line, width)),
397
+ "",
398
+ ...bodyLines.slice(offset, offset + pageSize).map(line => truncateToWidth(line, width)),
399
+ truncateToWidth(theme.fg("dim", `Enter/Esc back · ↑↓ PgUp/PgDn Home/End · ${position}`), width)
400
+ ]
383
401
  },
384
402
  invalidate() {},
385
403
  handleInput(data: string) {
386
404
  if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) done(undefined)
405
+ else if (matchesKey(data, Key.up)) {
406
+ offset--
407
+ follow = false
408
+ } else if (matchesKey(data, Key.down)) {
409
+ offset++
410
+ } else if (matchesKey(data, Key.pageUp)) {
411
+ offset -= pageSize
412
+ follow = false
413
+ } else if (matchesKey(data, Key.pageDown)) {
414
+ offset += pageSize
415
+ } else if (matchesKey(data, Key.home)) {
416
+ offset = 0
417
+ follow = false
418
+ } else if (matchesKey(data, Key.end)) {
419
+ offset = Math.max(0, total - pageSize)
420
+ follow = true
421
+ } else return
422
+ offset = Math.max(0, Math.min(offset, total - pageSize))
423
+ if (offset >= Math.max(0, total - pageSize)) follow = true
424
+ tui.requestRender()
387
425
  },
388
426
  dispose() {
389
427
  closed = true
@@ -610,6 +648,7 @@ function renderTask(task: TaskListRow): string {
610
648
  `Task: ${task.id}`,
611
649
  `State: ${task.state}`,
612
650
  `Outcome: ${task.latestOutcome ?? "none"}`,
651
+ ...(task.progress ? [`Progress: ${JSON.stringify(task.progress)}`] : []),
613
652
  ...(task.kind === "bash"
614
653
  ? [
615
654
  `Command: ${task.command}`,
@@ -12,8 +12,10 @@ import {
12
12
  TASK_REFERENCE_PATTERN,
13
13
  type TaskMetadata,
14
14
  type TaskStoragePaths,
15
- taskStoragePaths
15
+ taskStoragePaths,
16
+ truncateUtf8Tail
16
17
  } from "./state.js"
18
+ import { readTaskDiscardMarker } from "./storage.js"
17
19
  import { loadTaskList } from "./tools.js"
18
20
 
19
21
  export const NOTIFICATION_CUSTOM_TYPE = "lovely-agents:notification"
@@ -46,7 +48,10 @@ export async function prepareTaskNotification(
46
48
  type: NotificationType,
47
49
  outcome?: TaskMetadata["latestOutcome"]
48
50
  ): Promise<TaskNotification> {
49
- const output = truncateUtf8(metadata.latestReply?.text ?? "", NOTIFICATION_OUTPUT_PREVIEW_BYTES)
51
+ const output =
52
+ metadata.kind === "bash"
53
+ ? truncateUtf8Tail(metadata.latestReply?.text ?? "", NOTIFICATION_OUTPUT_PREVIEW_BYTES)
54
+ : truncateUtf8(metadata.latestReply?.text ?? "", NOTIFICATION_OUTPUT_PREVIEW_BYTES)
50
55
  const taskList = await loadTaskList(paths.workspace, metadata.parentSessionId).catch(() => undefined)
51
56
  const descendants = taskList?.details.tasks.find(task => task.id === metadata.taskRef)?.descendants
52
57
  const descendantText = descendants && descendants.total > 0 ? `\nDescendants: ${JSON.stringify(descendants)}` : ""
@@ -55,11 +60,12 @@ export async function prepareTaskNotification(
55
60
  const content = truncateUtf8(
56
61
  [
57
62
  `[Lovely ${metadata.kind === "bash" ? "Bash" : "Agent"} ${metadata.taskRef}:${run.id}:${type}]`,
58
- `Task ${metadata.taskRef} ${JSON.stringify(metadata.label)} ${status}`,
63
+ `Task ${metadata.taskRef} run=${run.sequence} ${JSON.stringify(metadata.label)} ${status}`,
59
64
  metadata.kind === "bash"
60
65
  ? `Command: ${truncateUtf8(metadata.command, 1024)}\nExit: ${metadata.exitCode ?? "unknown"}${metadata.signal ? ` signal=${metadata.signal}` : ""}`
61
66
  : `Model: ${metadata.model.provider}/${metadata.model.id}:${metadata.thinking}`,
62
- output ? `Output:\n${output}` : "Output: (empty)",
67
+ `Read: task_output(id: "${metadata.taskRef}", run: ${run.sequence})`,
68
+ output ? `Output preview:\n${output}` : "Output preview: (empty)",
63
69
  `Files: history=${pathsForDisplay.history} ${metadata.kind === "bash" ? `output=${pathsForDisplay.output}` : `session=${pathsForDisplay.session}`}${descendantText}`
64
70
  ].join("\n"),
65
71
  MAX_NOTIFICATION_CONTENT_BYTES
@@ -85,7 +91,8 @@ export function appendTaskNotification(notifications: TaskNotification[], notifi
85
91
  /** Sends every pending notification for one task, without marking delivery. */
86
92
  export async function deliverTaskNotifications(paths: TaskStoragePaths): Promise<number> {
87
93
  const loaded = await readTaskMetadata(paths)
88
- if (loaded.status !== "ok" || loaded.metadata.discardedAt !== null) return 0
94
+ // Discard leaves result paths intact; accepted notices still reconcile normally.
95
+ if (loaded.status !== "ok") return 0
89
96
  const routeKey = notificationRouteKey(paths.workspace, loaded.metadata.parentSessionId)
90
97
  const route = getAgentCoordinator().getNotificationRoute(routeKey)
91
98
  if (!route) return 0
@@ -118,6 +125,12 @@ export async function reconcileParentNotifications(
118
125
  for (const paths of await directTaskPaths(cwd, parentSessionId)) {
119
126
  const loaded = await readTaskMetadata(paths)
120
127
  if (loaded.status !== "ok") {
128
+ try {
129
+ if (await readTaskDiscardMarker(paths)) continue
130
+ } catch (error) {
131
+ result.diagnostics.push(`${paths.taskDirectory}: ${errorMessage(error)}`)
132
+ continue
133
+ }
121
134
  if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
122
135
  continue
123
136
  }