fraim-hub 2.0.246 → 2.0.248

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.
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hostSessionState = exports.HostSessionState = void 0;
4
+ class HostSessionState {
5
+ keyFor(owner) {
6
+ const configuredAgentId = typeof owner.configuredAgentId === 'string' ? owner.configuredAgentId.trim() : '';
7
+ return configuredAgentId || `${owner.baseHostId}-default`;
8
+ }
9
+ applySession(target, owner, sessionId, options = {}) {
10
+ const normalizedSessionId = sessionId.trim();
11
+ if (!normalizedSessionId)
12
+ return;
13
+ const at = options.at || new Date().toISOString();
14
+ const key = this.keyFor(owner);
15
+ target.hostSessions = {
16
+ ...(target.hostSessions || {}),
17
+ [key]: {
18
+ configuredAgentId: owner.configuredAgentId || null,
19
+ baseHostId: owner.baseHostId,
20
+ sessionId: normalizedSessionId,
21
+ status: options.status || 'valid',
22
+ sourceRunId: options.sourceRunId ?? target.id ?? null,
23
+ updatedAt: at,
24
+ },
25
+ };
26
+ target.sessionId = normalizedSessionId;
27
+ }
28
+ mergeFromRun(record, run) {
29
+ const owner = {
30
+ configuredAgentId: run.configuredAgentId || record.configuredAgentId || null,
31
+ baseHostId: (run.baseHostId || run.hostId || record.baseHostId || record.agentName),
32
+ };
33
+ record.hostSessions = { ...(record.hostSessions || {}) };
34
+ for (const [key, session] of Object.entries(run.hostSessions || {})) {
35
+ record.hostSessions[key] = { ...session };
36
+ }
37
+ if (run.sessionId) {
38
+ const projectedRun = {
39
+ id: run.id,
40
+ sessionId: record.sessionId || undefined,
41
+ hostSessions: record.hostSessions,
42
+ };
43
+ this.applySession(projectedRun, owner, run.sessionId, { sourceRunId: run.id });
44
+ record.hostSessions = projectedRun.hostSessions;
45
+ }
46
+ const active = this.resolve(record, owner);
47
+ record.sessionId = active?.sessionId || run.sessionId || record.sessionId || null;
48
+ }
49
+ resolve(conversation, owner) {
50
+ if (!conversation)
51
+ return null;
52
+ const sessions = conversation.hostSessions || {};
53
+ const key = this.keyFor(owner);
54
+ const exact = sessions[key];
55
+ if (this.isResumableForOwner(exact, owner))
56
+ return exact;
57
+ const fallback = Object.values(sessions).find((session) => this.isResumableForOwner(session, owner));
58
+ if (fallback)
59
+ return fallback;
60
+ const legacySessionId = typeof conversation.sessionId === 'string' ? conversation.sessionId.trim() : '';
61
+ if (!legacySessionId)
62
+ return null;
63
+ return {
64
+ configuredAgentId: owner.configuredAgentId || null,
65
+ baseHostId: owner.baseHostId,
66
+ sessionId: legacySessionId,
67
+ status: 'suspect',
68
+ sourceRunId: typeof conversation.runId === 'string' ? conversation.runId : null,
69
+ updatedAt: typeof conversation.lastUpdatedAt === 'string' ? conversation.lastUpdatedAt : new Date().toISOString(),
70
+ };
71
+ }
72
+ markInvalid(conversation, owner, sessionId, reason, at = new Date().toISOString()) {
73
+ const normalizedSessionId = sessionId.trim();
74
+ if (!normalizedSessionId)
75
+ return;
76
+ const key = this.keyFor(owner);
77
+ const existing = conversation.hostSessions?.[key];
78
+ conversation.hostSessions = { ...(conversation.hostSessions || {}) };
79
+ conversation.hostSessions[key] = {
80
+ configuredAgentId: owner.configuredAgentId || existing?.configuredAgentId || null,
81
+ baseHostId: owner.baseHostId,
82
+ sessionId: normalizedSessionId,
83
+ status: 'invalid',
84
+ sourceRunId: existing?.sourceRunId || (typeof conversation.runId === 'string' ? conversation.runId : null),
85
+ updatedAt: existing?.updatedAt || at,
86
+ invalidatedAt: at,
87
+ invalidationReason: reason,
88
+ };
89
+ if (conversation.sessionId === normalizedSessionId) {
90
+ const replacement = this.resolve({ ...conversation, sessionId: null }, owner);
91
+ conversation.sessionId = replacement?.sessionId || null;
92
+ }
93
+ }
94
+ markInvalidRun(run, owner, sessionId, reason, at = new Date().toISOString()) {
95
+ const normalizedSessionId = sessionId.trim();
96
+ if (!normalizedSessionId)
97
+ return;
98
+ const key = this.keyFor(owner);
99
+ const existing = run.hostSessions?.[key];
100
+ run.hostSessions = { ...(run.hostSessions || {}) };
101
+ run.hostSessions[key] = {
102
+ configuredAgentId: owner.configuredAgentId || existing?.configuredAgentId || null,
103
+ baseHostId: owner.baseHostId,
104
+ sessionId: normalizedSessionId,
105
+ status: 'invalid',
106
+ sourceRunId: existing?.sourceRunId || run.id || null,
107
+ updatedAt: existing?.updatedAt || at,
108
+ invalidatedAt: at,
109
+ invalidationReason: reason,
110
+ };
111
+ if (run.sessionId === normalizedSessionId) {
112
+ run.sessionId = undefined;
113
+ }
114
+ }
115
+ hasInvalidSession(conversation, owner, sessionId) {
116
+ const normalizedSessionId = sessionId.trim();
117
+ if (!conversation || !normalizedSessionId)
118
+ return false;
119
+ const key = this.keyFor(owner);
120
+ const exact = conversation.hostSessions?.[key];
121
+ if (exact?.status === 'invalid' && exact.sessionId === normalizedSessionId)
122
+ return true;
123
+ return Object.values(conversation.hostSessions || {}).some((session) => (session.status === 'invalid'
124
+ && session.sessionId === normalizedSessionId
125
+ && session.baseHostId === owner.baseHostId));
126
+ }
127
+ isResumableForOwner(session, owner) {
128
+ if (!session || !session.sessionId || session.status === 'invalid')
129
+ return false;
130
+ if (session.baseHostId !== owner.baseHostId)
131
+ return false;
132
+ const requestedConfiguredAgentId = typeof owner.configuredAgentId === 'string' ? owner.configuredAgentId.trim() : '';
133
+ const sessionConfiguredAgentId = typeof session.configuredAgentId === 'string' ? session.configuredAgentId.trim() : '';
134
+ return !requestedConfiguredAgentId || !sessionConfiguredAgentId || requestedConfiguredAgentId === sessionConfiguredAgentId;
135
+ }
136
+ }
137
+ exports.HostSessionState = HostSessionState;
138
+ exports.hostSessionState = new HostSessionState();
@@ -1578,7 +1578,10 @@ function parseHostLine(hostId, line) {
1578
1578
  if (parsed.type === 'result') {
1579
1579
  // Don't emit message — the 'assistant' event already captured the turn text.
1580
1580
  // result carries usage data (parsed by parseUsageSignal above via withSignal).
1581
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1581
+ // Claude result events can echo the requested resume id even when resume failed
1582
+ // with "No conversation found with session ID". Treat system events as the
1583
+ // authoritative session source instead of reinforcing a stale pointer.
1584
+ return withSignal({ raw: trimmed });
1582
1585
  }
1583
1586
  return withSignal({ raw: trimmed });
1584
1587
  }
@@ -112,16 +112,15 @@ function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
112
112
  const byPath = new Map();
113
113
  const currentCanonical = currentProjectPath ? canonicalProjectPath(currentProjectPath) : null;
114
114
  const removedKeys = new Set(normalizeRemovedProjectPaths(options.removedProjectPaths));
