arkaos 3.11.0 → 3.12.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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.11.0
1
+ 3.12.0
package/core/trash.py ADDED
@@ -0,0 +1,245 @@
1
+ """Trash + undo for destructive dashboard actions (PR85b v3.12.0).
2
+
3
+ Captures agent/persona deletions and agent moves into
4
+ ``~/.arkaos/trash/`` so the operator can undo recent mistakes.
5
+
6
+ Each trash entry is two files:
7
+ - ``<timestamp>-<id>.payload`` — the file content (for deletes) or
8
+ empty (for moves, which don't lose data)
9
+ - ``<timestamp>-<id>.meta.json`` — metadata: kind, original_path,
10
+ optional new_path, agent/persona id, timestamp
11
+
12
+ Trash is bounded by ``MAX_ENTRIES`` (newest 50 kept). Older entries
13
+ are pruned on every `record_*` call.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ import uuid
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Literal
24
+
25
+ TrashKind = Literal["agent-delete", "persona-delete", "agent-move"]
26
+
27
+ MAX_ENTRIES = 50
28
+ _TRASH_DIR_NAME = ".arkaos/trash"
29
+
30
+
31
+ def _trash_dir() -> Path:
32
+ target = Path.home() / _TRASH_DIR_NAME
33
+ target.mkdir(parents=True, exist_ok=True)
34
+ return target
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TrashEntry:
39
+ id: str
40
+ kind: TrashKind
41
+ item_id: str
42
+ timestamp: float
43
+ original_path: str
44
+ new_path: str | None
45
+ has_payload: bool
46
+
47
+
48
+ def record_deletion(
49
+ kind: TrashKind,
50
+ item_id: str,
51
+ original_path: str,
52
+ content: str,
53
+ ) -> str:
54
+ """Record a destructive delete. Returns the new trash entry id."""
55
+ return _record(
56
+ kind=kind,
57
+ item_id=item_id,
58
+ original_path=original_path,
59
+ new_path=None,
60
+ payload=content,
61
+ )
62
+
63
+
64
+ def record_move(item_id: str, from_path: str, to_path: str) -> str:
65
+ """Record an agent department move. Payload is empty."""
66
+ return _record(
67
+ kind="agent-move",
68
+ item_id=item_id,
69
+ original_path=from_path,
70
+ new_path=to_path,
71
+ payload="",
72
+ )
73
+
74
+
75
+ def list_trash(limit: int = 10) -> list[dict]:
76
+ """Return the newest N trash entries (sorted desc by timestamp)."""
77
+ entries = _scan()
78
+ return [_entry_to_dict(e) for e in entries[: max(0, int(limit))]]
79
+
80
+
81
+ def restore(trash_id: str) -> dict:
82
+ """Undo the action identified by trash_id. Returns a status dict."""
83
+ entry = _find(trash_id)
84
+ if entry is None:
85
+ return {"error": "trash entry not found"}
86
+ if entry.kind in ("agent-delete", "persona-delete"):
87
+ return _restore_delete(entry)
88
+ if entry.kind == "agent-move":
89
+ return _restore_move(entry)
90
+ return {"error": f"unknown trash kind: {entry.kind!r}"}
91
+
92
+
93
+ def purge(trash_id: str) -> dict:
94
+ """Drop a trash entry without restoring it."""
95
+ entry = _find(trash_id)
96
+ if entry is None:
97
+ return {"error": "trash entry not found"}
98
+ _delete_entry(entry)
99
+ return {"purged": True, "id": trash_id}
100
+
101
+
102
+ # --- Internal helpers ---
103
+
104
+
105
+ def _record(
106
+ *,
107
+ kind: TrashKind,
108
+ item_id: str,
109
+ original_path: str,
110
+ new_path: str | None,
111
+ payload: str,
112
+ ) -> str:
113
+ ts = time.time()
114
+ trash_id = f"{int(ts)}-{uuid.uuid4().hex[:6]}"
115
+ base = _trash_dir() / trash_id
116
+ if payload:
117
+ base.with_suffix(".payload").write_text(payload, encoding="utf-8")
118
+ meta = {
119
+ "id": trash_id,
120
+ "kind": kind,
121
+ "item_id": item_id,
122
+ "timestamp": ts,
123
+ "original_path": original_path,
124
+ "new_path": new_path,
125
+ "has_payload": bool(payload),
126
+ }
127
+ base.with_suffix(".meta.json").write_text(
128
+ json.dumps(meta, indent=2), encoding="utf-8",
129
+ )
130
+ _prune()
131
+ return trash_id
132
+
133
+
134
+ def _scan() -> list[TrashEntry]:
135
+ entries: list[TrashEntry] = []
136
+ for meta_file in _trash_dir().glob("*.meta.json"):
137
+ try:
138
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
139
+ except (OSError, json.JSONDecodeError):
140
+ continue
141
+ try:
142
+ entries.append(TrashEntry(
143
+ id=str(meta.get("id") or meta_file.stem.replace(".meta", "")),
144
+ kind=meta.get("kind") or "agent-delete",
145
+ item_id=str(meta.get("item_id") or ""),
146
+ timestamp=float(meta.get("timestamp") or 0.0),
147
+ original_path=str(meta.get("original_path") or ""),
148
+ new_path=meta.get("new_path"),
149
+ has_payload=bool(meta.get("has_payload")),
150
+ ))
151
+ except (TypeError, ValueError):
152
+ continue
153
+ entries.sort(key=lambda e: e.timestamp, reverse=True)
154
+ return entries
155
+
156
+
157
+ def _find(trash_id: str) -> TrashEntry | None:
158
+ for entry in _scan():
159
+ if entry.id == trash_id:
160
+ return entry
161
+ return None
162
+
163
+
164
+ def _restore_delete(entry: TrashEntry) -> dict:
165
+ payload_path = _trash_dir() / f"{entry.id}.payload"
166
+ if not payload_path.exists():
167
+ return {"error": "payload missing — cannot restore"}
168
+ dest = Path(entry.original_path)
169
+ if dest.exists():
170
+ return {"error": f"target already exists: {dest}"}
171
+ try:
172
+ dest.parent.mkdir(parents=True, exist_ok=True)
173
+ dest.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
174
+ except OSError as exc:
175
+ return {"error": f"restore failed: {exc}"}
176
+ _delete_entry(entry)
177
+ return {"restored": True, "kind": entry.kind, "path": str(dest)}
178
+
179
+
180
+ def _restore_move(entry: TrashEntry) -> dict:
181
+ if not entry.new_path:
182
+ return {"error": "move record has no new_path"}
183
+ current = Path(entry.new_path)
184
+ if not current.exists():
185
+ return {"error": f"file no longer at {current}"}
186
+ target = Path(entry.original_path)
187
+ if target.exists():
188
+ return {"error": f"original path occupied: {target}"}
189
+ try:
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+ # Rewrite the `department:` field back if YAML
192
+ if target.suffix in (".yaml", ".yml"):
193
+ try:
194
+ import yaml as _yaml
195
+ raw = _yaml.safe_load(current.read_text(encoding="utf-8")) or {}
196
+ if isinstance(raw, dict):
197
+ old_dept = target.parent.parent.name
198
+ raw["department"] = old_dept
199
+ current.write_text(
200
+ _yaml.safe_dump(
201
+ raw,
202
+ sort_keys=False,
203
+ allow_unicode=True,
204
+ default_flow_style=False,
205
+ ),
206
+ encoding="utf-8",
207
+ )
208
+ except Exception:
209
+ pass
210
+ current.rename(target)
211
+ except OSError as exc:
212
+ return {"error": f"restore failed: {exc}"}
213
+ _delete_entry(entry)
214
+ return {"restored": True, "kind": entry.kind, "path": str(target)}
215
+
216
+
217
+ def _delete_entry(entry: TrashEntry) -> None:
218
+ base = _trash_dir() / entry.id
219
+ for suffix in (".payload", ".meta.json"):
220
+ p = base.with_suffix(suffix)
221
+ if p.exists():
222
+ try:
223
+ p.unlink()
224
+ except OSError:
225
+ pass
226
+
227
+
228
+ def _prune() -> None:
229
+ entries = _scan()
230
+ if len(entries) <= MAX_ENTRIES:
231
+ return
232
+ for entry in entries[MAX_ENTRIES:]:
233
+ _delete_entry(entry)
234
+
235
+
236
+ def _entry_to_dict(entry: TrashEntry) -> dict:
237
+ return {
238
+ "id": entry.id,
239
+ "kind": entry.kind,
240
+ "item_id": entry.item_id,
241
+ "timestamp": entry.timestamp,
242
+ "original_path": entry.original_path,
243
+ "new_path": entry.new_path,
244
+ "has_payload": entry.has_payload,
245
+ }
@@ -66,6 +66,13 @@ const links = [[{
66
66
  onSelect: () => {
67
67
  open.value = false
68
68
  }
69
+ }, {
70
+ label: 'Trash',
71
+ icon: 'i-lucide-trash-2',
72
+ to: '/trash',
73
+ onSelect: () => {
74
+ open.value = false
75
+ }
69
76
  }, {
70
77
  label: 'GitHub',
71
78
  icon: 'i-simple-icons-github',
@@ -186,26 +186,32 @@ async function bulkMove(targetDept: string) {
186
186
  bulkMoving.value = true
187
187
  const results = await Promise.allSettled(
188
188
  ids.map((id) =>
189
- $fetch<{ moved?: boolean, error?: string }>(`${apiBase}/api/agents/${id}/move`, {
190
- method: 'POST',
191
- body: { department: targetDept },
192
- }),
189
+ $fetch<{ moved?: boolean, trash_id?: string, error?: string }>(
190
+ `${apiBase}/api/agents/${id}/move`,
191
+ { method: 'POST', body: { department: targetDept } },
192
+ ),
193
193
  ),
194
194
  )
195
195
  const successes = results.filter(
196
196
  (r) => r.status === 'fulfilled' && r.value.moved,
197
- ).length
198
- const failures = ids.length - successes
197
+ )
198
+ const trashIds = successes
199
+ .map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
200
+ .filter((v): v is string => Boolean(v))
201
+ const failures = ids.length - successes.length
199
202
  toast.add({
200
- title: successes > 0
201
- ? `Moved ${successes} agent${successes === 1 ? '' : 's'}`
203
+ title: successes.length > 0
204
+ ? `Moved ${successes.length} agent${successes.length === 1 ? '' : 's'}`
202
205
  : 'Nothing moved',
203
206
  description: failures > 0
204
207
  ? `${failures} skipped (Tier 0, collision, or missing)`
205
208
  : undefined,
206
- color: successes > 0 && failures === 0
209
+ color: successes.length > 0 && failures === 0
207
210
  ? 'success'
208
- : failures > 0 && successes > 0 ? 'warning' : 'error',
211
+ : failures > 0 && successes.length > 0 ? 'warning' : 'error',
212
+ actions: trashIds.length > 0
213
+ ? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
214
+ : undefined,
209
215
  })
210
216
  clearSelection()
211
217
  bulkMoving.value = false
@@ -252,20 +258,27 @@ async function bulkDelete() {
252
258
  bulkDeleting.value = true
253
259
  const results = await Promise.allSettled(
254
260
  ids.map((id) =>
255
- $fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/agents/${id}`, {
256
- method: 'DELETE',
257
- }),
261
+ $fetch<{ deleted?: boolean, trash_id?: string, error?: string }>(
262
+ `${apiBase}/api/agents/${id}`,
263
+ { method: 'DELETE' },
264
+ ),
258
265
  ),
259
266
  )
260
267
  const successes = results.filter(
261
268
  (r) => r.status === 'fulfilled' && r.value.deleted,
262
- ).length
263
- const failures = ids.length - successes
264
- if (successes > 0) {
269
+ )
270
+ const trashIds = successes
271
+ .map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
272
+ .filter((v): v is string => Boolean(v))
273
+ const failures = ids.length - successes.length
274
+ if (successes.length > 0) {
265
275
  toast.add({
266
- title: `Deleted ${successes} agent${successes === 1 ? '' : 's'}`,
267
- description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` : undefined,
276
+ title: `Deleted ${successes.length} agent${successes.length === 1 ? '' : 's'}`,
277
+ description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` : 'Undo from /trash within 50 ops.',
268
278
  color: failures > 0 ? 'warning' : 'success',
279
+ actions: trashIds.length > 0
280
+ ? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
281
+ : undefined,
269
282
  })
270
283
  } else {
271
284
  toast.add({
@@ -278,6 +291,26 @@ async function bulkDelete() {
278
291
  bulkDeleting.value = false
279
292
  await refreshAll()
280
293
  }
294
+
295
+ async function undoTrashIds(ids: string[]) {
296
+ const results = await Promise.allSettled(
297
+ ids.map((tid) =>
298
+ $fetch<{ restored?: boolean, error?: string }>(
299
+ `${apiBase}/api/trash/${tid}/restore`,
300
+ { method: 'POST' },
301
+ ),
302
+ ),
303
+ )
304
+ const restored = results.filter(
305
+ (r) => r.status === 'fulfilled' && r.value.restored,
306
+ ).length
307
+ toast.add({
308
+ title: restored > 0 ? `Restored ${restored}` : 'Undo failed',
309
+ description: restored < ids.length ? `${ids.length - restored} could not be restored.` : undefined,
310
+ color: restored === ids.length ? 'success' : restored > 0 ? 'warning' : 'error',
311
+ })
312
+ await refreshAll()
313
+ }
281
314
  </script>
282
315
 
283
316
  <template>
@@ -179,20 +179,27 @@ async function bulkDelete() {
179
179
  bulkDeleting.value = true
180
180
  const results = await Promise.allSettled(
181
181
  ids.map((id) =>
182
- $fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/personas/${id}`, {
183
- method: 'DELETE',
184
- }),
182
+ $fetch<{ deleted?: boolean, trash_id?: string, error?: string }>(
183
+ `${apiBase}/api/personas/${id}`,
184
+ { method: 'DELETE' },
185
+ ),
185
186
  ),
186
187
  )
187
188
  const successes = results.filter(
188
189
  (r) => r.status === 'fulfilled' && r.value.deleted,
189
- ).length
190
- const failures = ids.length - successes
191
- if (successes > 0) {
190
+ )
191
+ const trashIds = successes
192
+ .map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
193
+ .filter((v): v is string => Boolean(v))
194
+ const failures = ids.length - successes.length
195
+ if (successes.length > 0) {
192
196
  toast.add({
193
- title: `Deleted ${successes} persona${successes === 1 ? '' : 's'}`,
194
- description: failures > 0 ? `${failures} failed` : undefined,
197
+ title: `Deleted ${successes.length} persona${successes.length === 1 ? '' : 's'}`,
198
+ description: failures > 0 ? `${failures} failed` : 'Undo from /trash within 50 ops.',
195
199
  color: failures > 0 ? 'warning' : 'success',
200
+ actions: trashIds.length > 0
201
+ ? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
202
+ : undefined,
196
203
  })
197
204
  } else {
198
205
  toast.add({
@@ -204,6 +211,26 @@ async function bulkDelete() {
204
211
  bulkDeleting.value = false
205
212
  await refreshAll()
206
213
  }
214
+
215
+ async function undoTrashIds(ids: string[]) {
216
+ const results = await Promise.allSettled(
217
+ ids.map((tid) =>
218
+ $fetch<{ restored?: boolean, error?: string }>(
219
+ `${apiBase}/api/trash/${tid}/restore`,
220
+ { method: 'POST' },
221
+ ),
222
+ ),
223
+ )
224
+ const restored = results.filter(
225
+ (r) => r.status === 'fulfilled' && r.value.restored,
226
+ ).length
227
+ toast.add({
228
+ title: restored > 0 ? `Restored ${restored}` : 'Undo failed',
229
+ description: restored < ids.length ? `${ids.length - restored} could not be restored.` : undefined,
230
+ color: restored === ids.length ? 'success' : restored > 0 ? 'warning' : 'error',
231
+ })
232
+ await refreshAll()
233
+ }
207
234
  </script>
208
235
 
209
236
  <template>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ // PR85b v3.12.0 — Trash listing + Restore / Discard.
3
+
4
+ interface TrashEntry {
5
+ id: string
6
+ kind: 'agent-delete' | 'persona-delete' | 'agent-move'
7
+ item_id: string
8
+ timestamp: number
9
+ original_path: string
10
+ new_path: string | null
11
+ has_payload: boolean
12
+ }
13
+
14
+ const { fetchApi, apiBase } = useApi()
15
+ const toast = useToast()
16
+ const confirmDialog = useConfirmDialog()
17
+
18
+ const { data, status, error, refresh } = await fetchApi<{ entries: TrashEntry[] }>(
19
+ '/api/trash?limit=50',
20
+ )
21
+
22
+ async function restore(entry: TrashEntry) {
23
+ try {
24
+ const res = await $fetch<{ restored?: boolean, error?: string }>(
25
+ `${apiBase}/api/trash/${entry.id}/restore`,
26
+ { method: 'POST' },
27
+ )
28
+ if (res.error) throw new Error(res.error)
29
+ toast.add({
30
+ title: 'Restored',
31
+ description: kindLabel(entry.kind) + ' restored.',
32
+ color: 'success',
33
+ icon: 'i-lucide-rotate-ccw',
34
+ })
35
+ await refresh()
36
+ } catch (err) {
37
+ toast.add({
38
+ title: 'Restore failed',
39
+ description: err instanceof Error ? err.message : 'unknown error',
40
+ color: 'error',
41
+ })
42
+ }
43
+ }
44
+
45
+ async function discard(entry: TrashEntry) {
46
+ const ok = await confirmDialog({
47
+ title: 'Discard trash entry?',
48
+ description: `${kindLabel(entry.kind)} for ${entry.item_id} will be permanently dropped.`,
49
+ confirmLabel: 'Discard',
50
+ cancelLabel: 'Cancel',
51
+ variant: 'danger',
52
+ })
53
+ if (!ok) return
54
+ try {
55
+ await $fetch(`${apiBase}/api/trash/${entry.id}`, { method: 'DELETE' })
56
+ toast.add({ title: 'Discarded', color: 'success' })
57
+ await refresh()
58
+ } catch (err) {
59
+ toast.add({
60
+ title: 'Discard failed',
61
+ description: err instanceof Error ? err.message : 'unknown error',
62
+ color: 'error',
63
+ })
64
+ }
65
+ }
66
+
67
+ function kindLabel(kind: string): string {
68
+ return {
69
+ 'agent-delete': 'Agent delete',
70
+ 'persona-delete': 'Persona delete',
71
+ 'agent-move': 'Agent move',
72
+ }[kind] ?? kind
73
+ }
74
+
75
+ function kindColor(kind: string): 'error' | 'warning' | 'primary' | 'neutral' {
76
+ return ({
77
+ 'agent-delete': 'error',
78
+ 'persona-delete': 'error',
79
+ 'agent-move': 'warning',
80
+ } as const)[kind] ?? 'neutral'
81
+ }
82
+
83
+ function formatRelative(ts: number): string {
84
+ const diff = Date.now() - ts * 1000
85
+ const minutes = Math.floor(diff / 60_000)
86
+ if (minutes < 1) return 'just now'
87
+ if (minutes < 60) return `${minutes}m ago`
88
+ const hours = Math.floor(minutes / 60)
89
+ if (hours < 24) return `${hours}h ago`
90
+ const days = Math.floor(hours / 24)
91
+ return `${days}d ago`
92
+ }
93
+ </script>
94
+
95
+ <template>
96
+ <UDashboardPanel id="trash">
97
+ <template #header>
98
+ <UDashboardNavbar title="Trash">
99
+ <template #leading>
100
+ <UDashboardSidebarCollapse />
101
+ </template>
102
+ <template #trailing>
103
+ <UBadge
104
+ v-if="data?.entries?.length"
105
+ :label="`${data.entries.length} item${data.entries.length === 1 ? '' : 's'}`"
106
+ variant="subtle"
107
+ />
108
+ </template>
109
+ </UDashboardNavbar>
110
+ </template>
111
+
112
+ <template #body>
113
+ <DashboardState
114
+ :status="status"
115
+ :error="error"
116
+ :empty="!data?.entries?.length"
117
+ empty-title="Trash is empty"
118
+ empty-description="Deleted agents, personas, and department moves show up here for 50 entries."
119
+ empty-icon="i-lucide-trash"
120
+ loading-label="Loading trash"
121
+ :on-retry="() => refresh()"
122
+ >
123
+ <div class="space-y-2 max-w-4xl">
124
+ <div
125
+ v-for="entry in data?.entries ?? []"
126
+ :key="entry.id"
127
+ class="rounded-lg border border-default p-3 flex items-center gap-3 hover:border-primary/40 transition-colors"
128
+ >
129
+ <UBadge
130
+ :label="kindLabel(entry.kind)"
131
+ :color="kindColor(entry.kind)"
132
+ variant="subtle"
133
+ size="sm"
134
+ />
135
+ <div class="flex-1 min-w-0">
136
+ <p class="text-sm font-mono font-semibold truncate">{{ entry.item_id }}</p>
137
+ <p class="text-xs text-muted truncate" :title="entry.original_path">
138
+ {{ entry.original_path }}<span v-if="entry.new_path"> → {{ entry.new_path }}</span>
139
+ </p>
140
+ </div>
141
+ <span class="text-xs text-muted shrink-0">{{ formatRelative(entry.timestamp) }}</span>
142
+ <UButton
143
+ label="Restore"
144
+ icon="i-lucide-rotate-ccw"
145
+ size="xs"
146
+ variant="soft"
147
+ color="primary"
148
+ @click="restore(entry)"
149
+ />
150
+ <UButton
151
+ icon="i-lucide-x"
152
+ size="xs"
153
+ variant="ghost"
154
+ color="neutral"
155
+ aria-label="Discard"
156
+ @click="discard(entry)"
157
+ />
158
+ </div>
159
+ </div>
160
+ </DashboardState>
161
+ </template>
162
+ </UDashboardPanel>
163
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.11.0"
3
+ version = "3.12.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1223,9 +1223,20 @@ def agent_move(agent_id: str, body: dict):
1223
1223
  )
1224
1224
  tmp.replace(yaml_file)
1225
1225
  yaml_file.rename(dest_file)
1226
+ from core import trash as _trash
1227
+ trash_id = _trash.record_move(
1228
+ item_id=agent_id,
1229
+ from_path=str(yaml_file),
1230
+ to_path=str(dest_file),
1231
+ )
1226
1232
  except (OSError, ImportError) as exc:
1227
1233
  return {"error": f"move failed: {exc}"}
1228
- return {"moved": True, "id": agent_id, "yaml_path": str(dest_file)}
1234
+ return {
1235
+ "moved": True,
1236
+ "id": agent_id,
1237
+ "yaml_path": str(dest_file),
1238
+ "trash_id": trash_id,
1239
+ }
1229
1240
 
1230
1241
 
1231
1242
  @app.delete("/api/agents/{agent_id}")
@@ -1247,10 +1258,23 @@ def agent_delete(agent_id: str):
1247
1258
  if tier == 0:
1248
1259
  return {"error": "Cannot delete Tier 0 (C-Suite) agents from the dashboard"}
1249
1260
  try:
1261
+ from core import trash as _trash
1262
+ content = yaml_file.read_text(encoding="utf-8")
1263
+ trash_id = _trash.record_deletion(
1264
+ kind="agent-delete",
1265
+ item_id=agent_id,
1266
+ original_path=str(yaml_file),
1267
+ content=content,
1268
+ )
1250
1269
  yaml_file.unlink()
1251
1270
  except OSError as exc:
1252
1271
  return {"error": f"delete failed: {exc}"}
1253
- return {"deleted": True, "id": agent_id, "yaml_path": str(yaml_file)}
1272
+ return {
1273
+ "deleted": True,
1274
+ "id": agent_id,
1275
+ "yaml_path": str(yaml_file),
1276
+ "trash_id": trash_id,
1277
+ }
1254
1278
 
1255
1279
 
1256
1280
  def _resolve_agent_yaml(agent_id: str) -> Optional[Path]:
@@ -1282,11 +1306,51 @@ def persona_delete(persona_id: str):
1282
1306
  mgr = _get_persona_manager()
1283
1307
  if not mgr:
1284
1308
  return {"error": "Persona manager unavailable"}
1309
+ # Capture content + path BEFORE delete for trash record (best-effort)
1310
+ trash_id = None
1311
+ try:
1312
+ from core import trash as _trash
1313
+ target = mgr.get(persona_id) if hasattr(mgr, "get") else None
1314
+ if target is not None:
1315
+ import json as _json
1316
+ payload = _json.dumps(
1317
+ target if isinstance(target, dict) else target.model_dump(),
1318
+ indent=2,
1319
+ )
1320
+ store_path = getattr(mgr, "_store_path", None) or "personas.json"
1321
+ trash_id = _trash.record_deletion(
1322
+ kind="persona-delete",
1323
+ item_id=persona_id,
1324
+ original_path=str(store_path) + f"#{persona_id}",
1325
+ content=payload,
1326
+ )
1327
+ except Exception:
1328
+ pass
1285
1329
  if mgr.delete(persona_id):
1286
- return {"deleted": True}
1330
+ return {"deleted": True, "trash_id": trash_id}
1287
1331
  return {"error": "Persona not found"}
1288
1332
 
1289
1333
 
1334
+ # --- Trash / Undo (PR85b v3.12.0) ---
1335
+
1336
+ @app.get("/api/trash")
1337
+ def trash_list(limit: int = 10):
1338
+ from core import trash as _trash
1339
+ return {"entries": _trash.list_trash(limit=limit)}
1340
+
1341
+
1342
+ @app.post("/api/trash/{trash_id}/restore")
1343
+ def trash_restore(trash_id: str):
1344
+ from core import trash as _trash
1345
+ return _trash.restore(trash_id)
1346
+
1347
+
1348
+ @app.delete("/api/trash/{trash_id}")
1349
+ def trash_purge(trash_id: str):
1350
+ from core import trash as _trash
1351
+ return _trash.purge(trash_id)
1352
+
1353
+
1290
1354
  @app.post("/api/personas/build")
1291
1355
  def persona_build(body: dict):
1292
1356
  """PR57 v2.74.0 — AI-powered persona draft from already-indexed content.