@songsid/agend 2.3.2-beta.10 → 2.3.2-beta.11

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 (128) hide show
  1. package/dist/agent-cli.js +48 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.js +6 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/agent-whats-new.js +3 -0
  6. package/dist/agent-whats-new.js.map +1 -1
  7. package/dist/backend/kiro-classic-archive.d.ts +52 -0
  8. package/dist/backend/kiro-classic-archive.js +444 -0
  9. package/dist/backend/kiro-classic-archive.js.map +1 -0
  10. package/dist/backend/kiro-identity.d.ts +19 -0
  11. package/dist/backend/kiro-identity.js +43 -0
  12. package/dist/backend/kiro-identity.js.map +1 -1
  13. package/dist/backend/kiro-migration-verify.d.ts +10 -0
  14. package/dist/backend/kiro-migration-verify.js +52 -0
  15. package/dist/backend/kiro-migration-verify.js.map +1 -0
  16. package/dist/backend/kiro-v2-store-worker.js +21 -1
  17. package/dist/backend/kiro-v2-store-worker.js.map +1 -1
  18. package/dist/backend/kiro-v2-store.d.ts +8 -0
  19. package/dist/backend/kiro-v2-store.js.map +1 -1
  20. package/dist/backend/kiro.d.ts +99 -1
  21. package/dist/backend/kiro.js +352 -18
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/types.d.ts +49 -0
  24. package/dist/backend/types.js +9 -0
  25. package/dist/backend/types.js.map +1 -1
  26. package/dist/channel/ipc-timeouts.js +3 -0
  27. package/dist/channel/ipc-timeouts.js.map +1 -1
  28. package/dist/channel/mcp-tools.js +12 -0
  29. package/dist/channel/mcp-tools.js.map +1 -1
  30. package/dist/cli.js +33 -0
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config-validator.js +2 -0
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/config.js +2 -0
  35. package/dist/config.js.map +1 -1
  36. package/dist/daemon.d.ts +45 -0
  37. package/dist/daemon.js +342 -19
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/dashboard-permissions.d.ts +15 -4
  40. package/dist/dashboard-permissions.js +49 -23
  41. package/dist/dashboard-permissions.js.map +1 -1
  42. package/dist/fleet-control-audit.d.ts +1 -1
  43. package/dist/fleet-control-audit.js +3 -0
  44. package/dist/fleet-control-audit.js.map +1 -1
  45. package/dist/fleet-manager.d.ts +61 -2
  46. package/dist/fleet-manager.js +421 -28
  47. package/dist/fleet-manager.js.map +1 -1
  48. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +10 -0
  49. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +25 -0
  50. package/dist/general-knowledge/skills/web-appearance/SKILL.md +25 -0
  51. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +12 -2
  52. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +12 -2
  53. package/dist/instance-config-impact.js +2 -2
  54. package/dist/instance-config-impact.js.map +1 -1
  55. package/dist/instance-lifecycle.d.ts +4 -0
  56. package/dist/instance-lifecycle.js +7 -0
  57. package/dist/instance-lifecycle.js.map +1 -1
  58. package/dist/kiro-migrate.d.ts +11 -0
  59. package/dist/kiro-migrate.js +86 -31
  60. package/dist/kiro-migrate.js.map +1 -1
  61. package/dist/kiro-session-proof.d.ts +41 -0
  62. package/dist/kiro-session-proof.js +89 -0
  63. package/dist/kiro-session-proof.js.map +1 -0
  64. package/dist/kiro-tui-forensic.d.ts +5 -0
  65. package/dist/kiro-tui-forensic.js +3 -3
  66. package/dist/kiro-tui-forensic.js.map +1 -1
  67. package/dist/locale.js +142 -0
  68. package/dist/locale.js.map +1 -1
  69. package/dist/log-service.js +1 -1
  70. package/dist/log-service.js.map +1 -1
  71. package/dist/needs-you.d.ts +3 -1
  72. package/dist/needs-you.js +4 -2
  73. package/dist/needs-you.js.map +1 -1
  74. package/dist/office-share.d.ts +12 -2
  75. package/dist/office-share.js +27 -2
  76. package/dist/office-share.js.map +1 -1
  77. package/dist/outbound-schemas.d.ts +39 -0
  78. package/dist/outbound-schemas.js +29 -0
  79. package/dist/outbound-schemas.js.map +1 -1
  80. package/dist/remote-card-prompt.d.ts +38 -0
  81. package/dist/remote-card-prompt.js +86 -0
  82. package/dist/remote-card-prompt.js.map +1 -0
  83. package/dist/settings-change.js +8 -5
  84. package/dist/settings-change.js.map +1 -1
  85. package/dist/settings-instance-patch.js +1 -1
  86. package/dist/settings-instance-patch.js.map +1 -1
  87. package/dist/tool-permissions.js +17 -0
  88. package/dist/tool-permissions.js.map +1 -1
  89. package/dist/transcript-sources.d.ts +1 -0
  90. package/dist/transcript-sources.js +2 -1
  91. package/dist/transcript-sources.js.map +1 -1
  92. package/dist/types.d.ts +5 -0
  93. package/dist/ui/chat-store.js +3 -2
  94. package/dist/ui/chat-strings.js +2 -2
  95. package/dist/ui/fleet-office-editor.js +54 -2
  96. package/dist/ui/fleet-office-layout.js +44 -7
  97. package/dist/ui/fleet-office-pack.d.ts +6 -1
  98. package/dist/ui/fleet-office-pack.js +101 -15
  99. package/dist/ui/fleet-office-store.js +43 -0
  100. package/dist/ui/fleet-office-zones.js +64 -13
  101. package/dist/ui/fleet-office.js +5 -3
  102. package/dist/ui/panel-chat.js +14 -2
  103. package/dist/ui/panel-log.js +2 -2
  104. package/dist/ui/panel-needs.js +1 -1
  105. package/dist/ui/panel-settings.js +5 -5
  106. package/dist/ui/settings-model.js +17 -9
  107. package/dist/ui/settings-strings.js +2 -0
  108. package/dist/ui/shared/app-i18n.js +2 -2
  109. package/dist/ui/shared/app-stream.js +1 -1
  110. package/dist/ui/shared/app.css +18 -2
  111. package/dist/web-api.d.ts +5 -1
  112. package/dist/web-api.js +91 -3
  113. package/dist/web-api.js.map +1 -1
  114. package/dist/web-office-acts.js +2 -2
  115. package/dist/web-office-acts.js.map +1 -1
  116. package/dist/web-office-pack-read.d.ts +35 -0
  117. package/dist/web-office-pack-read.js +252 -0
  118. package/dist/web-office-pack-read.js.map +1 -0
  119. package/dist/web-office-pack-tools.d.ts +232 -0
  120. package/dist/web-office-pack-tools.js +754 -0
  121. package/dist/web-office-pack-tools.js.map +1 -0
  122. package/dist/web-office-skins.d.ts +68 -4
  123. package/dist/web-office-skins.js +318 -33
  124. package/dist/web-office-skins.js.map +1 -1
  125. package/dist/web-office-store.d.ts +5 -2
  126. package/dist/web-office-store.js +41 -15
  127. package/dist/web-office-store.js.map +1 -1
  128. package/package.json +1 -1
