herdr-plugin-amq 0.1.9 → 0.1.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.
package/src/protocol.mjs CHANGED
@@ -83,17 +83,48 @@ function openNoFollow(filePath, flags, mode) {
83
83
  return fs.openSync(filePath, flags | noFollow, mode);
84
84
  }
85
85
 
86
- function descriptorPath(fd) {
86
+ function isDirectoryHandle(handle) {
87
+ return Boolean(handle && typeof handle === "object");
88
+ }
89
+
90
+ function closeDirectory(handle) {
91
+ if (!isDirectoryHandle(handle)) fs.closeSync(handle);
92
+ }
93
+
94
+ function descriptorPath(handle) {
95
+ if (isDirectoryHandle(handle)) return handle.path;
87
96
  const base = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd";
88
- return path.join(base, String(fd));
97
+ return path.join(base, String(handle));
89
98
  }
90
99
 
91
- function openRelativeNoFollow(dirFd, name, flags, mode) {
100
+ function openRelativeNoFollow(dirHandle, name, flags, mode) {
92
101
  if (!name || path.basename(name) !== name) throw new Error("Invalid relative Maildir name");
93
- return openNoFollow(path.join(descriptorPath(dirFd), name), flags, mode);
102
+ return openNoFollow(path.join(descriptorPath(dirHandle), name), flags, mode);
103
+ }
104
+
105
+ function resolveDarwinDirectory(dirPath) {
106
+ const resolved = path.resolve(dirPath);
107
+ const parsed = path.parse(resolved);
108
+ const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
109
+ const systemSymlinkRoots = new Set(["/private", "/tmp", "/var"]);
110
+ let current = parsed.root;
111
+ for (const segment of segments) {
112
+ current = path.join(current, segment);
113
+ const stat = fs.lstatSync(current);
114
+ if (stat.isSymbolicLink()) {
115
+ if (!systemSymlinkRoots.has(current)) throw new Error("Symlinked directory is not allowed");
116
+ current = fs.realpathSync(current);
117
+ continue;
118
+ }
119
+ if (!stat.isDirectory()) throw new Error("Maildir path is not a directory");
120
+ }
121
+ const realPath = fs.realpathSync(current);
122
+ if (!fs.statSync(realPath).isDirectory()) throw new Error("Maildir path is not a directory");
123
+ return realPath;
94
124
  }
95
125
 
96
126
  function openDirectorySecure(dirPath) {
127
+ if (process.platform === "darwin") return { path: resolveDarwinDirectory(dirPath) };
97
128
  const resolved = path.resolve(dirPath);
98
129
  const parsed = path.parse(resolved);
99
130
  const segments = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
@@ -102,12 +133,12 @@ function openDirectorySecure(dirPath) {
102
133
  try {
103
134
  for (const segment of segments) {
104
135
  const next = openRelativeNoFollow(current, segment, directoryFlag);
105
- fs.closeSync(current);
136
+ closeDirectory(current);
106
137
  current = next;
107
138
  }
108
139
  return current;
109
140
  } catch (error) {
110
- fs.closeSync(current);
141
+ closeDirectory(current);
111
142
  throw error;
112
143
  }
113
144
  }
@@ -145,7 +176,7 @@ export function writeBoundedFileAtomic(filePath, content, maxBytes = MAX_MAILDIR
145
176
  try {
146
177
  writeFileAtomicBetweenDirectories(path.basename(filePath), dirFd, dirFd, content, maxBytes);
147
178
  } finally {
148
- fs.closeSync(dirFd);
179
+ closeDirectory(dirFd);
149
180
  }
150
181
  }
151
182
 
@@ -154,7 +185,7 @@ export function listMaildirMessageFiles(dirPath) {
154
185
  try {
155
186
  return fs.readdirSync(descriptorPath(dirFd));
156
187
  } finally {
157
- fs.closeSync(dirFd);
188
+ closeDirectory(dirFd);
158
189
  }
159
190
  }
160
191
 
@@ -176,8 +207,8 @@ export function moveMaildirMessage(amqRoot, handle, fromFolder, toFolder, fileNa
176
207
  );
177
208
  return path.join(toDir, fileName);
178
209
  } finally {
179
- fs.closeSync(fromFd);
180
- if (toFd !== undefined) fs.closeSync(toFd);
210
+ closeDirectory(fromFd);
211
+ if (toFd !== undefined) closeDirectory(toFd);
181
212
  }
182
213
  }
183
214
 
@@ -206,7 +237,7 @@ export function readMaildirMessageFile(filePath, maxBytes = MAX_MAILDIR_FILE_BYT
206
237
  return Buffer.concat(chunks, total).toString("utf8");
207
238
  } finally {
208
239
  if (fd !== undefined) fs.closeSync(fd);
209
- fs.closeSync(dirFd);
240
+ closeDirectory(dirFd);
210
241
  }
211
242
  }