115
- const add = (entry, isCurrent = false) => {
115
+ const add = (entry) => {
116
116
  if (!entry)
117
117
  return;
118
118
  const displayPath = normalizeProjectPath(entry.folderPath);
119
119
  const dedupKey = canonicalProjectPath(displayPath);
120
- // Issue #788: the current project is always shown in its own workspace, even when
121
- // tombstoned (#719 never-empty Hub). This is a display override ONLY the tombstone
122
- // is neither erased here nor in persistence, so switching away re-hides the project
123
- // and no subsequent add can resurrect it.
124
- if (!isCurrent && removedKeys.has(dedupKey))
120
+ // Issue #1044: tombstoned paths are never shown, even when the path is the active
121
+ // project. The caller must switch to a different project (or clear projectPath) before
122
+ // deleting the server now enforces this only when other projects remain (#719 reverted).
123
+ if (removedKeys.has(dedupKey))
125
124
  return;
126
125
  if (!options.includeMissing && dedupKey !== currentCanonical && !projectPathExists(displayPath))
127
126
  return;
@@ -129,7 +128,7 @@ function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
129
128
  byPath.set(dedupKey, existing ? { ...existing, ...entry, folderPath: existing.folderPath } : { ...entry, folderPath: displayPath });
130
129
  };
131
130
  if (currentProjectPath)
132
- add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath), true);
131
+ add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath));
133
132
  for (const project of projects)
134
133
  add(normalizeProjectEntry(project));
135
134
  return withUniqueProjectIds(Array.from(byPath.values()));
@@ -55,6 +55,7 @@ const custom_employees_1 = require("./custom-employees");
55
55
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
56
56
  const hosts_1 = require("./hosts");
57
57
  const configured_agents_1 = require("./configured-agents");
58
+ const host_session_state_1 = require("./host-session-state");
58
59
  const url_safety_1 = require("./url-safety");
59
60
  const manager_turns_1 = require("./manager-turns");
60
61
  const preferences_1 = require("./preferences");
@@ -117,6 +118,10 @@ function buildReviewApprovalSystemEventText(instructions) {
117
118
  return 'review_approved send_delivery cleanup_branch';
118
119
  return null;
119
120
  }
