bopodev-api 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -10,14 +10,15 @@
10
10
  "dependencies": {
11
11
  "cors": "^2.8.5",
12
12
  "cron-parser": "^5.3.1",
13
+ "dotenv": "^17.0.1",
13
14
  "drizzle-orm": "^0.44.5",
14
15
  "express": "^5.1.0",
15
16
  "nanoid": "^5.1.5",
16
17
  "ws": "^8.19.0",
17
18
  "zod": "^4.1.5",
18
- "bopodev-agent-sdk": "0.1.7",
19
- "bopodev-db": "0.1.7",
20
- "bopodev-contracts": "0.1.7"
19
+ "bopodev-agent-sdk": "0.1.9",
20
+ "bopodev-contracts": "0.1.9",
21
+ "bopodev-db": "0.1.9"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@types/cors": "^2.8.19",
@@ -29,6 +30,8 @@
29
30
  "dev": "PORT=${API_PORT:-4020} tsx watch src/server.ts",
30
31
  "start": "PORT=${API_PORT:-4020} tsx src/server.ts",
31
32
  "db:init": "tsx src/scripts/db-init.ts",
33
+ "onboard:seed": "tsx src/scripts/onboard-seed.ts",
34
+ "workspaces:backfill": "tsx src/scripts/backfill-project-workspaces.ts",
32
35
  "build": "tsc -p tsconfig.json",
33
36
  "lint": "tsc -p tsconfig.json --noEmit",
34
37
  "typecheck": "tsc -p tsconfig.json --noEmit"
@@ -0,0 +1,255 @@
1
+ import { AgentRuntimeConfigSchema, type AgentRuntimeConfig, type ThinkingEffort } from "bopodev-contracts";
2
+
3
+ export type LegacyRuntimeFields = {
4
+ runtimeCommand?: string;
5
+ runtimeArgs?: string[];
6
+ runtimeCwd?: string;
7
+ runtimeTimeoutMs?: number;
8
+ runtimeModel?: string;
9
+ runtimeThinkingEffort?: ThinkingEffort;
10
+ bootstrapPrompt?: string;
11
+ runtimeTimeoutSec?: number;
12
+ interruptGraceSec?: number;
13
+ runPolicy?: {
14
+ sandboxMode?: "workspace_write" | "full_access";
15
+ allowWebSearch?: boolean;
16
+ };
17
+ runtimeEnv?: Record<string, string>;
18
+ };
19
+
20
+ export type NormalizedRuntimeConfig = {
21
+ runtimeCommand?: string;
22
+ runtimeArgs: string[];
23
+ runtimeCwd?: string;
24
+ runtimeEnv: Record<string, string>;
25
+ runtimeModel?: string;
26
+ runtimeThinkingEffort: ThinkingEffort;
27
+ bootstrapPrompt?: string;
28
+ runtimeTimeoutSec: number;
29
+ interruptGraceSec: number;
30
+ runPolicy: {
31
+ sandboxMode: "workspace_write" | "full_access";
32
+ allowWebSearch: boolean;
33
+ };
34
+ };
35
+
36
+ export function requiresRuntimeCwd(providerType: string) {
37
+ return providerType === "codex" || providerType === "claude_code" || providerType === "shell";
38
+ }
39
+
40
+ export function normalizeRuntimeConfig(input: {
41
+ runtimeConfig?: Partial<AgentRuntimeConfig>;
42
+ legacy?: LegacyRuntimeFields;
43
+ defaultRuntimeCwd?: string;
44
+ }): NormalizedRuntimeConfig {
45
+ const runtimeConfig = input.runtimeConfig ?? {};
46
+ const legacy = input.legacy ?? {};
47
+ const merged: Record<string, unknown> = { ...runtimeConfig };
48
+
49
+ if (legacy.runtimeCommand !== undefined) {
50
+ merged.runtimeCommand = legacy.runtimeCommand;
51
+ }
52
+ if (legacy.runtimeArgs !== undefined) {
53
+ merged.runtimeArgs = legacy.runtimeArgs;
54
+ }
55
+ if (legacy.runtimeCwd !== undefined) {
56
+ merged.runtimeCwd = legacy.runtimeCwd;
57
+ }
58
+ if (legacy.runtimeModel !== undefined) {
59
+ merged.runtimeModel = legacy.runtimeModel;
60
+ }
61
+ if (legacy.runtimeThinkingEffort !== undefined) {
62
+ merged.runtimeThinkingEffort = legacy.runtimeThinkingEffort;
63
+ }
64
+ if (legacy.bootstrapPrompt !== undefined) {
65
+ merged.bootstrapPrompt = legacy.bootstrapPrompt;
66
+ }
67
+ if (legacy.interruptGraceSec !== undefined) {
68
+ merged.interruptGraceSec = legacy.interruptGraceSec;
69
+ }
70
+ if (legacy.runtimeEnv !== undefined) {
71
+ merged.runtimeEnv = legacy.runtimeEnv;
72
+ }
73
+ if (legacy.runPolicy !== undefined) {
74
+ merged.runPolicy = legacy.runPolicy;
75
+ }
76
+
77
+ const parsed = AgentRuntimeConfigSchema.partial().parse({
78
+ ...merged,
79
+ runtimeTimeoutSec:
80
+ runtimeConfig.runtimeTimeoutSec ??
81
+ legacy.runtimeTimeoutSec ??
82
+ toSeconds(legacy.runtimeTimeoutMs) ??
83
+ undefined
84
+ });
85
+ return {
86
+ runtimeCommand: parsed.runtimeCommand?.trim() || undefined,
87
+ runtimeArgs: parsed.runtimeArgs ?? [],
88
+ runtimeCwd: parsed.runtimeCwd?.trim() || input.defaultRuntimeCwd || undefined,
89
+ runtimeEnv: parsed.runtimeEnv ?? {},
90
+ runtimeModel: parsed.runtimeModel?.trim() || undefined,
91
+ runtimeThinkingEffort: parsed.runtimeThinkingEffort ?? "auto",
92
+ bootstrapPrompt: parsed.bootstrapPrompt?.trim() || undefined,
93
+ runtimeTimeoutSec: Math.max(0, parsed.runtimeTimeoutSec ?? 0),
94
+ interruptGraceSec: Math.max(0, parsed.interruptGraceSec ?? 15),
95
+ runPolicy: {
96
+ sandboxMode: parsed.runPolicy?.sandboxMode ?? "workspace_write",
97
+ allowWebSearch: parsed.runPolicy?.allowWebSearch ?? false
98
+ }
99
+ };
100
+ }
101
+
102
+ export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>): NormalizedRuntimeConfig {
103
+ const fallback = parseRuntimeFromStateBlob(agent.stateBlob);
104
+ const runtimeArgs = parseStringArray(agent.runtimeArgsJson) ?? fallback.args ?? [];
105
+ const runtimeEnv = parseStringRecord(agent.runtimeEnvJson) ?? fallback.env ?? {};
106
+ const runPolicy = parseRunPolicy(agent.runPolicyJson);
107
+ const timeoutSecFromColumn = toNumber(agent.runtimeTimeoutSec);
108
+ const timeoutSec =
109
+ timeoutSecFromColumn && timeoutSecFromColumn > 0
110
+ ? timeoutSecFromColumn
111
+ : (toSeconds(fallback.timeoutMs) ?? 0);
112
+
113
+ return {
114
+ runtimeCommand: toText(agent.runtimeCommand) ?? fallback.command,
115
+ runtimeArgs,
116
+ runtimeCwd: toText(agent.runtimeCwd) ?? fallback.cwd,
117
+ runtimeEnv,
118
+ runtimeModel: toText(agent.runtimeModel),
119
+ runtimeThinkingEffort: parseThinkingEffort(agent.runtimeThinkingEffort),
120
+ bootstrapPrompt: toText(agent.bootstrapPrompt),
121
+ runtimeTimeoutSec: Math.max(0, timeoutSec),
122
+ interruptGraceSec: Math.max(0, toNumber(agent.interruptGraceSec) ?? 15),
123
+ runPolicy
124
+ };
125
+ }
126
+
127
+ export function runtimeConfigToDb(runtime: NormalizedRuntimeConfig) {
128
+ return {
129
+ runtimeCommand: runtime.runtimeCommand ?? null,
130
+ runtimeArgsJson: JSON.stringify(runtime.runtimeArgs),
131
+ runtimeCwd: runtime.runtimeCwd ?? null,
132
+ runtimeEnvJson: JSON.stringify(runtime.runtimeEnv),
133
+ runtimeModel: runtime.runtimeModel ?? null,
134
+ runtimeThinkingEffort: runtime.runtimeThinkingEffort,
135
+ bootstrapPrompt: runtime.bootstrapPrompt ?? null,
136
+ runtimeTimeoutSec: runtime.runtimeTimeoutSec,
137
+ interruptGraceSec: runtime.interruptGraceSec,
138
+ runPolicyJson: JSON.stringify(runtime.runPolicy)
139
+ };
140
+ }
141
+
142
+ export function runtimeConfigToStateBlobPatch(runtime: NormalizedRuntimeConfig) {
143
+ return {
144
+ runtime: {
145
+ command: runtime.runtimeCommand,
146
+ args: runtime.runtimeArgs,
147
+ cwd: runtime.runtimeCwd,
148
+ env: runtime.runtimeEnv,
149
+ timeoutMs: runtime.runtimeTimeoutSec > 0 ? runtime.runtimeTimeoutSec * 1000 : undefined
150
+ },
151
+ promptTemplate: runtime.bootstrapPrompt
152
+ };
153
+ }
154
+
155
+ function parseRuntimeFromStateBlob(raw: unknown) {
156
+ if (typeof raw !== "string" || !raw.trim()) {
157
+ return {} as {
158
+ command?: string;
159
+ args?: string[];
160
+ cwd?: string;
161
+ env?: Record<string, string>;
162
+ timeoutMs?: number;
163
+ };
164
+ }
165
+ try {
166
+ const parsed = JSON.parse(raw) as {
167
+ runtime?: {
168
+ command?: unknown;
169
+ args?: unknown;
170
+ cwd?: unknown;
171
+ env?: unknown;
172
+ timeoutMs?: unknown;
173
+ };
174
+ };
175
+ const runtime = parsed.runtime ?? {};
176
+ return {
177
+ command: typeof runtime.command === "string" ? runtime.command : undefined,
178
+ args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined,
179
+ cwd: typeof runtime.cwd === "string" ? runtime.cwd : undefined,
180
+ env: toRecord(runtime.env),
181
+ timeoutMs: toNumber(runtime.timeoutMs)
182
+ };
183
+ } catch {
184
+ return {};
185
+ }
186
+ }
187
+
188
+ function parseStringArray(raw: unknown) {
189
+ if (typeof raw !== "string") {
190
+ return null;
191
+ }
192
+ try {
193
+ const parsed = JSON.parse(raw) as unknown;
194
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function parseStringRecord(raw: unknown) {
201
+ if (typeof raw !== "string") {
202
+ return null;
203
+ }
204
+ try {
205
+ return toRecord(JSON.parse(raw));
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ function parseRunPolicy(raw: unknown) {
212
+ const parsed = parseStringRecord(raw) as { sandboxMode?: unknown; allowWebSearch?: unknown } | null;
213
+ return {
214
+ sandboxMode: parsed?.sandboxMode === "full_access" ? "full_access" : "workspace_write",
215
+ allowWebSearch: Boolean(parsed?.allowWebSearch)
216
+ } as const;
217
+ }
218
+
219
+ function parseThinkingEffort(raw: unknown): ThinkingEffort {
220
+ return raw === "low" || raw === "medium" || raw === "high" ? raw : "auto";
221
+ }
222
+
223
+ function toRecord(value: unknown) {
224
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
225
+ return undefined;
226
+ }
227
+ return Object.fromEntries(
228
+ Object.entries(value).filter(([, item]) => typeof item === "string")
229
+ ) as Record<string, string>;
230
+ }
231
+
232
+ function toText(value: unknown) {
233
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
234
+ }
235
+
236
+ function toNumber(value: unknown) {
237
+ if (typeof value === "number" && Number.isFinite(value)) {
238
+ return value;
239
+ }
240
+ if (typeof value === "string") {
241
+ const parsed = Number(value);
242
+ if (Number.isFinite(parsed)) {
243
+ return parsed;
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ function toSeconds(milliseconds: unknown) {
250
+ const parsedMs = toNumber(milliseconds);
251
+ if (parsedMs === undefined) {
252
+ return undefined;
253
+ }
254
+ return Math.max(0, Math.floor(parsedMs / 1000));
255
+ }
@@ -0,0 +1,88 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+
4
+ const DEFAULT_INSTANCE_ID = "default";
5
+ const SAFE_PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ function expandHomePrefix(value: string) {
8
+ if (value === "~") {
9
+ return homedir();
10
+ }
11
+ if (value.startsWith("~/")) {
12
+ return resolve(homedir(), value.slice(2));
13
+ }
14
+ return value;
15
+ }
16
+
17
+ function normalizePath(raw: string) {
18
+ return resolve(expandHomePrefix(raw.trim()));
19
+ }
20
+
21
+ export function resolveBopoHomeDir() {
22
+ const configured = process.env.BOPO_HOME?.trim();
23
+ if (configured) {
24
+ return normalizePath(configured);
25
+ }
26
+ return resolve(homedir(), ".bopodev");
27
+ }
28
+
29
+ export function resolveBopoInstanceId() {
30
+ const configured = process.env.BOPO_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
31
+ if (!SAFE_PATH_SEGMENT_RE.test(configured)) {
32
+ throw new Error(`Invalid BOPO_INSTANCE_ID '${configured}'.`);
33
+ }
34
+ return configured;
35
+ }
36
+
37
+ export function resolveBopoInstanceRoot() {
38
+ const configuredRoot = process.env.BOPO_INSTANCE_ROOT?.trim();
39
+ if (configuredRoot) {
40
+ return normalizePath(configuredRoot);
41
+ }
42
+ if (process.env.NODE_ENV === "test") {
43
+ return join(tmpdir(), "bopodev-instances", resolveBopoInstanceId());
44
+ }
45
+ return join(resolveBopoHomeDir(), "instances", resolveBopoInstanceId());
46
+ }
47
+
48
+ export function resolveProjectWorkspacePath(companyId: string, projectId: string) {
49
+ assertPathSegment(companyId, "companyId");
50
+ assertPathSegment(projectId, "projectId");
51
+ return join(resolveBopoInstanceRoot(), "workspaces", companyId, "projects", projectId);
52
+ }
53
+
54
+ export function resolveCompanyProjectsWorkspacePath(companyId: string) {
55
+ assertPathSegment(companyId, "companyId");
56
+ return join(resolveBopoInstanceRoot(), "workspaces", companyId);
57
+ }
58
+
59
+ export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: string) {
60
+ assertPathSegment(companyId, "companyId");
61
+ assertPathSegment(agentId, "agentId");
62
+ return join(resolveBopoInstanceRoot(), "workspaces", companyId, "agents", agentId);
63
+ }
64
+
65
+ export function resolveStorageRoot() {
66
+ return join(resolveBopoInstanceRoot(), "data", "storage");
67
+ }
68
+
69
+ export function normalizeAbsolutePath(value: string) {
70
+ return normalizePath(value);
71
+ }
72
+
73
+ export function isInsidePath(parent: string, child: string) {
74
+ const parentResolved = resolve(parent);
75
+ const childResolved = resolve(child);
76
+ if (!isAbsolute(parentResolved) || !isAbsolute(childResolved)) {
77
+ return false;
78
+ }
79
+ const rel = relative(parentResolved, childResolved);
80
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
81
+ }
82
+
83
+ function assertPathSegment(value: string, label: string) {
84
+ const trimmed = value.trim();
85
+ if (!SAFE_PATH_SEGMENT_RE.test(trimmed)) {
86
+ throw new Error(`Invalid ${label} for workspace path '${value}'.`);
87
+ }
88
+ }
@@ -0,0 +1,75 @@
1
+ import { and, eq, inArray } from "drizzle-orm";
2
+ import type { BopoDb } from "bopodev-db";
3
+ import { projects } from "bopodev-db";
4
+ import {
5
+ isInsidePath,
6
+ normalizeAbsolutePath,
7
+ resolveAgentFallbackWorkspacePath,
8
+ resolveCompanyProjectsWorkspacePath
9
+ } from "./instance-paths";
10
+
11
+ export function hasText(value: string | null | undefined) {
12
+ return Boolean(value && value.trim().length > 0);
13
+ }
14
+
15
+ export function parseRuntimeCwd(stateBlob: string | null | undefined) {
16
+ if (!stateBlob) {
17
+ return null;
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(stateBlob) as { runtime?: { cwd?: unknown } };
21
+ const cwd = parsed.runtime?.cwd;
22
+ return typeof cwd === "string" ? cwd : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function inferSingleWorkspaceLocalPath(db: BopoDb, companyId: string) {
29
+ const rows = await db
30
+ .select({ workspaceLocalPath: projects.workspaceLocalPath })
31
+ .from(projects)
32
+ .where(eq(projects.companyId, companyId));
33
+ const paths = Array.from(
34
+ new Set(
35
+ rows
36
+ .map((row) => row.workspaceLocalPath?.trim() ?? "")
37
+ .filter((value) => value.length > 0)
38
+ )
39
+ );
40
+ const singlePath = paths.length === 1 ? paths[0] : null;
41
+ return singlePath ? normalizeAbsolutePath(singlePath) : null;
42
+ }
43
+
44
+ export async function resolveDefaultRuntimeCwdForCompany(db: BopoDb, companyId: string) {
45
+ const inferredSingleWorkspace = await inferSingleWorkspaceLocalPath(db, companyId);
46
+ if (inferredSingleWorkspace) {
47
+ return inferredSingleWorkspace;
48
+ }
49
+ return resolveCompanyProjectsWorkspacePath(companyId);
50
+ }
51
+
52
+ export async function getProjectWorkspaceMap(db: BopoDb, companyId: string, projectIds: string[]) {
53
+ if (projectIds.length === 0) {
54
+ return new Map<string, string | null>();
55
+ }
56
+ const rows = await db
57
+ .select({ id: projects.id, workspaceLocalPath: projects.workspaceLocalPath })
58
+ .from(projects)
59
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
60
+ return new Map(rows.map((row) => [row.id, row.workspaceLocalPath ? normalizeAbsolutePath(row.workspaceLocalPath) : null]));
61
+ }
62
+
63
+ export function ensureRuntimeInsideWorkspace(projectWorkspacePath: string, runtimeCwd: string) {
64
+ return isInsidePath(normalizeAbsolutePath(projectWorkspacePath), normalizeAbsolutePath(runtimeCwd));
65
+ }
66
+
67
+ export function ensureRuntimeWorkspaceCompatible(projectWorkspacePath: string, runtimeCwd: string) {
68
+ const projectPath = normalizeAbsolutePath(projectWorkspacePath);
69
+ const runtimePath = normalizeAbsolutePath(runtimeCwd);
70
+ return isInsidePath(projectPath, runtimePath) || isInsidePath(runtimePath, projectPath);
71
+ }
72
+
73
+ export function resolveAgentFallbackWorkspace(companyId: string, agentId: string) {
74
+ return resolveAgentFallbackWorkspacePath(companyId, agentId);
75
+ }
@@ -20,13 +20,28 @@ declare global {
20
20
 
21
21
  export function attachRequestActor(req: Request, _res: Response, next: NextFunction) {
22
22
  const actorTypeHeader = req.header("x-actor-type")?.trim().toLowerCase();
23
- const actorType = actorTypeHeader === "agent" || actorTypeHeader === "member" ? actorTypeHeader : "board";
24
- const actorId = req.header("x-actor-id")?.trim() || "local-board";
23
+ const actorTypeFromHeader =
24
+ actorTypeHeader === "agent" || actorTypeHeader === "member" || actorTypeHeader === "board"
25
+ ? actorTypeHeader
26
+ : null;
27
+ const actorIdHeader = req.header("x-actor-id")?.trim();
25
28
  const companyIdsHeader = req.header("x-actor-companies")?.trim();
26
29
  const permissionsHeader = req.header("x-actor-permissions")?.trim();
27
30
 
28
31
  const companyIds = parseCommaList(companyIdsHeader);
29
32
  const permissions = parseCommaList(permissionsHeader) ?? [];
33
+ const hasActorHeaders = Boolean(actorTypeFromHeader || actorIdHeader || companyIds || permissions.length > 0);
34
+ const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
35
+ const actorType = hasActorHeaders
36
+ ? actorTypeFromHeader ?? "member"
37
+ : allowLocalBoardFallback
38
+ ? "board"
39
+ : "member";
40
+ const actorId = hasActorHeaders
41
+ ? actorIdHeader || "unknown-actor"
42
+ : allowLocalBoardFallback
43
+ ? "local-board"
44
+ : "anonymous-member";
30
45
 
31
46
  req.actor = {
32
47
  type: actorType,
@@ -47,7 +62,7 @@ export function requirePermission(permission: string) {
47
62
  }
48
63
 
49
64
  export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
50
- if ((req.actor?.type ?? "board") !== "board") {
65
+ if (req.actor?.type !== "board") {
51
66
  return sendError(res, "Board role required.", 403);
52
67
  }
53
68
  next();
@@ -55,7 +70,10 @@ export function requireBoardRole(req: Request, res: Response, next: NextFunction
55
70
 
56
71
  export function canAccessCompany(req: Request, companyId: string) {
57
72
  const actor = req.actor;
58
- if (!actor || actor.type === "board") {
73
+ if (!actor) {
74
+ return false;
75
+ }
76
+ if (actor.type === "board") {
59
77
  return true;
60
78
  }
61
79
  return actor.companyIds?.includes(companyId) ?? false;
@@ -63,7 +81,10 @@ export function canAccessCompany(req: Request, companyId: string) {
63
81
 
64
82
  function hasPermission(req: Request, permission: string) {
65
83
  const actor = req.actor;
66
- if (!actor || actor.type === "board") {
84
+ if (!actor) {
85
+ return false;
86
+ }
87
+ if (actor.type === "board") {
67
88
  return true;
68
89
  }
69
90
  return actor.permissions.includes(permission);
@@ -135,6 +135,7 @@ async function loadOfficeOccupantForAgent(db: BopoDb, companyId: string, agentId
135
135
  id: agents.id,
136
136
  companyId: agents.companyId,
137
137
  name: agents.name,
138
+ avatarSeed: agents.avatarSeed,
138
139
  role: agents.role,
139
140
  status: agents.status,
140
141
  providerType: agents.providerType,
@@ -217,6 +218,7 @@ function deriveAgentOccupant(
217
218
  id: string;
218
219
  companyId: string;
219
220
  name: string;
221
+ avatarSeed?: string | null;
220
222
  role: string;
221
223
  status: string;
222
224
  providerType: string;
@@ -294,6 +296,7 @@ function deriveAgentOccupant(
294
296
  status: "waiting_for_approval",
295
297
  taskLabel: `${formatActionLabel(pendingApproval.action)} approval`,
296
298
  providerType: normalizeProviderType(agent.providerType),
299
+ avatarSeed: agent.avatarSeed ?? null,
297
300
  focusEntityType: "approval",
298
301
  focusEntityId: pendingApproval.id,
299
302
  updatedAt: pendingApproval.createdAt.toISOString()
@@ -313,6 +316,7 @@ function deriveAgentOccupant(
313
316
  status: "working",
314
317
  taskLabel: claimedIssues[0]?.title ?? "Checking in on work",
315
318
  providerType: normalizeProviderType(agent.providerType),
319
+ avatarSeed: agent.avatarSeed ?? null,
316
320
  focusEntityType: claimedIssues[0] ? "issue" : "agent",
317
321
  focusEntityId: claimedIssues[0]?.id ?? agent.id,
318
322
  updatedAt: activeRun.startedAt.toISOString()
@@ -332,6 +336,7 @@ function deriveAgentOccupant(
332
336
  status: "paused",
333
337
  taskLabel: "Paused",
334
338
  providerType: normalizeProviderType(agent.providerType),
339
+ avatarSeed: agent.avatarSeed ?? null,
335
340
  focusEntityType: "agent",
336
341
  focusEntityId: agent.id,
337
342
  updatedAt: agent.updatedAt.toISOString()
@@ -350,6 +355,7 @@ function deriveAgentOccupant(
350
355
  status: "idle",
351
356
  taskLabel: nextAssignedIssue ? `Up next: ${nextAssignedIssue.title}` : "Waiting for work",
352
357
  providerType: normalizeProviderType(agent.providerType),
358
+ avatarSeed: agent.avatarSeed ?? null,
353
359
  focusEntityType: nextAssignedIssue ? "issue" : "agent",
354
360
  focusEntityId: nextAssignedIssue?.id ?? agent.id,
355
361
  updatedAt: nextAssignedIssue?.updatedAt.toISOString() ?? agent.updatedAt.toISOString()
@@ -386,6 +392,7 @@ function deriveHireCandidateOccupant(approval: {
386
392
  status: "waiting_for_approval",
387
393
  taskLabel: "Awaiting hire approval",
388
394
  providerType,
395
+ avatarSeed: null,
389
396
  focusEntityType: "approval",
390
397
  focusEntityId: approval.id,
391
398
  updatedAt: approval.createdAt.toISOString()