@iniesta8888/agent-live 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CODE_OF_CONDUCT.md +7 -0
  2. package/CONTRIBUTING.md +56 -0
  3. package/LICENSE +21 -0
  4. package/README.md +92 -0
  5. package/SECURITY.md +13 -0
  6. package/dist/adapter-sdk/index.d.ts +45 -0
  7. package/dist/adapter-sdk/index.d.ts.map +1 -0
  8. package/dist/adapter-sdk/index.js +106 -0
  9. package/dist/adapter-sdk/index.js.map +1 -0
  10. package/dist/content/compiler.d.ts +31 -0
  11. package/dist/content/compiler.d.ts.map +1 -0
  12. package/dist/content/compiler.js +218 -0
  13. package/dist/content/compiler.js.map +1 -0
  14. package/dist/content/graph-validator.d.ts +56 -0
  15. package/dist/content/graph-validator.d.ts.map +1 -0
  16. package/dist/content/graph-validator.js +226 -0
  17. package/dist/content/graph-validator.js.map +1 -0
  18. package/dist/content/library.d.ts +4 -0
  19. package/dist/content/library.d.ts.map +1 -0
  20. package/dist/content/library.js +45 -0
  21. package/dist/content/library.js.map +1 -0
  22. package/dist/content/registry.d.ts +34 -0
  23. package/dist/content/registry.d.ts.map +1 -0
  24. package/dist/content/registry.js +130 -0
  25. package/dist/content/registry.js.map +1 -0
  26. package/dist/content/runtime-content.d.ts +63 -0
  27. package/dist/content/runtime-content.d.ts.map +1 -0
  28. package/dist/content/runtime-content.js +78 -0
  29. package/dist/content/runtime-content.js.map +1 -0
  30. package/dist/content/schema.d.ts +142 -0
  31. package/dist/content/schema.d.ts.map +1 -0
  32. package/dist/content/schema.js +280 -0
  33. package/dist/content/schema.js.map +1 -0
  34. package/dist/content/validator.d.ts +35 -0
  35. package/dist/content/validator.d.ts.map +1 -0
  36. package/dist/content/validator.js +205 -0
  37. package/dist/content/validator.js.map +1 -0
  38. package/dist/core/agents.d.ts +56 -0
  39. package/dist/core/agents.d.ts.map +1 -0
  40. package/dist/core/agents.js +148 -0
  41. package/dist/core/agents.js.map +1 -0
  42. package/dist/core/limits.d.ts +15 -0
  43. package/dist/core/limits.d.ts.map +1 -0
  44. package/dist/core/limits.js +16 -0
  45. package/dist/core/limits.js.map +1 -0
  46. package/dist/core/mapping.d.ts +17 -0
  47. package/dist/core/mapping.d.ts.map +1 -0
  48. package/dist/core/mapping.js +94 -0
  49. package/dist/core/mapping.js.map +1 -0
  50. package/dist/core/protocol.d.ts +105 -0
  51. package/dist/core/protocol.d.ts.map +1 -0
  52. package/dist/core/protocol.js +6 -0
  53. package/dist/core/protocol.js.map +1 -0
  54. package/dist/core/state.d.ts +49 -0
  55. package/dist/core/state.d.ts.map +1 -0
  56. package/dist/core/state.js +216 -0
  57. package/dist/core/state.js.map +1 -0
  58. package/dist/creator/commands.d.ts +19 -0
  59. package/dist/creator/commands.d.ts.map +1 -0
  60. package/dist/creator/commands.js +44 -0
  61. package/dist/creator/commands.js.map +1 -0
  62. package/dist/creator/mode.d.ts +10 -0
  63. package/dist/creator/mode.d.ts.map +1 -0
  64. package/dist/creator/mode.js +23 -0
  65. package/dist/creator/mode.js.map +1 -0
  66. package/dist/creator/service.d.ts +199 -0
  67. package/dist/creator/service.d.ts.map +1 -0
  68. package/dist/creator/service.js +159 -0
  69. package/dist/creator/service.js.map +1 -0
  70. package/dist/index.d.ts +14 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +10 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/runtime/agent-live-runtime.d.ts +17 -0
  75. package/dist/runtime/agent-live-runtime.d.ts.map +1 -0
  76. package/dist/runtime/agent-live-runtime.js +50 -0
  77. package/dist/runtime/agent-live-runtime.js.map +1 -0
  78. package/dist/runtime/content-service.d.ts +75 -0
  79. package/dist/runtime/content-service.d.ts.map +1 -0
  80. package/dist/runtime/content-service.js +60 -0
  81. package/dist/runtime/content-service.js.map +1 -0
  82. package/dist/runtime/resource-guard.d.ts +17 -0
  83. package/dist/runtime/resource-guard.d.ts.map +1 -0
  84. package/dist/runtime/resource-guard.js +54 -0
  85. package/dist/runtime/resource-guard.js.map +1 -0
  86. package/dist/runtime/server.d.ts +39 -0
  87. package/dist/runtime/server.d.ts.map +1 -0
  88. package/dist/runtime/server.js +286 -0
  89. package/dist/runtime/server.js.map +1 -0
  90. package/dist/web/app.d.ts +1 -0
  91. package/dist/web/app.js +1317 -0
  92. package/dist/web/index.html +45 -0
  93. package/dist/web/office.js +350 -0
  94. package/dist/web/sprites.js +185 -0
  95. package/dist/web/style.css +288 -0
  96. package/dist/web/v2/bootstrap.js +276 -0
  97. package/dist/web/v2/content/README.md +17 -0
  98. package/dist/web/v2/content/agent-skins/studio-team.json +33 -0
  99. package/dist/web/v2/content/agent-skins/tiny-developers.json +33 -0
  100. package/dist/web/v2/content/atmospheres/default.json +13 -0
  101. package/dist/web/v2/content/atmospheres/rainy-night.json +37 -0
  102. package/dist/web/v2/content/catalog.json +14 -0
  103. package/dist/web/v2/content/component-library/activity-implementations.json +873 -0
  104. package/dist/web/v2/content/component-library/activity-recipes.json +25 -0
  105. package/dist/web/v2/content/component-library/agent-profile-templates.json +18 -0
  106. package/dist/web/v2/content/component-library/catalog.json +38 -0
  107. package/dist/web/v2/content/component-library/npc-templates.json +42 -0
  108. package/dist/web/v2/content/component-library/props.json +41 -0
  109. package/dist/web/v2/content/environments/local-office.json +31 -0
  110. package/dist/web/v2/content/environments/rainy-night.json +28 -0
  111. package/dist/web/v2/content/environments/static-office.json +27 -0
  112. package/dist/web/v2/content/layouts/boardroom-office.json +122 -0
  113. package/dist/web/v2/content/layouts/demo-office.json +88 -0
  114. package/dist/web/v2/content/layouts/lively-office.json +96 -0
  115. package/dist/web/v2/content/layouts/old-school-office.json +154 -0
  116. package/dist/web/v2/content/layouts/tech-open-office.json +132 -0
  117. package/dist/web/v2/content/life-activities/boardroom-routines.json +67 -0
  118. package/dist/web/v2/content/life-activities/none.json +10 -0
  119. package/dist/web/v2/content/life-activities/office-basics.json +39 -0
  120. package/dist/web/v2/content/life-activities/old-school-routines.json +74 -0
  121. package/dist/web/v2/content/life-activities/tech-office-routines.json +148 -0
  122. package/dist/web/v2/content/npcs/boardroom-staff.json +54 -0
  123. package/dist/web/v2/content/npcs/none.json +10 -0
  124. package/dist/web/v2/content/npcs/office-staff.json +25 -0
  125. package/dist/web/v2/content/npcs/old-school-staff.json +39 -0
  126. package/dist/web/v2/content/npcs/tech-office-staff.json +40 -0
  127. package/dist/web/v2/content/official-offices/boardroom-office.json +25 -0
  128. package/dist/web/v2/content/official-offices/old-school-office.json +25 -0
  129. package/dist/web/v2/content/official-offices/tech-open-office.json +25 -0
  130. package/dist/web/v2/content/presets/boardroom-office.json +21 -0
  131. package/dist/web/v2/content/presets/cozy-studio.json +19 -0
  132. package/dist/web/v2/content/presets/demo-office.json +19 -0
  133. package/dist/web/v2/content/presets/lively-office.json +19 -0
  134. package/dist/web/v2/content/presets/night-shift.json +19 -0
  135. package/dist/web/v2/content/presets/old-school-office.json +20 -0
  136. package/dist/web/v2/content/presets/refined-demo.json +20 -0
  137. package/dist/web/v2/content/presets/tech-open-office.json +21 -0
  138. package/dist/web/v2/content/props/default-office.json +23 -0
  139. package/dist/web/v2/content/props/extended-office.json +39 -0
  140. package/dist/web/v2/content/props/tech-office.json +28 -0
  141. package/dist/web/v2/content/styles/pixel-classic.json +160 -0
  142. package/dist/web/v2/content/styles/warm-studio.json +150 -0
  143. package/dist/web/v2/environment-runtime.d.ts +1 -0
  144. package/dist/web/v2/environment-runtime.js +138 -0
  145. package/dist/web/v2/graph-validator.d.ts +55 -0
  146. package/dist/web/v2/graph-validator.js +225 -0
  147. package/dist/web/v2/i18n.js +54 -0
  148. package/dist/web/v2/locales/en.json +209 -0
  149. package/dist/web/v2/locales/zh-CN.json +52 -0
  150. package/dist/web/v2/office-engine.d.ts +1 -0
  151. package/dist/web/v2/office-engine.js +145 -0
  152. package/dist/web/v2/office-renderer.d.ts +1 -0
  153. package/dist/web/v2/office-renderer.js +843 -0
  154. package/dist/web/v2/parity.html +10 -0
  155. package/dist/web/v2/parity.js +82 -0
  156. package/dist/web/v2/sprite-renderer.d.ts +1 -0
  157. package/dist/web/v2/sprite-renderer.js +242 -0
  158. package/dist/web/v2/static-content.js +48 -0
  159. package/dist/web/v2/style.css +140 -0
  160. package/dist/web/v2.html +68 -0
  161. package/docs/ADAPTER-DEVELOPMENT.md +328 -0
  162. package/docs/ADAPTER-SDK.md +109 -0
  163. package/docs/ARCHITECTURE.md +223 -0
  164. package/docs/COMPONENT-LIBRARY.md +212 -0
  165. package/docs/CUSTOMIZATION.md +163 -0
  166. package/docs/DEVELOPER.md +186 -0
  167. package/docs/DSH-ADAPTER-DESIGN.md +173 -0
  168. package/docs/OFFICE-THEMES.md +516 -0
  169. package/docs/PRESET-CONFIG.md +460 -0
  170. package/docs/RELEASE-CHECK.md +23 -0
  171. package/docs/TECHNICAL.md +515 -0
  172. package/docs/USING-CODEX.md +24 -0
  173. package/docs/USING-DSH.md +44 -0
  174. package/docs/USING-PI.md +50 -0
  175. package/docs/VISION.md +146 -0
  176. package/examples/adapter/local-office.mjs +52 -0
  177. package/package.json +89 -0
  178. package/skills/agent-live-adapter-builder/SKILL.md +30 -0
  179. package/skills/agent-live-adapter-builder/agents/openai.yaml +4 -0
  180. package/skills/agent-live-adapter-builder/references/investigation.md +9 -0
  181. package/skills/agent-live-adapter-builder/references/review.md +7 -0
  182. package/skills/agent-live-adapter-builder/scripts/create-adapter.mjs +26 -0