@@ -42,33 +42,62 @@ export function overflowSpots(area, need = 0) {
42
42
  * slot }). Someone still in the same area keeps their slot; the others take the first free seat, in the area's order,
43
43
  * then an overflow spot (slot -1, -2, …). No two share one. Whoever finds neither is counted in `crowd` (area → n).
44
44
  * Returns { seats: Map name → { area, slot, x, y }, crowd: Map }.
45
+ *
46
+ * #2691: a seat may be reserved (its `for`). Whoever it is for, wanting its area, takes a free seat reserved for them
47
+ * before anything else (the one they sat on last time, else their first in the area's order) — owners are seated
48
+ * before anyone keeps a slot, so a borrower on an owner's seat moves off when the owner comes. Everyone else keeps
49
+ * their slot or takes the first free seat not reserved for someone else: a plain one, else a reserved one that says
50
+ * `lend` (one no owner of it took). A reserved seat not lent, none of whose owners wants the area, is unavailable:
51
+ * the area's overflow spots and `over` count without it. Seats without `for` work as before.
45
52
  */
46
53
  export function assignSeats(pack, wants, prev = new Map()) {
47
- const seats = new Map(), crowd = new Map(), taken = new Map(), spill = new Map(), demand = new Map();
54
+ const seats = new Map(), crowd = new Map(), taken = new Map(), spill = new Map(), demand = new Map(), wanting = new Map();
48
55
  const areaOf = (a) => own(pack.areas, a);
49
- for (const w of wants) demand.set(w.area, (demand.get(w.area) || 0) + 1);
56
+ for (const w of wants) {
57
+ demand.set(w.area, (demand.get(w.area) || 0) + 1);
58
+ if (!wanting.has(w.area)) wanting.set(w.area, new Set());
59
+ wanting.get(w.area).add(w.name);
60
+ }
61
+ const mine = (s, name) => !!s.for && s.for.includes(name);
62
+ const lent = (s) => !!s.for && s.lend === true;
63
+ // The seats someone wanting the area may end up on: plain, lent, or reserved for someone who wants it.
64
+ const usable = (area) => areaOf(area).seats.filter((s) => !s.for || s.lend === true || s.for.some((n) => wanting.get(area).has(n))).length;
50
65
  const spotsOf = (area) => {
51
- if (!spill.has(area)) spill.set(area, overflowSpots(areaOf(area), Math.max(0, (demand.get(area) || 0) - areaOf(area).seats.length)));
66
+ if (!spill.has(area)) spill.set(area, overflowSpots(areaOf(area), Math.max(0, (demand.get(area) || 0) - usable(area))));
52
67
  return spill.get(area);
53
68
  };
54
69
  const pos = (area, slot) => (slot >= 0 ? areaOf(area).seats[slot] : spotsOf(area)[-slot - 1]);
55
70
  const usedIn = (area) => { if (!taken.has(area)) taken.set(area, new Set()); return taken.get(area); };
56
71
  const give = (name, area, slot) => { const p = pos(area, slot); usedIn(area).add(slot); seats.set(name, { area, slot, x: p.x, y: p.y }); };
72
+ const free = (w) => !seats.has(w.name) && !!areaOf(w.area);
73
+ // Owners first: their own seat from last time, then their first free one.
74
+ for (const w of wants) {
75
+ const p = prev.get(w.name), s = p && p.area === w.area && p.slot >= 0 && areaOf(w.area) ? areaOf(w.area).seats[p.slot] : null;
76
+ if (s && mine(s, w.name) && !usedIn(w.area).has(p.slot)) give(w.name, w.area, p.slot);
77
+ }
78
+ for (const w of wants) {
79
+ if (!free(w)) continue;
80
+ const used = usedIn(w.area), slot = areaOf(w.area).seats.findIndex((s, i) => mine(s, w.name) && !used.has(i));
81
+ if (slot >= 0) give(w.name, w.area, slot);
82
+ }
57
83
  for (const w of wants) {
58
84
  const p = prev.get(w.name);
59
- if (!p || p.area !== w.area || !areaOf(w.area) || !pos(w.area, p.slot) || usedIn(w.area).has(p.slot)) continue;
85
+ if (!free(w) || !p || p.area !== w.area || !pos(w.area, p.slot) || usedIn(w.area).has(p.slot)) continue;
86
+ const s = p.slot >= 0 ? areaOf(w.area).seats[p.slot] : null;
87
+ if (s && s.for && !lent(s)) continue; // reserved for someone else (theirs would be taken above)
60
88
  give(w.name, w.area, p.slot);
61
89
  }
62
90
  for (const w of wants) {
63
- if (seats.has(w.name) || !areaOf(w.area)) continue;
91
+ if (!free(w)) continue;
64
92
  const used = usedIn(w.area), area = areaOf(w.area);
65
- let slot = area.seats.findIndex((_, i) => !used.has(i));
93
+ let slot = area.seats.findIndex((s, i) => !s.for && !used.has(i));
94
+ if (slot < 0) slot = area.seats.findIndex((s, i) => lent(s) && !used.has(i));
66
95
  if (slot < 0) { const j = spotsOf(w.area).findIndex((_, i) => !used.has(-i - 1)); slot = j < 0 ? null : -j - 1; }
67
96
  if (slot === null) crowd.set(w.area, (crowd.get(w.area) || 0) + 1);
68
97
  else give(w.name, w.area, slot);
69
98
  }
70
- // Over capacity: more people want the area than it has seats (their bubbles and tags are thinned, fleet-office.js).
71
- const over = new Set([...demand].filter(([a, n]) => areaOf(a) && n > areaOf(a).seats.length).map(([a]) => a));
99
+ // Over capacity: more people want the area than it has seats for them (their bubbles and tags are thinned, fleet-office.js).
100
+ const over = new Set([...demand].filter(([a, n]) => areaOf(a) && n > usable(a)).map(([a]) => a));
72
101
  return { seats, crowd, over };
73
102
  }
74
103
 
@@ -79,11 +108,33 @@ export function assignSeats(pack, wants, prev = new Map()) {
79
108
  */
80
109
  export function zonesLayout(pack, nodes, prev = new Map(), { night = false } = {}) {
81
110
  // At night (#2568) the idle go to bed: the area asleep people go to while it has a free seat; the rest doze where they are.
82
- const bed = zoneOf(pack, "paused"), beds = (own(pack.areas, bed)?.seats || []).length, sleepers = new Set(), inBed = new Set();
83
- let used = nodes.filter((n) => n.state !== "idle" && zoneOf(pack, n.state) === bed).length;
84
- if (night) for (const n of nodes) if (n.state === "idle") { sleepers.add(n.name); if (used < beds) { used++; inBed.add(n.name); } }
85
- const wants = nodes.filter((n) => n.state !== "missing").map((n) => ({ name: n.name, area: inBed.has(n.name) ? bed : zoneOf(pack, n.state) }));
86
- const { seats, crowd, over } = assignSeats(pack, wants, prev);
111
+ const bed = zoneOf(pack, "paused"), bedSeats = own(pack.areas, bed)?.seats || [], sleepers = new Set(), inBed = new Set();
112
+ const wantsOf = (beds) => nodes.filter((n) => n.state !== "missing").map((n) => ({ name: n.name, area: beds.has(n.name) ? bed : zoneOf(pack, n.state) }));
113
+ let placed = assignSeats(pack, wantsOf(inBed), prev);
114
+ if (night) {
115
+ // #2691 review: who goes to bed is decided by the seat assignment itself (assignSeats' reservation rules), one
116
+ // sleeper at a time — whoever has a bed of their own there first, then the rest in the fleet's order. A sleeper
117
+ // goes to bed only if it gives them a bed (never an overflow spot) without taking one from anyone already in one
118
+ // (the awake first, then the sleepers admitted before), unless it is a bed reserved for them: an owner comes first,
119
+ // as by day. Everyone else dozes where they are.
120
+ const inABed = (r) => new Set([...r.seats].filter(([, s]) => s.area === bed && s.slot >= 0).map(([name]) => name));
121
+ const idle = nodes.filter((n) => n.state === "idle");
122
+ const hasBed = (n) => bedSeats.some((s) => s.for && s.for.includes(n.name));
123
+ for (const n of idle) sleepers.add(n.name);
124
+ for (const n of [...idle.filter(hasBed), ...idle.filter((n) => !hasBed(n))]) {
125
+ const trial = assignSeats(pack, wantsOf(new Set([...inBed, n.name])), prev), s = trial.seats.get(n.name);
126
+ if (!s || s.area !== bed || s.slot < 0) {
127
+ if (!hasBed(n)) break; // no bed free for anyone without one: none for the rest either
128
+ continue;
129
+ }
130
+ const before = inABed(placed), after = inABed(trial);
131
+ const theirs = !!bedSeats[s.slot].for && bedSeats[s.slot].for.includes(n.name);
132
+ if (!theirs && [...before].some((m) => !after.has(m))) continue;
133
+ inBed.add(n.name);
134
+ placed = trial;
135
+ }
136
+ }
137
+ const { seats, crowd, over } = placed;
87
138
  const desks = nodes.filter((n) => seats.has(n.name)).map((n) => {
88
139
  const s = seats.get(n.name);
89
140
  return { key: `zone/${n.name}`, node: n, x: s.x - 24, y: s.y - 52, w: 48, h: 66, seat: { x: s.x, y: s.y }, wp: null, area: s.area, slot: s.slot, night: sleepers.has(n.name) };
@@ -70,7 +70,7 @@ import { POLL_MS } from "./fleet-traffic.js";
70
70
  import { OfficeEditor, officeForSave } from "./fleet-office-editor.js";
71
71
  import { LayoutEditor, handlesOf } from "./fleet-office-layout.js";
72
72
  import { ShareButton } from "./office-share-dialog.js";
73
- import { applyOverrides, fitNight } from "./fleet-office-pack.js";
73
+ import { applyOverrides, fitNight, fitSeatFor } from "./fleet-office-pack.js";
74
74
  import { ALL_ID, LOBBY_TILE, OV, createCrossFlights, cropToRooms, fromTile, inTile, labelAt, lobbyNodes, overviewOk, tileAt, tileBox, tileLayout, visibleTiles } from "./fleet-office-overview.js";
75
75
  import { centreOn, gestureState, gestures, miniPoint, toScreen, toWorld, wheelFactor, zoomKey, zoomTo } from "./fleet-canvas-view.js";
76
76
  import { OK_MAX, OK_MIN, clampPan, desksK, edgeMatte, edgeStrips, fitScene, layerScale, miniBox, miniFrame, openingView, pinchOffice, savedOf, stepK, viewFromSaved,
@@ -193,7 +193,7 @@ function looksOf(L, work, q, mode, zones) {
193
193
  function withOverrides(baseSkin, zones, office) {
194
194
  const ov = zones && office ? office.overrides : null;
195
195
  if (!ov || ov.pack !== baseSkin.id) return { skin: baseSkin, ignored: false };
196
- const a = applyOverrides(baseSkin.pack, fitNight(baseSkin.pack, ov)); // #2665: a night place the pack lost: left out
196
+ const a = applyOverrides(baseSkin.pack, fitSeatFor(baseSkin.pack, fitNight(baseSkin.pack, ov))); // #2665/#2691: a night place or seat the pack lost: left out
197
197
  return a.ok ? { skin: { ...baseSkin, pack: a.pack }, ignored: false } : { skin: baseSkin, ignored: true };
198
198
  }
199
199
  /** A stored office's scene (or the lobby tile's): seats in its style's areas, its own seats, or one room wrapping at `deskW`. */
@@ -1113,6 +1113,8 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1113
1113
  setTip(null);
1114
1114
  };
1115
1115
  const closeLayout = () => setLayout(null);
1116
+ // #2691: whom a seat can be reserved for — the office's members (its teams' too).
1117
+ const layoutMembers = useMemo(() => (layout && custom ? membersOf(custom, view).map((n) => n.name) : []), [layout && layout.id, custom, view]);
1116
1118
  const onDraft = (draft) => setLayout((cur) => (cur ? { ...cur, draft } : cur));
1117
1119
  // Save: the session's list, this office's overrides replaced (none: back to the style's own), with the session's rev.
1118
1120
  // A change made meanwhile (409) reads the list again: the session takes it (the view too), the draft is kept, and
@@ -1193,7 +1195,7 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1193
1195
  : html`<button key=${tl.key} ref=${at} type="button" class="oo-tile-label" data-office=${tl.key} title=${to("openOffice", tl.name)} aria-label=${to("openOffice", tl.name)}
1194
1196
  onClick=${() => pick(tl.key)}>${text}</button>`;
1195
1197
  }) : null}
1196
- ${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay} dock=${!fs}
1198
+ ${layout ? html`<${LayoutEditor} key=${layout.id} base=${layout.base} start=${layout.start} place=${placeOverlay} dock=${!fs} members=${layoutMembers}
1197
1199
  stale=${layoutStale ? t("orgl.stale") : ""} onChange=${onDraft} onSave=${saveLayout} onClose=${closeLayout} />` : null}
1198
1200
  ${tipText ? html`<div ref=${tipRef} class="oo-tip" role="tooltip">
1199
1201
  <strong>${tipText.primary}</strong>${tipText.secondary ? html` <span class="muted mono">${tipText.secondary}</span>` : null}
@@ -618,8 +618,20 @@ function Prompts({ name }) {
618
618
  if (!list.length) return null;
619
619
  return html`<div data-trust="permission" class="prompts" aria-live="polite">${list.map(p => html`<div key=${p.nonce} class=${`prompt${p.resolved ? " done" : ""}`} role="group" aria-label=${p.text}>
620
620
  <div class="txt">${p.resolved ? (p.outcome || t("chat.promptGone")) : textLinkSegments(p.text, p.links).map(sg => sg.href ? html`<a data-trust="link" class="st-link" href=${sg.href}>${sg.text}</a>` : sg.text)}</div>
621
- ${p.resolved ? null : html`<div class="acts">${p.actions.map(a => html`<button key=${a.id} type="button" class="btn" disabled=${!!p.busy} onClick=${() => store.answerPrompt(p, a.id)}>${a.label}</button>`)}
622
- ${p.busy ? html`<span class="wait">${t("chat.promptAnswering")}</span>` : null}</div>`}</div>`)}</div>`;
621
+ ${p.resolved ? null : html`<div class="acts">${p.actions.filter(a => !a.input).map(a => html`<button key=${a.id} type="button" class="btn" disabled=${!!p.busy} onClick=${() => store.answerPrompt(p, a.id)}>${a.label}</button>`)}
622
+ ${p.busy ? html`<span class="wait">${t("chat.promptAnswering")}</span>` : null}</div>`}
623
+ ${p.resolved ? null : p.actions.filter(a => a.input).map(a => html`<${PromptText} key=${a.id} p=${p} action=${a} />`)}</div>`)}</div>`;
624
+ }
625
+
626
+ /** #2675: a prompt's typed answer (a kiro question card's "answer in words"): a box and its send button. */
627
+ function PromptText({ p, action }) {
628
+ const [text, setText] = useState("");
629
+ const send = (e) => { e.preventDefault(); if (text.trim() && !p.busy) store.answerPrompt(p, action.id, text); };
630
+ return html`<form class="prompt-text" onSubmit=${send}>
631
+ <input type="text" maxlength="4000" aria-label=${action.label} placeholder=${t("chat.promptTextPlaceholder")} value=${text} disabled=${!!p.busy}
632
+ onInput=${(e) => setText(e.currentTarget.value)} />
633
+ <button type="submit" class="btn" disabled=${!!p.busy || !text.trim()}>${t("chat.promptTextSend")}</button>
634
+ </form>`;
623
635
  }
624
636
 
625
637
  /** "<name> is working… 0:42", "looks stuck", "Stopping…", or what it waits on at its terminal. Text only. */
@@ -55,7 +55,7 @@ register("log", {
55
55
  cat_read: "Read", cat_edit: "Edit", cat_search: "Search", cat_run: "Run", cat_web: "Web", cat_mcp: "MCP", cat_agent: "Sub-agent",
56
56
  cat_plan: "Plan", cat_message: "Message", cat_other: "Tool", plan_items: "{0} items",
57
57
  ev_crash_respawn: "Crashed and restarted", ev_crash_loop: "Keeps crashing", ev_hang_detected: "Looks stuck", ev_hang_action: "Stuck: acted on",
58
- ev_instance_paused: "Paused", ev_instance_resumed: "Resumed", ev_interactive_prompt: "Waiting at a prompt", ev_interactive_prompt_assist: "Prompt: asked for help",
58
+ ev_instance_paused: "Paused", ev_instance_resumed: "Resumed", ev_interactive_prompt: "Waiting at a prompt", ev_interactive_prompt_assist: "Prompt: asked for help", ev_remote_card: "Asked a question (posted to chat)", ev_remote_card_answer: "Question answered from chat",
59
59
  ev_dialog_parked: "A dialog waits", ev_model_failover: "Switched to another model", ev_model_recovered: "Model back",
60
60
  ev_mcp_died: "Tool server stopped", ev_mcp_recovered: "Tool server back", ev_mcp_auto_restart: "Tool server restarted",
61
61
  ev_message_failed: "A message was not delivered", ev_pty_error: "Terminal error", ev_pty_recovered: "Terminal recovered",
@@ -93,7 +93,7 @@ register("log", {
93
93
  cat_read: "讀取", cat_edit: "編輯", cat_search: "搜尋", cat_run: "執行", cat_web: "網頁", cat_mcp: "MCP", cat_agent: "子 agent",
94
94
  cat_plan: "計畫", cat_message: "訊息", cat_other: "工具", plan_items: "{0} 項",
95
95
  ev_crash_respawn: "當機後重新啟動", ev_crash_loop: "反覆當機", ev_hang_detected: "看起來卡住了", ev_hang_action: "卡住:已處理",
96
- ev_instance_paused: "已暫停", ev_instance_resumed: "已繼續", ev_interactive_prompt: "停在提示上等待", ev_interactive_prompt_assist: "提示:已請求協助",
96
+ ev_instance_paused: "已暫停", ev_instance_resumed: "已繼續", ev_interactive_prompt: "停在提示上等待", ev_interactive_prompt_assist: "提示:已請求協助", ev_remote_card: "提出問題(已貼到聊天)", ev_remote_card_answer: "問題已從聊天回答",
97
97
  ev_dialog_parked: "有對話框在等待", ev_model_failover: "改用另一個模型", ev_model_recovered: "模型恢復",
98
98
  ev_mcp_died: "工具伺服器停止", ev_mcp_recovered: "工具伺服器恢復", ev_mcp_auto_restart: "工具伺服器已重新啟動",
99
99
  ev_message_failed: "有訊息沒有送達", ev_pty_error: "終端機錯誤", ev_pty_recovered: "終端機恢復",
@@ -196,7 +196,7 @@ function NeedsItem({ item, nowMs, owner }) {
196
196
  <div class="n-title"><strong>${reasonText(item)}</strong><span class="n-age">${age(item.since, nowMs)}</span></div>
197
197
  ${detailOf(item) ? html`<p class="n-detail">${detailOf(item)}</p>` : null}
198
198
  <div class="n-actions">
199
- ${item.type === "prompt" && Array.isArray(item.actions) ? item.actions.map((a) => html`<button key=${a.id} type="button" class="btn btn-sm"
199
+ ${item.type === "prompt" && Array.isArray(item.actions) ? item.actions.filter((a) => !a.input).map((a) => html`<button key=${a.id} type="button" class="btn btn-sm"
200
200
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
201
201
  ${item.type === "delivery" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
202
202
  <${Icon} name="check" size=${14} />${ack === "busy" ? tn("acknowledging") : tn("acknowledge")}</button>` : null}
@@ -27,7 +27,7 @@ import "./settings-strings.js";
27
27
  import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
28
28
  import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
29
29
  import {
30
- ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, ownChoiceImpact, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
30
+ ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, SEPARATE_FEATURE_KEYS, DEFAULT_SCHEMA, ownChoiceImpact, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
31
31
  changedFields, channelId, channelsOf, chLabel, connectionState, dashboardDefault, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
32
32
  hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, sameValue, shortName, STATUS_ORDER, toYaml,
33
33
  toolSetDefault, visibilityDefault, worstImpact,
@@ -1024,16 +1024,16 @@ function General({ ctx, part = "all" }) {
1024
1024
  tips: noticeMode(f.tips), reply_triggers: f.replyTriggers, reply_completion_guard: f.reply, log_level: f.log_level,
1025
1025
  // The dashboard features every agent may use unless its own setting says otherwise. All of them go, because the write
1026
1026
  // to the fleet's defaults merges top-level keys only (`dashboard` is replaced whole): the untouched ones as saved.
1027
- dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, f[`dash_${k}`]])),
1028
- office_access: f.dash_office,
1027
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => !Object.hasOwn(SEPARATE_FEATURE_KEYS, k)).map((k) => [k, f[`dash_${k}`]])),
1028
+ ...Object.fromEntries(Object.entries(SEPARATE_FEATURE_KEYS).map(([k, key]) => [key, f[`dash_${k}`]])),
1029
1029
  hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
