fraim-hub 2.0.233 → 2.0.235

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.
@@ -1960,7 +1960,7 @@ class AiHubServer {
1960
1960
  },
1961
1961
  };
1962
1962
  }
1963
- if (existing.jobId && existing.jobId !== options.jobId) {
1963
+ if (existing.jobId && existing.jobId !== '__freeform__' && existing.jobId !== options.jobId) {
1964
1964
  console.warn('[ai-hub] hub.conversation_continuity.rejected', {
1965
1965
  conversationId,
1966
1966
  existingJobId: existing.jobId,
@@ -1978,6 +1978,15 @@ class AiHubServer {
1978
1978
  }
1979
1979
  return { ok: true, continuityDecision: 'same_continuity' };
1980
1980
  }
1981
+ resolveImplicitResumeConversation(options) {
1982
+ const candidates = this.conversationStore.loadProject(options.projectPath).conversations.filter((conversation) => {
1983
+ if (conversation.jobId !== options.jobId)
1984
+ return false;
1985
+ const existingAgentId = this.configuredAgentIdForConversation(conversation, options.employees);
1986
+ return existingAgentId === options.requestedAgent.id;
1987
+ });
1988
+ return candidates.length === 1 ? candidates[0] : null;
1989
+ }
1981
1990
  isTrustedHubOrigin(req) {
1982
1991
  const origin = req.get('origin');
1983
1992
  if (!origin)
@@ -4061,13 +4070,18 @@ class AiHubServer {
4061
4070
  throw new Error('A jobId is required to resume.');
4062
4071
  if (!instructions)
4063
4072
  throw new Error('Provide an instruction to continue.');
4064
- const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(body.configuredAgentId, requestedHostId);
4065
- const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
4073
+ const employees = this.hostRuntime.detectEmployees();
4074
+ const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(body.configuredAgentId, requestedHostId, employees);
4075
+ const requestedConversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
4066
4076
  ? body.conversationId.trim()
4067
4077
  : undefined;
4068
- const persistedConversation = conversationId
4069
- ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
4070
- : undefined;
4078
+ const inferredConversation = requestedConversationId
4079
+ ? null
4080
+ : this.resolveImplicitResumeConversation({ projectPath, jobId, requestedAgent: configuredAgent, employees });
4081
+ const conversationId = requestedConversationId || inferredConversation?.id;
4082
+ const persistedConversation = requestedConversationId
4083
+ ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === requestedConversationId)
4084
+ : inferredConversation ?? undefined;
4071
4085
  const persistedRun = readPersistedRunProjection(persistedConversation);
4072
4086
  const now = new Date().toISOString();
4073
4087
  const run = {
@@ -4078,12 +4092,16 @@ class AiHubServer {
4078
4092
  jobId, hostId, configuredAgentId: configuredAgent.id, configuredAgentLabel: configuredAgent.label, baseHostId: configuredAgent.baseHostId, projectPath, status: 'running', sessionId,
4079
4093
  createdAt: now, updatedAt: now, messages: [],
4080
4094
  events: [(0, hosts_1.createHubEvent)('system', `Resuming ${configuredAgent.label} (${hostId}) session ${sessionId} in ${projectPath}`)],
4081
- currentPhase: persistedRun?.currentPhase || null,
4082
- phaseHistory: persistedRun?.phaseHistory || [],
4095
+ // Only carry phase state forward when the prior run was interrupted mid-job
4096
+ // (status !== 'completed'). A completed conversation is a finished run;
4097
+ // resuming the session starts a fresh job, so the tracker must be blank.
4098
+ currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
4099
+ phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
4083
4100
  totals: persistedRun?.totals || emptyTotals(),
4084
4101
  lastStatusChangeAt: now,
4085
- runDiscriminant: persistedRun?.runDiscriminant || undefined,
4102
+ runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
4086
4103
  personaKey: getHubPersonaForJob(jobId),
4104
+ continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
4087
4105
  };
4088
4106
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
4089
4107
  // the shared-browser note so the resumed agent knows about it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.233",
3
+ "version": "2.0.235",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js"
@@ -158,7 +158,7 @@
158
158
  "electron": "^41.2.2",
159
159
  "electron-updater": "^6.8.9",
160
160
  "express": "^5.2.1",
161
- "fraim": "2.0.233",
161
+ "fraim": "2.0.235",
162
162
  "mongodb": "^7.0.0",
163
163
  "node-cron": "4.2.1",
164
164
  "node-edge-tts": "^1.2.10",
@@ -398,7 +398,7 @@
398
398
  <div class="conv-topline">
399
399
  <div class="employee-identity" id="active-identity"></div>
400
400
  <span id="active-title" class="conv-job-title"></span>
401
- <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Open focused conversation" title="Open focused conversation">Expand</button>
401
+ <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Pop-out conversation" title="Pop-out conversation">&#8599;</button>
402
402
  <button class="run-stop-btn" id="run-stop-btn" type="button" hidden title="Ask the employee to stop and wait for you">⏹ Stop</button>
403
403
  <div class="run-state-pill" id="run-state-pill"></div>
404
404
  </div>
@@ -1311,6 +1311,19 @@ function stripHubInjectedPromptBlocks(text) {
1311
1311
  .trim();
1312
1312
  }
1313
1313
 
1314
+ // Returns a validated http/https URL string suitable for use in an href, or null if unsafe.
1315
+ function _safeHref(raw) {
1316
+ try {
1317
+ const url = new URL(raw);
1318
+ return (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : null;
1319
+ } catch (_) { return null; }
1320
+ }
1321
+
1322
+ // Escape & and " in a URL for safe insertion into an href="..." attribute.
1323
+ function _escapeHref(href) {
1324
+ return href.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
1325
+ }
1326
+
1314
1327
  // R8: render markdown subset safely. HTML is escaped first.
1315
1328
  function formatEmployeeText(text) {
1316
1329
  if (!text) return '';
@@ -1326,6 +1339,27 @@ function formatEmployeeText(text) {
1326
1339
  s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
1327
1340
  // 3. Inline code (single backtick, non-greedy, no newlines).
1328
1341
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
1342
+ // 3.5. Markdown links [text](url) — only http/https hrefs allowed; link text is already HTML-escaped.
1343
+ // Empty-text links and non-absolute URLs fall back to plain text.
1344
+ s = s.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (match, linkText, rawUrl) => {
1345
+ const trimmed = linkText.trim();
1346
+ // Empty link text: render as plain text; sentinel prevents step 3.6 auto-linkify.
1347
+ if (!trimmed) return `\x00NOLINK\x00${rawUrl.trim()}\x00ENDNOLINK\x00`;
1348
+ // Reverse HTML entities step 1 introduced inside the URL before structural validation.
1349
+ const decodedUrl = rawUrl.trim().replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
1350
+ const href = _safeHref(decodedUrl);
1351
+ if (!href) return trimmed;
1352
+ return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${trimmed}</a>`;
1353
+ });
1354
+ // 3.6. Auto-linkify bare https:// / http:// URLs not already inside href="...".
1355
+ // Sentinel prefix prevents double-linkifying empty-text [](url) patterns.
1356
+ s = s.replace(/(?<!href=")(?<!href=')(?<!\x00NOLINK\x00)(https?:\/\/[^\s<>"'\x00]+)/g, (url) => {
1357
+ const href = _safeHref(url);
1358
+ if (!href) return url;
1359
+ return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${_escapeHref(href)}</a>`;
1360
+ });
1361
+ // Strip sentinels: emit the URL as plain text (no anchor).
1362
+ s = s.replace(/\x00NOLINK\x00(.*?)\x00ENDNOLINK\x00/g, (_, url) => url);
1329
1363
  // 4. Bold (**text**).
1330
1364
  s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1331
1365
  // 5–7. Process line by line for lists and paragraphs.
@@ -804,13 +804,19 @@ img.conv-employee-avatar {
804
804
  .run-stop-btn:disabled { opacity: .55; cursor: default; }
805
805
  .conversation-focus-btn {
806
806
  flex-shrink: 0;
807
- font-size: 12px;
807
+ display: inline-flex;
808
+ align-items: center;
809
+ justify-content: center;
810
+ width: 32px;
811
+ height: 32px;
812
+ font-size: 17px;
808
813
  font-weight: 700;
814
+ line-height: 1;
809
815
  color: var(--accent-strong);
810
816
  background: var(--accent-soft);
811
817
  border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line));
812
818
  border-radius: 8px;
813
- padding: 6px 10px;
819
+ padding: 0;
814
820
  cursor: pointer;
815
821
  }
816
822
  .conversation-focus-btn:hover {
@@ -826,6 +832,7 @@ img.conv-employee-avatar {
826
832
  .conv-job-title {
827
833
  flex: 1;
828
834
  min-width: 0;
835
+ max-width: 100%;
829
836
  font-size: 15px;
830
837
  font-weight: 600;
831
838
  color: var(--text);
@@ -1669,6 +1676,15 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1669
1676
  border: 1px solid var(--line);
1670
1677
  color: var(--text);
1671
1678
  }
1679
+ .message.employee .bubble a {
1680
+ color: var(--accent);
1681
+ text-decoration: underline;
1682
+ text-underline-offset: 2px;
1683
+ word-break: break-all;
1684
+ }
1685
+ .message.employee .bubble a:hover {
1686
+ color: var(--accent-strong);
1687
+ }
1672
1688
  .typing-indicator {
1673
1689
  animation: none;
1674
1690
  justify-items: start;