212
243
 
@@ -410,8 +441,8 @@ export function sendMaildirMessage(amqRoot, options = {}) {
410
441
  newFd = openDirectorySecure(path.join(recipientDir, "inbox", "new"));
411
442
  writeFileAtomicBetweenDirectories(`${msgId}.md`, tmpFd, newFd, fileText);
412
443
  } finally {
413
- fs.closeSync(tmpFd);
414
- if (newFd !== undefined) fs.closeSync(newFd);
444
+ closeDirectory(tmpFd);
445
+ if (newFd !== undefined) closeDirectory(newFd);
415
446
  }
416
447
  }
417
448
 
@@ -420,7 +451,7 @@ export function sendMaildirMessage(amqRoot, options = {}) {
420
451
  try {
421
452
  writeFileAtomicBetweenDirectories(`${msgId}.md`, sentFd, sentFd, fileText);
422
453
  } finally {
423
- fs.closeSync(sentFd);
454
+ closeDirectory(sentFd);
424
455
  }
425
456
 
426
457
  return {
@@ -449,8 +480,9 @@ export function markMaildirMessageRead(amqRoot, handle, msgId) {
449
480
  if (!agentDir) return { ok: false, error: "Mailbox not found" };
450
481
  const newPath = path.join(agentDir, "inbox", "new", `${msgId}.md`);
451
482
  const curPath = path.join(agentDir, "inbox", "cur", `${msgId}.md`);
483
+ const logicalCurPath = path.join(amqRoot, "agents", handle, "inbox", "cur", `${msgId}.md`);
452
484
  if (!fs.existsSync(newPath)) {
453
- if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath: curPath };
485
+ if (fs.existsSync(curPath)) return { ok: true, alreadyRead: true, id: msgId, filePath: logicalCurPath };
454
486
  return { ok: false, error: "Message not found" };
455
487
  }
456
488
 
@@ -464,7 +496,7 @@ export function markMaildirMessageRead(amqRoot, handle, msgId) {
464
496
 
465
497
  const filePath = moveMaildirMessage(amqRoot, handle, "new", "cur", `${msgId}.md`);
466
498
  if (!filePath) return { ok: false, error: "Message could not be marked read" };
467
- return { ok: true, alreadyRead: false, id: msgId, filePath };
499
+ return { ok: true, alreadyRead: false, id: msgId, filePath: logicalCurPath };
468
500
  } catch (error) {
469
501
  return { ok: false, error: error.message };
470
502
  }
package/src/server.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import http from "node:http";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { execFile } from "node:child_process";
4
5
  import { fileURLToPath } from "node:url";
5
- import { findAmqRoot, getAgentHandles } from "./config.mjs";
6
+ import { findAmqRoot, getAgentHandles, getHerdrBin } from "./config.mjs";
6
7
  import { markMaildirMessageRead } from "./protocol.mjs";
7
8
  import {
8
9
  loadAllMessages,
@@ -37,6 +38,7 @@ import {
37
38
  import {
38
39
  getHerdrAgents,
39
40
  getHerdrStatusMap,
41
+ normalizeHerdrStatus,
40
42
  subscribeHerdrEvents,
41
43
  isHerdrAvailable,
42
44
  getSocketPath,
@@ -58,6 +60,55 @@ import {
58
60
 
59
61
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
60
62
  const WEB_ROOT = path.join(__dirname, "web");
63
+ const HERDR_STATUS_EVENT_TYPES = new Set(["pane_agent_status_changed", "pane.agent_status_changed", "pane.updated", "agent.state_changed", "agent.updated"]);
64
+ const HERDR_REFRESH_EVENT_TYPES = new Set(["pane_agent_detected", "pane.created", "pane.closed", "workspace.created", "workspace.closed", "agent.created", "agent.closed"]);
65
+
66
+ // Strict CSP for the local dashboard. media-src 'self' covers same-origin
67
+ // <video> attachments streamed from /api/blob/* and /api/file.
68
+ export const CONTENT_SECURITY_POLICY =
69
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self'; connect-src 'self'; frame-ancestors 'none';";
70
+
71
+ export function isHerdrStatusEvent(type) {
72
+ return HERDR_STATUS_EVENT_TYPES.has(String(type || ""));
73
+ }
74
+
75
+ export function isHerdrRefreshEvent(type) {
76
+ return HERDR_REFRESH_EVENT_TYPES.has(String(type || ""));
77
+ }
78
+
79
+ export function findHerdrEventHandle(cache, event) {
80
+ if (!cache || !event) return null;
81
+ const directHandle = String(event.name || event.handle || "").trim();
82
+ if (directHandle && cache.has(directHandle)) return directHandle;
83
+ const paneId = String(event.pane_id || "").trim();
84
+ if (!paneId) return null;
85
+ for (const [handle, activity] of cache.entries()) {
86
+ if (activity?.herdrPaneId === paneId) return handle;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ export function mergeHerdrStatusEvent(cache, event, observedAt = new Date().toISOString()) {
92
+ const handle = findHerdrEventHandle(cache, event);
93
+ if (!handle || !event?.agent_status) return null;
94
+ const current = cache.get(handle) || {};
95
+ const nextSeq = Number(event.state_change_seq);
96
+ const currentSeq = Number(current.herdrStateChangeSeq);
97
+ if (Number.isFinite(nextSeq) && Number.isFinite(currentSeq) && nextSeq < currentSeq) return null;
98
+
99
+ const next = {
100
+ ...current,
101
+ herdrStatus: normalizeHerdrStatus(event.agent_status),
102
+ herdrObservedAt: observedAt,
103
+ };
104
+ if (Number.isFinite(nextSeq)) next.herdrStateChangeSeq = nextSeq;
105
+ if (event.state_labels && typeof event.state_labels === "object" && !Array.isArray(event.state_labels)) {
106
+ next.herdrStateLabels = { ...event.state_labels };
107
+ }
108
+ if (typeof event.title === "string" && event.title.trim()) next.herdrTitle = event.title.trim();
109
+ cache.set(handle, next);
110
+ return { handle, activity: next };
111
+ }
61
112
 
62
113
  export function startWebServer({
63
114
  port = 8505,
@@ -77,6 +128,8 @@ export function startWebServer({
77
128
  let herdrStatusCache = new Map();
78
129
  let herdrSubscription = null;
79
130
  let herdrRefreshDebounce = null;
131
+ let herdrStatusFlushTimer = null;
132
+ const pendingHerdrStatusEvents = new Map();
80
133
 
81
134
  function broadcastSSE(payload) {
82
135
  const msg = `data: ${JSON.stringify(payload)}\n\n`;
@@ -100,6 +153,36 @@ export function startWebServer({
100
153
  }, 80);
101
154
  }
102
155
 
156
+ function queueHerdrStatusEvent(event) {
157
+ const handle = findHerdrEventHandle(herdrStatusCache, event);
158
+ if (!handle || !event?.agent_status) {
159
+ scheduleHerdrRefresh("status");
160
+ return;
161
+ }
162
+ const current = herdrStatusCache.get(handle);
163
+ const nextStatus = normalizeHerdrStatus(event.agent_status);
164
+ if (current?.herdrStatus === nextStatus && !event.state_labels && !event.title) return;
165
+ pendingHerdrStatusEvents.set(handle, event);
166
+ if (herdrStatusFlushTimer) return;
167
+ herdrStatusFlushTimer = setTimeout(() => {
168
+ herdrStatusFlushTimer = null;
169
+ const pending = new Map(pendingHerdrStatusEvents);
170
+ pendingHerdrStatusEvents.clear();
171
+ for (const [pendingHandle, pendingEvent] of pending) {
172
+ const merged = mergeHerdrStatusEvent(herdrStatusCache, pendingEvent);
173
+ if (!merged || merged.handle !== pendingHandle) continue;
174
+ broadcastSSE({
175
+ type: "herdr_agent_update",
176
+ handle: merged.handle,
177
+ herdrStatus: merged.activity.herdrStatus,
178
+ stateLabels: merged.activity.herdrStateLabels || {},
179
+ title: merged.activity.herdrTitle || null,
180
+ at: merged.activity.herdrObservedAt,
181
+ });
182
+ }
183
+ }, 250);
184
+ }
185
+
103
186
  let isClosing = false;
104
187
  let herdrReconnectTimeout = null;
105
188
 
@@ -114,17 +197,17 @@ export function startWebServer({
114
197
  },
115
198
  onEvent: (event) => {
116
199
  const type = String(event?.type || "");
117
- if (
118
- type.startsWith("pane.") ||
119
- type.startsWith("agent.") ||
120
- type === "workspace.created" ||
121
- type === "workspace.closed"
122
- ) {
200
+ if (isHerdrStatusEvent(type)) {
201
+ queueHerdrStatusEvent(event);
202
+ } else if (isHerdrRefreshEvent(type)) {
123
203
  scheduleHerdrRefresh(type);
124
204
  }
125
205
  },
126
206
  onDisconnect: () => {
127
207
  herdrStatusCache = new Map();
208
+ pendingHerdrStatusEvents.clear();
209
+ if (herdrStatusFlushTimer) clearTimeout(herdrStatusFlushTimer);
210
+ herdrStatusFlushTimer = null;
128
211
  broadcastSSE({ type: "herdr_agents_refresh", reason: "disconnected", at: new Date().toISOString() });
129
212
  if (!isClosing) {
130
213
  herdrReconnectTimeout = setTimeout(startHerdrSubscription, 5000);
@@ -136,6 +219,26 @@ export function startWebServer({
136
219
  // Start Herdr integration (non-fatal if Herdr not running)
137
220
  refreshHerdrCache().then(() => startHerdrSubscription()).catch(() => {});
138
221
 
222
+ // Safety poll: socket events alone left the cache 28+ minutes stale when
223
+ // status transitions arrived under an unsubscribed type. Re-poll on a
224
+ // timer and broadcast only when the status signature actually changed.
225
+ const HERDR_SAFETY_POLL_MS = parseInt(process.env.HERDR_SAFETY_POLL_MS || "20000", 10);
226
+ const herdrCacheSignature = () =>
227
+ [...herdrStatusCache].map(([h, v]) => `${h}:${v?.herdrStatus || "?"}`).sort().join("|");
228
+ let lastHerdrSignature = herdrCacheSignature();
229
+ const herdrSafetyTimer = setInterval(async () => {
230
+ if (isClosing) return;
231
+ try {
232
+ await refreshHerdrCache();
233
+ const sig = herdrCacheSignature();
234
+ if (sig !== lastHerdrSignature) {
235
+ lastHerdrSignature = sig;
236
+ broadcastSSE({ type: "herdr_agents_refresh", reason: "safety-poll", at: new Date().toISOString() });
237
+ }
238
+ } catch {}
239
+ }, HERDR_SAFETY_POLL_MS);
240
+ if (herdrSafetyTimer.unref) herdrSafetyTimer.unref();
241
+
139
242
  let watchDebounce = null;
140
243
  let watcher = null;
141
244
  try {
@@ -177,7 +280,7 @@ export function startWebServer({
177
280
  res.setHeader("X-Frame-Options", "DENY");
178
281
  res.setHeader(
179
282
  "Content-Security-Policy",
180
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none';"
283
+ CONTENT_SECURITY_POLICY
181
284
  );
182
285
  res.setHeader("Referrer-Policy", "no-referrer");
183
286
 
@@ -306,11 +409,50 @@ export function startWebServer({
306
409
  if (pathname === "/api/herdr-agents" && req.method === "GET") {
307
410
  // Raw Herdr agent snapshot — all panes, not just named ones
308
411
  const agents = await getHerdrAgents();
309
- res.writeHead(200, { "Content-Type": "application/json" });
412
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
310
413
  res.end(JSON.stringify(agents));
311
414
  return;
312
415
  }
313
416
 
417
+ if (pathname === "/api/panes" && req.method === "GET") {
418
+ // Live terminal tails per registered lane via `herdr agent read`.
419
+ // Bounded: clamped line count, per-pane timeout, truncated output.
420
+ const rawLines = parseInt(url.searchParams.get("lines") || "40", 10);
421
+ const lineCount = Number.isFinite(rawLines) ? Math.min(200, Math.max(5, rawLines)) : 40;
422
+ const only = (url.searchParams.get("handle") || "").trim();
423
+ const agents = loadAgentDirectory(amqRoot).filter((a) => !only || a.handle === only);
424
+ const panes = await Promise.all(
425
+ agents.map(
426
+ (a) =>
427
+ new Promise((resolve) => {
428
+ execFile(
429
+ getHerdrBin(),
430
+ ["agent", "read", a.handle, "--lines", String(lineCount), "--format", "text"],
431
+ { timeout: 8000, maxBuffer: 1024 * 1024 },
432
+ (err, stdout) => {
433
+ const clean = String(stdout || "")
434
+ // eslint-disable-next-line no-control-regex
435
+ .replace(/\u001b\[[0-9;]*m/g, "")
436
+ .replace(/\r/g, "");
437
+ // Stable error code only: raw spawn errors leak tmp paths
438
+ // and binary locations into the UI (and API responses).
439
+ resolve({
440
+ handle: a.handle,
441
+ ok: !err,
442
+ error: err ? "herdr-unavailable" : null,
443
+ output: clean.slice(-6000),
444
+ at: new Date().toISOString(),
445
+ });
446
+ }
447
+ );
448
+ })
449
+ )
450
+ );
451
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
452
+ res.end(JSON.stringify(panes));
453
+ return;
454
+ }
455
+
314
456
  if (pathname === "/api/agents" && req.method === "POST") {
315
457
  const body = await parseJsonBody(req);
316
458
  if (body.__error) {
@@ -813,6 +955,9 @@ export function startWebServer({
813
955
  }
814
956
  if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
815
957
  if (herdrRefreshDebounce) clearTimeout(herdrRefreshDebounce);
958
+ if (herdrStatusFlushTimer) clearTimeout(herdrStatusFlushTimer);
959
+ if (herdrSafetyTimer) clearInterval(herdrSafetyTimer);
960
+ pendingHerdrStatusEvents.clear();
816
961
  if (watchDebounce) clearTimeout(watchDebounce);
817
962
  if (herdrSubscription) {
818
963
  try { herdrSubscription.close(); } catch {}
package/src/store.mjs CHANGED
@@ -13,6 +13,7 @@ const PALETTE = [
13
13
  "#9c27b0", "#009688", "#e91e63", "#3f51b5", "#00bcd4",
14
14
  "#795548", "#607d8b", "#673ab7", "#2e7d32", "#c2185b"
15
15
  ];
16
+ const LEGACY_PLACEHOLDER_MODEL = "gemini 3.8 flash (high)";
16
17
 
17
18
  export function getAgentColor(handle = "") {
18
19
  let hash = 0;
@@ -205,6 +206,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
205
206
 
206
207
  const ext = path.extname(clean).toLowerCase();
207
208
  const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
209
+ const isVideo = [".mp4", ".m4v", ".webm"].includes(ext);
208
210
  const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
209
211
 
210
212
  // 1. Check disk resolution first (sub-millisecond)
@@ -221,6 +223,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
221
223
  name: base,
222
224
  ext,
223
225
  isImage,
226
+ isVideo,
224
227
  isLog,
225
228
  exists: true,
226
229
  sizeBytes,
@@ -236,6 +239,10 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
236
239
  if (hashMatch) {
237
240
  const stored = getBlob(hashMatch[1], amqRoot);
238
241
  if (stored) {
242
+ const storedExt = (stored.ext || ext).toLowerCase();
243
+ const storedIsImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(storedExt);
244
+ const storedIsVideo = [".mp4", ".m4v", ".webm"].includes(storedExt);
245
+ const storedIsLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(storedExt);
239
246
  attachments.push({
240
247
  type: "blob",
241
248
  sha256: stored.sha256,
@@ -243,8 +250,9 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
243
250
  ext: stored.ext,
244
251
  mime: stored.mime,
245
252
  sizeBytes: stored.sizeBytes,
246
- isImage,
247
- isLog,
253
+ isImage: storedIsImage,
254
+ isVideo: storedIsVideo,
255
+ isLog: storedIsLog,
248
256
  exists: true,
249
257
  sizeDisplay: formatFileSize(stored.sizeBytes),
250
258
  url: `/api/blob/${stored.sha256}${stored.ext ? `?ext=${encodeURIComponent(stored.ext)}` : ""}`,
@@ -261,6 +269,7 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
261
269
  name: base,
262
270
  ext,
263
271
  isImage,
272
+ isVideo,
264
273
  isLog,
265
274
  exists: false,
266
275
  sizeBytes: 0,
@@ -822,7 +831,7 @@ export function loadAgentDirectory(amqRoot) {
822
831
  const brief = briefs.get(h) || null;
823
832
  const name = customProfile?.name || presence?.name || brief?.name || formatAgentTitle(h);
824
833
  const role = customProfile?.role || (presence?.role && presence.role !== "Swarm Agent" ? presence.role : null) || brief?.role || brief?.description || (h === "user" ? "Human Operator" : "Swarm Agent");
825
- const model = customProfile?.model || brief?.model || "claude-3-7-sonnet";
834
+ const model = normalizeModel(customProfile?.model) || normalizeModel(brief?.model);
826
835
  const emoji = customProfile?.emoji || presence?.emoji || (h === "user" ? "👤" : h.slice(0, 1).toUpperCase());
827
836
  const color = customProfile?.color || presence?.color || getAgentColor(h);
828
837
  const worktree = customProfile?.worktree || null;
@@ -883,6 +892,12 @@ function normalizeText(value, fallback, maxLength) {
883
892
  return normalized ? normalized.slice(0, maxLength) : fallback;
884
893
  }
885
894
 
895
+ function normalizeModel(value, fallback = null) {
896
+ const normalized = normalizeText(value, fallback, 200);
897
+ if (typeof normalized !== "string") return normalized;
898
+ return normalized.toLowerCase() === LEGACY_PLACEHOLDER_MODEL ? null : normalized;
899
+ }
900
+
886
901
  function normalizeMultiline(value, fallback, maxLength) {
887
902
  if (typeof value !== "string") return fallback;
888
903
  const normalized = value
@@ -980,7 +995,7 @@ function registerAgentLocked(amqRoot, agentDir, safeHandle, options) {
980
995
  handle: safeHandle,
981
996
  name: normalizeText(name, normalizeText(existingProfile?.name, defaultName, 120), 120),
982
997
  role: normalizeText(role, normalizeText(existingProfile?.role, "Autonomous Specialist", 240), 240),
983
- model: normalizeText(model, normalizeText(existingProfile?.model, "claude-3-7-sonnet", 200), 200),
998
+ model: normalizeModel(model, normalizeModel(existingProfile?.model)),
984
999
  emoji: normalizeText(emoji, normalizeText(existingProfile?.emoji, defaultEmoji, 16), 16),
985
1000
  color: normalizeText(color, normalizeText(existingProfile?.color, getAgentColor(safeHandle), 64), 64),
986
1001
  worktree: normalizeText(worktree, normalizeText(existingProfile?.worktree, "", 1024), 1024) || null,