1030
1030
  startup: { ...(f.concurrency.trim() === "" ? {} : { concurrency: num(f.concurrency) }), stagger_delay_ms: num(f.stagger) },
1031
1031
  }, {
1032
1032
  backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
1033
1033
  agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
1034
1034
  tips: d.tips ?? true, cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1035
- dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, dashboardDefault(d, k)])),
1036
- office_access: dashboardDefault(d, "office"),
1035
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => !Object.hasOwn(SEPARATE_FEATURE_KEYS, k)).map((k) => [k, dashboardDefault(d, k)])),
1036
+ ...Object.fromEntries(Object.entries(SEPARATE_FEATURE_KEYS).map(([k, key]) => [key, dashboardDefault(d, k)])),
1037
1037
  hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
1038
1038
  startup: { ...(startup.concurrency == null ? {} : { concurrency: startup.concurrency }), stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
1039
1039
  });
@@ -40,26 +40,32 @@ export function changedFields(next, baseline) {
40
40
 
41
41
  /**
42
42
  * The dashboard features an agent may use (dashboard-permissions.ts): each "inherit" (the fleet default: `defaults.dashboard`,
43
- * else allowed — off for OFF_UNLESS_ON, `office`), "on" or "off". The patch names only the features that changed: null
43
+ * else allowed — off for OFF_UNLESS_ON, `office` and `office_packs`), "on" or "off". The patch names only the features that changed: null
44
44
  * follows the default again.
45
45
  */
46
- export const DASHBOARD_FEATURES = ["appearance", "task_board", "web_settings", "office"];
47
- /** #2578: the features a person switches on — unset is off (dashboard-permissions.ts OFF_UNLESS_ON). */
48
- export const OFF_UNLESS_ON = ["office"];
46
+ export const DASHBOARD_FEATURES = ["appearance", "task_board", "web_settings", "office", "office_packs"];
47
+ /** #2578, #2692: the features a person switches on — unset is off (dashboard-permissions.ts OFF_UNLESS_ON). */
48
+ export const OFF_UNLESS_ON = ["office", "office_packs"];
49
+ /** #2601, #2692: the features kept beside `dashboard` under their own key (dashboard-permissions.ts SEPARATE_FEATURE_KEYS). */
50
+ export const SEPARATE_FEATURE_KEYS = { office: "office_access", office_packs: "office_pack_access" };
49
51
  export const DASHBOARD_CHOICES = ["inherit", "on", "off"];
50
52
  const dashboardOf = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
51
53
  /** What a feature is for an agent with no setting of its own: the default's, else allowed (off for OFF_UNLESS_ON). */
52
54
  export const dashboardDefault = (defaults, feature) => {
53
- const v = feature === "office" && defaults && Object.hasOwn(defaults, "office_access") ? defaults.office_access : dashboardOf(defaults && defaults.dashboard)[feature];
55
+ const key = Object.hasOwn(SEPARATE_FEATURE_KEYS, feature) ? SEPARATE_FEATURE_KEYS[feature] : null;
56
+ const v = key && defaults && Object.hasOwn(defaults, key) ? defaults[key] : dashboardOf(defaults && defaults.dashboard)[feature];
54
57
  return OFF_UNLESS_ON.includes(feature) ? v === true : v !== false;
55
58
  };
56
59
  export function dashboardForm(inst) {
57
- const own = { ...dashboardOf(inst.dashboard), ...(Object.hasOwn(inst, "office_access") ? { office: inst.office_access } : {}) };
60
+ const own = { ...dashboardOf(inst.dashboard) };
61
+ for (const [feature, key] of Object.entries(SEPARATE_FEATURE_KEYS)) if (Object.hasOwn(inst, key)) own[feature] = inst[key];
58
62
  return Object.fromEntries(DASHBOARD_FEATURES.map((f) => [f, own[f] === true ? "on" : own[f] === false ? "off" : "inherit"]));
59
63
  }
60
64
  export function dashboardPatchValue(choices) {
61
- return Object.fromEntries(DASHBOARD_FEATURES.filter((f) => f !== "office").map((f) => [f, choices[f] === "on" ? true : choices[f] === "off" ? false : null]));
65
+ return Object.fromEntries(DASHBOARD_FEATURES.filter((f) => !Object.hasOwn(SEPARATE_FEATURE_KEYS, f)).map((f) => [f, choices[f] === "on" ? true : choices[f] === "off" ? false : null]));
62
66
  }
67
+ /** A separately kept feature's own key in a patch (office_access, office_pack_access): true, false or null (inherit). */
68
+ export const separateChoice = (choice) => (choice === "on" ? true : choice === "off" ? false : null);
63
69
 
64
70
  /** #1302: cross-instance notices. Unset, or a value the fleet does not know, means full. */
65
71
  export const visibilityDefault = (defaults) => (VISIBILITY_MODES.includes(defaults && defaults.cross_instance_visibility) ? defaults.cross_instance_visibility : "full");
@@ -181,7 +187,8 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
181
187
  display_name: form.inherit.display_name ? null : form.display_name.trim(),
182
188
  status_emojis: form.status_emojis,
183
189
  dashboard: dashboardPatchValue(form.dashboard),
184
- office_access: form.dashboard.office === "on" ? true : form.dashboard.office === "off" ? false : null,
190
+ office_access: separateChoice(form.dashboard.office),
191
+ office_pack_access: separateChoice(form.dashboard.office_packs),
185
192
  };
