zigmini 1.0.0-beta.3 → 1.1.0-beta.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.
Files changed (138) hide show
  1. package/README.ko.md +29 -2
  2. package/README.md +29 -2
  3. package/assets/agent/gateway-request-rules.md +9 -2
  4. package/assets/agent/gateway-rules.md +13 -6
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
package/src/agent/api.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { YAML } from "bun";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { Hub } from "../hub.ts";
4
5
  import { MEDIA_MAX_BYTES } from "../media.ts";
5
6
  import type { Store } from "../store.ts";
6
7
  import { mergeAgentDefinition, parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
7
8
  import { registryMetadata } from "./fields.ts";
8
- import { AgentFileError, AgentFileStore, unreadableAgent } from "./files.ts";
9
+ import { agentFileTrashName, AgentFileError, AgentFileStore, parseAgentFileTrashName, unreadableAgent } from "./files.ts";
10
+ import { AgentContentError, AgentContentFiles, contentIdentity, numberedName, sourceName } from "./file-core.ts";
9
11
 
10
12
  class AgentApiError extends Error {
11
13
  constructor(readonly status: number, readonly code: string) {
@@ -17,15 +19,76 @@ function object(value: unknown): value is Record<string, unknown> {
17
19
  return typeof value === "object" && value !== null && !Array.isArray(value);
18
20
  }
19
21
 
22
+ /** A draft keeps the editor text of a new agent; 64 KB is twice the definition cap, room for a text being edited. */
23
+ export const DRAFT_MAX_BYTES = 65536;
24
+
25
+ /** Draft ids are made by the web (12 lowercase letters or digits); nothing else reaches the file system. */
26
+ function draftId(value: string): string {
27
+ if (!/^[a-z0-9]{12}$/.test(value)) throw new AgentApiError(400, "invalid_draft_id");
28
+ return value;
29
+ }
30
+
31
+ interface DraftBody { readonly source: string; readonly kind: "new"; readonly files: readonly { readonly name: string; readonly size: number }[] }
32
+
33
+ /** The source is kept unvalidated (it is still being typed); only the shape is checked. */
34
+ function draftBody(value: unknown): DraftBody {
35
+ if (!object(value) || typeof value.source !== "string" || value.kind !== "new" || !Array.isArray(value.files)) throw new AgentApiError(400, "invalid_draft");
36
+ const files = value.files.map((file: unknown) => {
37
+ if (!object(file) || typeof file.name !== "string" || typeof file.size !== "number" || !Number.isSafeInteger(file.size) || file.size < 0) throw new AgentApiError(400, "invalid_draft");
38
+ return { name: file.name, size: file.size };
39
+ });
40
+ if (Buffer.byteLength(value.source) > DRAFT_MAX_BYTES) throw new AgentApiError(413, "too_large");
41
+ return { source: value.source, kind: "new", files };
42
+ }
43
+
44
+ /** The name a half-typed draft shows in the list, or null while its frontmatter does not parse yet. */
45
+ function draftName(source: string): string | null {
46
+ const frontmatter = /^---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(source)?.[1];
47
+ if (frontmatter === undefined) return null;
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = YAML.parse(frontmatter);
51
+ } catch {
52
+ return null;
53
+ }
54
+ return object(parsed) && typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null;
55
+ }
56
+
57
+ type KnowledgeArea = "source" | "outputs" | "notes" | "memory" | "other";
58
+
59
+ /** The area of a path relative to knowledge/: uploads, results, notes, MEMORY.md, or anything else. */
60
+ function knowledgeArea(parts: readonly string[]): KnowledgeArea {
61
+ if (parts.length === 1 && parts[0] === "MEMORY.md") return "memory";
62
+ const top = parts[0];
63
+ return top === "source" || top === "outputs" || top === "notes" ? top : "other";
64
+ }
65
+
66
+ /** RFC 5987 value: encodeURIComponent leaves ' ( ) * as they are, which the header grammar does not allow. */
67
+ function attachment(name: string): string {
68
+ return `attachment; filename*=UTF-8''${encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
69
+ }
70
+
20
71
  async function source(req: Request): Promise<string> {
21
72
  const value: unknown = await req.json();
22
73
  if (!object(value) || typeof value.source !== "string") throw new AgentApiError(400, "source_required");
23
74
  return value.source;
24
75
  }
25
76
 
77
+ /**
78
+ * The text the editor holds, for POST /api/agents/preview: a text up to the draft size; a save's own limit is reported in the
79
+ * answer. `kind: "new"` marks the create screen's text, which may not have its name and description yet (agent-ux int).
80
+ */
81
+ function previewSource(value: unknown): { readonly source: string; readonly kind: "agent" | "new" } {
82
+ if (!object(value) || typeof value.source !== "string") throw new AgentApiError(400, "source_required");
83
+ if (value.kind !== undefined && value.kind !== "agent" && value.kind !== "new") throw new AgentApiError(400, "invalid_kind");
84
+ if (Buffer.byteLength(value.source) > DRAFT_MAX_BYTES) throw new AgentApiError(413, "too_large");
85
+ return { source: value.source, kind: value.kind === "new" ? "new" : "agent" };
86
+ }
87
+
26
88
  /** Only metadata leaves list endpoints; definition text is returned by the detail endpoint. */
27
89
  export class AgentApi {
28
90
  private readonly deleting = new Set<string>();
91
+ private lastTime = 0;
29
92
 
30
93
  constructor(private readonly files: AgentFileStore, private readonly store: Store, private readonly hub: Hub) {}
31
94
 
@@ -41,21 +104,62 @@ export class AgentApi {
41
104
  const applied = this.store.eventsAfter(conv.id, this.store.lastSeq(conv.id, "session.rotated"))
42
105
  .filter((event) => event.type === "agent.applied").at(-1)?.data;
43
106
  const appliedSha = object(applied) && typeof applied.sha === "string" ? applied.sha : null;
107
+ const opened = this.hub.hasSession(conv.id) || applied !== undefined;
108
+ const stale = opened && appliedSha !== merged.sha;
44
109
  return {
45
110
  id, name: definition.fields.name, description: definition.fields.description, icon: definition.fields.icon ?? null,
46
- convId: conv.id, definitionSha: merged.sha, stale: appliedSha !== null && appliedSha !== merged.sha,
111
+ convId: conv.id, definitionSha: merged.sha, appliedSha, stale,
112
+ ...(opened && appliedSha === null ? { staleReason: "unknown" } : {}),
47
113
  };
48
114
  }
49
115
 
50
- /** The definition text, the merged result and the field registry the editor draws its widgets from. */
116
+ /**
117
+ * The definition text, the merged result, the shared layer as this gateway parsed it (`defaultsFields`: what the merge applied
118
+ * under the agent's own values, which the editor reads instead of parsing defaults.md itself) and the field registry.
119
+ */
51
120
  private detail(id: string) {
52
121
  const definition = this.files.read(id);
122
+ const defaults = this.files.readDefaults();
53
123
  return { ...this.summary(id, definition), source: serializeAgentDefinition(definition),
54
- ...mergeAgentDefinition(definition, this.files.readDefaults()), registry: registryMetadata() };
124
+ ...mergeAgentDefinition(definition, defaults), defaultsFields: defaults?.fields ?? {}, registry: registryMetadata(), lastDaily: this.lastDaily(id) };
125
+ }
126
+
127
+ /** The date of the newest notes/daily/YYYY-MM-DD.md, or null; a linked folder or note does not count. */
128
+ private lastDaily(id: string): string | null {
129
+ let folder: string;
130
+ try {
131
+ folder = this.path([id, "knowledge", "notes", "daily"]);
132
+ } catch (error) {
133
+ if (error instanceof AgentApiError) return null;
134
+ throw error;
135
+ }
136
+ if (!existsSync(folder)) return null;
137
+ const dates = readdirSync(folder).filter((name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name) && lstatSync(join(folder, name)).isFile()).sort();
138
+ return dates.at(-1)?.slice(0, 10) ?? null;
139
+ }
140
+
141
+ /**
142
+ * The editor's current text evaluated exactly as a save reads it back (agent-ux todo 12): schema 1 filled in, parsed and
143
+ * validated by this gateway's parser, merged with the current defaults.md; nothing is written. Text a save would refuse
144
+ * answers `valid: false` with the save's field-level code, so the editor shows no guess for it. A new agent's text without
145
+ * its name or description yet is evaluated with a placeholder for them (agent-ux int); the answer leaves those fields out.
146
+ */
147
+ private preview({ source, kind }: { readonly source: string; readonly kind: "agent" | "new" }) {
148
+ try {
149
+ const { definition, filled } = kind === "new" ? this.files.parseNew(source) : { definition: this.files.parse(source), filled: [] };
150
+ const merged = mergeAgentDefinition(definition, this.files.readDefaults());
151
+ const own = (entries: object) => Object.fromEntries(Object.entries(entries).filter(([name]) => !filled.includes(name)));
152
+ return { valid: true, fields: own(merged.fields), origins: own(merged.origins) };
153
+ } catch (error) {
154
+ if (error instanceof AgentDefinitionError) return { valid: false, error: "invalid_agent_definition", field: error.field };
155
+ throw error;
156
+ }
55
157
  }
56
158
 
159
+ /** defaults.md as text (what the defaults editor edits) and as this gateway parsed it (`fields`: the layer the create screen reads under a new agent's values). */
57
160
  private defaults() {
58
- return { source: this.files.readDefaults()?.source ?? "---\n{}\n---\n", registry: registryMetadata() };
161
+ const defaults = this.files.readDefaults();
162
+ return { source: defaults?.source ?? "---\n{}\n---\n", fields: defaults?.fields ?? {}, registry: registryMetadata() };
59
163
  }
60
164
 
61
165
  private list() {
@@ -79,13 +183,180 @@ export class AgentApi {
79
183
  return path;
80
184
  }
81
185
 
186
+ private timestamp(): string {
187
+ this.lastTime = Math.max(Date.now(), this.lastTime + 1);
188
+ return new Date(this.lastTime).toISOString();
189
+ }
190
+
191
+ /** A request path relative to knowledge/: every part checked by path() (no links, no ".."), with its area. */
192
+ private knowledge(id: string, value: unknown): { readonly parts: string[]; readonly file: string; readonly area: KnowledgeArea } {
193
+ if (typeof value !== "string") throw new AgentApiError(400, "invalid_path");
194
+ const parts = value.split("/");
195
+ return { parts, file: this.path([id, "knowledge", ...parts]), area: knowledgeArea(parts) };
196
+ }
197
+
198
+ /** The size of a regular file; a missing one is 404 file_not_found, anything else 400 `notFile`. */
199
+ private regularFile(file: string, notFile: string): number {
200
+ try {
201
+ const stat = lstatSync(file);
202
+ if (!stat.isFile()) throw new AgentApiError(400, notFile);
203
+ return stat.size;
204
+ } catch (error) {
205
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new AgentApiError(404, "file_not_found");
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ /** The first of `name`, `name (2)` ... that nothing (no file, folder or link) takes in the folder `parts`. */
211
+ private freePath(parts: readonly string[], name: string): { readonly name: string; readonly file: string } {
212
+ for (let n = 1; n <= 1000; n++) {
213
+ const candidate = numberedName(name, n);
214
+ const file = this.path([...parts, candidate]);
215
+ if (!existsSync(file)) return { name: candidate, file };
216
+ }
217
+ throw new AgentApiError(409, "file_exists");
218
+ }
219
+
220
+ /**
221
+ * Knowledge file actions. Delete moves a regular file of source/ or outputs/ to agents/.file-trash/<id>/ (outside
222
+ * .trash/, whose folders the archived-agent cleanup takes whole); housekeeping offers it to the operator after 30 days.
223
+ */
224
+ private async fileAction(req: Request, id: string, action: string, entryPart: string | undefined): Promise<Response> {
225
+ if (action === "download" && req.method === "GET") {
226
+ const query = new URL(req.url).searchParams, artifactId = query.get("artifactId");
227
+ if (artifactId !== null) {
228
+ if (!artifactId || query.has("path") || !query.get("requestId")) throw new AgentApiError(400, "invalid_artifact_selector");
229
+ const requestId = query.get("requestId") ?? "";
230
+ const conv = this.conversation(id), request = this.store.getAgentRequest(requestId);
231
+ const artifact = this.store.agentArtifact(artifactId);
232
+ if (!request || !artifact || artifact.requestId !== requestId) throw new AgentApiError(404, "artifact_not_found");
233
+ if (request.requesterConv !== conv.id && request.targetConv !== conv.id) throw new AgentApiError(403, "request_forbidden");
234
+ const grant = this.store.agentArtifactGrant(artifactId, conv.id, requestId);
235
+ if (!grant) throw new AgentApiError(403, "artifact_forbidden");
236
+ if (grant.revoked || artifact.state === "trashed") throw new AgentApiError(410, "artifact_revoked");
237
+ // T3's readGranted requires a live native binding. An owner download must not fabricate one.
238
+ return Response.json({ error: "capability_unavailable", capability: "owner_artifact_release" }, { status: 503 });
239
+ }
240
+ const target = this.knowledge(id, new URL(req.url).searchParams.get("path"));
241
+ if (target.area === "other") throw new AgentApiError(400, "file_not_readable");
242
+ this.regularFile(target.file, "not_a_file");
243
+ return new Response(readFileSync(target.file), {
244
+ headers: { "Content-Type": "application/octet-stream", "Content-Disposition": attachment(target.parts.at(-1) ?? "file") },
245
+ });
246
+ }
247
+ if (action === "trash" && req.method === "GET") {
248
+ const folder = this.path([".file-trash", id]);
249
+ if (!existsSync(folder)) return Response.json([]);
250
+ const rows = readdirSync(folder).flatMap((entry) => {
251
+ const parsed = parseAgentFileTrashName(entry);
252
+ const stat = lstatSync(join(folder, entry));
253
+ return parsed === null || !stat.isFile() ? [] : [{ entry, path: parsed.path, size: stat.size, deletedAt: new Date(parsed.deletedAt).toISOString() }];
254
+ });
255
+ return Response.json(rows.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt) || b.entry.localeCompare(a.entry)));
256
+ }
257
+ if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
258
+ if (action === "delete") {
259
+ const body: unknown = await req.json();
260
+ const target = this.knowledge(id, object(body) ? body.path : undefined);
261
+ if ((target.area !== "source" && target.area !== "outputs") || target.parts.at(-1)?.endsWith(".lock")) throw new AgentApiError(400, "file_not_deletable");
262
+ this.regularFile(target.file, "file_not_deletable");
263
+ const folder = this.path([".file-trash", id]);
264
+ let entry = agentFileTrashName(target.parts.join("/"), this.timestamp());
265
+ if (Buffer.byteLength(entry) > 255) throw new AgentApiError(400, "path_too_long");
266
+ this.conversation(id);
267
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
268
+ mkdirSync(folder, { recursive: true });
269
+ while (existsSync(this.path([".file-trash", id, entry]))) entry = agentFileTrashName(target.parts.join("/"), this.timestamp());
270
+ renameSync(target.file, this.path([".file-trash", id, entry]));
271
+ this.hub.agentUpdated(this.summary(id, this.files.read(id)));
272
+ return Response.json({ entry });
273
+ }
274
+ if (action.startsWith("trash/") && entryPart !== undefined) {
275
+ const entry = decodeURIComponent(entryPart);
276
+ const parsed = parseAgentFileTrashName(entry);
277
+ if (parsed === null) throw new AgentApiError(400, "invalid_trash_entry");
278
+ const archived = this.path([".file-trash", id, entry]);
279
+ const target = this.knowledge(id, parsed.path);
280
+ if (target.area !== "source" && target.area !== "outputs") throw new AgentApiError(400, "invalid_trash_entry");
281
+ this.regularFile(archived, "invalid_trash_entry");
282
+ this.conversation(id);
283
+ if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
284
+ const parent = target.parts.slice(0, -1);
285
+ mkdirSync(this.path([id, "knowledge", ...parent]), { recursive: true });
286
+ const free = this.freePath([id, "knowledge", ...parent], target.parts.at(-1) ?? "");
287
+ renameSync(archived, free.file);
288
+ this.hub.agentUpdated(this.summary(id, this.files.read(id)));
289
+ return Response.json({ path: [...parent, free.name].join("/") });
290
+ }
291
+ throw new AgentApiError(405, "method_not_allowed");
292
+ }
293
+
294
+ /** Moves a draft to agents/.drafts-trash/ (outside .trash/); housekeeping offers it to the operator after 30 days. */
295
+ private archiveDraft(id: string): boolean {
296
+ const file = this.path([".drafts", `${id}.json`]);
297
+ if (!existsSync(file)) return false;
298
+ mkdirSync(this.path([".drafts-trash"]), { recursive: true });
299
+ let entry = `${this.timestamp()}-${id}.json`;
300
+ while (existsSync(this.path([".drafts-trash", entry]))) entry = `${this.timestamp()}-${id}.json`;
301
+ renameSync(file, this.path([".drafts-trash", entry]));
302
+ return true;
303
+ }
304
+
305
+ /** An id whose draft was archived (created into an agent or discarded) is closed: a late save must not bring it back. */
306
+ private draftClosed(id: string): boolean {
307
+ const trash = this.path([".drafts-trash"]);
308
+ return existsSync(trash) && readdirSync(trash).some((name) => name.endsWith(`-${id}.json`));
309
+ }
310
+
311
+ private drafts() {
312
+ const folder = this.path([".drafts"]);
313
+ if (!existsSync(folder)) return [];
314
+ const rows = readdirSync(folder).filter((name) => /^[a-z0-9]{12}\.json$/.test(name)).flatMap((name) => {
315
+ try {
316
+ const draft: unknown = JSON.parse(readFileSync(this.path([".drafts", name]), "utf8"));
317
+ if (!object(draft) || typeof draft.updatedAt !== "string") return [];
318
+ return [{ id: name.slice(0, 12), name: typeof draft.name === "string" ? draft.name : null, updatedAt: draft.updatedAt }];
319
+ } catch (error) {
320
+ if (error instanceof SyntaxError || (error instanceof AgentApiError && error.code === "symlink_not_allowed")) return [];
321
+ throw error;
322
+ }
323
+ });
324
+ return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
325
+ }
326
+
327
+ private async draft(req: Request, id: string): Promise<Response> {
328
+ const file = this.path([".drafts", `${id}.json`]);
329
+ if (req.method === "GET") {
330
+ if (!existsSync(file)) throw new AgentApiError(404, "draft_not_found");
331
+ return new Response(readFileSync(file), { headers: { "Content-Type": "application/json" } });
332
+ }
333
+ if (req.method === "DELETE") {
334
+ if (!this.archiveDraft(id)) throw new AgentApiError(404, "draft_not_found");
335
+ return Response.json({ ok: true });
336
+ }
337
+ if (req.method !== "PUT") throw new AgentApiError(405, "method_not_allowed");
338
+ if (Number(req.headers.get("content-length")) > DRAFT_MAX_BYTES * 4 + 65536) throw new AgentApiError(413, "too_large");
339
+ const body = draftBody(await req.json());
340
+ // Checked right before the write (no await between): a create or discard that archived the draft meanwhile wins.
341
+ if (!existsSync(file) && this.draftClosed(id)) throw new AgentApiError(409, "draft_closed");
342
+ const draft = { id, name: draftName(body.source), ...body, updatedAt: this.timestamp() };
343
+ mkdirSync(this.path([".drafts"]), { recursive: true });
344
+ writeFileSync(file, JSON.stringify(draft));
345
+ return Response.json(draft);
346
+ }
347
+
82
348
  private tree(id: string) {
83
- const entries: { path: string; type: "file" | "directory"; size: number }[] = [];
349
+ const copies = new Set(this.store.eventsAfter(this.conversation(id).id, 0).flatMap(event =>
350
+ event.type === "agent.collaboration.materialized" && object(event.data) && typeof event.data.fileIdentity === "string" ? [event.data.fileIdentity] : []));
351
+ const entries: { path: string; type: "file" | "directory"; size: number; area: KnowledgeArea; modifiedAt: string | null; origin?: "user-upload" | "collaboration-copy" }[] = [];
84
352
  const walk = (parts: readonly string[]) => {
85
353
  for (const name of readdirSync(this.path([id, "knowledge", ...parts]))) {
86
- const stat = lstatSync(this.path([id, "knowledge", ...parts, name]));
354
+ const stat = lstatSync(this.path([id, "knowledge", ...parts, name]), { bigint: true });
87
355
  if (!stat.isFile() && !stat.isDirectory()) throw new AgentApiError(400, "unsupported_file");
88
- entries.push({ path: [...parts, name].join("/"), type: stat.isDirectory() ? "directory" : "file", size: stat.isFile() ? stat.size : 0 });
356
+ // The modified time rides along for the editor's outputs list (agent-ux todo 17); a folder has none.
357
+ const path = [...parts, name].join("/");
358
+ entries.push({ path, type: stat.isDirectory() ? "directory" : "file", size: stat.isFile() ? Number(stat.size) : 0, area: knowledgeArea([...parts, name]), modifiedAt: stat.isFile() ? stat.mtime.toISOString() : null,
359
+ ...(stat.isFile() && knowledgeArea([...parts, name]) === "source" ? { origin: copies.has(contentIdentity(stat)) ? "collaboration-copy" as const : "user-upload" as const } : {}) });
89
360
  if (stat.isDirectory()) walk([...parts, name]);
90
361
  }
91
362
  };
@@ -93,6 +364,25 @@ export class AgentApi {
93
364
  return entries;
94
365
  }
95
366
 
367
+ /**
368
+ * Restores an archive entry beside a live agent of the same id. The entry's own conversation comes back only while it is
369
+ * still hidden (its agent is gone); a conversation that a live agent shows stays with it, and a new one is opened.
370
+ */
371
+ private restoreAs(entry: string, oldId: string): Response {
372
+ const visible = this.store.listConversations(false);
373
+ const hidden = this.store.listConversations(true).find((row) => row.agentId === oldId && !visible.some((shown) => shown.id === row.id));
374
+ const id = this.files.restoreAs(entry);
375
+ // A new id is a new agent (C9): managed with zero grants; the archived agent's grants stay with its old id.
376
+ this.store.toolPermissions.startManaged(id);
377
+ if (hidden) {
378
+ this.store.setConversationAgent(hidden.id, id);
379
+ this.store.setConversationHidden(hidden.id, false);
380
+ } else {
381
+ this.hub.createConversation(String(this.files.read(id).fields.name), id);
382
+ }
383
+ return this.changed(id);
384
+ }
385
+
96
386
  private changed(id: string) {
97
387
  const detail = this.detail(id);
98
388
  this.hub.agentUpdated(this.summary(id, this.files.read(id)));
@@ -106,11 +396,29 @@ export class AgentApi {
106
396
  if (path === "/api/agents") {
107
397
  if (req.method === "GET") return Response.json(this.list());
108
398
  if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
109
- const id = this.files.create(await source(req));
399
+ const body: unknown = await req.json();
400
+ if (!object(body) || typeof body.source !== "string") throw new AgentApiError(400, "source_required");
401
+ if (body.draftId !== undefined && typeof body.draftId !== "string") throw new AgentApiError(400, "invalid_draft_id");
402
+ const fromDraft = body.draftId === undefined ? null : draftId(body.draftId);
403
+ const id = this.files.create(body.source);
404
+ // New agents start managed with zero grants; tools.web in the file or in defaults.md grants nothing (C9).
405
+ this.store.toolPermissions.startManaged(id);
110
406
  const definition = this.files.read(id);
111
407
  this.hub.createConversation(String(definition.fields.name), id);
408
+ if (fromDraft !== null) this.archiveDraft(fromDraft);
112
409
  return new Response((await this.changed(id)).body, { status: 201, headers: { "Content-Type": "application/json" } });
113
410
  }
411
+ if (path === "/api/agents/preview") {
412
+ if (req.method !== "POST") throw new AgentApiError(405, "method_not_allowed");
413
+ if (Number(req.headers.get("content-length")) > DRAFT_MAX_BYTES * 4 + 65536) throw new AgentApiError(413, "too_large");
414
+ return Response.json(this.preview(previewSource(await req.json())));
415
+ }
416
+ if (path === "/api/agents/drafts") {
417
+ if (req.method !== "GET") throw new AgentApiError(405, "method_not_allowed");
418
+ return Response.json(this.drafts());
419
+ }
420
+ const draft = /^\/api\/agents\/drafts\/([^/]+)$/.exec(path);
421
+ if (draft?.[1]) return await this.draft(req, draftId(decodeURIComponent(draft[1])));
114
422
  if (path === "/api/agents/defaults") {
115
423
  if (req.method === "GET") return Response.json(this.defaults());
116
424
  if (req.method !== "PUT") throw new AgentApiError(405, "method_not_allowed");
@@ -137,11 +445,28 @@ export class AgentApi {
137
445
  const entry = decodeURIComponent(restore[1]);
138
446
  const id = /^([a-f0-9]{8})-/.exec(entry)?.[1];
139
447
  if (!id) throw new AgentApiError(400, "invalid_trash_entry");
448
+ const text = await req.text();
449
+ const body: unknown = text.trim() ? JSON.parse(text) : {};
450
+ if (!object(body) || (body.newId !== undefined && typeof body.newId !== "boolean")) throw new AgentApiError(400, "invalid_request");
451
+ if (body.newId === true) return this.restoreAs(entry, id);
140
452
  const conv = this.conversation(id, false);
141
- this.files.restore(entry);
453
+ try {
454
+ this.files.restore(entry);
455
+ } catch (error) {
456
+ if (error instanceof AgentFileError && error.code === "exists") return Response.json({ error: "exists", id }, { status: 409 });
457
+ throw error;
458
+ }
459
+ // A restored agent is a new lifecycle: earlier requests, grants and cancellations never revive.
460
+ this.store.advanceAgentLifecycle(id, this.store.ensureAgentLifecycle(id));
142
461
  this.store.setConversationHidden(conv.id, false);
143
462
  return this.changed(id);
144
463
  }
464
+ const fileRoute = /^\/api\/agents\/([a-f0-9]{8})\/files\/(delete|trash|download|trash\/([^/]+)\/restore)$/.exec(path);
465
+ if (fileRoute?.[1] && fileRoute[2]) {
466
+ this.conversation(fileRoute[1]);
467
+ if (this.deleting.has(fileRoute[1])) throw new AgentApiError(409, "agent_busy");
468
+ return await this.fileAction(req, fileRoute[1], fileRoute[2], fileRoute[3]);
469
+ }
145
470
  const match = /^\/api\/agents\/([a-f0-9]{8})(?:\/(clone|delete|files|source))?$/.exec(path);
146
471
  const id = match?.[1];
147
472
  if (!id) throw new AgentApiError(404, "not_found");
@@ -159,6 +484,8 @@ export class AgentApi {
159
484
  if (action === "files" && req.method === "GET") return Response.json(this.tree(id));
160
485
  if (action === "clone" && req.method === "POST") {
161
486
  const cloned = this.files.clone(id);
487
+ // A clone never copies the source agent's grants or legacy scope (C9, Q09).
488
+ this.store.toolPermissions.startManaged(cloned);
162
489
  this.hub.createConversation(String(this.files.read(cloned).fields.name), cloned);
163
490
  return this.changed(cloned);
164
491
  }
@@ -167,6 +494,8 @@ export class AgentApi {
167
494
  this.conversation(id);
168
495
  if (!object(body) || body.name !== this.files.read(id).fields.name) throw new AgentApiError(400, "name_mismatch");
169
496
  if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
497
+ // Open collaboration obligations keep the agent and its files; cancel or let them finish, then retry.
498
+ if (this.store.agentCollaborationBusy(this.conversation(id).id)) throw new AgentApiError(409, "agent_collaboration_busy");
170
499
  this.deleting.add(id);
171
500
  try {
172
501
  const conv = this.conversation(id);
@@ -184,22 +513,18 @@ export class AgentApi {
184
513
  const file = form.get("file");
185
514
  if (!(file instanceof File) || file.size === 0) throw new AgentApiError(400, "missing_file");
186
515
  if (file.size > MEDIA_MAX_BYTES) throw new AgentApiError(413, "too_large");
187
- // Reject traversal rather than silently turning it into a different name.
188
- if (!file.name || file.name.startsWith(".") || /[/\\\0]/.test(file.name)) throw new AgentApiError(400, "invalid_filename");
189
- let name = "";
190
- for (const character of file.name.replace(/[^\p{L}\p{N}._-]/gu, "_")) {
191
- if (Buffer.byteLength(name + character) > 180) break;
192
- name += character;
193
- }
516
+ const name = sourceName(file.name);
194
517
  const bytes = new Uint8Array(await file.arrayBuffer());
195
518
  this.conversation(id);
196
519
  if (this.deleting.has(id)) throw new AgentApiError(409, "agent_busy");
197
- writeFileSync(this.path([id, "knowledge", "source", name]), bytes, { flag: "wx" });
520
+ using content = new AgentContentFiles(this.files.root);
521
+ const free = content.publish({ directory: [id, "knowledge", "source"], name, bytes, immutable: false });
198
522
  this.hub.agentUpdated(this.summary(id, this.files.read(id)));
199
- return Response.json({ name, size: bytes.length }, { status: 201 });
523
+ return Response.json({ name: free.name, size: bytes.length }, { status: 201 });
200
524
  }
201
525
  throw new AgentApiError(405, "method_not_allowed");
202
526
  } catch (error) {
527
+ if (error instanceof AgentContentError) return Response.json({ error: error.code }, { status: error.status });
203
528
  if (error instanceof AgentApiError) return Response.json({ error: error.code }, { status: error.status });
204
529
  if (error instanceof AgentDefinitionError) return Response.json({ error: "invalid_agent_definition", field: error.field }, { status: 400 });
205
530
  if (error instanceof AgentFileError) return Response.json({ error: error.code }, { status: error.code === "exists" ? 409 : 400 });