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/web/agents.js CHANGED
@@ -13,19 +13,21 @@ registerStrings(strings);
13
13
  export const AGENTS_PATH = "/api/agents";
14
14
  export const DEFAULTS_PATH = "/api/agents/defaults";
15
15
  export const TRASH_PATH = "/api/agents/trash";
16
+ export const DRAFTS_PATH = "/api/agents/drafts";
17
+ /** The quiet period after the last change before the create screen saves its draft (agent-ux todo 11). */
18
+ export const DRAFT_DELAY_MS = 1500;
19
+ /** How long a draft reopen waits for that draft's saves still on the wire before it opens from the newest local state (todo 18 fix 4). */
20
+ export const DRAFT_REOPEN_WAIT_MS = 10_000;
16
21
  export const AGENT_MAX_BYTES = 32768;
17
22
  export const DESCRIPTION_MAX = 120;
18
23
  export const MEMORY_MAX_BYTES = 32768;
19
24
  export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
20
25
  export let ORIGIN_LABELS = originLabels();
21
26
  export const DEFAULT_ICON = "◎";
22
- export let STALE_LABEL = t("agents.stale.label");
23
27
  export let STALE_TITLE = t("agents.stale.title");
24
28
  export let DELETE_HINT = t("agents.delete.hint");
25
29
  export let EMPTY_AGENTS_LINE = t("agents.empty.list");
26
30
  export let EMPTY_TRASH_LINE = t("agents.empty.trash");
27
- export let EMPTY_FILES_LINE = t("agents.empty.files");
28
- export let NEW_AGENT_BODY = t("agents.new.body");
29
31
 