121
+ function extractMissingHostSessionId(text) {
122
+ const match = text.match(/No conversation found with session ID:\s*([a-zA-Z0-9-]+)/i);
123
+ return match?.[1]?.trim() || null;
124
+ }
120
125
  function loadManagerHiringModule() {
121
126
  const cached = managerHiringModule;
122
127
  if (cached !== undefined) {
@@ -1444,7 +1449,10 @@ function ensureDirectoryPath(projectPath) {
1444
1449
  }
1445
1450
  return resolved;
1446
1451
  }
1447
- function readRunScope(raw) {
1452
+ function readRunScope(raw, jobId) {
1453
+ if ((raw === undefined || raw === null) && jobId && MACHINE_LEVEL_JOB_IDS.has(jobId)) {
1454
+ return 'manager';
1455
+ }
1448
1456
  return raw === 'manager' || raw === 'company' ? raw : 'project';
1449
1457
  }
1450
1458
  // Working directory for a project-independent run when no project exists: the user-level
@@ -1612,7 +1620,7 @@ function classifyExit(run, exitCode) {
1612
1620
  if (lastDeclared && lastDeclared.id === currentPhase) {
1613
1621
  return { action: 'done', pauseReason: 'done' };
1614
1622
  }
1615
- return { action: 'resume', pauseReason: 'working' };
1623
+ return { action: 'park', pauseReason: 'awaiting_user' };
1616
1624
  }
1617
1625
  // Phase mid-flight (started but not completed/incomplete): conservative park.
1618
1626
  return { action: 'park', pauseReason: 'awaiting_user' };
@@ -2229,7 +2237,7 @@ class AiHubServer {
2229
2237
  conversationRecordFromRun(run) {
2230
2238
  const lastUpdatedAt = run.updatedAt || new Date().toISOString();
2231
2239
  const stages = deriveStages(run, run.projectPath);
2232
- return {
2240
+ const record = {
2233
2241
  id: run.conversationId || run.id,
2234
2242
  projectPath: path_1.default.resolve(run.projectPath),
2235
2243
  title: run.conversationTitle || run.jobTitle || run.jobId,
@@ -2279,6 +2287,7 @@ class AiHubServer {
2279
2287
  handoffSummary: run.handoffSummary || null,
2280
2288
  continuityDecision: run.continuityDecision,
2281
2289
  forkedFromConversationId: run.forkedFromConversationId ?? null,
2290
+ hostSessions: { ...(run.hostSessions || {}) },
2282
2291
  restartRecovery: run.restartRecovery || null,
2283
2292
  restartRecoverySkippedReason: run.restartRecoverySkippedReason || null,
2284
2293
  // Issue #578: preserve trigger source so the UI can render the chip.
@@ -2296,6 +2305,8 @@ class AiHubServer {
2296
2305
  runDiscriminant: run.runDiscriminant || null,
2297
2306
  },
2298
2307
  };
2308
+ host_session_state_1.hostSessionState.mergeFromRun(record, run);
2309
+ return record;
2299
2310
  }
2300
2311
  persistRunConversationNow(run, activeId) {
2301
2312
  try {
@@ -2344,7 +2355,7 @@ class AiHubServer {
2344
2355
  onEvent: (event, channel) => {
2345
2356
  this.runRegistry.update(run.id, (current) => {
2346
2357
  if (event.sessionId) {
2347
- current.sessionId = event.sessionId;
2358
+ host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
2348
2359
  current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
2349
2360
  }
2350
2361
  appendHostMessage(current, run.hostId, event, channel);
@@ -3906,7 +3917,7 @@ class AiHubServer {
3906
3917
  onEvent: (event, channel) => {
3907
3918
  this.runRegistry.update(run.id, (current) => {
3908
3919
  if (event.sessionId) {
3909
- current.sessionId = event.sessionId;
3920
+ host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
3910
3921
  current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
3911
3922
  }
3912
3923
  appendHostMessage(current, run.hostId, event, channel);
@@ -4039,10 +4050,14 @@ class AiHubServer {
4039
4050
  || (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
4040
4051
  if (!entry)
4041
4052
  return res.status(404).json({ error: 'Project not found.' });
4042
- // The current projectPath is unconditionally re-injected on every load, so
4043
- // deleting it would resurrect on the next request reject as a sequencing
4044
- // error; the client switches workspaces before deleting (R5/D4).
4045
- if (sameDirectoryPath(entry.folderPath, projectPath)) {
4053
+ // Issue #1044: when the project being removed is the active workspace AND other
4054
+ // projects remain, the client must switch first (the current path is re-injected
4055
+ // on every load, so it would resurface). When it is the LAST project, allow the
4056
+ // delete and let the Hub reach an empty state — projectPath is cleared by the
4057
+ // tombstone filter (normalizeAiHubProjectList no longer bypasses for isCurrent).
4058
+ const isCurrentProject = sameDirectoryPath(entry.folderPath, projectPath);
4059
+ const otherProjectsExist = known.some((p) => p.id !== entry.id);
4060
+ if (isCurrentProject && otherProjectsExist) {
4046
4061
  return res.status(409).json({ error: 'Cannot remove the current project. Switch to another project first.' });
4047
4062
  }
4048
4063
  // A live schedule/webhook would fire later, write a conversation under the
@@ -4452,7 +4467,7 @@ class AiHubServer {
4452
4467
  try {
4453
4468
  // Issue #892: project-independent (manager/company) runs resolve a working dir
4454
4469
  // even with no project; project runs still require an existing project directory.
4455
- const scope = readRunScope(req.body.scope);
4470
+ const scope = readRunScope(req.body.scope, req.body.jobId);
4456
4471
  const projectPath = this.resolveRunProjectPath(req.body.projectPath, scope);
4457
4472
  const requestedHostId = req.body.hostId;
4458
4473
  const instructions = (req.body.instructions || '').trim();
@@ -4877,14 +4892,14 @@ class AiHubServer {
4877
4892
  const body = (req.body ?? {});
4878
4893
  // Issue #892: a mid-flight project-independent onboarding (manager/company) must
4879
4894
  // resume even with no project (e.g. after a Hub restart).
4880
- const scope = readRunScope(body.scope);
4895
+ const scope = readRunScope(body.scope, body.jobId);
4881
4896
  const projectPath = this.resolveRunProjectPath(body.projectPath, scope);
4882
4897
  const requestedHostId = body.hostId;
4883
- const sessionId = (body.sessionId || '').trim();
4898
+ const requestedSessionId = (body.sessionId || '').trim();
4884
4899
  const jobId = (body.jobId || '').trim();
4885
4900
  const instructions = (body.instructions || '').trim();
4886
4901
  const coachingJobId = body.coachingJobId?.trim() || undefined;
4887
- if (!sessionId)
4902
+ if (!requestedSessionId)
4888
4903
  throw new Error('A host sessionId is required to resume.');
4889
4904
  if (!jobId)
4890
4905
  throw new Error('A jobId is required to resume.');
@@ -4904,6 +4919,17 @@ class AiHubServer {
4904
4919
  ? this.conversationStore.loadProject(conversationBucketKey).conversations.find((entry) => entry.id === requestedConversationId)
4905
4920
  : inferredConversation ?? undefined;
4906
4921
  const persistedRun = readPersistedRunProjection(persistedConversation);
4922
+ const resolvedHostSession = host_session_state_1.hostSessionState.resolve(persistedConversation, {
4923
+ configuredAgentId: configuredAgent.id,
4924
+ baseHostId: hostId,
4925
+ });
4926
+ if (!resolvedHostSession && host_session_state_1.hostSessionState.hasInvalidSession(persistedConversation, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, requestedSessionId)) {
4927
+ return res.status(409).json({
4928
+ error: 'The saved host session was already marked invalid. Start a fresh handoff-backed run instead of retrying the same session.',
4929
+ hostSessionStatus: 'invalid',
4930
+ });
4931
+ }
4932
+ const sessionId = resolvedHostSession?.sessionId || requestedSessionId;
4907
4933
  const now = new Date().toISOString();
4908
4934
  const run = {
4909
4935
  id: (0, crypto_1.randomUUID)(),
@@ -4911,6 +4937,7 @@ class AiHubServer {
4911
4937
  conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
4912
4938
  jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
4913
4939
  jobId, hostId, configuredAgentId: configuredAgent.id, configuredAgentLabel: configuredAgent.label, baseHostId: configuredAgent.baseHostId, projectPath, status: 'running', sessionId,
4940
+ hostSessions: persistedConversation?.hostSessions ? { ...persistedConversation.hostSessions } : undefined,
4914
4941
  // Issue #892: keep the invocation scope so a resumed manager/company run stays
4915
4942
  // in its project-independent conversation bucket.
4916
4943
  scope,
@@ -4927,6 +4954,7 @@ class AiHubServer {
4927
4954
  personaKey: getHubPersonaForJob(jobId),
4928
4955
  continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
4929
4956
  };
4957
+ host_session_state_1.hostSessionState.applySession(run, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, sessionId, { sourceRunId: run.id, status: resolvedHostSession?.status || 'suspect' });
4930
4958
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
4931
4959
  // the shared-browser note so the resumed agent knows about it.
4932
4960
  const preparedResume = this.prepareContinueMessage(run, instructions, coachingJobId);
@@ -4945,11 +4973,15 @@ class AiHubServer {
4945
4973
  onEvent: (event, channel) => {
4946
4974
  this.runRegistry.update(run.id, (current) => {
4947
4975
  if (event.sessionId) {
4948
- current.sessionId = event.sessionId;
4976
+ host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
4949
4977
  current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
4950
4978
  }
4951
4979
  appendHostMessage(current, hostId, event, channel);
4952
4980
  if (event.raw) {
4981
+ const missingSessionId = extractMissingHostSessionId(event.raw);
4982
+ if (missingSessionId) {
4983
+ host_session_state_1.hostSessionState.markInvalidRun(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, missingSessionId, 'host-reported-missing-session');
4984
+ }
4953
4985
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
4954
4986
  applyReviewProjection(current, event.raw);
4955
4987
  }
@@ -5446,14 +5478,23 @@ class AiHubServer {
5446
5478
  return res.status(404).json({ error: `Custom employee ${key} not found.` });
5447
5479
  }
5448
5480
  const { displayName, role, icon, jobIds } = req.body;
5481
+ if (displayName !== undefined && (typeof displayName !== 'string' || !displayName.trim())) {
5482
+ return res.status(400).json({ error: 'displayName is required.' });
5483
+ }
5484
+ if (jobIds !== undefined && (!Array.isArray(jobIds) || jobIds.length === 0)) {
5485
+ return res.status(400).json({ error: 'jobIds must be a non-empty array.' });
5486
+ }
5487
+ const nextDisplayName = displayName !== undefined ? displayName.trim() : record.displayName;
5449
5488
  const VALID_ICON_KINDS_PATCH = new Set(['emoji', 'generated', 'image']);
5450
5489
  const patchedIcon = icon !== undefined
5451
5490
  ? { kind: (VALID_ICON_KINDS_PATCH.has(icon.kind) ? icon.kind : record.icon.kind), value: icon.value ?? record.icon.value }
5452
- : undefined;
5491
+ : (displayName !== undefined && record.icon.kind === 'generated')
5492
+ ? { ...record.icon, value: nextDisplayName }
5493
+ : undefined;
5453
5494
  const updated = {
5454
5495
  ...record,
5455
- ...(displayName !== undefined ? { displayName } : {}),
5456
- ...(role !== undefined ? { role } : {}),
5496
+ ...(displayName !== undefined ? { displayName: nextDisplayName } : {}),
5497
+ ...(role !== undefined ? { role: role.trim() } : {}),
5457
5498
  ...(patchedIcon !== undefined ? { icon: patchedIcon } : {}),
5458
5499
  ...(jobIds !== undefined ? { jobIds } : {}),
5459
5500
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.246",
3
+ "version": "2.0.248",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -161,7 +161,7 @@
161
161
  "electron": "^41.2.2",
162
162
  "electron-updater": "^6.8.9",
163
163
  "express": "^5.2.1",
164
- "fraim": "2.0.246",
164
+ "fraim": "2.0.248",
165
165
  "mongodb": "^7.0.0",
166
166
  "node-cron": "4.2.1",
167
167
  "node-edge-tts": "^1.2.10",
@@ -67,6 +67,7 @@ const state = {
67
67
  pendingConnectedSurfaceAfterAuth: null,
68
68
  panelState: {}, // { [convId]: { coach?: boolean } }
69
69
  wordContext: null, // WordContext pushed from taskpane.html via postMessage
70
+ taskPaneProjects: [],
70
71
  // Pending coaching job selected via a quick-coach button or template picker.
71
72
  // Sent as coachingJobId in the next continueRun call and then cleared.
72
73
  // The invocation prefix ($fraim / /fraim) is added server-side based on
@@ -248,6 +249,13 @@ function applyBootstrap(bootstrap, docUrl) {
248
249
  bootstrap = mergePersonaProjection(bootstrap, state.bootstrap);
249
250
  state.bootstrap = bootstrap;
250
251
  state.projectPath = bootstrap.project.path;
252
+ // Issue #1044: seed client-side tombstone set from server preferences so
253
+ // tfEnsureCurrentProject does not re-inject a path the user just deleted.
254
+ if (bootstrap.preferences && Array.isArray(bootstrap.preferences.removedProjectPaths)) {
255
+ for (const p of bootstrap.preferences.removedProjectPaths) {
256
+ if (typeof p === 'string' && p.length > 0) tf.removedPaths.add(tfCanonicalProjectPath(p));
257
+ }
258
+ }
251
259
  state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
252
260
  if (bootstrap.preferences && bootstrap.preferences.apiKey) {
253
261
  state.storedApiKey = bootstrap.preferences.apiKey;
@@ -420,6 +428,7 @@ function applyWordContext(ctx, partial) {
420
428
  }
421
429
  renderWordContextBar();
422
430
  renderWordContextInModal();
431
+ renderTaskPaneLauncher();
423
432
  }
424
433
 
425
434
  function setupWordBridge() {
@@ -520,6 +529,217 @@ function renderWordContextInModal() {
520
529
  body.textContent = buildWordContextBlock(wc);
521
530
  }
522
531
 
532
+ function isTaskPaneSurface() {
533
+ return document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension';
534
+ }
535
+
536
+ function taskPaneProjectEntries() {
537
+ const byPath = new Map();
538
+ const add = (project) => {
539
+ if (!project) return;
540
+ const folderPath = project.folderPath || project.path || project.folder || '';
541
+ if (!folderPath || byPath.has(tfCanonicalProjectPath(folderPath))) return;
542
+ byPath.set(tfCanonicalProjectPath(folderPath), {
543
+ ...project,
544
+ folderPath,
545
+ name: project.name || friendlyProjectShortName(folderPath),
546
+ });
547
+ };
548
+ add({ folderPath: state.projectPath, name: friendlyProjectShortName(state.projectPath) });
549
+ (state.taskPaneProjects || []).forEach(add);
550
+ ((state.bootstrap && state.bootstrap.projects) || []).forEach(add);
551
+ return Array.from(byPath.values());
552
+ }
553
+
554
+ function taskPaneJobEntries() {
555
+ const jobs = [
556
+ ...((state.bootstrap && state.bootstrap.jobs) || []),
557
+ ...((state.bootstrap && state.bootstrap.managerTemplates) || []),
558
+ ];
559
+ const seen = new Set();
560
+ return jobs
561
+ .filter((job) => job && job.id && !PAGE_SCOPED_JOBS.has(job.id))
562
+ .filter((job) => {
563
+ if (seen.has(job.id)) return false;
564
+ seen.add(job.id);
565
+ return true;
566
+ });
567
+ }
568
+
569
+ function taskPaneInstructionSeed() {
570
+ const wc = state.wordContext || {};
571
+ if (wc.selection) return wc.selection;
572
+ if (wc.bodyPreview) return wc.bodyPreview.slice(0, 500);
573
+ return '';
574
+ }
575
+
576
+ function taskPaneSetOptions(select, entries, valueOf, labelOf, selectedValue) {
577
+ if (!select) return;
578
+ const previous = select.value;
579
+ select.innerHTML = '';
580
+ entries.forEach((entry) => {
581
+ const opt = document.createElement('option');
582
+ opt.value = valueOf(entry);
583
+ opt.textContent = labelOf(entry);
584
+ select.appendChild(opt);
585
+ });
586
+ const wanted = selectedValue || previous;
587
+ if (wanted && entries.some((entry) => valueOf(entry) === wanted)) select.value = wanted;
588
+ }
589
+
590
+ function ensureTaskPaneLauncher() {
591
+ let root = document.getElementById('task-pane-launcher');
592
+ if (root) return root;
593
+ root = document.createElement('section');
594
+ root.id = 'task-pane-launcher';
595
+ root.className = 'task-pane-launcher';
596
+
597
+ const header = document.createElement('div');
598
+ header.className = 'tp-launcher-header';
599
+ const title = document.createElement('div');
600
+ title.className = 'tp-launcher-title';
601
+ title.textContent = 'Delegate from Word';
602
+ const context = document.createElement('div');
603
+ context.id = 'task-pane-context-summary';
604
+ context.className = 'tp-launcher-context';
605
+ header.appendChild(title);
606
+ header.appendChild(context);
607
+ root.appendChild(header);
608
+
609
+ const fields = [
610
+ ['Project', 'task-pane-project-select', 'select'],
611
+ ['Agent tool', 'task-pane-employee-select', 'select'],
612
+ ['Job', 'task-pane-job-select', 'select'],
613
+ ['Instructions', 'task-pane-instructions', 'textarea'],
614
+ ];
615
+ fields.forEach(([labelText, id, tag]) => {
616
+ const label = document.createElement('label');
617
+ label.className = 'tp-launcher-field';
618
+ label.setAttribute('for', id);
619
+ const span = document.createElement('span');
620
+ span.textContent = labelText;
621
+ const input = document.createElement(tag);
622
+ input.id = id;
623
+ if (tag === 'textarea') {
624
+ input.rows = 4;
625
+ input.placeholder = 'Add the outcome you want from this Word context.';
626
+ input.addEventListener('input', () => { input.dataset.userTouched = '1'; });
627
+ }
628
+ label.appendChild(span);
629
+ label.appendChild(input);
630
+ root.appendChild(label);
631
+ });
632
+
633
+ const actions = document.createElement('div');
634
+ actions.className = 'tp-launcher-actions';
635
+ const status = document.createElement('div');
636
+ status.id = 'task-pane-start-status';
637
+ status.className = 'tp-launcher-status';
638
+ const start = document.createElement('button');
639
+ start.id = 'task-pane-start-job';
640
+ start.className = 'send-button';
641
+ start.type = 'button';
642
+ start.textContent = 'Start job';
643
+ start.addEventListener('click', taskPaneStartSelectedJob);
644
+ actions.appendChild(status);
645
+ actions.appendChild(start);
646
+ root.appendChild(actions);
647
+
648
+ document.body.insertBefore(root, document.body.firstChild);
649
+ document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
650
+ const statusEl = document.getElementById('task-pane-start-status');
651
+ const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
652
+ if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
653
+ });
654
+ return root;
655
+ }
656
+
657
+ function renderTaskPaneLauncher() {
658
+ if (!isTaskPaneSurface() || !state.bootstrap) return;
659
+ ensureTaskPaneLauncher();
660
+ const projects = taskPaneProjectEntries();
661
+ const jobs = taskPaneJobEntries();
662
+ const agents = hubConfiguredAgents().filter((agent) => agent.available !== false && agent.enabled !== false);
663
+ const projectSelect = document.getElementById('task-pane-project-select');
664
+ const employeeSelect = document.getElementById('task-pane-employee-select');
665
+ const jobSelect = document.getElementById('task-pane-job-select');
666
+ const instructions = document.getElementById('task-pane-instructions');
667
+ const start = document.getElementById('task-pane-start-job');
668
+ const context = document.getElementById('task-pane-context-summary');
669
+
670
+ taskPaneSetOptions(projectSelect, projects, (project) => project.folderPath, (project) => project.name || friendlyProjectShortName(project.folderPath), state.projectPath);
671
+ taskPaneSetOptions(employeeSelect, agents, (agent) => agent.id, (agent) => agent.label || agent.id, state.selectedEmployeeId);
672
+ taskPaneSetOptions(jobSelect, jobs, (job) => job.id, (job) => job.title || job.id, null);
673
+
674
+ if (instructions && instructions.dataset.userTouched !== '1') {
675
+ instructions.value = taskPaneInstructionSeed();
676
+ }
677
+ if (context) {
678
+ const wc = state.wordContext || {};
679
+ const projectName = friendlyProjectShortName(state.projectPath);
680
+ const selected = wc.selection ? wc.selection.split(/\s+/).filter(Boolean).length + ' selected words' : 'Word context ready';
681
+ context.textContent = projectName ? `${projectName} - ${selected}` : selected;
682
+ }
683
+ if (start) start.disabled = projects.length === 0 || agents.length === 0 || jobs.length === 0;
684
+ }
685
+
686
+ async function refreshTaskPaneProjects() {
687
+ if (!isTaskPaneSurface()) return;
688
+ const query = state.projectPath ? '?projectPath=' + encodeURIComponent(state.projectPath) : '';
689
+ const payload = await requestJson('/api/ai-hub/projects' + query).catch(() => null);
690
+ state.taskPaneProjects = payload && Array.isArray(payload.projects) ? payload.projects : [];
691
+ renderTaskPaneLauncher();
692
+ }
693
+
694
+ async function taskPaneSwitchProject(folderPath) {
695
+ if (!folderPath || tfCanonicalProjectPath(folderPath) === tfCanonicalProjectPath(state.projectPath)) return;
696
+ const priorEmployee = document.getElementById('task-pane-employee-select')?.value || state.selectedEmployeeId;
697
+ await loadBootstrap(folderPath, new URLSearchParams(window.location.search).get('docUrl') || '', { preferCache: true });
698
+ await hydrateConversationsFromServer();
699
+ const latestEmployee = document.getElementById('task-pane-employee-select')?.value || priorEmployee;
700
+ if (latestEmployee && hubConfiguredAgents().some((agent) => agent.id === latestEmployee)) {
701
+ state.selectedEmployeeId = latestEmployee;
702
+ }
703
+ await refreshTaskPaneProjects();
704
+ renderRail();
705
+ renderActive();
706
+ }
707
+
708
+ async function taskPaneStartSelectedJob() {
709
+ const projectSelect = document.getElementById('task-pane-project-select');
710
+ const employeeSelect = document.getElementById('task-pane-employee-select');
711
+ const jobSelect = document.getElementById('task-pane-job-select');
712
+ const instructions = document.getElementById('task-pane-instructions');
713
+ const start = document.getElementById('task-pane-start-job');
714
+ const statusEl = document.getElementById('task-pane-start-status');
715
+ const selectedProject = projectSelect && projectSelect.value;
716
+ const requestedEmployeeId = employeeSelect && employeeSelect.value;
717
+ if (selectedProject && tfCanonicalProjectPath(selectedProject) !== tfCanonicalProjectPath(state.projectPath)) {
718
+ await taskPaneSwitchProject(selectedProject);
719
+ const updatedEmployeeSelect = document.getElementById('task-pane-employee-select');
720
+ if (updatedEmployeeSelect && requestedEmployeeId) updatedEmployeeSelect.value = requestedEmployeeId;
721
+ }
722
+ const jobs = taskPaneJobEntries();
723
+ const job = jobs.find((entry) => entry.id === (jobSelect && jobSelect.value));
724
+ const employeeId = requestedEmployeeId || (employeeSelect && employeeSelect.value);
725
+ if (!job || !employeeId) return;
726
+ if (start) start.disabled = true;
727
+ if (statusEl) statusEl.textContent = 'Starting...';
728
+ try {
729
+ const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
730
+ if (result && result.ok) {
731
+ if (statusEl) statusEl.textContent = 'Started.';
732
+ } else if (statusEl) {
733
+ statusEl.textContent = (result && result.error) || 'Could not start.';
734
+ }
735
+ } catch (error) {
736
+ if (statusEl) statusEl.textContent = 'Could not start.';
737
+ console.warn('[ai-hub] task-pane start failed:', error);
738
+ } finally {
739
+ renderTaskPaneLauncher();
740
+ }
741
+ }
742
+
523
743
  // ---------------------------------------------------------------------------
524
744
  // Concept popover population (drives the welcome-line "see ... jobs" lists)
525
745
  // ---------------------------------------------------------------------------
@@ -1593,6 +1813,128 @@ function aggregateDotClass(dots) {
1593
1813
  return 'grey';
1594
1814
  }
1595
1815
 
1816
+ function closeEmployeeManageMenu() {
1817
+ const existing = document.querySelector('.emp-manage-pop');
1818
+ if (existing) existing.remove();
1819
+ }
1820
+
1821
+ async function refreshCustomEmployeeSurfaces() {
1822
+ const projectPath = (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.projectPath) || state.projectPath || '';
1823
+ if (typeof loadBootstrap === 'function') {
1824
+ await loadBootstrap(projectPath);
1825
+ } else {
1826
+ if (typeof renderRail === 'function') renderRail();
1827
+ if (typeof tfRenderTree === 'function') tfRenderTree();
1828
+ if (typeof renderActive === 'function') renderActive();
1829
+ }
1830
+ }
1831
+
1832
+ function openEmployeeManageMenu(group, anchorEl, detailsEl) {
1833
+ closeEmployeeManageMenu();
1834
+ const pop = document.createElement('div');
1835
+ pop.className = 'emp-manage-pop';
1836
+ pop.dataset.employeeKey = group.key;
1837
+
1838
+ const edit = document.createElement('button');
1839
+ edit.type = 'button';
1840
+ edit.className = 'emp-manage-edit';
1841
+ edit.textContent = 'Edit';
1842
+ edit.addEventListener('click', (e) => {
1843
+ e.preventDefault();
1844
+ e.stopPropagation();
1845
+ closeEmployeeManageMenu();
1846
+ const persona = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(group.key) : null;
1847
+ if (persona) openInlineCreateEmployee({ mode: 'edit', employee: persona });
1848
+ });
1849
+ pop.appendChild(edit);
1850
+
1851
+ const del = document.createElement('button');
1852
+ del.type = 'button';
1853
+ del.className = 'emp-manage-delete';
1854
+ del.textContent = 'Delete';
1855
+ del.addEventListener('click', (e) => {
1856
+ e.preventDefault();
1857
+ e.stopPropagation();
1858
+ closeEmployeeManageMenu();
1859
+ showCustomEmployeeDeleteConfirm(group, detailsEl);
1860
+ });
1861
+ pop.appendChild(del);
1862
+
1863
+ const dismiss = (e) => {
1864
+ if (!pop.contains(e.target) && e.target !== anchorEl) {
1865
+ closeEmployeeManageMenu();
1866
+ document.removeEventListener('click', dismiss, true);
1867
+ }
1868
+ };
1869
+ setTimeout(() => document.addEventListener('click', dismiss, true), 0);
1870
+
1871
+ document.body.appendChild(pop);
1872
+ if (anchorEl) {
1873
+ const rect = anchorEl.getBoundingClientRect();
1874
+ pop.style.position = 'fixed';
1875
+ pop.style.top = (rect.bottom + 4) + 'px';
1876
+ pop.style.left = Math.max(8, rect.right - 112) + 'px';
1877
+ }
1878
+ }
1879
+
1880
+ function showCustomEmployeeDeleteConfirm(group, detailsEl) {
1881
+ if (!detailsEl) return;
1882
+ const existing = detailsEl.querySelector('.emp-del-confirm');
1883
+ if (existing) {
1884
+ existing.remove();
1885
+ return;
1886
+ }
1887
+ document.querySelectorAll('.emp-del-confirm').forEach((el) => el.remove());
1888
+ detailsEl.open = true;
1889
+ const confirm = document.createElement('div');
1890
+ confirm.className = 'del-confirm emp-del-confirm';
1891
+ confirm.innerHTML = '<div>Delete ' + tfEscape(group.label) + '?</div>' +
1892
+ '<div class="dc-detail">The employee definition leaves the active roster. Prior conversations and artifacts remain.</div>';
1893
+ const row = document.createElement('div');
1894
+ row.className = 'dc-row';
1895
+ const cancel = document.createElement('button');
1896
+ cancel.type = 'button';
1897
+ cancel.className = 'dc-cancel';
1898
+ cancel.textContent = 'Cancel';
1899
+ cancel.addEventListener('click', (e) => {
1900
+ e.preventDefault();
1901
+ e.stopPropagation();
1902
+ confirm.remove();
1903
+ });
1904
+ const del = document.createElement('button');
1905
+ del.type = 'button';
1906
+ del.className = 'dc-del';
1907
+ del.textContent = 'Delete employee';
1908
+ const detail = confirm.querySelector('.dc-detail');
1909
+ del.addEventListener('click', async (e) => {
1910
+ e.preventDefault();
1911
+ e.stopPropagation();
1912
+ del.disabled = true;
1913
+ del.textContent = 'Deleting...';
1914
+ try {
1915
+ const projectPath = (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.projectPath) || state.projectPath || '';
1916
+ const resp = await fetch('/api/ai-hub/custom-employees/' + encodeURIComponent(group.key) + '?projectPath=' + encodeURIComponent(projectPath), {
1917
+ method: 'DELETE',
1918
+ });
1919
+ if (!resp.ok && resp.status !== 404) throw new Error('Server error: ' + resp.status);
1920
+ if (state.cpPersonaOverride === group.key && typeof closePalette === 'function') closePalette();
1921
+ const overlay = document.querySelector('.ipe-overlay');
1922
+ if (overlay) overlay.remove();
1923
+ await refreshCustomEmployeeSurfaces();
1924
+ } catch (error) {
1925
+ del.disabled = false;
1926
+ del.textContent = 'Delete employee';
1927
+ if (detail) detail.textContent = 'Could not delete employee. Try again.';
1928
+ }
1929
+ });
1930
+ row.appendChild(cancel);
1931
+ row.appendChild(del);
1932
+ confirm.appendChild(row);
1933
+ const summary = detailsEl.querySelector('summary');
1934
+ if (summary && summary.nextSibling) detailsEl.insertBefore(confirm, summary.nextSibling);
1935
+ else detailsEl.appendChild(confirm);
1936
+ }
1937
+
1596
1938
  // ---------------------------------------------------------------------------
1597
1939
  // Rail rendering
1598
1940
  // ---------------------------------------------------------------------------
@@ -1919,28 +2261,53 @@ function renderRail() {
1919
2261
  const count = document.createElement('span');
1920
2262
  count.className = 'conv-employee-tab-count';
1921
2263
  count.textContent = String(group.conversations.length);
1922
- const addBtn = document.createElement('button');
1923
- addBtn.type = 'button';
1924
- addBtn.className = 'conv-employee-add';
1925
- addBtn.textContent = '+';
1926
- addBtn.title = 'Assign a job to ' + group.label;
1927
- addBtn.setAttribute('aria-label', 'Assign a job to ' + group.label);
1928
- addBtn.addEventListener('click', (e) => {
1929
- e.preventDefault();
1930
- e.stopPropagation();
1931
- // Issue #945: custom employees open the standard palette pre-filtered to their assigned jobs.
1932
- // prefixSearch uses the display name so the user sees /nova, not /custom:nova.
1933
- // personaKey is passed separately so renderCpRows can look up the jobIds correctly.
1934
- if (group.key.startsWith('custom:')) {
1935
- const displaySlug = group.label ? group.label.toLowerCase().replace(/\s+/g, '-') : group.key.replace('custom:', '');
1936
- openPalette({ prefixSearch: '/' + displaySlug, personaKey: group.key });
1937
- return;
1938
- }
1939
- const personaKey = group.sample && group.sample.personaKey ? group.key : null;
1940
- openPalette(personaKey ? { employeeId: state.bootstrap && state.bootstrap.preferences ? state.bootstrap.preferences.employeeId : 'claude', prefixSearch: '/' + personaKey } : {});
1941
- });
1942
- // Issue #945: add Personalized badge for user-authored employees (no hire CTA).
1943
- if (group.key.startsWith('custom:')) {
2264
+ const groupPersona = getConversationPersona(group.sample);
2265
+ const groupIsRemoved = isRemovedCustomPersona(groupPersona);
2266
+ const groupIsActiveCustom = group.key.startsWith('custom:') && isActiveCustomPersona(groupPersona);
2267
+ let addBtn = null;
2268
+ if (!groupIsRemoved) {
2269
+ addBtn = document.createElement('button');
2270
+ addBtn.type = 'button';
2271
+ addBtn.className = 'conv-employee-add';
2272
+ addBtn.textContent = '+';
2273
+ addBtn.title = 'Assign a job to ' + group.label;
2274
+ addBtn.setAttribute('aria-label', 'Assign a job to ' + group.label);
2275
+ addBtn.addEventListener('click', (e) => {
2276
+ e.preventDefault();
2277
+ e.stopPropagation();
2278
+ // Issue #945: custom employees open the standard palette pre-filtered to their assigned jobs.
2279
+ // prefixSearch uses the display name so the user sees /nova, not /custom:nova.
2280
+ // personaKey is passed separately so renderCpRows can look up the jobIds correctly.
2281
+ if (group.key.startsWith('custom:')) {
2282
+ const displaySlug = group.label ? group.label.toLowerCase().replace(/\s+/g, '-') : group.key.replace('custom:', '');
2283
+ openPalette({ prefixSearch: '/' + displaySlug, personaKey: group.key });
2284
+ return;
2285
+ }
2286
+ const personaKey = group.sample && group.sample.personaKey ? group.key : null;
2287
+ openPalette(personaKey ? { employeeId: state.bootstrap && state.bootstrap.preferences ? state.bootstrap.preferences.employeeId : 'claude', prefixSearch: '/' + personaKey } : {});
2288
+ });
2289
+ }
2290
+ let manageBtn = null;
2291
+ if (groupIsActiveCustom) {
2292
+ manageBtn = document.createElement('button');
2293
+ manageBtn.type = 'button';
2294
+ manageBtn.className = 'conv-employee-manage';
2295
+ manageBtn.textContent = '...';
2296
+ manageBtn.title = 'Manage ' + group.label;
2297
+ manageBtn.setAttribute('aria-label', 'Manage ' + group.label);
2298
+ manageBtn.addEventListener('click', (e) => {
2299
+ e.preventDefault();
2300
+ e.stopPropagation();
2301
+ openEmployeeManageMenu(group, manageBtn, details);
2302
+ });
2303
+ }
2304
+ if (groupIsRemoved) {
2305
+ details.classList.add('conv-employee-group--removed');
2306
+ const removed = document.createElement('span');
2307
+ removed.className = 'emp-removed-pill';
2308
+ removed.textContent = 'Removed';
2309
+ label.appendChild(removed);
2310
+ } else if (groupIsActiveCustom) {
1944
2311
  const badge = document.createElement('span');
1945
2312
  badge.className = 'taught-badge';
1946
2313
  badge.textContent = 'Personalized';
@@ -1960,7 +2327,8 @@ function renderRail() {
1960
2327
  }
1961
2328
  summary.appendChild(avatar);
1962
2329
  summary.appendChild(copy);
1963
- summary.appendChild(addBtn);
2330
+ if (addBtn) summary.appendChild(addBtn);
2331
+ if (manageBtn) summary.appendChild(manageBtn);
1964
2332
  summary.appendChild(count);
1965
2333
  details.appendChild(summary);
1966
2334
  if (group.conversations.length > 0) {
@@ -2121,10 +2489,57 @@ function personaMap() {
2121
2489
  return map;
2122
2490
  }
2123
2491
 
2492
+ function customEmployeeNameFromKey(key) {
2493
+ const slug = String(key || '').replace(/^custom:/, '');
2494
+ const words = slug.split(/[-_]+/).filter(Boolean);
2495
+ const label = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
2496
+ return label || 'Employee';
2497
+ }
2498
+
2499
+ function conversationPersonaSnapshot(conv) {
2500
+ const snap = conv && conv.personaSnapshot;
2501
+ if (!snap || typeof snap !== 'object') return null;
2502
+ return {
2503
+ displayName: typeof snap.displayName === 'string' && snap.displayName.trim()
2504
+ ? snap.displayName.trim()
2505
+ : '',
2506
+ role: typeof snap.role === 'string' && snap.role.trim()
2507
+ ? snap.role.trim()
2508
+ : '',
2509
+ avatarUrl: typeof snap.avatarUrl === 'string' && snap.avatarUrl.trim()
2510
+ ? snap.avatarUrl.trim()
2511
+ : '',
2512
+ };
2513
+ }
2514
+
2515
+ function removedCustomPersonaForConversation(conv) {
2516
+ const key = conv && conv.personaKey;
2517
+ if (!key || !key.startsWith('custom:')) return null;
2518
+ const snapshot = conversationPersonaSnapshot(conv);
2519
+ const name = (snapshot && snapshot.displayName) || customEmployeeNameFromKey(key);
2520
+ return {
2521
+ key,
2522
+ displayName: 'Removed employee: ' + name,
2523
+ role: (snapshot && snapshot.role) || 'Historical employee',
2524
+ avatarUrl: (snapshot && snapshot.avatarUrl) || '',
2525
+ origin: 'custom',
2526
+ jobIds: [],
2527
+ removed: true,
2528
+ };
2529
+ }
2530
+
2531
+ function isRemovedCustomPersona(persona) {
2532
+ return !!(persona && persona.removed);
2533
+ }
2534
+
2535
+ function isActiveCustomPersona(persona) {
2536
+ return !!(persona && !persona.removed && (persona.origin === 'custom' || String(persona.key || '').startsWith('custom:')));
2537
+ }
2538
+
2124
2539
  function getConversationPersona(conv) {
2125
2540
  if (!conv || !conv.personaKey) return null;
2126
2541
  if (conv.personaKey === GENERIC_WORKER_PERSONA_KEY) return GENERIC_WORKER_PERSONA;
2127
- return personaMap().get(conv.personaKey) || null;
2542
+ return personaMap().get(conv.personaKey) || removedCustomPersonaForConversation(conv);
2128
2543
  }
2129
2544
 
2130
2545
  // Unprotected jobs default to mandy so unassigned work surfaces
@@ -6779,6 +7194,17 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
6779
7194
  // Issue #442: read the A/B toggle state from the modal before it closes.
6780
7195
  const abToggle = document.getElementById('ab-toggle');
6781
7196
  const isAB = !isFreeform && abToggle && abToggle.checked;
7197
+ const assignedPersonaKey = assignedPersonaKeyForJob(job);
7198
+ const assignedPersona = assignedPersonaKey && assignedPersonaKey.startsWith('custom:')
7199
+ ? (typeof tfPersonaByKey === 'function' ? tfPersonaByKey(assignedPersonaKey) : null)
7200
+ : null;
7201
+ const personaSnapshot = assignedPersona
7202
+ ? {
7203
+ displayName: assignedPersona.displayName || assignedPersonaKey,
7204
+ role: assignedPersona.role || '',
7205
+ avatarUrl: assignedPersona.avatarUrl || '',
7206
+ }
7207
+ : null;
6782
7208
 
6783
7209
  const conv = {
6784
7210
  id: preassignedConvId || newConversationId(),
@@ -6792,7 +7218,8 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
6792
7218
  baseHostId: baseHostIdForAgent(employeeId),
6793
7219
  // Structured jobs without a protected employee default to MANdy; freeform
6794
7220
  // described goals remain employee-less until promotion assigns ownership.
6795
- personaKey: assignedPersonaKeyForJob(job),
7221
+ personaKey: assignedPersonaKey,
7222
+ ...(personaSnapshot ? { personaSnapshot } : {}),
6796
7223
  runId: null,
6797
7224
  sessionId: null,
6798
7225
  status: 'running',
@@ -7071,6 +7498,9 @@ function foldRunIntoConversation(conv, run) {
7071
7498
  conv.title = conversationTitle(conv);
7072
7499
  // Track session for resumption.
7073
7500
  if (run.sessionId) conv.sessionId = run.sessionId;
7501
+ if (run.hostSessions && typeof run.hostSessions === 'object') {
7502
+ conv.hostSessions = { ...(conv.hostSessions || {}), ...run.hostSessions };
7503
+ }
7074
7504
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
7075
7505
  if (runHandoff) {
7076
7506
  conv.reviewHandoff = runHandoff;
@@ -8233,13 +8663,13 @@ function wireEvents() {
8233
8663
  }
8234
8664
 
8235
8665
  // Task-pane and extension surfaces: skip welcome/onboarding, show active job
8236
- // or open the new-job picker. Layout is compact (rail hidden via CSS).
8666
+ // plus a compact project/job launcher. Layout is compact (full shell hidden via CSS).
8237
8667
  if (surface === 'task-pane' || surface === 'extension') {
8668
+ await refreshTaskPaneProjects();
8669
+ renderTaskPaneLauncher();
8238
8670
  const conv = activeConversation();
8239
8671
  if (conv && conv.projectPath === state.projectPath) {
8240
8672
  if (convNeedsPolling(conv)) startPolling();
8241
- } else {
8242
- openModal();
8243
8673
  }
8244
8674
  return;
8245
8675
  }
@@ -9028,9 +9458,8 @@ function tfRenderOverview() {
9028
9458
  }
9029
9459
  card.appendChild(team);
9030
9460
  card.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
9031
- // #719 R1/R1b: hover/focus-revealed remove controlOverview cards only, and
9032
- // only while another project remains (the Hub always keeps at least one).
9033
- if (tf.projects.length > 1) tfAttachProjectRemove(card, proj);
9461
+ // Issue #1044: remove button shown on every project card the Hub can be empty.
9462
+ tfAttachProjectRemove(card, proj);
9034
9463
  grid.appendChild(card);
9035
9464
  }
9036
9465
  const addCard = document.createElement('div');
@@ -9200,17 +9629,19 @@ async function tfRefreshProjectsFromServerInBackground() {
9200
9629
 
9201
9630
  async function tfRemoveProject(proj) {
9202
9631
  const removedCanonical = tfCanonicalProjectPath(proj.folderPath || '');
9203
- // R5/D4: never delete the workspace we are standing in the server re-injects
9204
- // the current projectPath on every load, so switch to another project first.
9632
+ // Issue #1044: when other projects exist, switch away from the active one before
9633
+ // deleting it (the server returns 409 if we don't). When it is the last project,
9634
+ // skip the switch — the server now permits the delete and the Hub shows empty state.
9205
9635
  if (removedCanonical === tfCanonicalProjectPath(state.projectPath)) {
9206
9636
  const next = tf.projects.find((p) => p.id !== proj.id && tfCanonicalProjectPath(p.folderPath || '') !== removedCanonical);
9207
- if (!next) { showStatus('Cannot remove the only project in the Hub.', true); return; }
9208
- try {
9209
- await tfSwitchProjectFolder(next.folderPath);
9210
- tf.activeProjectId = next.id;
9211
- } catch (e) {
9212
- showStatus('Could not switch to another project first; nothing was removed.', true);
9213
- return;
9637
+ if (next) {
9638
+ try {
9639
+ await tfSwitchProjectFolder(next.folderPath);
9640
+ tf.activeProjectId = next.id;
9641
+ } catch (e) {
9642
+ showStatus('Could not switch to another project first; nothing was removed.', true);
9643
+ return;
9644
+ }
9214
9645
  }
9215
9646
  }
9216
9647
 
@@ -13942,10 +14373,16 @@ function openCustomEmployeePicker(employeeKey, persona, anchorEl) {
13942
14373
  }
13943
14374
  }
13944
14375
 
13945
- // Renders the inline create card for a new custom employee.
13946
- function openInlineCreateEmployee() {
14376
+ // Renders the inline create/edit card for a custom employee.
14377
+ function openInlineCreateEmployee(options) {
14378
+ const opts = options || {};
14379
+ const editEmployee = opts.mode === 'edit' ? opts.employee : null;
14380
+ const isEdit = !!(editEmployee && editEmployee.key);
13947
14381
  const existingOverlay = document.querySelector('.ipe-overlay');
13948
- if (existingOverlay) { existingOverlay.remove(); return; }
14382
+ if (existingOverlay) {
14383
+ existingOverlay.remove();
14384
+ if (!isEdit) return;
14385
+ }
13949
14386
  const projectPath = (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.projectPath) || state.projectPath || '';
13950
14387
  const jobs = (state.bootstrap && state.bootstrap.jobs) ? state.bootstrap.jobs : [];
13951
14388
 
@@ -13961,7 +14398,7 @@ function openInlineCreateEmployee() {
13961
14398
  const hdr = document.createElement('div');
13962
14399
  hdr.className = 'modal-hdr';
13963
14400
  const hdrTitle = document.createElement('h2');
13964
- hdrTitle.textContent = 'New employee';
14401
+ hdrTitle.textContent = isEdit ? 'Edit employee' : 'New employee';
13965
14402
  const closeBtn = document.createElement('button');
13966
14403
  closeBtn.type = 'button';
13967
14404
  closeBtn.className = 'modal-close';
@@ -13988,6 +14425,7 @@ function openInlineCreateEmployee() {
13988
14425
  nameInput.placeholder = 'e.g. Nova';
13989
14426
  nameInput.setAttribute('aria-label', 'Employee name');
13990
14427
  nameInput.autocomplete = 'off';
14428
+ if (isEdit) nameInput.value = editEmployee.displayName || '';
13991
14429
  nameField.appendChild(nameLabel);
13992
14430
  nameField.appendChild(nameInput);
13993
14431
  body.appendChild(nameField);
@@ -14003,12 +14441,13 @@ function openInlineCreateEmployee() {
14003
14441
  roleInput.name = 'role';
14004
14442
  roleInput.placeholder = 'e.g. AI Data Analyst';
14005
14443
  roleInput.autocomplete = 'off';
14444
+ if (isEdit) roleInput.value = editEmployee.role || '';
14006
14445
  roleField.appendChild(roleLabel);
14007
14446
  roleField.appendChild(roleInput);
14008
14447
  body.appendChild(roleField);
14009
14448
 
14010
14449
  // Job picker — searchable list matching the command palette style.
14011
- const selectedJobIds = new Set();
14450
+ const selectedJobIds = new Set(isEdit && Array.isArray(editEmployee.jobIds) ? editEmployee.jobIds : []);
14012
14451
  const jobsField = document.createElement('div');
14013
14452
  jobsField.className = 'ipe-card-field';
14014
14453
  const jobsLabel = document.createElement('div');
@@ -14098,7 +14537,7 @@ function openInlineCreateEmployee() {
14098
14537
  const createBtn = document.createElement('button');
14099
14538
  createBtn.type = 'button';
14100
14539
  createBtn.className = 'ipe-btn-create';
14101
- createBtn.textContent = 'Create';
14540
+ createBtn.textContent = isEdit ? 'Save' : 'Create';
14102
14541
  createBtn.addEventListener('click', async () => {
14103
14542
  const displayName = nameInput.value.trim();
14104
14543
  const pickedJobIds = Array.from(selectedJobIds);
@@ -14115,7 +14554,7 @@ function openInlineCreateEmployee() {
14115
14554
  }
14116
14555
  errorEl.style.display = 'none';
14117
14556
  createBtn.disabled = true;
14118
- createBtn.textContent = 'Creating';
14557
+ createBtn.textContent = isEdit ? 'Saving...' : 'Creating...';
14119
14558
  try {
14120
14559
  const body = {
14121
14560
  projectPath,
@@ -14124,26 +14563,30 @@ function openInlineCreateEmployee() {
14124
14563
  icon: { kind: 'generated', value: displayName },
14125
14564
  jobIds: pickedJobIds,
14126
14565
  };
14127
- const resp = await fetch('/api/ai-hub/custom-employees', {
14128
- method: 'POST',
14566
+ const resp = await fetch(isEdit
14567
+ ? '/api/ai-hub/custom-employees/' + encodeURIComponent(editEmployee.key)
14568
+ : '/api/ai-hub/custom-employees', {
14569
+ method: isEdit ? 'PATCH' : 'POST',
14129
14570
  headers: { 'Content-Type': 'application/json' },
14130
14571
  body: JSON.stringify(body),
14131
14572
  });
14132
- if (!resp.ok) throw new Error('Server error: ' + resp.status);
14133
- overlay.remove();
14134
- // Refresh bootstrap so the new employee shows in the tree.
14135
- if (typeof tfBootstrap === 'function') {
14136
- await tfBootstrap();
14137
- } else if (typeof loadBootstrap === 'function') {
14138
- await loadBootstrap();
14573
+ if (!resp.ok) {
14574
+ const error = new Error('Server error: ' + resp.status);
14575
+ error.status = resp.status;
14576
+ throw error;
14139
14577
  }
14140
- if (typeof renderRail === 'function') renderRail();
14141
- if (typeof tfRenderTree === 'function') tfRenderTree();
14578
+ overlay.remove();
14579
+ await refreshCustomEmployeeSurfaces();
14142
14580
  } catch (err) {
14143
14581
  createBtn.disabled = false;
14144
- createBtn.textContent = 'Create';
14145
- errorEl.textContent = 'Could not create employee. Try again.';
14582
+ createBtn.textContent = isEdit ? 'Save' : 'Create';
14583
+ errorEl.textContent = isEdit
14584
+ ? (err && err.status === 404 ? 'This employee no longer exists.' : 'Could not save employee. Try again.')
14585
+ : 'Could not create employee. Try again.';
14146
14586
  errorEl.style.display = '';
14587
+ if (isEdit && err && err.status === 404) {
14588
+ await refreshCustomEmployeeSurfaces().catch(() => undefined);
14589
+ }
14147
14590
  }
14148
14591
  });
14149
14592
  actions.appendChild(cancelBtn);
@@ -596,6 +596,72 @@ img.conv-employee-avatar {
596
596
  background: var(--accent-soft);
597
597
  border-color: rgba(0,113,227,.28);
598
598
  }
599
+ .conv-employee-manage {
600
+ width: 20px;
601
+ height: 20px;
602
+ border-radius: 6px;
603
+ border: 1px solid var(--line);
604
+ background: var(--surface);
605
+ color: var(--muted);
606
+ font-size: 13px;
607
+ line-height: 14px;
608
+ font-weight: 700;
609
+ text-align: center;
610
+ cursor: pointer;
611
+ flex-shrink: 0;
612
+ padding: 0 0 3px;
613
+ }
614
+ .conv-employee-manage:hover,
615
+ .conv-employee-manage:focus-visible {
616
+ background: var(--accent-soft);
617
+ color: var(--accent);
618
+ border-color: rgba(0,113,227,.28);
619
+ }
620
+ .emp-manage-pop {
621
+ z-index: var(--z-dropdown-menu);
622
+ min-width: 112px;
623
+ display: grid;
624
+ gap: 2px;
625
+ padding: 5px;
626
+ border: 1px solid var(--line);
627
+ border-radius: 8px;
628
+ background: var(--surface);
629
+ box-shadow: var(--shadow-lg);
630
+ }
631
+ .emp-manage-pop button {
632
+ border: 0;
633
+ border-radius: 6px;
634
+ background: transparent;
635
+ color: var(--text);
636
+ font-size: 12px;
637
+ text-align: left;
638
+ padding: 6px 8px;
639
+ }
640
+ .emp-manage-pop button:hover,
641
+ .emp-manage-pop button:focus-visible {
642
+ background: var(--accent-soft);
643
+ outline: none;
644
+ }
645
+ .emp-manage-delete { color: var(--danger) !important; }
646
+ .conv-employee-group--removed .conv-employee-tab-label {
647
+ color: var(--muted);
648
+ letter-spacing: 0;
649
+ text-transform: none;
650
+ }
651
+ .emp-removed-pill {
652
+ display: inline-flex;
653
+ align-items: center;
654
+ margin-left: 6px;
655
+ padding: 1px 5px;
656
+ border-radius: 999px;
657
+ background: var(--warn-soft);
658
+ color: var(--warn);
659
+ font-size: 9px;
660
+ font-weight: 700;
661
+ letter-spacing: 0;
662
+ text-transform: uppercase;
663
+ }
664
+ .emp-del-confirm { margin-left: 28px; margin-right: 4px; }
599
665
  /* Issue #1008: No FRAIM employee group modifier */
600
666
  /* R4: dashed avatar placeholder — no image or persona initials */
601
667
  .conv-employee-avatar--absence,
@@ -2804,6 +2870,81 @@ body:is([data-surface="task-pane"],[data-surface="extension"]) {
2804
2870
  font-size: 13px;
2805
2871
  overflow: auto;
2806
2872
  }
2873
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .hub-tabs,
2874
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .hub-area,
2875
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .proj-tabs {
2876
+ display: none !important;
2877
+ }
2878
+ .task-pane-launcher { display: none; }
2879
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .task-pane-launcher {
2880
+ display: flex;
2881
+ flex-direction: column;
2882
+ gap: 10px;
2883
+ padding: 12px;
2884
+ border-bottom: 1px solid var(--border, rgba(0,0,0,0.07));
2885
+ background: var(--surface, #fff);
2886
+ }
2887
+ .tp-launcher-header {
2888
+ display: flex;
2889
+ flex-direction: column;
2890
+ gap: 2px;
2891
+ }
2892
+ .tp-launcher-title {
2893
+ font-size: 14px;
2894
+ font-weight: 650;
2895
+ color: var(--text, #171717);
2896
+ }
2897
+ .tp-launcher-context,
2898
+ .tp-launcher-status {
2899
+ min-height: 16px;
2900
+ font-size: 11px;
2901
+ color: var(--muted);
2902
+ overflow: hidden;
2903
+ text-overflow: ellipsis;
2904
+ white-space: nowrap;
2905
+ }
2906
+ .tp-launcher-field {
2907
+ display: flex;
2908
+ flex-direction: column;
2909
+ gap: 4px;
2910
+ min-width: 0;
2911
+ font-size: 11px;
2912
+ font-weight: 600;
2913
+ color: var(--muted);
2914
+ }
2915
+ .tp-launcher-field select,
2916
+ .tp-launcher-field textarea {
2917
+ width: 100%;
2918
+ min-width: 0;
2919
+ border: 1px solid var(--border, rgba(0,0,0,0.12));
2920
+ border-radius: 8px;
2921
+ background: var(--panel, #fff);
2922
+ color: var(--text, #171717);
2923
+ font: inherit;
2924
+ font-size: 12px;
2925
+ }
2926
+ .tp-launcher-field select {
2927
+ height: 34px;
2928
+ padding: 0 8px;
2929
+ }
2930
+ .tp-launcher-field textarea {
2931
+ min-height: 78px;
2932
+ resize: vertical;
2933
+ padding: 8px;
2934
+ line-height: 1.35;
2935
+ }
2936
+ .tp-launcher-actions {
2937
+ display: grid;
2938
+ grid-template-columns: 1fr auto;
2939
+ align-items: center;
2940
+ gap: 8px;
2941
+ }
2942
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-start-job {
2943
+ min-height: 34px;
2944
+ padding: 7px 12px;
2945
+ border-radius: 8px;
2946
+ font-size: 12px;
2947
+ }
2807
2948
  body:is([data-surface="task-pane"],[data-surface="extension"]) .page {
2808
2949
  padding: 10px 12px 8px;
2809
2950
  gap: 10px;