186
193
  return changedFields(next, {
187
194
  backend: inst.backend || defaults.backend || "claude-code",
@@ -205,7 +212,8 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
205
212
  display_name: hasOwn(inst, "display_name") ? inst.display_name : null,
206
213
  status_emojis: statusEmojisBaseline,
207
214
  dashboard: dashboardPatchValue(dashboardForm(inst)),
208
- office_access: dashboardForm(inst).office === "on" ? true : dashboardForm(inst).office === "off" ? false : null,
215
+ office_access: separateChoice(dashboardForm(inst).office),
216
+ office_pack_access: separateChoice(dashboardForm(inst).office_packs),
209
217
  });
210
218
  }
211
219
 
@@ -113,6 +113,7 @@ register("settings", {
113
113
  dashboard_task_board: "The task board",
114
114
  dashboard_web_settings: "Your web appearance settings",
115
115
  dashboard_office: "Office: walk, speak and act in the Office view",
116
+ dashboard_office_packs: "Office skin packs: check, upload, replace and restore them (everyone sees them)",
116
117
  dashboardInherit: "Fleet default ({0})",
117
118
  dashboardOn: "allowed",
118
119
  dashboardOff: "off",
@@ -762,6 +763,7 @@ register("settings", {
762
763
  dashboard_task_board: "任務看板",
763
764
  dashboard_web_settings: "你的網頁外觀設定",
764
765
  dashboard_office: "辦公室:在辦公室畫面走動、說話、做動作",
766
+ dashboard_office_packs: "辦公室外觀包:檢查、上傳、取代與還原(全 fleet 都看得到)",
765
767
  dashboardInherit: "跟 fleet 預設({0})",
766
768
  dashboardOn: "允許",
767
769
  dashboardOff: "關閉",
@@ -105,7 +105,7 @@ register("app", {
105
105
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
106
106
  sidePanelLeave: "Leave {0} without saving? Your changes there will be lost.", sidePanelDiscard: "Discard changes",
107
107
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
108
- needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
108
+ needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_question: "Asking you a question", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
109
109
  },
110
110
  "zh-TW": {
111
111
  brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance", newAgent: "新增 Agent",
@@ -145,6 +145,6 @@ register("app", {
145
145
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
146
146
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
147
147
  sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
148
- needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_crashed: "已崩潰", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
148
+ needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_question: "正在問你問題", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_crashed: "已崩潰", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
149
149
  },
150
150
  });
@@ -37,7 +37,7 @@
37
37
  // offices — the stored offices changed ({ rev }; SSE only: the Office view reads /ui/offices again).
38
38
  // office_skins — a skin pack was uploaded or deleted ({ rev }; SSE only: the Office view reads /ui/office-skins again).
39
39
  // office_acts — the agents' live acts in the Office view changed ({ acts }, #2578; a poll page reads /ui/office-acts).
40
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts"];
40
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts", "office_pack_changes"];
41
41
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
42
42
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
43
43
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -873,6 +873,9 @@ img.amb-p { object-fit: contain; }
873
873
  .st-link:hover { text-decoration-thickness: 2px; }
874
874
  .prompt .acts { display: flex; gap: var(--s-2); margin-top: var(--s-2); align-items: center; flex-wrap: wrap; }
875
875
  .prompt .wait { font-size: var(--fs-xs); color: var(--text-3); }
876
+ /* #2675: a prompt's typed answer. 16px keeps iOS from zooming in on focus (#1612). */
877
+ .prompt .prompt-text { display: flex; gap: var(--s-2); margin-top: var(--s-2); }
878
+ .prompt .prompt-text input { flex: 1; min-width: 0; font-size: 16px; }
876
879
  .work-bar { display: flex; gap: var(--s-2); align-items: center; padding: 0 var(--s-2); font-size: var(--fs-sm); color: var(--text-2); }
877
880
  .work-bar .lbl { flex: 1; min-width: 0; }
878
881
  .work-bar .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); animation: pulse 1.2s ease-in-out infinite; flex-shrink: 0; }
@@ -1852,6 +1855,10 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
1852
1855
  .ol-night > circle { fill: var(--surface); fill-opacity: 0.85; }
1853
1856
  .ol-night > .ol-moon { fill: var(--warn); stroke: none; pointer-events: none; }
1854
1857
  .ol-night-line { stroke: var(--warn); stroke-width: 1.5; stroke-dasharray: 4 3; stroke-opacity: 0.8; pointer-events: none; }
1858
+ /* #2691: a reserved seat — a dashed ring round its dot; the selected seat's "Reserved for" picks. */
1859
+ .ol-seat > .ol-for-ring { fill: none; stroke: var(--warn); stroke-width: 2; stroke-dasharray: 3 3; pointer-events: none; }
1860
+ .ol-for { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px var(--s-2); }
1861
+ .ol-for > legend { float: left; margin-right: var(--s-2); }
1855
1862
  .ol-waypoint > circle { fill: var(--focus); }
1856
1863
  .ol-handle.on > circle, .ol-handle.on > rect, .ol-handle:focus-visible > circle, .ol-handle:focus-visible > rect { stroke: var(--focus); stroke-width: 4; }
1857
1864
  .ol-bar { position: absolute; left: 0; right: 0; bottom: 0; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); padding: var(--s-2);
@@ -1863,14 +1870,23 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
1863
1870
  .ol-corridors { display: inline-flex; flex-wrap: wrap; gap: 4px; }
1864
1871
  .ol-msg { flex-basis: 100%; margin: 0; }
1865
1872
  @media (max-width: 640px) {
1866
- /* A phone: the bars at the column's narrower padding; the stage at least most of a screen; no overview (as the canvas). */
1873
+ /* A phone: the bars at the column's narrower padding; no overview (as the canvas). #2689: the stage takes the panel's
1874
+ height that is left under the bars (the column, .oc-office and .oo grow into it) and no more, so zoomed in the
1875
+ scene's bottom is on screen: a floor fixed to the window (100dvh less a guess at the bars) put it under the tab bar
1876
+ once the bars wrapped. Its own floor is low enough for a 375 × 667 phone with the Office bar on five rows (215 px
1877
+ left); only a shorter screen scrolls the panel (on the bars: the stage's touches are its own). */
1867
1878
  .oc-office > .oc-bar { padding: var(--s-2) var(--s-3); }
1868
1879
  .oc-office > .oc-legend { margin: 0 var(--s-3) var(--s-2); }
1869
1880
  .oo > :not(.oo-stage):not(dialog) { margin-left: var(--s-3); margin-right: var(--s-3); }
1870
- .oo-stage { min-height: max(300px, calc(100dvh - 300px)); }
1881
+ .oo-stage { min-height: 200px; }
1871
1882
  .oo-pick .tb-select { max-width: 150px; }
1872
1883
  .oo-mini { display: none; }
1873
1884
  .oo-zoom .icon-btn { width: 40px; height: 40px; }
1885
+ /* #2689: a column of the label and five 40 px buttons (~240 px) is taller than that stage: a row along its top
1886
+ instead, out of fullscreen (there the stage is the screen, and the office switcher is on the left of its top). */
1887
+ .oo:not(.fs) .oo-zoom { flex-direction: row; }
1888
+ .oo:not(.fs) .oo-zoom-pct { display: flex; align-items: center; padding: 0 6px; border-bottom: 0; border-right: 1px solid var(--border); }
1889
+ .oo:not(.fs) .oo-zoom .icon-btn + .icon-btn { border-top: 0; border-left: 1px solid var(--border); }
1874
1890
  }
1875
1891
  /* A phone: Fleet's five tabs fit when only the open one shows its label (the others keep theirs for screen readers). */
1876
1892
  @media (max-width: 479px) {
package/dist/web-api.d.ts CHANGED
@@ -11,6 +11,7 @@ import type { ThemeStore } from "./web-theme-store.js";
11
11
  import type { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
12
12
  import { type OfficeSkinStore } from "./web-office-skins.js";
13
13
  import type { OfficeActs } from "./web-office-acts.js";
14
+ import type { OfficePackTools } from "./web-office-pack-tools.js";
14
15
  import type { WebAppearance } from "./web-appearance.js";
15
16
  import type { SendOpts } from "./channel/types.js";
16
17
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -214,6 +215,8 @@ export interface WebApiContext {
214
215
  };
215
216
  /** #2636: a share was made or ended from the page — said in General (who, which office, until when). */
216
217
  officeShareNotice?(event: "created" | "revoked", who: string, share: import("./office-share.js").OfficeShare | null, count: number): void;
218
+ /** #2692: the agents' skin-pack changes (approval mode, pending changes, log); absent: /ui/office-pack-changes is not offered. */
219
+ readonly officePackTools?: Pick<OfficePackTools, "changes" | "setApproval" | "decide">;
217
220
  /** #2513: the Office view's uploaded skin packs (web-office-skins/); absent: /ui/office-skins is not offered. */
218
221
  readonly officeSkins?: OfficeSkinStore;
219
222
  /** How often a person may upload or delete a skin pack (absent: no limit). */
@@ -324,7 +327,8 @@ export interface WebApiContext {
324
327
  message: string;
325
328
  };
326
329
  /** Answer one of them, exactly as a click on its platform button would. */
327
- clickWebPrompt?(instance: string, nonce: string, action: string): Promise<{
330
+ /** `text`: #2675 — the typed answer, for the one action the page shows a text box for. */
331
+ clickWebPrompt?(instance: string, nonce: string, action: string, text?: string): Promise<{
328
332
  status: number;
329
333
  error?: string;
330
334
  }>;
package/dist/web-api.js CHANGED
@@ -672,6 +672,92 @@ function handleOfficeSkins(req, res, url, ctx, path, method) {
672
672
  });
673
673
  return true;
674
674
  }
675
+ /**
676
+ * #2692: the agents' skin-pack changes, for the Office page (Edit offices → Skin packs) — an admin session only, never
677
+ * the CLI token, a guest or the public link (as uploading a pack by hand). GET { approval, agents, pending, log };
678
+ * POST { approval: "apply" | "approve" } sets the fleet's approval mode; POST /<id> { decision: "approve" | "reject" }
679
+ * decides a pending change (judged again on a yes). Every change is `office_pack_changes` on the stream.
680
+ */
681
+ function handleOfficePackChanges(req, res, url, ctx, path, method) {
682
+ const tools = ctx.officePackTools;
683
+ if (!tools) {
684
+ json(res, 404, { error: "Pack changes are not kept on this fleet" });
685
+ return true;
686
+ }
687
+ const one = /^\/ui\/office-pack-changes\/([0-9a-f]{32})$/.exec(path);
688
+ if (!(method === "GET" && path === "/ui/office-pack-changes") && !(method === "POST" && (path === "/ui/office-pack-changes" || one))) {
689
+ json(res, 404, { error: "not found" });
690
+ return true;
691
+ }
692
+ if (gatewayRequestContext(req)) {
693
+ json(res, 403, { error: "pack_changes_not_over_public_link" });
694
+ return true;
695
+ }
696
+ const who = () => {
697
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
698
+ if (verdict.kind !== "allow")
699
+ return { status: 401 };
700
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? { person: verdict.session.owner ? webPersonKey(verdict.session.owner) : "admin-session" } : { status: 403 };
701
+ };
702
+ const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "pack_changes_need_an_admin_session" });
703
+ const first = who();
704
+ if ("status" in first) {
705
+ refuse(first.status);
706
+ return true;
707
+ }
708
+ if (method === "GET") {
709
+ res.setHeader("Cache-Control", "private, no-store");
710
+ json(res, 200, tools.changes());
711
+ return true;
712
+ }
713
+ (async () => {
714
+ let body;
715
+ try {
716
+ body = await parseBody(req);
717
+ }
718
+ catch {
719
+ json(res, 400, { error: "Invalid JSON" });
720
+ return;
721
+ }
722
+ if (!permitWebContinuation(req, res, ctx))
723
+ return;
724
+ const now = who();
725
+ if ("status" in now) {
726
+ refuse(now.status);
727
+ return;
728
+ }
729
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
730
+ json(res, 400, { error: "an object" });
731
+ return;
732
+ }
733
+ if (!one) {
734
+ if (Object.keys(body).some(k => k !== "approval")) {
735
+ json(res, 400, { error: "pack_changes_invalid", detail: "{ approval }" });
736
+ return;
737
+ }
738
+ const r = tools.setApproval(body.approval, now.person);
739
+ if (!r.ok) {
740
+ json(res, 400, { error: "pack_changes_invalid", detail: r.error });
741
+ return;
742
+ }
743
+ json(res, 200, tools.changes());
744
+ return;
745
+ }
746
+ if (Object.keys(body).some(k => k !== "decision") || (body.decision !== "approve" && body.decision !== "reject")) {
747
+ json(res, 400, { error: "pack_changes_invalid", detail: '{ decision: "approve" | "reject" }' });
748
+ return;
749
+ }
750
+ const r = tools.decide(one[1], body.decision, now.person);
751
+ json(res, r.ok ? 200 : r.status, r.ok ? { text: r.text, ...(r.applied ? { applied: r.applied } : {}) } : { error: r.error, detail: r.text });
752
+ })().catch(err => {
753
+ ctx.logger.error({ err: err.message }, "A pack change failed");
754
+ try {
755
+ json(res, 500, { error: "A pack change failed" });
756
+ }
757
+ catch { /* already answered */ }
758
+ });
759
+ return true;
760
+ }
675
761
  /**
676
762
  * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
677
763
  * signed-in human session is, or the status to answer.
@@ -2411,6 +2497,8 @@ export function handleWebRequest(req, res, url, ctx) {
2411
2497
  }
2412
2498
  if (path === "/ui/office-skins" || path.startsWith("/ui/office-skins/"))
2413
2499
  return handleOfficeSkins(req, res, url, ctx, path, method);
2500
+ if (path === "/ui/office-pack-changes" || path.startsWith("/ui/office-pack-changes/"))
2501
+ return handleOfficePackChanges(req, res, url, ctx, path, method);
2414
2502
  if (path === "/ui/office-share" || path.startsWith("/ui/office-share/"))
2415
2503
  return handleOfficeShare(req, res, url, ctx, path, method);
2416
2504
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
@@ -2500,12 +2588,12 @@ export function handleWebRequest(req, res, url, ctx) {
2500
2588
  json(res, 400, { error: "instance, nonce and action required" });
2501
2589
  return;
2502
2590
  }
2503
- const { instance, nonce, action } = body;
2504
- if (typeof instance !== "string" || typeof nonce !== "string" || typeof action !== "string") {
2591
+ const { instance, nonce, action, text } = body;
2592
+ if (typeof instance !== "string" || typeof nonce !== "string" || typeof action !== "string" || (text !== undefined && typeof text !== "string")) {
2505
2593
  json(res, 400, { error: "instance, nonce and action required" });
2506
2594
  return;
2507
2595
  }
2508
- const r = await click(instance, nonce, action);
2596
+ const r = text === undefined ? await click(instance, nonce, action) : await click(instance, nonce, action, text);
2509
2597
  // 409: answered elsewhere first, expired, or never open — the page drops the buttons on `gone`.
2510
2598
  json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
2511
2599
  })().catch(err => {