30
32
  function originLabels() {
31
33
  return Object.freeze({ package: t("agents.origin.package"), defaults: t("agents.origin.defaults"), agent: t("agents.origin.agent"), none: t("agents.origin.none") });
@@ -34,23 +36,31 @@ function originLabels() {
34
36
  // The exported texts follow the UI language (app.js reads them at render time).
35
37
  onLanguageChange(() => {
36
38
  ORIGIN_LABELS = originLabels();
37
- STALE_LABEL = t("agents.stale.label");
38
39
  STALE_TITLE = t("agents.stale.title");
39
40
  DELETE_HINT = t("agents.delete.hint");
40
41
  EMPTY_AGENTS_LINE = t("agents.empty.list");
41
42
  EMPTY_TRASH_LINE = t("agents.empty.trash");
42
- EMPTY_FILES_LINE = t("agents.empty.files");
43
- NEW_AGENT_BODY = t("agents.new.body");
44
43
  });
45
44
 
45
+ /**
46
+ * The four headings a role instruction should cover (agent-ux todo 9), in the UI language: the scope, the tasks (with
47
+ * the outputs/ folder), what not to do, and the form of address (which overrides the profile nickname rule when set).
48
+ * app.js draws them as the guide above the body; the body's placeholder is an example with the same headings.
49
+ */
50
+ export function roleGuideItems() {
51
+ return ["scope", "tasks", "limits", "address"].map((key) => ({ key, label: t(`agents.roleGuide.${key}`), text: t(`agents.roleGuide.${key}.text`) }));
52
+ }
53
+
46
54
  /**
47
55
  * The editor's labels and hints by registry field name. The field list itself (names, types, widgets, defaults, forbidden
48
56
  * rows) is the gateway's registry (src/agent/fields.ts), sent as `registry` in every GET /api/agents/:id and
49
57
  * /api/agents/defaults answer; editorFields() joins the two. A gateway row without an entry here renders with its name
50
- * as the label, so a new registry row needs no web change.
58
+ * as the label, so a new registry row needs no web change. `hidden: true` keeps a row out of the editor while its value
59
+ * stays in the saved source: the schema version is hidden here as well as by the gateway's flag, so an older gateway's
60
+ * registry without the flag hides it too (agent-ux todo 8).
51
61
  */
52
62
  export const FIELD_PRESENTATION = Object.freeze({
53
- schema: { label: "agents.field.schema.label", help: "agents.field.schema.help" },
63
+ schema: { label: "agents.field.schema.label", help: "agents.field.schema.help", hidden: true },
54
64
  name: { label: "agents.field.name.label", help: "agents.field.name.help" },
55
65
  description: { label: "agents.field.description.label", help: "agents.field.description.help" },
56
66
  icon: { label: "agents.field.icon.label", help: "agents.field.icon.help" },
@@ -58,8 +68,11 @@ export const FIELD_PRESENTATION = Object.freeze({
58
68
  "model.provider": { label: "agents.field.model.provider.label" }, "model.modelId": { label: "agents.field.model.modelId.label" },
59
69
  thinking: { label: "agents.field.thinking.label", options: THINKING_LEVELS },
60
70
  skills: { label: "agents.field.skills.label", help: "agents.field.skills.help" },
71
+ // The gateway marks the collaboration rows hidden for older webs; this web draws them (292 C-10) as a directional check list.
72
+ collaboration: { label: "agents.field.collaboration.label", hidden: false },
73
+ "collaboration.targets": { label: "agents.field.collaboration.targets.label", help: "agents.field.collaboration.targets.help", hidden: false, widget: "targets" },
61
74
  tools: { label: "agents.field.tools.label" }, "tools.web": { label: "agents.field.tools.web.label" },
62
- "tools.bash": { label: "agents.field.tools.bash.label" }, "tools.eval": { label: "agents.field.tools.eval.label" },
75
+ "tools.bash": { label: "agents.field.tools.bash.label", help: "agents.field.tools.forbidden.help" }, "tools.eval": { label: "agents.field.tools.eval.label", help: "agents.field.tools.forbidden.help" },
63
76
  notify: { label: "agents.field.notify.label" }, rules: { label: "agents.field.rules.label" },
64
77
  "rules.gateway": { label: "agents.field.rules.gateway.label" }, "rules.install": { label: "agents.field.rules.install.label" },
65
78
  memory: { label: "agents.field.memory.label" },
@@ -489,8 +502,33 @@ export function splitSource(source) {
489
502
  * `registry` decides which frontmatter keys are fields and which stay raw YAML.
490
503
  */
491
504
  export function editorState(detail, kind = "agent") {
492
- const split = splitSource(detail?.source ?? "");
493
505
  const registry = editorFields(detail?.registry);
506
+ const { split, own, rawByKey, unparsed, unknownRaw } = parseEntries(detail?.source ?? "", registry);
507
+ // defaults.md as the gateway parsed it (todo 12 fix 2): `fields` of the defaults answer, `defaultsFields` of a detail. It is the shared
508
+ // layer under an agent's own values; the defaults editor edits that layer itself, so it has none above its own values. The text is
509
+ // never parsed here: this reader covers the block style the editor writes, the gateway's parser also flow style and anchors.
510
+ const defaults = kind !== "defaults" && isObject(detail?.defaultsFields) ? structuredClone(detail.defaultsFields) : {};
511
+ // The layers under this definition's own values, and for an existing agent what the gateway merged for it (its own values included).
512
+ const inherited = inheritedFields(registry, defaults);
513
+ const merged = kind === "agent" && isObject(detail?.fields) ? detail.fields : {};
514
+ return {
515
+ kind, registry, id: kind === "agent" ? detail?.id ?? null : null, name: kind === "agent" ? detail?.name ?? null : null,
516
+ defaults,
517
+ // Only a new agent has a draft (todo 11): the id the web made, or the id of the draft being reopened.
518
+ draftId: kind === "new" && typeof detail?.draftId === "string" ? detail.draftId : null,
519
+ // What the gateway holds now (todo 13): the merged sha the save answer is compared with, and the text an untouched editor composes.
520
+ definitionSha: kind === "agent" && typeof detail?.definitionSha === "string" ? detail.definitionSha : null,
521
+ serverSource: kind !== "new" && typeof detail?.source === "string" ? detail.source : null,
522
+ convId: kind === "agent" ? detail?.convId ?? null : null, stale: kind === "agent" && detail?.stale === true,
523
+ own, rawByKey, touched: new Set(), unparsed, unknownRaw, unknownTouched: false, preamble: split.preamble,
524
+ body: split.body, bodyTouched: false,
525
+ inherited, merged, origins: kind === "agent" && isObject(detail?.origins) ? detail.origins : {}, dirty: false,
526
+ };
527
+ }
528
+
529
+ /** A definition text split by its known top-level entries: `own` holds what the reader could parse, `unparsed` the keys it could not, `unknownRaw` the rest. */
530
+ function parseEntries(source, registry) {
531
+ const split = splitSource(source);
494
532
  const known = topLevelKeys(registry);
495
533
  const own = {};
496
534
  const rawByKey = {};
@@ -508,14 +546,7 @@ export function editorState(detail, kind = "agent") {
508
546
  }
509
547
  } else unknownRaw += entry.raw;
510
548
  }
511
- const inherited = kind === "agent" && isObject(detail?.fields) ? detail.fields : packageDefaults(registry);
512
- return {
513
- kind, registry, id: kind === "agent" ? detail?.id ?? null : null, name: kind === "agent" ? detail?.name ?? null : null,
514
- convId: kind === "agent" ? detail?.convId ?? null : null, stale: kind === "agent" && detail?.stale === true,
515
- own, rawByKey, touched: new Set(), unparsed, unknownRaw, unknownTouched: false, preamble: split.preamble,
516
- body: split.ok ? split.body : kind === "new" ? NEW_AGENT_BODY : split.body, bodyTouched: false,
517
- inherited, origins: kind === "agent" && isObject(detail?.origins) ? detail.origins : {}, dirty: false,
518
- };
549
+ return { split, own, rawByKey, unparsed, unknownRaw };
519
550
  }
520
551
 
521
552
  /** The registry's package defaults as nested fields (the values every agent has before defaults.md and agent.md). */
@@ -525,11 +556,19 @@ export function packageDefaults(registry) {
525
556
  return fields;
526
557
  }
527
558
 
528
- /** The rows the editor draws, in the gateway's registry order: a `group` row before its children and a `field` row per leaf. */
559
+ /**
560
+ * The rows the editor draws, in the gateway's registry order: a `group` row before its children and a `field` row per
561
+ * leaf. A hidden row is left out (its value stays in the source). A forbidden row is a read-only `forbidden` row: the
562
+ * editor shows it switched off with the reason in `help` and never writes a value for it.
563
+ */
529
564
  export function editorRows(state) {
530
565
  const rows = [];
531
566
  for (const field of state.registry) {
532
- if (field.forbidden) continue;
567
+ if (field.hidden === true) continue;
568
+ if (field.forbidden) {
569
+ rows.push({ kind: "forbidden", name: field.name, label: field.label, help: field.help });
570
+ continue;
571
+ }
533
572
  if (state.kind === "defaults" && field.required) continue;
534
573
  const top = field.name.split(".")[0];
535
574
  if (field.widget === "group" || field.widget === "model") {
@@ -538,11 +577,16 @@ export function editorRows(state) {
538
577
  }
539
578
  const ownValue = fieldAt(state.own, field.name);
540
579
  const own = ownValue !== undefined;
541
- const inheritedValue = state.kind === "agent" ? fieldAt(state.inherited, field.name) : field.default;
580
+ // An untouched field of an existing agent shows what the gateway merged for it and the origin it named (exact even for
581
+ // text the reader cannot parse). A touched field, and every field while creating, shows the layers under the own values
582
+ // (package defaults, then defaults.md), so a cleared override falls back to the shared or package value (gate review B3).
583
+ const fromServer = state.kind === "agent" && !own && !state.touched.has(top);
584
+ const inheritedValue = state.kind === "defaults" ? field.default : fieldAt(fromServer ? state.merged : state.inherited, field.name);
542
585
  const value = own ? ownValue : inheritedValue;
543
586
  let origin;
544
587
  if (own) origin = state.kind === "defaults" ? "defaults" : "agent";
545
- else if (state.kind === "agent") origin = state.origins[field.name] ?? (value === undefined ? "none" : "package");
588
+ else if (fromServer) origin = state.origins[field.name] ?? (value === undefined ? "none" : "package");
589
+ else if (fieldAt(state.defaults, field.name) !== undefined) origin = "defaults";
546
590
  else origin = field.default === undefined ? "none" : "package";
547
591
  rows.push({
548
592
  kind: "field", name: field.name, label: field.label, help: field.help, widget: field.widget, type: field.type, required: field.required,
@@ -555,6 +599,88 @@ export function editorRows(state) {
555
599
  return rows;
556
600
  }
557
601
 
602
+ export const PREVIEW_PATH = "/api/agents/preview";
603
+ /** The box asks the gateway this long after the last change: shorter than the draft's quiet period, so the box follows typing. */
604
+ export const PREVIEW_DELAY_MS = 300;
605
+
606
+ /**
607
+ * The can/cannot box's request (agent-ux todo 12): the editor's composed text, raw YAML included, for the gateway to
608
+ * evaluate exactly as a save would read it (its own parser and merge with the current defaults.md); nothing is written.
609
+ * The browser computes no permission itself: its reader covers only the block style the editor writes. The create screen
610
+ * sends `kind: "new"`, so the box shows what the new agent could do before it has a name and description (agent-ux int).
611
+ */
612
+ export function previewRequest(state) {
613
+ const body = state.kind === "new" ? { source: composeSource(state), kind: "new" } : { source: composeSource(state) };
614
+ return { method: "POST", path: PREVIEW_PATH, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
615
+ }
616
+
617
+ /**
618
+ * The box's state after the gateway answered: a valid answer brings the merged fields a save of that text would apply;
619
+ * text the gateway refuses gives `unavailable` (the box shows a line, never a guess); a failed request or an unreadable
620
+ * answer gives `failed` - the current text was not evaluated, so no older answer is shown as if it were (todo 12 fix 4).
621
+ */
622
+ export function previewState(answer) {
623
+ if (isObject(answer) && answer.valid === true && isObject(answer.fields)) return { status: "ready", fields: answer.fields };
624
+ if (isObject(answer) && answer.valid === false) return { status: "unavailable", fields: null };
625
+ return { status: "failed", fields: null };
626
+ }
627
+
628
+ /** What a definition inherits before its own values: the registry's package defaults with defaults.md over them (what the rows show). */
629
+ function inheritedFields(registry, defaults) {
630
+ const fields = packageDefaults(registry);
631
+ applyLayer(fields, defaults, registry);
632
+ return fields;
633
+ }
634
+
635
+ /** One definition layer over `fields`, as the gateway applies it: a registry row with merge "keys" merges an object per key; anything else replaces the value. */
636
+ function applyLayer(fields, input, registry, prefix = []) {
637
+ for (const [key, value] of Object.entries(input)) {
638
+ const path = [...prefix, key];
639
+ const field = registry.find((row) => row.name === path.join("."));
640
+ if (field?.merge === "keys") {
641
+ if (isObject(value)) {
642
+ applyLayer(fields, value, registry, path);
643
+ continue;
644
+ }
645
+ }
646
+ setPath(fields, path, structuredClone(value));
647
+ }
648
+ }
649
+
650
+ /**
651
+ * What the agent can and cannot do with the merged fields the gateway answered (agent-ux todo 12), as the extension
652
+ * enforces it: the file tools confined to knowledge/ with writes only under outputs/, notes/ and MEMORY.md
653
+ * (host/omo-bridge/agent-control.ts BASE_TOOLS and the write scope), its own history search, confirmation questions, web
654
+ * research only with tools.web, the checked skills, and the end-of-reply notification with notify. Each item carries a
655
+ * tooltip with the detail.
656
+ */
657
+ export function capabilities(merged) {
658
+ const item = (key, text, tip) => ({ key, text, tip });
659
+ const web = fieldAt(merged, "tools.web") === true;
660
+ const notify = fieldAt(merged, "notify") === true;
661
+ const skills = fieldAt(merged, "skills");
662
+ const names = Array.isArray(skills) ? skills.filter((name) => typeof name === "string" && name.trim()).map((name) => name.trim()) : [];
663
+ const can = [
664
+ item("read", t("agents.cap.read"), t("agents.cap.read.tip")),
665
+ item("write", t("agents.cap.write"), t("agents.cap.write.tip")),
666
+ item("history", t("agents.cap.history"), t("agents.cap.history.tip")),
667
+ item("ask", t("agents.cap.ask"), t("agents.cap.ask.tip")),
668
+ ];
669
+ const cannot = [
670
+ item("shell", t("agents.cap.shell"), t("agents.cap.shell.tip")),
671
+ item("source", t("agents.cap.source"), t("agents.cap.source.tip")),
672
+ item("outside", t("agents.cap.outside"), t("agents.cap.outside.tip")),
673
+ item("works", t("agents.cap.works"), t("agents.cap.works.tip")),
674
+ item("others", t("agents.cap.others"), t("agents.cap.others.tip")),
675
+ ];
676
+ if (web) can.push(item("web", t("agents.cap.web"), t("agents.cap.web.on.tip")));
677
+ else cannot.push(item("web", t("agents.cap.web"), t("agents.cap.web.off.tip")));
678
+ if (names.length > 0) can.push(item("skills", t("agents.cap.skills", { count: names.length, names: names.join(", ") }), t("agents.cap.skills.tip")));
679
+ else cannot.push(item("skills", t("agents.cap.skills.none"), t("agents.cap.skills.tip")));
680
+ if (notify) can.push(item("notify", t("agents.cap.notify"), t("agents.cap.notify.tip")));
681
+ return { can, cannot };
682
+ }
683
+
558
684
  /** The value a widget's input means for its field; `undefined` clears an optional override; `{ error }` refuses it. */
559
685
  export function widgetValue(field, input) {
560
686
  if (field.widget === "toggle") return { value: Boolean(input) };
@@ -566,9 +692,10 @@ export function widgetValue(field, input) {
566
692
  if (field.min !== undefined && number < field.min || field.max !== undefined && number > field.max) return { error: t("agents.error.range", { label: field.label, min: field.min ?? "", max: field.max ?? "" }) };
567
693
  return { value: number };
568
694
  }
569
- if (field.widget === "skills") {
570
- const items = [...new Set(text.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))];
571
- return { value: items };
695
+ if (field.widget === "skills" || field.widget === "targets") {
696
+ // The check list (todo 10, 292 targets) gives the checked names as an array; a text value is still read as a comma or line list.
697
+ const names = Array.isArray(input) ? input.map((item) => String(item)) : text.split(/[\n,]+/);
698
+ return { value: [...new Set(names.map((item) => item.trim()).filter(Boolean))] };
572
699
  }
573
700
  if (field.widget === "select") {
574
701
  if (text === "") return { value: undefined };
@@ -578,6 +705,28 @@ export function widgetValue(field, input) {
578
705
  return { value: text.trim() === "" ? undefined : text };
579
706
  }
580
707
 
708
+ /**
709
+ * The allowed-skills check list (agent-ux todo 10). `commands` is GET /api/commands?conv=main: its `source: "skill"` rows
710
+ * (the `skill:` prefix removed, duplicates dropped, sorted) are the installed skills, checked when the saved value names
711
+ * them (in either form, as the gateway's allowedAgentCommands matches). A saved name that is not installed stays first,
712
+ * checked and `installed: false`, so saving keeps it and the user can still uncheck it. A `commands` that is not a list
713
+ * (the gateway answered 503 or the request failed) gives `loaded: false` and only the saved names, `installed: null`.
714
+ */
715
+ export function skillOptions(commands, saved) {
716
+ const bare = (name) => String(name).replace(/^skill:/, "").trim();
717
+ const savedNames = [...new Set((Array.isArray(saved) ? saved : []).filter((name) => typeof name === "string").map(bare).filter(Boolean))];
718
+ if (!Array.isArray(commands)) return { loaded: false, options: savedNames.map((name) => ({ name, checked: true, installed: null, description: "" })) };
719
+ const installed = new Map();
720
+ for (const command of commands) {
721
+ if (!isObject(command) || command.source !== "skill" || typeof command.name !== "string") continue;
722
+ const name = bare(command.name);
723
+ if (name && !installed.has(name)) installed.set(name, typeof command.description === "string" ? command.description : "");
724
+ }
725
+ const missing = savedNames.filter((name) => !installed.has(name)).map((name) => ({ name, checked: true, installed: false, description: "" }));
726
+ const listed = [...installed].sort(([a], [b]) => a.localeCompare(b)).map(([name, description]) => ({ name, checked: savedNames.includes(name), installed: true, description }));
727
+ return { loaded: true, options: [...missing, ...listed] };
728
+ }
729
+
581
730
  /** A new state with the field set (`undefined` removes the override and empties parents); never changes `state`. */
582
731
  export function setField(state, name, value) {
583
732
  const own = structuredClone(state.own);
@@ -646,13 +795,189 @@ export function validateEditor(state) {
646
795
  return errors;
647
796
  }
648
797
 
649
- /** The request that saves the editor: POST for a new agent, PUT for an existing one or the defaults. */
798
+ /**
799
+ * The request that saves the editor: POST for a new agent (with its draft id, so the gateway archives the draft), PUT
800
+ * for an existing one or the defaults.
801
+ */
650
802
  export function saveRequest(state) {
651
- const body = JSON.stringify({ source: composeSource(state) });
652
803
  const headers = { "content-type": "application/json" };
653
- if (state.kind === "defaults") return { method: "PUT", path: DEFAULTS_PATH, headers, body };
654
- if (state.kind === "new" || !state.id) return { method: "POST", path: AGENTS_PATH, headers, body };
655
- return { method: "PUT", path: `${AGENTS_PATH}/${encodeURIComponent(state.id)}`, headers, body };
804
+ if (state.kind === "defaults") return { method: "PUT", path: DEFAULTS_PATH, headers, body: JSON.stringify({ source: composeSource(state) }) };
805
+ if (state.kind === "new" || !state.id) {
806
+ const body = JSON.stringify(typeof state.draftId === "string" && state.draftId ? { source: composeSource(state), draftId: state.draftId } : { source: composeSource(state) });
807
+ return { method: "POST", path: AGENTS_PATH, headers, body };
808
+ }
809
+ return { method: "PUT", path: `${AGENTS_PATH}/${encodeURIComponent(state.id)}`, headers, body: JSON.stringify({ source: composeSource(state) }) };
810
+ }
811
+
812
+ const DRAFT_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
813
+
814
+ /** A draft id the web makes (agent-ux todo 11): 12 lowercase letters or digits; the random source is injectable for tests. */
815
+ export function newDraftId(random = cryptoRandom) {
816
+ let id = "";
817
+ for (let i = 0; i < 12; i += 1) id += DRAFT_ID_ALPHABET[Math.min(DRAFT_ID_ALPHABET.length - 1, Math.floor(random() * DRAFT_ID_ALPHABET.length))];
818
+ return id;
819
+ }
820
+
821
+ function cryptoRandom() {
822
+ return crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32;
823
+ }
824
+
825
+ /**
826
+ * The create screen's draft autosave (agent-ux todo 11, 18): durable, one lane per draft id. `touch(state)` keeps the
827
+ * latest state of the lane of `state.draftId` and (re)starts that lane's quiet period through the injected scheduler; when
828
+ * it ends the state is due. A due state is never dropped: it goes out at once, or behind the lane's request on the wire,
829
+ * in order, whatever is touched afterwards for any draft (one request per draft on the wire, so an older answer never
830
+ * lands after a newer one; a lane's own newer state waits for its own quiet period). `flush()` makes every waiting
831
+ * state due at once (the dialog closes); `settle(draftId)` does that for one draft and resolves once its wire is clear,
832
+ * returning false when the last save returned false or rejected; `latest(draftId)` is the newest state this page holds for a draft; `pending` says whether any
833
+ * state still waits; `idle` resolves once every wire is clear.
834
+ */
835
+ export function createDraftSaver(save, { delayMs = DRAFT_DELAY_MS, setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (handle) => clearTimeout(handle) } = {}) {
836
+ const lanes = new Map();
837
+ const laneOf = (id) => {
838
+ let lane = lanes.get(id);
839
+ if (!lane) {
840
+ lane = { latest: null, handle: null, due: [], inflight: null, last: null, saved: true };
841
+ lanes.set(id, lane);
842
+ }
843
+ return lane;
844
+ };
845
+ // A lane stays in the map once made (a handful of small objects per session): a clean lane and a fresh one behave the same.
846
+ const send = (lane, state) => {
847
+ const result = save(state);
848
+ if (typeof result?.then !== "function") {
849
+ lane.saved = result !== false;
850
+ if (lane.due.length > 0) send(lane, lane.due.shift());
851
+ return;
852
+ }
853
+ lane.inflight = Promise.resolve(result).then((saved) => { lane.saved = saved !== false; }, () => { lane.saved = false; }).then(() => {
854
+ lane.inflight = null;
855
+ if (lane.due.length > 0) send(lane, lane.due.shift());
856
+ });
857
+ };
858
+ const fire = (lane) => {
859
+ lane.handle = null;
860
+ if (lane.latest === null) return;
861
+ const state = lane.latest;
862
+ lane.latest = null;
863
+ if (lane.inflight !== null) lane.due.push(state);
864
+ else send(lane, state);
865
+ };
866
+ return {
867
+ touch(state) {
868
+ const lane = laneOf(state.draftId);
869
+ lane.latest = state;
870
+ lane.last = state;
871
+ if (lane.handle !== null) clearTimer(lane.handle);
872
+ lane.handle = setTimer(() => fire(lane), delayMs);
873
+ },
874
+ flush() {
875
+ let any = false;
876
+ for (const lane of [...lanes.values()]) {
877
+ if (lane.handle === null) continue;
878
+ clearTimer(lane.handle);
879
+ any = true;
880
+ fire(lane);
881
+ }
882
+ return any;
883
+ },
884
+ async settle(draftId) {
885
+ const lane = lanes.get(draftId);
886
+ if (!lane) return true;
887
+ if (lane.handle !== null) {
888
+ clearTimer(lane.handle);
889
+ fire(lane);
890
+ }
891
+ while (lane.inflight !== null) await lane.inflight;
892
+ return lane.saved;
893
+ },
894
+ /** The newest state this page holds for a draft (waiting, due or sent), or null: what a reopen falls back to when the wire does not answer. */
895
+ latest(draftId) { return lanes.get(draftId)?.last ?? null; },
896
+ get pending() { return [...lanes.values()].some((lane) => lane.handle !== null || lane.due.length > 0); },
897
+ get idle() { return Promise.all([...lanes.values()].map((lane) => lane.inflight ?? Promise.resolve())).then(() => undefined); },
898
+ };
899
+ }
900
+
901
+ /**
902
+ * The can/cannot box's preview requests (agent-ux todo 12, 18): disposable, owned by one editor at a time. `touch(state)`
903
+ * restarts the quiet period for the current owner and `flush()` asks at once; one request on the wire, the latest state
904
+ * after it. `request(state, token)` gets the owner token of the moment it goes out; `discard()` (the editor closes,
905
+ * changes or is replaced) drops what waits - a state flushed behind the wire included - and moves the token on, so the
906
+ * caller ignores the answer of an earlier owner (`owns(token)` is false) and nothing of a gone editor is ever drawn.
907
+ */
908
+ export function createPreviewRequester(request, { delayMs = DRAFT_DELAY_MS, setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (handle) => clearTimeout(handle) } = {}) {
909
+ let owner = 1;
910
+ let handle = null;
911
+ let latest = null;
912
+ let queued = false;
913
+ let inflight = null;
914
+ const send = (state) => {
915
+ const result = request(state, owner);
916
+ if (typeof result?.then !== "function") return;
917
+ inflight = Promise.resolve(result).then(() => undefined, () => undefined).then(() => {
918
+ inflight = null;
919
+ if (!queued) return;
920
+ queued = false;
921
+ const next = latest;
922
+ latest = null;
923
+ send(next);
924
+ });
925
+ };
926
+ const fire = () => {
927
+ handle = null;
928
+ if (latest === null) return;
929
+ if (inflight !== null) {
930
+ queued = true;
931
+ return;
932
+ }
933
+ const state = latest;
934
+ latest = null;
935
+ send(state);
936
+ };
937
+ return {
938
+ touch(state) {
939
+ latest = state;
940
+ queued = false;
941
+ if (handle !== null) clearTimer(handle);
942
+ handle = setTimer(fire, delayMs);
943
+ },
944
+ flush() {
945
+ if (handle === null) return false;
946
+ clearTimer(handle);
947
+ fire();
948
+ return true;
949
+ },
950
+ discard() {
951
+ if (handle !== null) clearTimer(handle);
952
+ handle = null;
953
+ latest = null;
954
+ queued = false;
955
+ owner += 1;
956
+ },
957
+ owns(token) { return token === owner; },
958
+ get token() { return owner; },
959
+ get pending() { return handle !== null || queued; },
960
+ get idle() { return inflight ?? Promise.resolve(); },
961
+ };
962
+ }
963
+
964
+ /** The autosave request: the composed source as typed (the gateway does not validate it) with the picked files' names and sizes. */
965
+ export function draftRequest(state, files = []) {
966
+ return { method: "PUT", path: `${DRAFTS_PATH}/${encodeURIComponent(state.draftId)}`, headers: { "content-type": "application/json" }, body: JSON.stringify({ source: composeSource(state), kind: "new", files }) };
967
+ }
968
+
969
+ /** GET /api/agents/drafts rows as the gateway sends them (newest first); a half-typed draft without a name has null. */
970
+ export function draftRows(list) {
971
+ if (!Array.isArray(list)) return [];
972
+ return list.filter((row) => isObject(row) && typeof row.id === "string").map((row) => ({ id: row.id, name: typeof row.name === "string" && row.name ? row.name : null, updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "" }));
973
+ }
974
+
975
+ export function draftLoadRequest(id) {
976
+ return { method: "GET", path: `${DRAFTS_PATH}/${encodeURIComponent(id)}` };
977
+ }
978
+
979
+ export function discardDraftRequest(id) {
980
+ return { method: "DELETE", path: `${DRAFTS_PATH}/${encodeURIComponent(id)}` };
656
981
  }
657
982
 
658
983
  /** The delete button works only when the typed name is exactly the agent's current name (the gateway checks the same). */
@@ -668,8 +993,106 @@ export function cloneRequest(id) {
668
993
  return { method: "POST", path: `${AGENTS_PATH}/${encodeURIComponent(id)}/clone` };
669
994
  }
670
995
 
671
- export function restoreRequest(entry) {
672
- return { method: "POST", path: `${TRASH_PATH}/${encodeURIComponent(entry)}/restore` };
996
+ /** The restore call of an archive entry; `newId: true` asks the gateway for a fresh id when the entry's own id is live again (plan todo 22). */
997
+ export function restoreRequest(entry, { newId = false } = {}) {
998
+ const path = `${TRASH_PATH}/${encodeURIComponent(entry)}/restore`;
999
+ return newId ? { method: "POST", path, headers: { "content-type": "application/json" }, body: JSON.stringify({ newId: true }) } : { method: "POST", path };
1000
+ }
1001
+
1002
+ /**
1003
+ * What the archive row shows for a restore answer: the restored agent's detail, the same-id conflict (409 `exists`, the
1004
+ * row offers a restore under a new id) or the refusal line.
1005
+ */
1006
+ export function restoreOutcome(status, body) {
1007
+ if (status >= 200 && status < 300) return isObject(body) ? { kind: "restored", detail: body } : { kind: "error", message: errorLine(null, status) };
1008
+ if (status === 409 && isObject(body) && body.error === "exists") return { kind: "conflict", id: typeof body.id === "string" ? body.id : "" };
1009
+ return { kind: "error", message: errorLine(body, status) };
1010
+ }
1011
+
1012
+ /** The gateway's upload limit (src/media.ts MEDIA_MAX_BYTES); the web refuses a larger file before any request (agent-ux todo 14). */
1013
+ export const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
1014
+
1015
+ let uploadKeys = 0;
1016
+
1017
+ /**
1018
+ * The upload queue of the files the user picked or dropped (agent-ux todo 14): one row per file with its progress and
1019
+ * state. A file the gateway would refuse (over the limit, empty, or without a name) fails here at once and is never sent.
1020
+ */
1021
+ export function uploadRows(files) {
1022
+ if (!files || typeof files.length !== "number") return [];
1023
+ return Array.from(files, (file) => {
1024
+ const name = typeof file?.name === "string" ? file.name : "";
1025
+ const size = typeof file?.size === "number" && Number.isFinite(file.size) ? file.size : 0;
1026
+ const error = size > UPLOAD_MAX_BYTES ? "too_large" : size <= 0 ? "missing_file" : !name || name.startsWith(".") || /[/\\\0]/.test(name) ? "invalid_filename" : null;
1027
+ uploadKeys += 1;
1028
+ return { key: `u${uploadKeys}`, file, name, size, status: error ? "failed" : "waiting", progress: 0, error, errorLine: error ? uploadErrorLine(error) : null, savedName: null };
1029
+ });
1030
+ }
1031
+
1032
+ /** The sentence for a refused upload: the gateway's code, or the web's own size check with the limit. */
1033
+ export function uploadErrorLine(code) {
1034
+ if (code === "too_large") return t("agents.upload.tooLarge", { max: formatBytes(UPLOAD_MAX_BYTES) });
1035
+ const key = `agents.error.${code}`;
1036
+ const line = t(key);
1037
+ return line === key ? t("agents.error.rejected") : line;
1038
+ }
1039
+
1040
+ /**
1041
+ * The create screen's queue (agent-ux todo 18): the same rows, but `queued` instead of `waiting`, so nextUpload never sends
1042
+ * them - the definition does not exist yet. uploadsAfterCreate turns them into waiting rows once the gateway answered with an id.
1043
+ */
1044
+ export function queuedUploadRows(files) {
1045
+ return uploadRows(files).map((row) => (row.status === "waiting" ? { ...row, status: "queued" } : row));
1046
+ }
1047
+
1048
+ /** What the draft keeps of the queue: names and sizes of the files that would be uploaded, never the files (todo 11, 18). */
1049
+ export function draftFileList(rows) {
1050
+ return rows.filter((row) => row.status === "queued").map((row) => ({ name: row.name, size: row.size }));
1051
+ }
1052
+
1053
+ /** After the create request: the new agent's id and the queue as waiting rows, or null when the answer carries no id or nothing is queued. */
1054
+ export function uploadsAfterCreate(body, rows) {
1055
+ const id = isObject(body) && typeof body.id === "string" && body.id ? body.id : null;
1056
+ if (id === null || !Array.isArray(rows) || !rows.some((row) => row.status === "queued")) return null;
1057
+ return { id, rows: rows.map((row) => (row.status === "queued" ? { ...row, status: "waiting" } : row)) };
1058
+ }
1059
+
1060
+ /** The first row still waiting (uploads go one by one, in order); null when nothing waits. */
1061
+ export function nextUpload(rows) {
1062
+ return rows.find((row) => row.status === "waiting") ?? null;
1063
+ }
1064
+
1065
+ /** The rows with one row changed: a new state, its progress (0..1) and, when done, the name the gateway saved it under. */
1066
+ export function advanceUpload(rows, key, patch) {
1067
+ return rows.map((row) => {
1068
+ if (row.key !== key) return row;
1069
+ const status = patch.status ?? row.status;
1070
+ const progress = status === "done" ? 1 : status === "failed" || status === "waiting" ? 0 : typeof patch.progress === "number" ? Math.min(1, Math.max(0, patch.progress)) : row.progress;
1071
+ const error = status === "failed" ? patch.error ?? row.error ?? "rejected" : null;
1072
+ return { ...row, status, progress, error, errorLine: status === "failed" ? patch.errorLine ?? uploadErrorLine(error) : null, savedName: status === "done" ? patch.savedName ?? row.name : row.savedName };
1073
+ });
1074
+ }
1075
+
1076
+ /** A failed upload back in the queue; a file the web itself refused (too large, empty, bad name) stays failed, since sending it would fail the same way. */
1077
+ export function retryUpload(rows, key) {
1078
+ return rows.map((row) => (row.key === key && row.status === "failed" && !(row.error === "too_large" && row.size > UPLOAD_MAX_BYTES) && row.error !== "missing_file" && row.error !== "invalid_filename"
1079
+ ? { ...row, status: "waiting", progress: 0, error: null, errorLine: null } : row));
1080
+ }
1081
+
1082
+ export function uploadSummary(rows) {
1083
+ const count = (status) => rows.filter((row) => row.status === status).length;
1084
+ const uploading = count("uploading");
1085
+ const waiting = count("waiting");
1086
+ return { total: rows.length, done: count("done"), failed: count("failed"), waiting, uploading, active: uploading > 0 || waiting > 0 };
1087
+ }
1088
+
1089
+ /** The state text of one row: 대기, 만들기 뒤 올립니다, 올리는 중 N%, 완료, or 실패 with its reason. */
1090
+ export function uploadStateLine(row) {
1091
+ if (row.status === "queued") return t("agents.upload.state.queued");
1092
+ if (row.status === "uploading") return t("agents.upload.state.uploading", { percent: Math.round(row.progress * 100) });
1093
+ if (row.status === "done") return t("agents.upload.state.done");
1094
+ if (row.status === "failed") return t("agents.upload.state.failed", { reason: row.errorLine ?? uploadErrorLine(row.error) });
1095
+ return t("agents.upload.state.waiting");
673
1096
  }
674
1097
 
675
1098
  export function filesPath(id) {
@@ -680,8 +1103,130 @@ export function sourceUploadPath(id) {
680
1103
  return `${AGENTS_PATH}/${encodeURIComponent(id)}/source`;
681
1104
  }
682
1105
 
1106
+ /** The editor composes exactly the gateway's text (nothing was changed), so there is nothing to save (todo 13, bug 285). */
1107
+ export function unchangedSource(state) {
1108
+ return typeof state.serverSource === "string" && composeSource(state) === state.serverSource;
1109
+ }
1110
+
1111
+ /**
1112
+ * An `agent.updated` for the agent the open editor shows, whose saved definition changed elsewhere (another tab revoked a
1113
+ * collaboration target, say): "reload" for an untouched editor, so it never shows or saves back what was revoked; "changed" for
1114
+ * an editor with unsaved edits, which keeps them and must say that saving writes its copy over the change; else null.
1115
+ */
1116
+ export function editorUpdateAction(editor, data) {
1117
+ if (!isObject(editor) || editor.kind !== "agent" || !isObject(data) || data.id !== editor.id || data.hidden === true) return null;
1118
+ if (typeof data.definitionSha !== "string" || data.definitionSha === editor.definitionSha) return null;
1119
+ return editor.dirty === true ? "changed" : "reload";
1120
+ }
1121
+
1122
+ /** What a save did: the merged sha the gateway answered against the sha before, and whether an open session now lags. */
1123
+ export function saveOutcome(state, body) {
1124
+ const sha = typeof body?.definitionSha === "string" ? body.definitionSha : null;
1125
+ return { changed: sha === null || sha !== state.definitionSha, stale: body?.stale === true };
1126
+ }
1127
+
1128
+ /**
1129
+ * agent-ux todo 21: what "지금 적용" applies after a save - the agent, its conversation and the saved sha - only when the save
1130
+ * changed the definition and the agent's open session now lags; else null (no button).
1131
+ */
1132
+ export function applyNowTarget(outcome, body) {
1133
+ if (!outcome.changed || !outcome.stale) return null;
1134
+ if (typeof body?.id !== "string" || typeof body.convId !== "string" || typeof body.definitionSha !== "string") return null;
1135
+ return { agentId: body.id, convId: body.convId, sha: body.definitionSha };
1136
+ }
1137
+
1138
+ /** The button stays only while that agent is still stale (an agent.applied with the saved sha ends it). */
1139
+ export function applyNowShown(target, agents) {
1140
+ return target !== null && agents.some((p) => p.id === target.agentId && p.stale === true);
1141
+ }
1142
+
1143
+ /** The header new-session request: an agent conversation whose definition changed opens its next session at once (todo 21). */
1144
+ export function rotateRequest(convId, view) {
1145
+ if (view.agent && view.stale) return { type: "conversation.rotate", conv: convId, open: true };
1146
+ return { type: "conversation.rotate", conv: convId };
1147
+ }
1148
+
1149
+ /** How long "지금 적용" waits for the rotation and the new session's agent.applied before it says it could not confirm. */
1150
+ export const APPLY_WAIT_MS = 30_000;
1151
+
1152
+ /**
1153
+ * Follows one "지금 적용" request through its conversation's events: null while it waits, else the outcome. Success is
1154
+ * only the agent.applied after session.rotated whose sha is the saved one; another sha (or null, a mixed session) is a
1155
+ * mismatch. The gateway's agent_open_failed and turn_running refusals end the wait; other events are ignored.
1156
+ */
1157
+ export function applyWatch(target) {
1158
+ let rotated = false;
1159
+ return (event) => {
1160
+ if (event?.conv !== target.convId) return null;
1161
+ if (event.type === "session.rotated") rotated = true;
1162
+ if (event.type === "agent.applied" && rotated) return event.data?.sha === target.sha ? "applied" : "mismatch";
1163
+ if (event.type === "error" && event.data?.message === "agent_open_failed") return "open_failed";
1164
+ if (event.type === "error" && event.data?.code === "turn_running") return "turn_running";
1165
+ return null;
1166
+ };
1167
+ }
1168
+
1169
+ /**
1170
+ * T21W-B1: the events after which the page reads the gateway's agent list again - an agent conversation's rotation, any
1171
+ * agent.applied and a failed open - so the header and its open:true decision follow the gateway, not a retired session.
1172
+ */
1173
+ export function reconcileAgents(event, agents) {
1174
+ if (event?.type === "agent.applied") return true;
1175
+ if (event?.type === "session.rotated") return agents.some((p) => p.convId === event.conv);
1176
+ return event?.type === "error" && event.data?.message === "agent_open_failed";
1177
+ }
1178
+
1179
+ /**
1180
+ * T21W-B2: the one ordering rule of the agent list. Every write (a list answer, agent.updated, a save answer,
1181
+ * agent.applied) moves the list's stamp on; a list answer may land only when the stamp is still the one its request
1182
+ * was sent at, so it never overwrites a newer write.
1183
+ */
1184
+ export function freshListAnswer(sentStamp, stampNow) {
1185
+ return sentStamp === stampNow;
1186
+ }
1187
+
1188
+ const APPLY_LINES = Object.freeze({
1189
+ applied: "agents.apply.done",
1190
+ mismatch: "agents.apply.mismatch",
1191
+ open_failed: "agents.apply.openFailed",
1192
+ turn_running: "agents.apply.turnRunning",
1193
+ unconfirmed: "agents.apply.unconfirmed",
1194
+ });
1195
+
1196
+ /** The editor status line for an outcome of applyWatch (or "unconfirmed" when nothing answered in time). */
1197
+ export function applyLine(result) {
1198
+ return t(Object.hasOwn(APPLY_LINES, result) ? APPLY_LINES[result] : APPLY_LINES.unconfirmed);
1199
+ }
1200
+
1201
+ const SAVE_ERROR_LINES = Object.freeze({
1202
+ agent_busy: "agents.error.save.agent_busy",
1203
+ exists: "agents.error.save.exists",
1204
+ file_exists: "agents.error.save.file_exists",
1205
+ too_large: "agents.error.save.too_large",
1206
+ invalid_filename: "agents.error.save.invalid_filename",
1207
+ file_not_deletable: "agents.error.save.file_not_deletable",
1208
+ });
1209
+
1210
+ /**
1211
+ * The sentence for a failed save (todo 13): the gateway's code (app.js agentApi puts `code`, `field`, `status` and
1212
+ * `network` on the thrown error) becomes what happened and what to do; an invalid definition names the field by its
1213
+ * editor label; a request that never reached the gateway says the text is kept. Other errors keep their message.
1214
+ */
1215
+ export function saveErrorLine(error, fields = []) {
1216
+ if (error?.network === true) return t("agents.error.save.network");
1217
+ const code = typeof error?.code === "string" ? error.code : null;
1218
+ if (code === "invalid_agent_definition") {
1219
+ const field = typeof error.field === "string" && error.field ? error.field : null;
1220
+ if (field === null) return t("agents.error.invalid_agent_definition");
1221
+ return t("agents.error.save.invalid_field", { label: fields.find((row) => row.name === field)?.label ?? field });
1222
+ }
1223
+ if (code !== null && Object.hasOwn(SAVE_ERROR_LINES, code)) return t(SAVE_ERROR_LINES[code]);
1224
+ return typeof error?.message === "string" ? error.message : String(error);
1225
+ }
1226
+
683
1227
  const ERROR_LINES = Object.freeze({
684
1228
  name_mismatch: "agents.error.name_mismatch",
1229
+ draft_closed: "agents.error.draft_closed",
685
1230
  agent_busy: "agents.error.agent_busy",
686
1231
  agent_not_found: "agents.error.agent_not_found",
687
1232
  invalid_agent_definition: "agents.error.invalid_agent_definition",
@@ -692,6 +1237,9 @@ const ERROR_LINES = Object.freeze({
692
1237
  file_exists: "agents.error.file_exists",
693
1238
  exists: "agents.error.exists",
694
1239
  invalid_trash_entry: "agents.error.invalid_trash_entry",
1240
+ file_not_deletable: "agents.error.file_not_deletable",
1241
+ file_not_found: "agents.error.file_not_found",
1242
+ invalid_path: "agents.error.invalid_path",
695
1243
  forbidden: "agents.error.forbidden",
696
1244
  method_not_allowed: "agents.error.method_not_allowed",
697
1245
  });
@@ -727,11 +1275,18 @@ export function applyAgentUpdate(agents, conversations, data, now = Date.now())
727
1275
  return { agents: next, conversations: convs, removedConv: null, restoredConv };
728
1276
  }
729
1277
 
730
- /** `agent.applied` of a conversation: the new session carries `sha`; the badge stays only while the definition differs. */
1278
+ /**
1279
+ * `agent.applied` of a conversation: the new session carries `sha`; the notice stays only while the definition differs.
1280
+ * A known sha also ends an `unknown` applied state the list was loaded with (bug 283).
1281
+ */
731
1282
  export function applyAgentApplied(agents, convId, data) {
732
1283
  const sha = typeof data?.sha === "string" ? data.sha : null;
733
1284
  if (!sha) return agents;
734
- return agents.map((p) => (p.convId === convId ? { ...p, stale: p.definitionSha !== sha } : p));
1285
+ return agents.map((p) => {
1286
+ if (p.convId !== convId) return p;
1287
+ const { staleReason: _reason, ...known } = p;
1288
+ return { ...known, stale: p.definitionSha !== sha };
1289
+ });
735
1290
  }
736
1291
 
737
1292
  /**
@@ -778,18 +1333,45 @@ export function sidebarItems(conversations, agents, view) {
778
1333
  return items;
779
1334
  }
780
1335
 
781
- /** The chat header for the open conversation: the agent name and its stale badge, or nothing for main. */
1336
+ /**
1337
+ * The chat header for the open conversation: the agent name and its definition state, or nothing for main. `staleReason`
1338
+ * is "unknown" while the gateway could not learn which definition the open session applied (todo 3), else null.
1339
+ */
782
1340
  export function headerView(conv, agents) {
783
- if (!conv?.agentId) return { agent: false, icon: "", name: "", stale: false, agentId: null };
1341
+ if (!conv?.agentId) return { agent: false, icon: "", name: "", stale: false, staleReason: null, agentId: null };
784
1342
  const agent = agents.find((p) => p.id === conv.agentId) ?? null;
785
1343
  const name = agent?.name ?? conv.title;
1344
+ const stale = agent?.stale === true;
786
1345
  return {
787
1346
  agent: true, agentId: conv.agentId, name,
788
1347
  icon: typeof agent?.icon === "string" && agent.icon.trim() ? agent.icon.trim() : agentInitial(name),
789
- stale: agent?.stale === true,
1348
+ stale, staleReason: stale && agent.staleReason === "unknown" ? "unknown" : null,
1349
+ };
1350
+ }
1351
+
1352
+ /**
1353
+ * Bug 283: the header only says the definition state; the new-session button carries the action. For [view] this is the
1354
+ * status line to show (a key of web/i18n/main.js, drawn in the UI language by app.js), whether the button is highlighted,
1355
+ * and the keys of its label and of the question it asks before `conversation.rotate`.
1356
+ */
1357
+ export function staleNotice(view) {
1358
+ if (!view.stale) return { shown: false, textKey: null, attention: false, labelKey: "head.newSession", confirmKey: "head.newSession.confirm" };
1359
+ return {
1360
+ shown: true, textKey: view.staleReason === "unknown" ? "agent.stale.unknown" : "agent.stale.changed",
1361
+ attention: true, labelKey: "head.newSession.apply", confirmKey: "head.newSession.confirm.apply",
790
1362
  };
791
1363
  }
792
1364
 
1365
+ /**
1366
+ * Who speaks in [conv] (bug 284): an agent conversation's bubbles, reference labels and sidebar profile carry the agent's
1367
+ * name and text icon (its emoji, else the name's first letter); main keeps [main]'s name and icon picture.
1368
+ */
1369
+ export function speakerView(conv, agents, main) {
1370
+ const view = headerView(conv, agents);
1371
+ if (!view.agent) return { agent: false, agentId: null, name: main.name, icon: null, iconUrl: main.iconUrl ?? null };
1372
+ return { agent: true, agentId: view.agentId, name: view.name, icon: view.icon, iconUrl: null };
1373
+ }
1374
+
793
1375
  /** 보관함 rows: the entry name carries the agent id and the UTC time it was deleted. */
794
1376
  export function trashRows(entries) {
795
1377
  if (!Array.isArray(entries)) return [];
@@ -800,14 +1382,291 @@ export function trashRows(entries) {
800
1382
  });
801
1383
  }
802
1384
 
803
- /** Knowledge file rows, folders first within each folder, with a size label; `source/` files are the ones the web uploads. */
1385
+ const KNOWLEDGE_AREAS = Object.freeze(["source", "outputs", "notes", "memory", "other"]);
1386
+
1387
+ /** The area of a knowledge path: the gateway's `area` field, or read from the path for an older gateway's answer. */
1388
+ function knowledgeArea(path, area) {
1389
+ if (KNOWLEDGE_AREAS.includes(area)) return area;
1390
+ if (path === "MEMORY.md") return "memory";
1391
+ const top = path.split("/")[0];
1392
+ return top === "source" || top === "outputs" || top === "notes" ? top : "other";
1393
+ }
1394
+
1395
+ /** Knowledge file rows, folders first within each folder, with a size label and their area; `source/` files are the ones the web uploads. */
804
1396
  export function fileRows(entries) {
805
1397
  if (!Array.isArray(entries)) return [];
806
1398
  const rows = entries.filter((e) => typeof e?.path === "string").map((e) => ({
807
1399
  path: e.path, type: e.type === "directory" ? "directory" : "file", size: typeof e.size === "number" ? e.size : 0,
808
1400
  depth: e.path.split("/").length - 1, name: e.path.slice(e.path.lastIndexOf("/") + 1),
809
1401
  label: e.type === "directory" ? t("agents.file.folder") : formatBytes(typeof e.size === "number" ? e.size : null),
810
- source: e.path === "source" || e.path.startsWith("source/"),
1402
+ source: e.path === "source" || e.path.startsWith("source/"), area: knowledgeArea(e.path, e.area),
1403
+ modifiedAt: typeof e.modifiedAt === "string" ? e.modifiedAt : null,
1404
+ origin: e.origin === "collaboration-copy" || e.origin === "user-upload" ? e.origin : null,
811
1405
  }));
812
1406
  return rows.sort((a, b) => a.path.localeCompare(b.path, "ko"));
813
1407
  }
1408
+
1409
+ /** A source/ or outputs/ file can be downloaded (agent-ux todo 17); the gateway serves the todo 2 download route for them. */
1410
+ export function downloadable(row) {
1411
+ return row.type === "file" && (row.area === "source" || row.area === "outputs");
1412
+ }
1413
+
1414
+ export function downloadPath(id, path) {
1415
+ return `${filesPath(id)}/download?path=${encodeURIComponent(path)}`;
1416
+ }
1417
+
1418
+ /** A file's modified time in the UI locale, or nothing when the gateway sent none. */
1419
+ export function modifiedLabel(modifiedAt, locale) {
1420
+ const ms = typeof modifiedAt === "string" ? Date.parse(modifiedAt) : Number.NaN;
1421
+ return Number.isNaN(ms) ? "" : new Date(ms).toLocaleString(locale, { dateStyle: "short", timeStyle: "short" });
1422
+ }
1423
+
1424
+ /** Whether an ended turn belongs to the agent whose editor is open, so its files (the outputs the answer wrote) are read again. */
1425
+ export function turnEndRefreshes(editor, conv) {
1426
+ return editor?.kind === "agent" && typeof editor.convId === "string" && editor.convId === conv;
1427
+ }
1428
+
1429
+ /**
1430
+ * The rows by screen block (agent-ux todo 15): 올린 자료 (source/), 결과물 (outputs/) and 기록 (MEMORY.md, then notes/). The top
1431
+ * folders are the headings, so they are not rows, and depth counts under them; paths outside the three areas are left out.
1432
+ */
1433
+ export function fileAreas(rows) {
1434
+ const under = (area) => rows.filter((row) => row.area === area && row.path !== area).map((row) => ({ ...row, depth: row.depth - 1 }));
1435
+ return { source: under("source"), outputs: under("outputs"), notes: [...rows.filter((row) => row.area === "memory"), ...under("notes")] };
1436
+ }
1437
+
1438
+ /** The status line after uploads (bug 285): one file or a count, that the agent uses them at once, and every name the gateway changed. */
1439
+ export function uploadedLine(saved) {
1440
+ if (saved.length === 0) return "";
1441
+ const line = saved.length === 1 ? t("agents.uploaded.one", { name: saved[0].name }) : t("agents.uploaded.many", { count: saved.length });
1442
+ const renamed = saved.filter((file) => file.savedName !== file.name).map((file) => t("agents.uploaded.renamed", { saved: file.savedName }));
1443
+ return [line, ...renamed].join(" ");
1444
+ }
1445
+
1446
+ /** Only a regular file under source/ or outputs/ can be deleted (what the gateway accepts, agent-ux todo 16); MEMORY.md, notes/, folders and lock files never. */
1447
+ export function deletableFile(row) {
1448
+ return row.type === "file" && (row.area === "source" || row.area === "outputs") && row.path !== row.area && !row.name.endsWith(".lock");
1449
+ }
1450
+
1451
+ export function fileDeleteRequest(id, path) {
1452
+ return { method: "POST", path: `${filesPath(id)}/delete`, headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) };
1453
+ }
1454
+
1455
+ export function fileTrashPath(id) {
1456
+ return `${filesPath(id)}/trash`;
1457
+ }
1458
+
1459
+ export function fileRestoreRequest(id, entry) {
1460
+ return { method: "POST", path: `${fileTrashPath(id)}/${encodeURIComponent(entry)}/restore` };
1461
+ }
1462
+
1463
+ /** The deleted files of an agent as the gateway lists them (newest first): the archive entry, the original path, its size and when it was deleted. */
1464
+ export function fileTrashRows(entries) {
1465
+ if (!Array.isArray(entries)) return [];
1466
+ return entries.filter((e) => isObject(e) && typeof e.entry === "string" && typeof e.path === "string").map((e) => ({
1467
+ entry: e.entry, path: e.path, name: e.path.slice(e.path.lastIndexOf("/") + 1), area: knowledgeArea(e.path, undefined),
1468
+ size: typeof e.size === "number" ? e.size : 0, label: formatBytes(typeof e.size === "number" ? e.size : null), deletedAt: typeof e.deletedAt === "string" ? e.deletedAt : null,
1469
+ }));
1470
+ }
1471
+
1472
+ export function fileTrashLabel(count) {
1473
+ return t("agents.files.trash", { count });
1474
+ }
1475
+
1476
+ /** The 기록 block's last daily note line: the date of the newest notes/daily file the gateway found, or that there is none yet. */
1477
+ export function lastDailyLine(lastDaily) {
1478
+ return typeof lastDaily === "string" && lastDaily ? t("agents.lastDaily", { date: lastDaily }) : t("agents.lastDaily.none");
1479
+ }
1480
+
1481
+ // The 도구 권한 section of an agent's editor (plan 294 item 9, C8/C10): the grants the gateway lists
1482
+ // (GET /api/agents/:id/tool-permissions), their state, the revoke / revoke-all / edit requests and the scope draft an
1483
+ // edit works on. The tool, operation and target words come from web/decisions.js through the `describe` parameter;
1484
+ // this module imports no other web module, so the agent-web mutation tree stays self-contained.
1485
+ const TOOL_STATES = ["allowed", "applying", "revoked", "suspended", "blocked"];
1486
+ const TOOL_PERMISSION_ERROR_KEYS = {
1487
+ grant_conflict: "grant", policy_conflict: "policy", cursor_stale: "policy", cursor_invalid: "policy", grant_not_found: "gone", not_found: "unsupported",
1488
+ page_nonce_invalid: "expired", agent_tool_client_required: "expired", forbidden: "forbidden", forbidden_origin: "forbidden", forbidden_host: "forbidden",
1489
+ custom_header_required: "forbidden", agent_access_blocked: "blocked", operation_conflict: "operation", tool_request_invalid: "invalid", method_not_allowed: "invalid",
1490
+ };
1491
+ const STALE_POLICY_CODES = new Set(["policy_conflict", "cursor_stale", "cursor_invalid"]);
1492
+ const EXPIRED_CLIENT_CODES = new Set(["page_nonce_invalid", "agent_tool_client_required"]);
1493
+ const UNEDITABLE_SCOPE_KEYS = new Set(["kind", "selection", "targetHandle", "targetId", "targetLabel", "targetTitle", "account", "host"]);
1494
+ const IDENTITY_DESCRIBE = {
1495
+ toolName: (toolKey) => (isObject(toolKey) && typeof toolKey.originalName === "string" ? toolKey.originalName : ""),
1496
+ operationLabel: (_toolKey, operation) => (typeof operation === "string" ? operation : ""),
1497
+ scopeText: () => "",
1498
+ };
1499
+
1500
+ export function toolPermissionsPath(agentId) {
1501
+ return `/api/agents/${encodeURIComponent(agentId)}/tool-permissions`;
1502
+ }
1503
+
1504
+ export function toolPermissionsRequest(agentId, cursor = null) {
1505
+ const path = toolPermissionsPath(agentId);
1506
+ return { method: "GET", path: cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path };
1507
+ }
1508
+
1509
+ /** The design states (C8): a revoked grant stays revoked; blocked access hides every other state; applying means the sessions have not acknowledged the policy yet. */
1510
+ export function grantState(grant, policy) {
1511
+ const state = isObject(grant) ? grant.state : null;
1512
+ if (state === "revoked") return "revoked";
1513
+ if (isObject(policy) && policy.accessState === "blocked") return "blocked";
1514
+ if (state === "suspended") return "suspended";
1515
+ if (isObject(policy) && typeof policy.appliedRevision === "number" && typeof policy.policyRevision === "number" && policy.appliedRevision < policy.policyRevision) return "applying";
1516
+ return "allowed";
1517
+ }
1518
+
1519
+ export function grantStateLabel(state) {
1520
+ return t(`agents.tool.state.${TOOL_STATES.includes(state) ? state : "blocked"}`);
1521
+ }
1522
+
1523
+ export function accessSummary(policy) {
1524
+ if (!isObject(policy) || typeof policy.policyRevision !== "number") return "";
1525
+ return t(policy.accessState === "blocked" ? "agents.tool.access.blocked" : "agents.tool.access.open", { revision: policy.policyRevision });
1526
+ }
1527
+
1528
+ const stringList = (value) => (Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : null);
1529
+
1530
+ export function toolGrantRows(page, describe = IDENTITY_DESCRIBE) {
1531
+ if (!isObject(page) || !Array.isArray(page.items)) return [];
1532
+ const policy = isObject(page.policy) ? page.policy : null;
1533
+ const rows = [];
1534
+ for (const grant of page.items) {
1535
+ if (!isObject(grant) || typeof grant.id !== "string") continue;
1536
+ const operations = stringList(grant.operations) ?? [];
1537
+ const state = grantState(grant, policy);
1538
+ rows.push({
1539
+ id: grant.id,
1540
+ tool: describe.toolName(grant.toolKey) || IDENTITY_DESCRIBE.toolName(grant.toolKey),
1541
+ operation: operations.map((operation) => describe.operationLabel(grant.toolKey, operation) || operation).join(", "),
1542
+ target: describe.scopeText(grant.scope),
1543
+ state,
1544
+ stateLabel: grantStateLabel(state),
1545
+ revision: typeof grant.revision === "number" ? grant.revision : 0,
1546
+ operations,
1547
+ origin: typeof grant.originDecisionId === "string" ? grant.originDecisionId : "",
1548
+ });
1549
+ }
1550
+ return rows;
1551
+ }
1552
+
1553
+ export function revokeRequest(agentId, grant, operationId) {
1554
+ return { method: "POST", path: `${toolPermissionsPath(agentId)}/${encodeURIComponent(grant.id)}/revoke`, body: { operationId, expectedRevision: grant.revision } };
1555
+ }
1556
+
1557
+ export function revokeAllRequest(agentId, policy, operationId) {
1558
+ return { method: "POST", path: `${toolPermissionsPath(agentId)}/revoke-all`, body: { operationId, expectedPolicyRevision: policy.policyRevision } };
1559
+ }
1560
+
1561
+ function cloneScope(scope) {
1562
+ const copy = {};
1563
+ if (!isObject(scope)) return copy;
1564
+ for (const [key, value] of Object.entries(scope)) copy[key] = Array.isArray(value) ? [...value] : value;
1565
+ return copy;
1566
+ }
1567
+
1568
+ /** POST .../tool-permissions/:grantId/edit (C3): the whole draft against the grant revision the editor started from. */
1569
+ export function editRequest(agentId, grant, draft, operationId) {
1570
+ return {
1571
+ method: "POST", path: `${toolPermissionsPath(agentId)}/${encodeURIComponent(grant.id)}/edit`,
1572
+ body: { operationId, expectedRevision: grant.revision, operations: [...draft.operations], scope: cloneScope(draft.scope) },
1573
+ };
1574
+ }
1575
+
1576
+ /** A copy of the grant's operations and scope to edit, with the text of every editable list value; opaque handles stay as they are. */
1577
+ export function scopeDraft(grant) {
1578
+ const scope = cloneScope(isObject(grant) ? grant.scope : null);
1579
+ const text = {};
1580
+ for (const [key, value] of Object.entries(scope)) {
1581
+ if (UNEDITABLE_SCOPE_KEYS.has(key) || !stringList(value)) continue;
1582
+ text[key] = value.join(", ");
1583
+ }
1584
+ return { operations: [...(stringList(isObject(grant) ? grant.operations : null) ?? [])], scope, text };
1585
+ }
1586
+
1587
+ export function parseScopeText(text) {
1588
+ if (typeof text !== "string") return [];
1589
+ return [...new Set(text.split(/[\n,]/).map((entry) => entry.trim()).filter(Boolean))];
1590
+ }
1591
+
1592
+ /**
1593
+ * Narrowing (fewer operations, fewer list values, a new restriction key) applies at once; anything added, a dropped
1594
+ * restriction key or a changed single value is a widening the inbox has to confirm (C8).
1595
+ */
1596
+ export function classifyEdit(grant, draft) {
1597
+ const before = scopeDraft(grant);
1598
+ const operations = stringList(draft?.operations) ?? [];
1599
+ const scope = isObject(draft?.scope) ? draft.scope : {};
1600
+ const added = { operations: operations.filter((operation) => !before.operations.includes(operation)), scope: {} };
1601
+ const removed = { operations: before.operations.filter((operation) => !operations.includes(operation)), scope: {} };
1602
+ let widened = added.operations.length > 0;
1603
+ for (const key of new Set([...Object.keys(before.scope), ...Object.keys(scope)])) {
1604
+ if (!(key in scope)) {
1605
+ widened = true;
1606
+ continue;
1607
+ }
1608
+ if (!(key in before.scope)) continue;
1609
+ const was = stringList(before.scope[key]);
1610
+ const now = stringList(scope[key]);
1611
+ if (was && now) {
1612
+ const plus = now.filter((entry) => !was.includes(entry));
1613
+ const minus = was.filter((entry) => !now.includes(entry));
1614
+ if (plus.length > 0) {
1615
+ added.scope[key] = plus;
1616
+ widened = true;
1617
+ }
1618
+ if (minus.length > 0) removed.scope[key] = minus;
1619
+ continue;
1620
+ }
1621
+ if (JSON.stringify(before.scope[key]) !== JSON.stringify(scope[key])) widened = true;
1622
+ }
1623
+ const narrowed = removed.operations.length > 0 || Object.keys(removed.scope).length > 0 || Object.keys(scope).some((key) => !(key in before.scope));
1624
+ return { kind: widened ? "widen" : narrowed ? "narrow" : "same", added, removed };
1625
+ }
1626
+
1627
+ export function editLine(kind) {
1628
+ return t(`agents.tool.edit.${kind === "widen" || kind === "narrow" ? kind : "same"}`);
1629
+ }
1630
+
1631
+ /** The list after a revoke answer: the grant is revoked at the answered (or next) revision and the policy revision moves on. */
1632
+ export function applyRevoke(page, grantId, result) {
1633
+ const answer = isObject(result) ? result : {};
1634
+ const items = page.items.map((grant) => {
1635
+ if (grant.id !== grantId) return grant;
1636
+ const revoked = isObject(answer.grant) && answer.grant.id === grantId ? answer.grant : { revision: grant.revision + 1 };
1637
+ return { ...grant, ...revoked, state: "revoked" };
1638
+ });
1639
+ const policyRevision = typeof answer.policyRevision === "number" ? answer.policyRevision : page.policy.policyRevision;
1640
+ return { ...page, items, policy: { ...page.policy, policyRevision } };
1641
+ }
1642
+
1643
+ export function revokeLine(result) {
1644
+ const running = isObject(result) && Array.isArray(result.running) ? result.running : [];
1645
+ return t(running.length > 0 ? "agents.tool.revokedRunning" : "agents.tool.revoked");
1646
+ }
1647
+
1648
+ export function interpretPermissionResponse(status, body) {
1649
+ const error = isObject(body) && typeof body.error === "string" ? body.error : null;
1650
+ if (status === 200) return { kind: "ok", body };
1651
+ if (status === 202) return { kind: "confirm", decisionId: isObject(body) && typeof body.decisionId === "string" ? body.decisionId : null, body };
1652
+ if (status === 409) return { kind: STALE_POLICY_CODES.has(error) ? "stale" : "conflict", error };
1653
+ if (status === 404) return { kind: error === null || error === "not_found" ? "unsupported" : "gone", error };
1654
+ if (status === 403 && EXPIRED_CLIENT_CODES.has(error)) return { kind: "expired", error };
1655
+ if (status === 400 || status === 401 || status === 403 || status === 410) return { kind: "refused", error };
1656
+ return { kind: "unavailable", error, status };
1657
+ }
1658
+
1659
+ /** C10 agent.tool_permissions.updated: the open editor reloads when its agent's policy moved past the loaded page. */
1660
+ export function policyEventStale(page, event) {
1661
+ if (!isObject(page) || !isObject(page.policy) || !isObject(event) || event.agentId !== page.policy.agentId || typeof event.policyRevision !== "number") return false;
1662
+ const policy = page.policy;
1663
+ return event.policyRevision !== policy.policyRevision ||
1664
+ (typeof event.authorityEpoch === "number" && event.authorityEpoch !== policy.authorityEpoch) ||
1665
+ (typeof event.accessState === "string" && event.accessState !== policy.accessState);
1666
+ }
1667
+
1668
+ export function toolPermissionErrorLine(error, status = 0) {
1669
+ if (error === null && status === 0) return t("agents.tool.error.network");
1670
+ const key = typeof error === "string" ? TOOL_PERMISSION_ERROR_KEYS[error] : undefined;
1671
+ return t(`agents.tool.error.${key ?? "generic"}`);
1672
+ }