@@ -0,0 +1,1317 @@
1
+ /**
2
+ * Agent Live renderer.
3
+ *
4
+ * Consumes the office event stream (SSE) and drives one pixel character per
5
+ * agent: where it walks, what it does, and what it is thinking out loud.
6
+ */
7
+ (() => {
8
+ const { Office, Sprites } = window;
9
+ const I18n = window.AgentLiveI18n ?? { t: (key) => key, text: (value) => value };
10
+ const t = (key, vars) => I18n.t(key, vars);
11
+ const tx = (value) => I18n.text(value);
12
+ const clientKind = window.AgentLiveClientKind ?? new URLSearchParams(location.search).get("client");
13
+ const accessToken = new URLSearchParams(location.search).get("token") ?? "";
14
+ const authenticatedFetch = (path) => fetch(path, {
15
+ cache: "no-store",
16
+ headers: accessToken ? { "x-agent-live-token": accessToken } : {},
17
+ });
18
+ const hideCost = clientKind === "codex" || clientKind === "dsh";
19
+ const officeContent = window.OfficeContent ?? null;
20
+ const lifeActivities = officeContent?.lifeActivities?.entries ?? [];
21
+ const npcEntries = officeContent?.npcs?.entries ?? [];
22
+
23
+ function displayAgentName(view, isLead) {
24
+ const profileName = officeContent?.agentProfile?.name;
25
+ if (isLead) return tx(profileName ?? view.name);
26
+ const fallback = /^Teammate(?:\s+(\d+))?$/.exec(String(view.name ?? ""));
27
+ if (profileName && fallback) return t("agent.teammate", { name: profileName, index: fallback[1] ? " " + fallback[1] : "" });
28
+ return tx(view.name);
29
+ }
30
+ const canvas = document.getElementById("stage");
31
+ const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true });
32
+
33
+ const SPEED = 44; // logical px per second
34
+ const REPLAY_RATE = 3;
35
+ const BUBBLE_MS = { think: 4200, say: 6000, do: 2800, life: 3200 };
36
+
37
+ let dpr = 1;
38
+ let scale = 3;
39
+ let offX = 0;
40
+ let offY = 0;
41
+ let resizeFrame = 0;
42
+ let lastFrameAt = performance.now();
43
+ let contextLost = false;
44
+ let lastFrameError = null;
45
+
46
+ function resize() {
47
+ const rect = canvas.parentElement.getBoundingClientRect();
48
+ // A host may keep the view mounted at display:none while another tab is
49
+ // active. Do not replace a valid backing store with a stretched 1x1 frame.
50
+ if (rect.width < 2 || rect.height < 2) return;
51
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
52
+ const dw = Math.max(1, Math.floor(rect.width * dpr));
53
+ const dh = Math.max(1, Math.floor(rect.height * dpr));
54
+ // Assigning either dimension clears the backing store. ResizeObserver and
55
+ // host tab transitions may report the same size repeatedly, so only reset
56
+ // the canvas when its physical dimensions actually changed.
57
+ if (canvas.width !== dw) canvas.width = dw;
58
+ if (canvas.height !== dh) canvas.height = dh;
59
+ scale = Math.max(1, Math.floor(Math.min(dw / Office.W, dh / Office.H)));
60
+ offX = Math.floor((dw - Office.W * scale) / 2);
61
+ offY = Math.floor((dh - Office.H * scale) / 2);
62
+ }
63
+ function scheduleResize() {
64
+ if (resizeFrame) return;
65
+ resizeFrame = requestAnimationFrame(() => {
66
+ resizeFrame = 0;
67
+ resize();
68
+ });
69
+ }
70
+ window.addEventListener("resize", scheduleResize);
71
+ document.addEventListener("visibilitychange", () => {
72
+ if (!document.hidden) {
73
+ lastFrameAt = performance.now();
74
+ scheduleResize();
75
+ }
76
+ });
77
+ window.addEventListener("pageshow", () => {
78
+ lastFrameAt = performance.now();
79
+ scheduleResize();
80
+ });
81
+ const sizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(scheduleResize) : null;
82
+ sizeObserver?.observe(canvas.parentElement);
83
+ canvas.addEventListener("contextlost", (event) => {
84
+ event.preventDefault();
85
+ contextLost = true;
86
+ });
87
+ canvas.addEventListener("contextrestored", () => {
88
+ contextLost = false;
89
+ lastFrameAt = performance.now();
90
+ scheduleResize();
91
+ });
92
+
93
+ const toScreen = (wx, wy) => ({ x: offX + wx * scale, y: offY + wy * scale });
94
+
95
+ // ---------------------------------------------------------------- state
96
+
97
+ /** @type {Map<string, any>} */
98
+ const actors = new Map();
99
+ const particles = [];
100
+ const hot = new Set();
101
+ const lifeHot = new Set();
102
+ let session = { model: "—", turns: 0, busy: false, cwd: "" };
103
+ let meeting = null;
104
+ const meetQueue = [];
105
+ let sound = false;
106
+ let nextNpcShiftCheck = 0;
107
+ let eventHistory = [];
108
+ let replay = null;
109
+ const replayButton = document.getElementById("replay");
110
+ const replayStatus = document.getElementById("replayStatus");
111
+ const MAX_EVENT_HISTORY = 4000;
112
+ const isReplayable = (entry) => ["task", "thought", "say", "action", "delegate"].includes(entry?.event?.type);
113
+ function recordEvent(event) {
114
+ eventHistory.push({ at: Date.now(), event });
115
+ if (eventHistory.length > MAX_EVENT_HISTORY) eventHistory.splice(0, eventHistory.length - MAX_EVENT_HISTORY);
116
+ }
117
+
118
+ function makeActor(view) {
119
+ const isLead = !view.parent;
120
+ const profile = isLead ? officeContent?.agentProfile : null;
121
+ const seat = view.seat;
122
+ const placementKey = Sprites.hash(view.id);
123
+ const spawn = isLead && seat != null ? Office.seatAnchor(seat) : Office.TARGETS.entry;
124
+ return {
125
+ id: view.id,
126
+ name: displayAgentName(view, isLead),
127
+ role: tx(profile?.title ?? view.role),
128
+ title: tx(profile?.title ?? view.role),
129
+ model: view.model,
130
+ isLead,
131
+ isNpc: false,
132
+ seat,
133
+ placementKey,
134
+ palette: { ...Sprites.paletteFor(view.id, view.name, isLead), ...(profile?.appearance ?? {}) },
135
+ seed: (Sprites.hash(view.id) % 100) / 10,
136
+ x: spawn.x,
137
+ y: spawn.y,
138
+ lane: spawn.lane,
139
+ zone: spawn.zone,
140
+ dir: spawn.dir,
141
+ pose: isLead ? "sit" : "stand",
142
+ walkPhase: 0,
143
+ path: [],
144
+ dest: spawn,
145
+ state: view.state ?? "idle",
146
+ detail: tx(view.detail ?? ""),
147
+ task: view.task ?? "",
148
+ action: view.action ?? null,
149
+ tokens: view.tokens ?? 0,
150
+ cost: view.cost ?? 0,
151
+ bubble: null,
152
+ inMeeting: false,
153
+ leaving: false,
154
+ actions: new Map(),
155
+ life: null,
156
+ lifeCycle: 0,
157
+ nextLifeAt: 0,
158
+ spawnAt: performance.now(),
159
+ };
160
+ }
161
+
162
+ function goTo(actor, target) {
163
+ if (!target) return;
164
+ actor.dest = target;
165
+ actor.path = Office.path(actor, target);
166
+ }
167
+
168
+ /** Pick where a character should be, given what it is currently doing. */
169
+ function retarget(actor) {
170
+ if (actor.inMeeting || actor.leaving) return;
171
+ if (actor.isNpc) {
172
+ goTo(actor, actor.homeTarget ?? Office.TARGETS.entry);
173
+ return;
174
+ }
175
+ goTo(actor, Office.anchorFor(actor.action, actor.seat, actor.placementKey));
176
+ }
177
+
178
+ function restPose(actor) {
179
+ if (actor.life && !actor.path.length) return actor.life.step?.pose ?? "stand";
180
+ if (actor.isNpc) return actor.idlePose ?? "stand";
181
+ const a = actor.action;
182
+ if (a === "archive" || a === "server") return "reach";
183
+ if (a === "whiteboard") return "reach";
184
+ if (a === "phone") return "talk";
185
+ if (a === "coffee") return "stand";
186
+ if (actor.inMeeting) return "talk";
187
+ if (actor.state === "working" && (!a || a === "type")) return "type";
188
+ if (!a || a === "type" || a === "delegate") return actor.seat == null ? "stand" : "sit";
189
+ return "stand";
190
+ }
191
+
192
+ function bubble(actor, kind, text) {
193
+ if (!text) return;
194
+ const queued = [...actors.values()].filter((candidate) => candidate.bubble).length;
195
+ if (!actor.bubble && queued >= (window.SceneLimits?.queuedBubbles ?? 8)) return;
196
+ actor.bubble = {
197
+ kind,
198
+ text: String(text),
199
+ shown: 0,
200
+ remainingMs: BUBBLE_MS[kind] ?? 3000,
201
+ };
202
+ }
203
+
204
+ function spawn(kind, x, y, opts = {}) {
205
+ const maxEffects = window.SceneLimits?.effects ?? 40;
206
+ if (particles.length >= maxEffects) particles.shift();
207
+ particles.push({
208
+ kind,
209
+ x,
210
+ y,
211
+ vx: opts.vx ?? 0,
212
+ vy: opts.vy ?? -14,
213
+ life: opts.life ?? 1,
214
+ maxLife: opts.life ?? 1,
215
+ target: opts.target ?? null,
216
+ t: 0,
217
+ color: opts.color,
218
+ });
219
+ }
220
+
221
+ // ----------------------------------------------------------- office life
222
+
223
+ function rangedDelay(range, seed) {
224
+ if (typeof range === "number") return Math.max(0, range);
225
+ const min = Math.max(0, Number(range?.[0] ?? 0));
226
+ const max = Math.max(min, Number(range?.[1] ?? min));
227
+ if (max === min) return min;
228
+ return min + (Sprites.hash(seed) % (max - min + 1));
229
+ }
230
+
231
+ function matchesParticipant(actor, participant) {
232
+ // `person` is the shared social participant: either a real host Agent or
233
+ // a background colleague may take part. Work/tool activities remain
234
+ // explicitly `agent`, while role-specific routines remain `npc`.
235
+ if (participant.kind !== "person" && (participant.kind === "npc") !== actor.isNpc) return false;
236
+ // Role filters describe NPC identities. Real Agents may still join a
237
+ // mixed `person` activity regardless of their host/model role label.
238
+ if (actor.isNpc && participant.roles?.length && !participant.roles.includes(actor.role)) return false;
239
+ if (participant.states?.length && !participant.states.includes(actor.state)) return false;
240
+ return true;
241
+ }
242
+
243
+ function activitiesFor(actor) {
244
+ return lifeActivities.filter((activity) => {
245
+ const participant = activity.participant ?? {};
246
+ if (!matchesParticipant(actor, participant)) return false;
247
+ if (activity.onlyWhenSessionIdle && session.busy) return false;
248
+ if (Number(participant.minAgents ?? 1) > 1) {
249
+ const available = [...actors.values()].filter((candidate) =>
250
+ !candidate.leaving && matchesParticipant(candidate, participant),
251
+ );
252
+ if (available.length < Number(participant.minAgents)) return false;
253
+ }
254
+ if (activity.steps?.some((step) => step.target === "near-colleague")) {
255
+ const hasAvailableColleague = [...actors.values()].some((candidate) =>
256
+ candidate.id !== actor.id && candidate.isNpc && candidate.role === "colleague" && !candidate.leaving,
257
+ );
258
+ if (!hasAvailableColleague) return false;
259
+ }
260
+ return true;
261
+ });
262
+ }
263
+
264
+ function scheduleLife(actor, activity, now, initial = false) {
265
+ actor.lifeCycle += 1;
266
+ const range = initial ? activity.initialDelayMs : activity.cooldownMs;
267
+ actor.nextLifeAt = now + rangedDelay(range, `${actor.id}:${activity.id}:${actor.lifeCycle}`);
268
+ }
269
+
270
+ function clearLifeStep(actor) {
271
+ const hotKey = actor.life?.step?.hot;
272
+ if (hotKey) lifeHot.delete(hotKey);
273
+ }
274
+
275
+ function cancelLife(actor, now = performance.now()) {
276
+ if (!actor?.life) return;
277
+ clearLifeStep(actor);
278
+ const activity = actor.life.activity;
279
+ actor.life = null;
280
+ if (actor.bubble?.kind === "life") actor.bubble = null;
281
+ scheduleLife(actor, activity, now);
282
+ }
283
+
284
+ function beginLifeStep(actor, activity, index, now, slot = actor.life?.slot ?? 0) {
285
+ const configuredStep = activity.steps[index];
286
+ if (!configuredStep) {
287
+ actor.life = null;
288
+ scheduleLife(actor, activity, now);
289
+ retarget(actor);
290
+ return;
291
+ }
292
+ const targetName = configuredStep.targets?.[slot % configuredStep.targets.length] ?? configuredStep.target;
293
+ const step = {
294
+ ...configuredStep,
295
+ target: targetName,
296
+ bubble: configuredStep.bubbles?.[slot % configuredStep.bubbles.length] ?? configuredStep.bubble,
297
+ };
298
+ let target = Office.TARGETS[step.target];
299
+ if (step.target === "near-colleague") {
300
+ const colleagues = [...actors.values()].filter((candidate) =>
301
+ candidate.id !== actor.id && candidate.isNpc && candidate.role === "colleague" && !candidate.leaving && !candidate.life && !candidate.path.length,
302
+ );
303
+ const colleague = colleagues[Number(step.personIndex ?? index) % Math.max(1, colleagues.length)];
304
+ if (colleague) {
305
+ const side = colleague.x > Office.W / 2 ? -1 : 1;
306
+ target = {
307
+ x: Math.max(8, Math.min(Office.W - 8, colleague.x + side * 16)),
308
+ y: colleague.y,
309
+ dir: side > 0 ? "left" : "right",
310
+ lane: colleague.lane,
311
+ zone: colleague.zone,
312
+ };
313
+ }
314
+ }
315
+ actor._lifeAcc = 0;
316
+ actor.life = { activity, index, step, slot, phase: "walk", until: 0 };
317
+ goTo(actor, target);
318
+ if (index === 0 && activity.startBubble) bubble(actor, "life", tx(activity.startBubble));
319
+ }
320
+
321
+ function startLife(actor, activity, now) {
322
+ const participant = activity.participant ?? {};
323
+ const minAgents = Math.max(1, Number(participant.minAgents ?? 1));
324
+ if (minAgents === 1) {
325
+ beginLifeStep(actor, activity, 0, now);
326
+ return;
327
+ }
328
+ const partners = [...actors.values()].filter((candidate) =>
329
+ candidate.id !== actor.id &&
330
+ !candidate.life &&
331
+ !candidate.path.length &&
332
+ !candidate.action &&
333
+ !candidate.inMeeting &&
334
+ !candidate.leaving &&
335
+ matchesParticipant(candidate, participant),
336
+ );
337
+ const group = [actor, ...partners.slice(0, minAgents - 1)];
338
+ if (group.length < minAgents) {
339
+ scheduleLife(actor, activity, now);
340
+ return;
341
+ }
342
+ for (let slot = 0; slot < group.length; slot++) beginLifeStep(group[slot], activity, 0, now, slot);
343
+ }
344
+
345
+ function updateActorLife(actor, now, dt) {
346
+ // Completed/cancelled real Agents are waiting only for their short exit
347
+ // animation. They must never enter the idle NPC activity scheduler.
348
+ if (!actor.isNpc && ["done", "error"].includes(actor.state)) return;
349
+ if (!actor.life) {
350
+ if (actor.path.length || actor.inMeeting || actor.leaving || actor.action) return;
351
+ const choices = activitiesFor(actor);
352
+ if (!choices.length) {
353
+ actor.nextLifeAt = 0;
354
+ return;
355
+ }
356
+ const activity = choices[(Sprites.hash(`${actor.id}:${actor.lifeCycle}`) >>> 4) % choices.length];
357
+ if (!actor.nextLifeAt) scheduleLife(actor, activity, now, true);
358
+ if (now >= actor.nextLifeAt) startLife(actor, activity, now);
359
+ return;
360
+ }
361
+
362
+ const life = actor.life;
363
+ if (life.phase === "walk" && !actor.path.length) {
364
+ life.phase = "dwell";
365
+ life.remainingMs = Number(life.step.durationMs ?? 1500);
366
+ if (life.step.hot) lifeHot.add(life.step.hot);
367
+ if (life.step.bubble) bubble(actor, "life", tx(life.step.bubble));
368
+ actor.pose = life.step.pose ?? "stand";
369
+ }
370
+ if (life.phase !== "dwell") return;
371
+
372
+ actor._lifeAcc = (actor._lifeAcc ?? 0) + dt;
373
+ if (life.step.particle && actor._lifeAcc >= 0.42) {
374
+ actor._lifeAcc = 0;
375
+ spawn(life.step.particle, actor.x + (Math.random() - 0.5) * 8, actor.y - 14, {
376
+ life: 0.55,
377
+ vy: -10,
378
+ color: Office.colors.waterActive,
379
+ });
380
+ }
381
+ life.remainingMs -= dt * 1000;
382
+ if (life.remainingMs > 0) return;
383
+ clearLifeStep(actor);
384
+ beginLifeStep(actor, life.activity, life.index + 1, now, life.slot);
385
+ }
386
+
387
+ function makeNpc(entry, fromEntry = false) {
388
+ const colleagueEntries = npcEntries.filter((candidate) => candidate.role === "colleague");
389
+ const colleagueIndex = colleagueEntries.findIndex((candidate) => candidate.id === entry.id);
390
+ const seatCount = Array.isArray(Office.SEATS) ? Office.SEATS.length : 0;
391
+ // Seat 0 belongs to the real Agent. Background colleagues use the
392
+ // remaining desks instead of sharing a role-specific NPC target such as
393
+ // the executive chair. This is presentation placement only; they do not
394
+ // become coding Agents or consume host events.
395
+ const colleagueSeat = entry.role === "colleague" && seatCount > 1
396
+ ? 1 + Math.max(0, colleagueIndex) % (seatCount - 1)
397
+ : -1;
398
+ const workPoint = Office.npcHomeTarget(entry, colleagueIndex);
399
+ const spawnPoint = fromEntry ? Office.TARGETS.entry : workPoint;
400
+ return {
401
+ id: `npc:${entry.id}`,
402
+ name: tx(entry.name),
403
+ role: entry.role,
404
+ title: tx(entry.title ?? entry.role),
405
+ isLead: false,
406
+ isNpc: true,
407
+ seat: colleagueSeat,
408
+ palette: { ...Sprites.paletteFor(entry.id, entry.role, false), ...(entry.appearance ?? {}) },
409
+ seed: (Sprites.hash(entry.id) % 100) / 10,
410
+ x: spawnPoint.x,
411
+ y: spawnPoint.y,
412
+ lane: spawnPoint.lane,
413
+ zone: spawnPoint.zone,
414
+ dir: spawnPoint.dir,
415
+ pose: entry.pose ?? "stand",
416
+ idlePose: entry.pose ?? "stand",
417
+ // `entry.spawn` describes the configured role location/entrance. It is
418
+ // not necessarily where this concrete NPC should return after an
419
+ // activity: ordinary colleagues are assigned a real desk above.
420
+ homeTarget: { ...workPoint },
421
+ shift: entry.shift,
422
+ walkPhase: 0,
423
+ path: [],
424
+ dest: spawnPoint,
425
+ state: "idle",
426
+ detail: "",
427
+ task: "",
428
+ action: null,
429
+ tokens: 0,
430
+ cost: 0,
431
+ bubble: null,
432
+ inMeeting: false,
433
+ leaving: false,
434
+ actions: new Map(),
435
+ life: null,
436
+ lifeCycle: 0,
437
+ nextLifeAt: 0,
438
+ spawnAt: performance.now(),
439
+ };
440
+ }
441
+
442
+ function restoreNpcs(fromEntry = false) {
443
+ for (const entry of npcEntries) {
444
+ if (!isNpcOnDuty(entry)) continue;
445
+ const npc = makeNpc(entry, fromEntry);
446
+ actors.set(npc.id, npc);
447
+ if (fromEntry) {
448
+ bubble(npc, "life", tx(Office.npcShiftLabel?.("arrival") ?? t("npc.arrival")));
449
+ retarget(npc);
450
+ }
451
+ }
452
+ }
453
+
454
+ function isNpcOnDuty(entry) {
455
+ return Office.isNpcOnDuty?.(entry.role, entry.shift, entry.id) ?? true;
456
+ }
457
+
458
+ function sendNpcHome(actor) {
459
+ if (!actor?.isNpc || actor.leaving) return;
460
+ cancelLife(actor);
461
+ actor.leaving = true;
462
+ actor.pose = "stand";
463
+ actor.nextLifeAt = 0;
464
+ bubble(actor, "life", tx(Office.npcShiftLabel?.("departure") ?? t("npc.departure")));
465
+ goTo(actor, Office.TARGETS.entry);
466
+ }
467
+
468
+ function bringNpcToWork(entry) {
469
+ const id = `npc:${entry.id}`;
470
+ if (actors.has(id)) return;
471
+ const npc = makeNpc(entry, true);
472
+ actors.set(id, npc);
473
+ bubble(npc, "life", tx(Office.npcShiftLabel?.("arrival") ?? t("npc.arrival")));
474
+ retarget(npc);
475
+ }
476
+
477
+ function syncNpcShift(now) {
478
+ if (now < nextNpcShiftCheck) return;
479
+ nextNpcShiftCheck = now + 250;
480
+ for (const entry of npcEntries) {
481
+ const actor = actors.get(`npc:${entry.id}`);
482
+ if (isNpcOnDuty(entry)) {
483
+ if (!actor) bringNpcToWork(entry);
484
+ } else if (actor) {
485
+ sendNpcHome(actor);
486
+ }
487
+ }
488
+ }
489
+
490
+ // ---------------------------------------------------------------- events
491
+
492
+ /**
493
+ * Append to the activity feed. The server broadcasts `log` only for
494
+ * join/leave/delegate; thought/say/tool arrive as their own events and are
495
+ * fed in here at full length, while the bubble shows a trimmed version.
496
+ */
497
+ function feed(agentId, kind, text) {
498
+ if (!text) return;
499
+ logLine({ at: Date.now(), agentId, kind, text });
500
+ }
501
+
502
+ function apply(ev) {
503
+ switch (ev.type) {
504
+ case "snapshot": {
505
+ eventHistory = Array.isArray(ev.history) ? ev.history.slice(-MAX_EVENT_HISTORY) : eventHistory;
506
+ actors.clear();
507
+ particles.length = 0;
508
+ hot.clear();
509
+ lifeHot.clear();
510
+ nextNpcShiftCheck = 0;
511
+ document.getElementById("log").innerHTML = "";
512
+ for (const view of ev.agents) actors.set(view.id, makeActor(view));
513
+ restoreNpcs();
514
+ for (const actor of actors.values()) {
515
+ if (actor.isNpc) continue;
516
+ if (actor.action) hot.add(Office.stationKey(actor.action, actor.seat));
517
+ // Snapshot agents are created at the entrance. Move idle agents to
518
+ // their seat/standing anchor as well, otherwise a restored child with
519
+ // no active tool remains parked in the doorway forever.
520
+ retarget(actor);
521
+ }
522
+ for (const item of ev.log) logLine(item);
523
+ session = ev.session;
524
+ if (ev.agents[0]?.task) setTask(ev.agents[0].task);
525
+ renderCrew();
526
+ break;
527
+ }
528
+ case "session":
529
+ session = ev.session;
530
+ if (session.busy) {
531
+ for (const actor of actors.values()) if (!actor.isNpc) cancelLife(actor);
532
+ }
533
+ break;
534
+ case "agent_join": {
535
+ const existing = actors.get(ev.agent.id);
536
+ if (existing) {
537
+ existing.name = displayAgentName({ ...ev.agent, name: ev.agent.name ?? existing.name }, existing.isLead);
538
+ existing.role = tx(existing.isLead && officeContent?.agentProfile?.title ? officeContent.agentProfile.title : ev.agent.role ?? existing.role);
539
+ existing.title = existing.role;
540
+ existing.model = ev.agent.model ?? existing.model;
541
+ existing.task = ev.agent.task ?? existing.task;
542
+ existing.detail = tx(ev.agent.detail ?? existing.detail);
543
+ renderCrew();
544
+ } else {
545
+ const actor = makeActor(ev.agent);
546
+ actors.set(ev.agent.id, actor);
547
+ if (!actor.isLead) {
548
+ bubble(actor, "say", t("agent.joined"));
549
+ beep(660, 0.06);
550
+ }
551
+ retarget(actor);
552
+ renderCrew();
553
+ }
554
+ break;
555
+ }
556
+ case "agent_leave": {
557
+ const actor = actors.get(ev.id);
558
+ if (!actor) break;
559
+ cancelLife(actor);
560
+ actor.actions.clear();
561
+ hot.clear();
562
+ for (const current of actors.values()) for (const action of current.actions.values()) hot.add(Office.stationKey(action, current.seat));
563
+ actor.leaving = true;
564
+ actor.inMeeting = false;
565
+ if (ev.ok !== undefined) spawn(ev.ok ? "check" : "cross", actor.x, actor.y - 26, { life: 1.1 });
566
+ goTo(actor, Office.TARGETS.entry);
567
+ // A character may already be at the entrance. An empty route has no
568
+ // final movement frame, so remove it immediately.
569
+ if (!actor.path.length) {
570
+ actors.delete(actor.id);
571
+ renderCrew();
572
+ }
573
+ break;
574
+ }
575
+ case "agent_state": {
576
+ const actor = actors.get(ev.id);
577
+ if (!actor) break;
578
+ if (ev.state !== "idle") cancelLife(actor);
579
+ actor.state = ev.state;
580
+ actor.detail = tx(ev.detail ?? "");
581
+ if (ev.state === "done") spawn("check", actor.x, actor.y - 26, { life: 1.2 });
582
+ if (ev.state === "error") spawn("cross", actor.x, actor.y - 26, { life: 1.2 });
583
+ if (!actor.path.length) actor.pose = restPose(actor);
584
+ renderCrew();
585
+ break;
586
+ }
587
+ case "task": {
588
+ const actor = actors.get(ev.id);
589
+ if (actor) {
590
+ cancelLife(actor);
591
+ actor.task = ev.task;
592
+ }
593
+ if (actor?.isLead) setTask(ev.task);
594
+ break;
595
+ }
596
+ case "thought": {
597
+ const actor = actors.get(ev.id);
598
+ if (!actor) break;
599
+ cancelLife(actor);
600
+ bubble(actor, "think", tail(ev.text, 52));
601
+ feed(ev.id, "thought", ev.text);
602
+ break;
603
+ }
604
+ case "say": {
605
+ const actor = actors.get(ev.id);
606
+ if (!actor) break;
607
+ cancelLife(actor);
608
+ bubble(actor, "say", head(ev.text, 90));
609
+ feed(ev.id, "say", ev.text);
610
+ break;
611
+ }
612
+ case "action": {
613
+ const actor = actors.get(ev.id);
614
+ if (!actor) break;
615
+ cancelLife(actor);
616
+ actor.action = ev.action;
617
+ actor.actions.set(ev.toolCallId, ev.action);
618
+ actor.state = "working";
619
+ actor.detail = ev.label;
620
+ hot.add(Office.stationKey(ev.action, actor.seat));
621
+ bubble(actor, "do", tx(ev.label));
622
+ feed(ev.id, "tool", tx(ev.label));
623
+ retarget(actor);
624
+ renderCrew();
625
+ break;
626
+ }
627
+ case "action_end": {
628
+ const actor = actors.get(ev.id);
629
+ if (!actor) break;
630
+ actor.actions.delete(ev.toolCallId);
631
+ actor.action = [...actor.actions.values()].at(-1) ?? null;
632
+ hot.clear();
633
+ for (const current of actors.values()) for (const action of current.actions.values()) hot.add(Office.stationKey(action, current.seat));
634
+ if (!ev.ok) spawn("cross", actor.x, actor.y - 26, { life: 1 });
635
+ retarget(actor);
636
+ break;
637
+ }
638
+ case "delegate": {
639
+ cancelLife(actors.get(ev.from));
640
+ cancelLife(actors.get(ev.to));
641
+ if (Office.interactions?.handoff === "seated") {
642
+ const from = actors.get(ev.from);
643
+ const to = actors.get(ev.to);
644
+ const director = [...actors.values()].find((actor) =>
645
+ actor.isNpc && actor.role === Office.interactions.directorNpcRole,
646
+ );
647
+ if (director) bubble(director, "say", t("delegate.owner", { name: to?.name ?? "Teammate" }));
648
+ if (from && to) {
649
+ spawn("paper", from.x + 6, from.y - 14, {
650
+ life: 0.9,
651
+ target: { x: to.x - 6, y: to.y - 14 },
652
+ });
653
+ spawn("bang", to.x, to.y - 30, { life: 1.1, vy: -8 });
654
+ bubble(to, "say", head(ev.task, 60));
655
+ }
656
+ beep(520, 0.07);
657
+ } else {
658
+ meetQueue.push({ from: ev.from, to: ev.to, task: ev.task });
659
+ }
660
+ break;
661
+ }
662
+ case "usage": {
663
+ const actor = actors.get(ev.id);
664
+ if (actor) {
665
+ actor.tokens = ev.tokens;
666
+ actor.cost = ev.cost;
667
+ }
668
+ renderCrew();
669
+ break;
670
+ }
671
+ case "log":
672
+ logLine(ev.item);
673
+ break;
674
+ }
675
+ renderBar();
676
+ }
677
+
678
+ // ------------------------------------------------------- meeting (handoff)
679
+
680
+ /**
681
+ * The ChatDev-style pair seminar: both agents walk to the meeting table,
682
+ * face each other, and the task is physically handed over.
683
+ */
684
+ function updateMeeting(now, dt) {
685
+ if (!meeting && meetQueue.length) {
686
+ const next = meetQueue.shift();
687
+ const from = actors.get(next.from);
688
+ const to = actors.get(next.to);
689
+ if (!from || !to) return;
690
+ meeting = { ...next, phase: "walk", t: 0 };
691
+ from.inMeeting = true;
692
+ to.inMeeting = true;
693
+ goTo(from, Office.TARGETS.meetA);
694
+ goTo(to, Office.TARGETS.meetB);
695
+ bubble(from, "do", t("delegate.assign", { task: head(next.task, 40) }));
696
+ }
697
+ if (!meeting) return;
698
+
699
+ const from = actors.get(meeting.from);
700
+ const to = actors.get(meeting.to);
701
+ if (!from || !to) {
702
+ endMeeting();
703
+ return;
704
+ }
705
+ meeting.t += dt;
706
+
707
+ if (meeting.phase === "walk") {
708
+ const ready = !from.path.length && !to.path.length;
709
+ if (ready || meeting.t > 5) {
710
+ meeting.phase = "hand";
711
+ meeting.t = 0;
712
+ from.pose = "talk";
713
+ to.pose = "talk";
714
+ spawn("paper", from.x + 6, from.y - 14, {
715
+ life: 0.9,
716
+ target: { x: to.x - 6, y: to.y - 14 },
717
+ });
718
+ spawn("bang", to.x, to.y - 30, { life: 1.1, vy: -8 });
719
+ bubble(to, "say", head(meeting.task, 60));
720
+ beep(520, 0.07);
721
+ setTimeout(() => beep(700, 0.07), 90);
722
+ }
723
+ return;
724
+ }
725
+
726
+ if (meeting.phase === "hand" && meeting.t > 1.5) endMeeting();
727
+ }
728
+
729
+ function endMeeting() {
730
+ if (!meeting) return;
731
+ for (const id of [meeting.from, meeting.to]) {
732
+ const actor = actors.get(id);
733
+ if (!actor) continue;
734
+ actor.inMeeting = false;
735
+ retarget(actor);
736
+ }
737
+ meeting = null;
738
+ }
739
+
740
+ // ---------------------------------------------------------------- update
741
+
742
+ function update(dt, now) {
743
+ if (replay) dt *= REPLAY_RATE;
744
+ syncNpcShift(now);
745
+ updateMeeting(now, dt);
746
+
747
+ for (const actor of [...actors.values()]) {
748
+ if (actor.path.length) {
749
+ const next = actor.path[0];
750
+ const dx = next.x - actor.x;
751
+ const dy = next.y - actor.y;
752
+ const dist = Math.abs(dx) + Math.abs(dy);
753
+ const move = SPEED * dt;
754
+ if (dist <= move) {
755
+ actor.x = next.x;
756
+ actor.y = next.y;
757
+ actor.path.shift();
758
+ if (next.zone) actor.zone = next.zone;
759
+ if (!actor.path.length) {
760
+ actor.dir = actor.dest.dir ?? actor.dir;
761
+ actor.lane = actor.dest.lane ?? actor.lane;
762
+ actor.zone = actor.dest.zone ?? actor.zone;
763
+ actor.pose = restPose(actor);
764
+ if (actor.leaving) {
765
+ actors.delete(actor.id);
766
+ renderCrew();
767
+ continue;
768
+ }
769
+ }
770
+ } else if (Math.abs(dx) > 0.01) {
771
+ actor.x += Math.sign(dx) * move;
772
+ actor.dir = dx > 0 ? "right" : "left";
773
+ actor.pose = "walk";
774
+ } else {
775
+ actor.y += Math.sign(dy) * move;
776
+ actor.dir = dy > 0 ? "down" : "up";
777
+ actor.pose = "walk";
778
+ }
779
+ actor.walkPhase += dt;
780
+ } else if (actor.pose !== restPose(actor)) {
781
+ actor.pose = restPose(actor);
782
+ }
783
+
784
+ updateActorLife(actor, now, dt);
785
+
786
+ if (actor.bubble) {
787
+ actor.bubble.shown = Math.min(
788
+ actor.bubble.text.length,
789
+ actor.bubble.shown + dt * 46,
790
+ );
791
+ actor.bubble.remainingMs -= dt * 1000;
792
+ if (actor.bubble.remainingMs <= 0) actor.bubble = null;
793
+ }
794
+
795
+ emitWorkParticles(actor, dt);
796
+ }
797
+
798
+ for (let i = particles.length - 1; i >= 0; i--) {
799
+ const p = particles[i];
800
+ p.t += dt;
801
+ p.life -= dt;
802
+ if (p.target) {
803
+ const k = Math.min(1, p.t / p.maxLife);
804
+ p.x += (p.target.x - p.x) * Math.min(1, k * 0.28);
805
+ p.y += (p.target.y - p.y) * Math.min(1, k * 0.28) - dt * 6;
806
+ } else {
807
+ p.x += p.vx * dt;
808
+ p.y += p.vy * dt;
809
+ }
810
+ if (p.life <= 0) particles.splice(i, 1);
811
+ }
812
+ }
813
+
814
+ function emitWorkParticles(actor, dt) {
815
+ if (actor.path.length || actor.state !== "working") return;
816
+ actor._acc = (actor._acc ?? 0) + dt;
817
+ const rate = actor.action === "server" ? 0.18 : 0.26;
818
+ if (actor._acc < rate) return;
819
+ actor._acc = 0;
820
+ const jitter = (Math.random() - 0.5) * 10;
821
+ switch (actor.action) {
822
+ case "archive":
823
+ spawn("paper", actor.x + jitter, actor.y - 22, { life: 0.8, vy: -18 });
824
+ break;
825
+ case "server":
826
+ spawn("spark", actor.x + jitter, actor.y - 20, { life: 0.6, vy: -22 });
827
+ break;
828
+ case "phone":
829
+ case "coffee":
830
+ break;
831
+ default:
832
+ spawn("key", actor.x + jitter, actor.y - 24, { life: 0.7, vy: -16 });
833
+ }
834
+ }
835
+
836
+ // ---------------------------------------------------------------- render
837
+
838
+ function render(now) {
839
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
840
+ ctx.fillStyle = "#07090d";
841
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
842
+ ctx.setTransform(scale, 0, 0, scale, offX, offY);
843
+
844
+ const occupied = new Set();
845
+ for (const actor of actors.values()) {
846
+ if (actor.seat != null && !actor.path.length && !actor.action && !actor.inMeeting) occupied.add(actor.seat);
847
+ }
848
+ Office.drawRoom(ctx, now, new Set([...hot, ...lifeHot]), occupied);
849
+
850
+ const ordered = [...actors.values()].sort((a, b) => a.y - b.y);
851
+ for (const actor of ordered) Sprites.drawCharacter(ctx, actor, now);
852
+ for (const p of particles) Sprites.drawParticle(ctx, p);
853
+
854
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
855
+ for (const actor of ordered) drawLabel(actor);
856
+ for (const actor of ordered.filter((actor) => actor.bubble).slice(0, window.SceneLimits?.visibleBubbles ?? 4)) {
857
+ drawBubble(actor);
858
+ }
859
+ }
860
+
861
+ function drawLabel(actor) {
862
+ const p = toScreen(actor.x, actor.y - 22);
863
+ const size = Math.max(10 * dpr, Math.round(scale * 3));
864
+ ctx.font = `${size}px ui-monospace, Menlo, monospace`;
865
+ ctx.textAlign = "center";
866
+ ctx.textBaseline = "alphabetic";
867
+ const text = actor.name;
868
+ const w = ctx.measureText(text).width;
869
+ ctx.fillStyle = "rgba(8,11,16,0.72)";
870
+ ctx.fillRect(p.x - w / 2 - 3 * dpr, p.y - size, w + 6 * dpr, size + 4 * dpr);
871
+ ctx.fillStyle = actor.isLead ? "#ffcc4d" : actor.isNpc ? "#9fd6bd" : "#cbd4e2";
872
+ ctx.fillText(text, p.x, p.y);
873
+ }
874
+
875
+ function drawBubble(actor) {
876
+ const b = actor.bubble;
877
+ if (!b) return;
878
+ const style =
879
+ b.kind === "think"
880
+ ? { bg: "rgba(20,30,52,0.94)", border: "#3d5580", fg: "#a9c6ff" }
881
+ : b.kind === "do"
882
+ ? { bg: "rgba(38,30,12,0.94)", border: "#7a6224", fg: "#ffd98a" }
883
+ : b.kind === "life"
884
+ ? { bg: "rgba(16,38,32,0.95)", border: "#38715e", fg: "#b7eadb" }
885
+ : { bg: "rgba(16,22,32,0.95)", border: "#4a5668", fg: "#e6ecf6" };
886
+
887
+ const size = Math.max(11 * dpr, Math.round(scale * 3.2));
888
+ ctx.font = `${size}px ui-monospace, Menlo, "PingFang SC", monospace`;
889
+ ctx.textAlign = "left";
890
+ ctx.textBaseline = "top";
891
+
892
+ const visible = b.text.slice(0, Math.ceil(b.shown));
893
+ const maxW = Math.min(canvas.width * 0.42, 260 * dpr);
894
+ const lines = wrap(visible, maxW);
895
+ const lineH = Math.round(size * 1.35);
896
+ const padX = 6 * dpr;
897
+ const padY = 5 * dpr;
898
+ const boxW = Math.max(...lines.map((l) => ctx.measureText(l).width)) + padX * 2;
899
+ const boxH = lines.length * lineH + padY * 2;
900
+
901
+ const anchor = toScreen(actor.x, actor.y - 30);
902
+ let x = anchor.x - boxW / 2;
903
+ let y = anchor.y - boxH;
904
+ x = Math.max(4 * dpr, Math.min(canvas.width - boxW - 4 * dpr, x));
905
+ y = Math.max(4 * dpr, y);
906
+
907
+ ctx.fillStyle = style.bg;
908
+ ctx.fillRect(x, y, boxW, boxH);
909
+ ctx.strokeStyle = style.border;
910
+ ctx.lineWidth = Math.max(1, dpr);
911
+ ctx.strokeRect(x + 0.5, y + 0.5, boxW - 1, boxH - 1);
912
+
913
+ const tipX = Math.max(x + 6 * dpr, Math.min(x + boxW - 10 * dpr, anchor.x - 3 * dpr));
914
+ ctx.fillStyle = style.bg;
915
+ ctx.fillRect(tipX, y + boxH, 6 * dpr, 4 * dpr);
916
+ if (b.kind === "think") {
917
+ ctx.fillRect(tipX + 1 * dpr, y + boxH + 6 * dpr, 3 * dpr, 3 * dpr);
918
+ }
919
+
920
+ ctx.fillStyle = style.fg;
921
+ lines.forEach((line, i) => ctx.fillText(line, x + padX, y + padY + i * lineH));
922
+ }
923
+
924
+ function wrap(text, maxW) {
925
+ const out = [];
926
+ let line = "";
927
+ for (const ch of text) {
928
+ const test = line + ch;
929
+ if (ctx.measureText(test).width > maxW && line) {
930
+ out.push(line);
931
+ line = ch;
932
+ } else {
933
+ line = test;
934
+ }
935
+ if (out.length >= 3) break;
936
+ }
937
+ if (line && out.length < 3) out.push(line);
938
+ return out.length ? out : [""];
939
+ }
940
+
941
+ // ---------------------------------------------------------------- sidebar
942
+
943
+ const stateLabel = (state) => t(`state.${state}`);
944
+
945
+ function renderCrew() {
946
+ const host = document.getElementById("crew");
947
+ const list = [...actors.values()].filter((actor) => !actor.isNpc).sort(
948
+ (a, b) => Number(b.isLead) - Number(a.isLead) || a.spawnAt - b.spawnAt,
949
+ );
950
+ host.innerHTML = list
951
+ .map((a) => {
952
+ const role = Sprites.roleFor(a.name);
953
+ return `<div class="card">
954
+ <div class="avatar" style="background:${a.palette.shirt}"></div>
955
+ <div class="body">
956
+ <div><span class="name">${esc(a.name)}</span>
957
+ <span class="role">${esc(tx(role ? role.title : a.role ?? ""))}</span>
958
+ <span class="badge ${a.state}">${stateLabel(a.state)}</span></div>
959
+ <div class="detail">${esc(a.detail || a.task || "—")}</div>
960
+ <div class="meta">${hideCost ? `${fmtTokens(a.tokens)} tok` : `${fmtTokens(a.tokens)} tok · $${(a.cost ?? 0).toFixed(4)}`}</div>
961
+ </div>
962
+ </div>`;
963
+ })
964
+ .join("");
965
+ const lead = list.find((actor) => actor.isLead);
966
+ if (!replayButton) return;
967
+ replayButton.hidden = !lead;
968
+ const hasActivity = eventHistory.some(isReplayable) || (session.turns ?? 0) > 0;
969
+ if (lead) replayButton.textContent = replay
970
+ ? t("replay.playing")
971
+ : hasActivity ? `▶ ${t("replay.day", { name: lead.name })}` : t("replay.noActivity");
972
+ replayButton.disabled = !lead || session.busy || Boolean(replay) || !hasActivity;
973
+ }
974
+
975
+ function logLine(item) {
976
+ const host = document.getElementById("log");
977
+ const actor = actors.get(item.agentId);
978
+ const el = document.createElement("div");
979
+ el.className = `line ${item.kind}`;
980
+ el.innerHTML = `<span class="who">${esc(actor?.name ?? "·")}</span><span class="txt">${esc(tx(item.text))}</span>`;
981
+ host.appendChild(el);
982
+ while (host.childNodes.length > 220) host.removeChild(host.firstChild);
983
+ host.scrollTop = host.scrollHeight;
984
+ }
985
+
986
+ function renderBar() {
987
+ let tokens = 0;
988
+ let cost = 0;
989
+ for (const a of actors.values()) {
990
+ if (a.isNpc) continue;
991
+ tokens += a.tokens ?? 0;
992
+ cost += a.cost ?? 0;
993
+ }
994
+ const effort = session.thinkingLevel ? ` · ${session.thinkingLevel}` : "";
995
+ document.getElementById("model").textContent = `model: ${session.model ?? "—"}${effort}`;
996
+ document.getElementById("turns").textContent = `turn ${session.turns ?? 0}${session.busy ? ` · ${t("status.running")}` : ""}`;
997
+ document.getElementById("usage").textContent = hideCost ? `${fmtTokens(tokens)} tok` : `${fmtTokens(tokens)} tok · $${cost.toFixed(4)}`;
998
+ }
999
+
1000
+ function setTask(text) {
1001
+ document.getElementById("task").textContent = text || t("task.empty");
1002
+ }
1003
+
1004
+ // ---------------------------------------------------------------- helpers
1005
+
1006
+ const esc = (s) =>
1007
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1008
+ const head = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
1009
+ const tail = (s, n) => (s.length > n ? `…${s.slice(-n)}` : s);
1010
+ const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n | 0));
1011
+
1012
+ let audio = null;
1013
+ function beep(freq, dur) {
1014
+ if (!sound) return;
1015
+ try {
1016
+ audio = audio ?? new (window.AudioContext ?? window.webkitAudioContext)();
1017
+ const osc = audio.createOscillator();
1018
+ const gain = audio.createGain();
1019
+ osc.type = "square";
1020
+ osc.frequency.value = freq;
1021
+ gain.gain.value = 0.03;
1022
+ osc.connect(gain).connect(audio.destination);
1023
+ osc.start();
1024
+ osc.stop(audio.currentTime + dur);
1025
+ } catch {
1026
+ sound = false;
1027
+ }
1028
+ }
1029
+
1030
+ // --------------------------------------------------------------- replay
1031
+
1032
+ function isPriorityLiveEvent(event) {
1033
+ if (event.type === "session") return Boolean(event.session?.busy);
1034
+ return ["task", "thought", "say", "action", "delegate", "agent_join", "agent_leave"].includes(event.type) ||
1035
+ (event.type === "agent_state" && !["idle", "done"].includes(event.state));
1036
+ }
1037
+
1038
+ function waitForReplay(ms, run) {
1039
+ return new Promise((resolve) => {
1040
+ run.resolveWait = resolve;
1041
+ run.timer = setTimeout(resolve, Math.min(3_000, Math.max(220, ms)));
1042
+ });
1043
+ }
1044
+
1045
+ async function restoreLiveSnapshot() {
1046
+ if (typeof window.AgentLiveGetSnapshot === "function") {
1047
+ const latest = await window.AgentLiveGetSnapshot();
1048
+ if (latest) {
1049
+ apply(latest);
1050
+ }
1051
+ return;
1052
+ }
1053
+ try {
1054
+ const response = await authenticatedFetch("/api/state");
1055
+ if (response.ok) apply(await response.json());
1056
+ } catch {
1057
+ // The live SSE stream will deliver the next authoritative event.
1058
+ }
1059
+ }
1060
+
1061
+ function cancelReplay(restore = false) {
1062
+ const run = replay;
1063
+ if (!run) return;
1064
+ run.cancelled = true;
1065
+ if (run.timer) clearTimeout(run.timer);
1066
+ run.resolveWait?.();
1067
+ replay = null;
1068
+ if (replayStatus) replayStatus.hidden = true;
1069
+ renderCrew();
1070
+ if (restore) void restoreLiveSnapshot();
1071
+ }
1072
+
1073
+ function finishReplay() {
1074
+ for (const actor of actors.values()) {
1075
+ if (actor.isNpc) continue;
1076
+ cancelLife(actor);
1077
+ actor.action = null;
1078
+ actor.state = "idle";
1079
+ actor.detail = t("state.idle");
1080
+ retarget(actor);
1081
+ }
1082
+ session = { ...session, busy: false };
1083
+ replay = null;
1084
+ if (replayStatus) {
1085
+ replayStatus.textContent = t("replay.complete");
1086
+ replayStatus.hidden = false;
1087
+ setTimeout(() => { if (!replay) replayStatus.hidden = true; }, 1400);
1088
+ }
1089
+ renderCrew();
1090
+ renderBar();
1091
+ }
1092
+
1093
+ async function runHistoryReplay() {
1094
+ if (replay || session.busy) return;
1095
+ const latest = typeof window.AgentLiveGetSnapshot === "function"
1096
+ ? await window.AgentLiveGetSnapshot()
1097
+ : await authenticatedFetch("/api/state").then((response) => response.ok ? response.json() : null);
1098
+ if (!latest) return;
1099
+ const history = Array.isArray(latest.history) ? latest.history.slice() : [];
1100
+ if (!history.some(isReplayable)) {
1101
+ if (replayButton) replayButton.textContent = t("replay.empty");
1102
+ return;
1103
+ }
1104
+ const run = { cancelled: false, timer: null, resolveWait: null };
1105
+ replay = run;
1106
+ const replayLeads = (latest.agents ?? [])
1107
+ .filter((agent) => !agent.parent)
1108
+ .map((agent) => ({ ...agent, state: "idle", action: undefined, detail: t("state.idle") }));
1109
+ apply({
1110
+ type: "snapshot",
1111
+ agents: replayLeads,
1112
+ log: [],
1113
+ session: { ...latest.session, busy: false, turns: 0 },
1114
+ history,
1115
+ });
1116
+ renderCrew();
1117
+ let previousAt = history[0].at;
1118
+ for (let index = 0; index < history.length; index++) {
1119
+ const entry = history[index];
1120
+ if (run.cancelled) return;
1121
+ await waitForReplay((entry.at - previousAt) / REPLAY_RATE, run);
1122
+ if (run.cancelled) return;
1123
+ if (replayStatus) {
1124
+ replayStatus.textContent = t("replay.progress", { current: index + 1, total: history.length });
1125
+ replayStatus.hidden = false;
1126
+ }
1127
+ apply(entry.event);
1128
+ previousAt = entry.at;
1129
+ }
1130
+ if (!run.cancelled) finishReplay();
1131
+ }
1132
+
1133
+ // ---------------------------------------------------------------- stream
1134
+
1135
+ function connect() {
1136
+ const pill = document.getElementById("conn");
1137
+ if (typeof window.AgentLiveSubscribe === "function") {
1138
+ pill.textContent = t("connection.connected");
1139
+ pill.className = "pill online";
1140
+ window.AgentLiveSubscribe((event) => {
1141
+ if (event.type !== "snapshot") recordEvent(event);
1142
+ if (replay) {
1143
+ if (isPriorityLiveEvent(event)) cancelReplay(true);
1144
+ return;
1145
+ }
1146
+ apply(event);
1147
+ });
1148
+ return;
1149
+ }
1150
+ const es = new EventSource(accessToken ? `/events?token=${encodeURIComponent(accessToken)}` : "/events");
1151
+ es.onopen = () => {
1152
+ pill.textContent = t("connection.connected");
1153
+ pill.className = "pill online";
1154
+ };
1155
+ es.onerror = () => {
1156
+ pill.textContent = t("connection.reconnecting");
1157
+ pill.className = "pill offline";
1158
+ };
1159
+ es.onmessage = (e) => {
1160
+ try {
1161
+ const event = JSON.parse(e.data);
1162
+ if (event.type !== "snapshot") recordEvent(event);
1163
+ if (replay) {
1164
+ if (isPriorityLiveEvent(event)) cancelReplay(true);
1165
+ return;
1166
+ }
1167
+ apply(event);
1168
+ } catch (err) {
1169
+ console.error("bad event", err);
1170
+ }
1171
+ };
1172
+ }
1173
+
1174
+ // ---------------------------------------------------------------- demo
1175
+
1176
+ /** Local scripted scene, so the visuals can be reviewed without spending tokens. */
1177
+ async function runDemo() {
1178
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
1179
+ const now = () => Date.now();
1180
+ const log = (agentId, kind, text) => apply({ type: "log", item: { at: now(), agentId, kind, text } });
1181
+ Office.startDayPreview?.(24000);
1182
+
1183
+ apply({
1184
+ type: "snapshot",
1185
+ agents: [
1186
+ {
1187
+ id: "main",
1188
+ name: "啊派",
1189
+ role: "主管",
1190
+ state: "idle",
1191
+ tokens: 0,
1192
+ cost: 0,
1193
+ toolCalls: 0,
1194
+ joinedAt: now(),
1195
+ seat: 0,
1196
+ },
1197
+ ],
1198
+ log: [],
1199
+ session: { cwd: "demo", model: "demo-model", busy: true, turns: 1, startedAt: now() },
1200
+ });
1201
+ apply({ type: "task", id: "main", task: "给会话存储加上 Redis 缓存" });
1202
+ apply({ type: "agent_state", id: "main", state: "thinking", detail: "拆解需求" });
1203
+ for (const t of [
1204
+ "先看看 session store 现在怎么写的",
1205
+ "缓存要能失效,不能只做读缓存",
1206
+ "可以并行:一个人摸代码,一个人出方案",
1207
+ ]) {
1208
+ apply({ type: "thought", id: "main", text: t });
1209
+ await wait(1500);
1210
+ }
1211
+
1212
+ apply({ type: "action", id: "main", action: "archive", label: "查阅 …/core/session-manager.ts", toolCallId: "d1" });
1213
+ await wait(2600);
1214
+ apply({ type: "action_end", id: "main", toolCallId: "d1", ok: true });
1215
+
1216
+ for (const [id, name, seat, task] of [
1217
+ ["scout", "scout", 1, "定位 session store 的读写入口"],
1218
+ ["planner", "planner", 2, "基于侦察结果给出 Redis 缓存方案"],
1219
+ ]) {
1220
+ apply({
1221
+ type: "agent_join",
1222
+ agent: { id, name, role: "外援", parent: "main", state: "idle", tokens: 0, cost: 0, toolCalls: 0, joinedAt: now(), seat },
1223
+ });
1224
+ apply({ type: "delegate", from: "main", to: id, task });
1225
+ log("main", "delegate", `啊派 → ${name}: ${task}`);
1226
+ await wait(4200);
1227
+ }
1228
+ apply({ type: "agent_state", id: "main", state: "waiting", detail: "等待 2 位同事" });
1229
+
1230
+ apply({ type: "action", id: "scout", action: "server", label: "执行 rg -n 'getSession'", toolCallId: "d2" });
1231
+ apply({ type: "agent_state", id: "planner", state: "thinking", detail: "对比方案" });
1232
+ apply({ type: "thought", id: "planner", text: "写穿 vs 旁路缓存,先看一致性要求" });
1233
+ await wait(2600);
1234
+ apply({ type: "action_end", id: "scout", toolCallId: "d2", ok: true });
1235
+ apply({ type: "action", id: "scout", action: "archive", label: "查阅 …/store/redis.ts", toolCallId: "d3" });
1236
+ await wait(2400);
1237
+ apply({ type: "action_end", id: "scout", toolCallId: "d3", ok: true });
1238
+ apply({ type: "say", id: "scout", text: "读写都走 SessionManager.load/save,缓存挂这里最省事" });
1239
+ apply({ type: "usage", id: "scout", tokens: 8200, cost: 0.0031 });
1240
+ await wait(1800);
1241
+ apply({ type: "agent_state", id: "scout", state: "done", detail: "已交付" });
1242
+
1243
+ apply({ type: "action", id: "planner", action: "whiteboard", label: "更新任务板", toolCallId: "d4" });
1244
+ await wait(2600);
1245
+ apply({ type: "action_end", id: "planner", toolCallId: "d4", ok: true });
1246
+ apply({ type: "say", id: "planner", text: "方案:旁路缓存 + 写时失效,30s TTL 兜底" });
1247
+ apply({ type: "usage", id: "planner", tokens: 12400, cost: 0.0088 });
1248
+ apply({ type: "agent_state", id: "planner", state: "done", detail: "已交付" });
1249
+ await wait(1400);
1250
+
1251
+ apply({ type: "action", id: "main", action: "type", label: "修改 …/core/session-manager.ts", toolCallId: "d5" });
1252
+ await wait(3200);
1253
+ apply({ type: "action_end", id: "main", toolCallId: "d5", ok: true });
1254
+ apply({ type: "action", id: "main", action: "server", label: "执行 npm test -- session", toolCallId: "d6" });
1255
+ await wait(3000);
1256
+ apply({ type: "action_end", id: "main", toolCallId: "d6", ok: true });
1257
+ apply({ type: "usage", id: "main", tokens: 46200, cost: 0.184 });
1258
+ apply({ type: "say", id: "main", text: "缓存接好了,测试通过。要不要我再补一个压测脚本?" });
1259
+ await wait(1500);
1260
+ apply({ type: "agent_state", id: "main", state: "idle", detail: "待命" });
1261
+ apply({ type: "session", session: { cwd: "demo", model: "demo-model", busy: false, turns: 3, startedAt: now() } });
1262
+ await wait(20000);
1263
+ apply({ type: "agent_leave", id: "scout", ok: true });
1264
+ apply({ type: "agent_leave", id: "planner", ok: true });
1265
+ }
1266
+
1267
+ // ---------------------------------------------------------------- boot
1268
+
1269
+ document.getElementById("demo").addEventListener("click", () => void runDemo());
1270
+ replayButton?.addEventListener("click", () => void runHistoryReplay());
1271
+ // The sound toggle owns its label from state. A static translation would go
1272
+ // stale as soon as the frame switches language, or lie about a live setting.
1273
+ const muteButton = document.getElementById("mute");
1274
+ const renderSoundLabel = () => {
1275
+ if (!muteButton) return;
1276
+ muteButton.textContent = window.AgentLiveI18n
1277
+ ? t(sound ? "nav.soundOn" : "nav.soundOff")
1278
+ : sound ? "音效: 开" : "音效: 关";
1279
+ };
1280
+ muteButton?.addEventListener("click", () => {
1281
+ sound = !sound;
1282
+ renderSoundLabel();
1283
+ });
1284
+ window.addEventListener("agent-live:locale", renderSoundLabel);
1285
+ renderSoundLabel();
1286
+
1287
+ resize();
1288
+ renderBar();
1289
+ function frame(now) {
1290
+ // Schedule first: one malformed transient state must never permanently
1291
+ // kill the shared renderer. A dead RAF looks frozen until the host repaints
1292
+ // its tab, at which point the discarded canvas appears as a black screen.
1293
+ requestAnimationFrame(frame);
1294
+ const dt = Math.min(0.05, Math.max(0, (now - lastFrameAt) / 1000));
1295
+ lastFrameAt = now;
1296
+ if (contextLost || document.hidden) return;
1297
+ try {
1298
+ update(dt, now);
1299
+ render(now);
1300
+ lastFrameError = null;
1301
+ } catch (error) {
1302
+ const signature = String(error?.stack ?? error);
1303
+ if (signature !== lastFrameError) {
1304
+ lastFrameError = signature;
1305
+ console.error("Agent Live frame failed; the renderer will recover on the next frame.", error);
1306
+ }
1307
+ }
1308
+ }
1309
+ requestAnimationFrame(frame);
1310
+
1311
+ if (new URLSearchParams(location.search).has("demo")) {
1312
+ const pill = document.getElementById("conn");
1313
+ pill.textContent = t("nav.demo");
1314
+ pill.className = "pill online";
1315
+ void runDemo();
1316
+ } else connect();
1317
+ })();