fraim 2.0.159 → 2.0.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/src/ai-hub/cert-store.js +70 -0
  3. package/dist/src/ai-hub/desktop-main.js +225 -50
  4. package/dist/src/ai-hub/manager-turns.js +38 -0
  5. package/dist/src/ai-hub/office-sideload.js +138 -0
  6. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  7. package/dist/src/ai-hub/server.js +346 -115
  8. package/dist/src/ai-hub/word-sideload.js +95 -0
  9. package/dist/src/cli/commands/add-ide.js +9 -0
  10. package/dist/src/cli/commands/login.js +1 -2
  11. package/dist/src/cli/commands/setup.js +0 -2
  12. package/dist/src/cli/commands/sync.js +19 -10
  13. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
  14. package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
  15. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  16. package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
  17. package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
  18. package/dist/src/cli/utils/fraim-gitignore.js +11 -0
  19. package/dist/src/cli/utils/remote-sync.js +1 -1
  20. package/dist/src/core/config-loader.js +1 -2
  21. package/dist/src/core/fraim-config-schema.generated.js +0 -5
  22. package/dist/src/core/types.js +0 -1
  23. package/dist/src/first-run/session-service.js +3 -3
  24. package/package.json +2 -1
  25. package/public/ai-hub/index.html +20 -2
  26. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  27. package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
  28. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  29. package/public/ai-hub/script.js +337 -120
  30. package/public/ai-hub/styles.css +456 -135
@@ -24,6 +24,13 @@ const state = {
24
24
  modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
25
  storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
26
26
  panelState: {}, // { [convId]: { coach?: boolean } }
27
+ wordContext: null, // WordContext pushed from taskpane.html via postMessage
28
+ // Pending coaching job selected via a quick-coach button or template picker.
29
+ // Sent as coachingJobId in the next continueRun call and then cleared.
30
+ // The invocation prefix ($fraim / /fraim) is added server-side based on
31
+ // the active employee; the user never sees the raw invocation syntax.
32
+ pendingCoachingJobId: null,
33
+ pendingCoachingLabel: null,
27
34
  };
28
35
 
29
36
  const els = {};
@@ -60,6 +67,9 @@ function gatherElements() {
60
67
  // Issue #442: A/B mode elements.
61
68
  'ab-toggle-wrap', 'ab-direct-panel',
62
69
  'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
70
+ // Issue #489: Word context elements.
71
+ 'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
72
+ 'word-context-card', 'word-ctx-card-label', 'word-ctx-card-body', 'word-ctx-card-toggle',
63
73
  ];
64
74
  for (const id of ids) {
65
75
  els[id] = document.getElementById(id);
@@ -67,6 +77,14 @@ function gatherElements() {
67
77
  }
68
78
 
69
79
  async function requestJson(url, options) {
80
+ // On localhost inject x-api-key: local-dev so the dev server auth bypass fires.
81
+ // The server's isLocalDev check already gates this to NODE_ENV !== 'production',
82
+ // so this header is harmless if accidentally sent to a non-dev server.
83
+ const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
84
+ if (isLocalHost) {
85
+ const headers = { 'x-api-key': 'local-dev', ...((options && options.headers) || {}) };
86
+ options = { ...(options || {}), headers };
87
+ }
70
88
  const response = await fetch(url, options);
71
89
  let payload = null;
72
90
  try { payload = await response.json(); } catch { /* may be empty */ }
@@ -78,10 +96,13 @@ async function requestJson(url, options) {
78
96
  return payload;
79
97
  }
80
98
 
81
- async function loadBootstrap(projectPath) {
82
- const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
99
+ async function loadBootstrap(projectPath, docUrl) {
100
+ const params = new URLSearchParams();
101
+ if (projectPath) params.set('projectPath', projectPath);
102
+ if (docUrl) params.set('docUrl', docUrl);
103
+ const query = params.toString() ? '?' + params.toString() : '';
83
104
  const fetchOptions = {};
84
- if (state.storedApiKey) fetchOptions.headers = { 'x-fraim-api-key': state.storedApiKey };
105
+ if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
85
106
  const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
86
107
  state.bootstrap = bootstrap;
87
108
  state.projectPath = bootstrap.project.path;
@@ -94,6 +115,20 @@ async function loadBootstrap(projectPath) {
94
115
  els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
95
116
  els['status-line'].textContent = bootstrap.project.message || '';
96
117
  els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
118
+ // Update the compact project label shown in task-pane surface rail.
119
+ const tpLabel = document.getElementById('tp-project-label');
120
+ if (tpLabel) {
121
+ const projectName = friendlyProjectName(bootstrap.project.path);
122
+ const resolvedDocUrl = docUrl || new URLSearchParams(window.location.search).get('docUrl') || '';
123
+ let docName = '';
124
+ if (resolvedDocUrl) {
125
+ try {
126
+ const rawName = resolvedDocUrl.replace(/\\/g, '/').split('/').pop() || '';
127
+ docName = decodeURIComponent(rawName).replace(/\.[^.]+$/, '');
128
+ } catch {}
129
+ }
130
+ tpLabel.textContent = docName ? `${projectName} · ${docName}` : projectName;
131
+ }
97
132
  // Populate the welcome-line popovers with REAL jobs from the bootstrap.
98
133
  populateConceptPopovers();
99
134
  // Render rail and active state for this project.
@@ -101,6 +136,121 @@ async function loadBootstrap(projectPath) {
101
136
  renderActive();
102
137
  }
103
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // Word context bridge (postMessage from taskpane.html parent frame)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ // Apply a full or partial Word context update and re-render dependent UI.
144
+ // Called by the postMessage bridge and usable directly in tests.
145
+ function applyWordContext(ctx, partial) {
146
+ if (partial) {
147
+ if (!state.wordContext) state.wordContext = {};
148
+ Object.assign(state.wordContext, ctx || {});
149
+ } else {
150
+ state.wordContext = ctx || null;
151
+ }
152
+ renderWordContextBar();
153
+ renderWordContextInModal();
154
+ }
155
+
156
+ function setupWordBridge() {
157
+ if (window.parent === window) return; // not inside an iframe — no bridge needed
158
+ window.addEventListener('message', function(event) {
159
+ if (event.source !== window.parent) return;
160
+ const msg = event.data || {};
161
+ if (msg.type === 'word-context') {
162
+ applyWordContext(msg.payload, false);
163
+ } else if (msg.type === 'word-context-update') {
164
+ applyWordContext(msg.payload, true);
165
+ }
166
+ // word-response messages are handled by per-request listeners in requestWordContext()
167
+ });
168
+ // Signal taskpane.html that Hub is ready to receive context.
169
+ window.parent.postMessage({ type: 'hub-ready' }, '*');
170
+ }
171
+
172
+ // Send a word-request to taskpane.html and await the word-response.
173
+ // Resolves null if not in iframe, no response, or timeout (3 s).
174
+ function requestWordContext(action, payload) {
175
+ return new Promise(function(resolve) {
176
+ if (window.parent === window) { resolve(null); return; }
177
+ const requestId = Math.random().toString(36).slice(2);
178
+ const timer = setTimeout(function() { cleanup(); resolve(null); }, 3000);
179
+ function onMsg(event) {
180
+ if (event.source !== window.parent) return;
181
+ const m = event.data || {};
182
+ if (m.type === 'word-response' && m.requestId === requestId) {
183
+ cleanup(); resolve(m.payload || null);
184
+ }
185
+ }
186
+ function cleanup() { clearTimeout(timer); window.removeEventListener('message', onMsg); }
187
+ window.addEventListener('message', onMsg);
188
+ window.parent.postMessage({ type: 'word-request', requestId, action, payload: payload || {} }, '*');
189
+ });
190
+ }
191
+
192
+ function buildWordContextBlock(wc) {
193
+ if (!wc) return '';
194
+ const parts = [];
195
+ if (wc.docTitle) parts.push(`Document: ${wc.docTitle}`);
196
+ if (wc.selection) {
197
+ const wordCount = wc.selection.split(/\s+/).filter(Boolean).length;
198
+ parts.push(`Selected text (${wordCount} word${wordCount !== 1 ? 's' : ''}):\n"""\n${wc.selection.slice(0, 500)}\n"""`);
199
+ } else if (wc.bodyPreview) {
200
+ parts.push(`Document preview:\n"""\n${wc.bodyPreview.slice(0, 800)}\n"""`);
201
+ }
202
+ if (wc.wordCount) parts.push(`Document length: ~${wc.wordCount} words`);
203
+ if (wc.comments && wc.comments.length > 0) {
204
+ const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
205
+ parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
206
+ }
207
+ if (parts.length === 0) return '';
208
+ return `[Word Document Context]\n${parts.join('\n\n')}`;
209
+ }
210
+
211
+ function renderWordContextBar() {
212
+ const bar = els['word-context-bar'];
213
+ const txt = els['word-ctx-text'];
214
+ if (!bar || !txt) return;
215
+ const wc = state.wordContext;
216
+ if (!wc || (!wc.docTitle && !wc.selection && !wc.bodyPreview)) {
217
+ bar.hidden = true;
218
+ return;
219
+ }
220
+ bar.hidden = false;
221
+ if (wc.selection) {
222
+ const words = wc.selection.split(/\s+/).filter(Boolean).length;
223
+ txt.textContent = `${words} word${words !== 1 ? 's' : ''} selected — "${wc.selection.slice(0, 60)}${wc.selection.length > 60 ? '…' : ''}"`;
224
+ } else if (wc.bodyPreview) {
225
+ const words = wc.wordCount || 0;
226
+ txt.textContent = `${wc.docTitle || 'Document'} · ${words} words — full doc context active`;
227
+ } else {
228
+ txt.textContent = wc.docTitle || 'Document context active';
229
+ }
230
+ }
231
+
232
+ function renderWordContextInModal() {
233
+ const card = els['word-context-card'];
234
+ const label = els['word-ctx-card-label'];
235
+ const body = els['word-ctx-card-body'];
236
+ if (!card || !label || !body) return;
237
+ const wc = state.wordContext;
238
+ if (!wc || (!wc.selection && !wc.bodyPreview && !wc.comments?.length)) {
239
+ card.hidden = true;
240
+ return;
241
+ }
242
+ card.hidden = false;
243
+ // Label line: "3 words selected · MyDoc" or "MyDoc · 1,234 words"
244
+ if (wc.selection) {
245
+ const words = wc.selection.split(/\s+/).filter(Boolean).length;
246
+ label.textContent = `${words} word${words !== 1 ? 's' : ''} selected${wc.docTitle ? ' · ' + wc.docTitle : ''}`;
247
+ } else {
248
+ label.textContent = (wc.docTitle || 'Document') + (wc.wordCount ? ` · ${wc.wordCount} words` : '');
249
+ }
250
+ // Body: shows block that will be sent to the agent
251
+ body.textContent = buildWordContextBlock(wc);
252
+ }
253
+
104
254
  // ---------------------------------------------------------------------------
105
255
  // Concept popover population (drives the welcome-line "see ... jobs" lists)
106
256
  // ---------------------------------------------------------------------------
@@ -714,6 +864,9 @@ function renderActive() {
714
864
  renderedMessageCount = 0;
715
865
  renderedEventCount = 0;
716
866
  renderedDirectEventCount = 0;
867
+ // A pending coaching job is scoped to the active conversation — discard it
868
+ // whenever the user switches to a different conversation.
869
+ clearPendingCoachingJob();
717
870
  }
718
871
  const statusChanged = renderedStatus !== conv.status;
719
872
  syncConversationPanels(conv, switchedConv);
@@ -1238,26 +1391,21 @@ function closeTemplatePopover() {
1238
1391
  }
1239
1392
 
1240
1393
  function applyTemplateInvocation(managerJobId) {
1241
- const conv = activeConversation();
1242
- // Use the conversation's own employee for the invocation symbol, NOT
1243
- // the manager's last selection in another conversation (R2.5).
1244
- const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
1245
- const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1246
- const invocation = `${symbol} ${managerJobId}`;
1394
+ // The invocation prefix ($fraim / /fraim) is an implementation detail that
1395
+ // the server adds based on the active employee the user should never see it.
1396
+ // Store the job ID as a pending coaching job. The #coach-note shows a
1397
+ // human-readable label; the textarea stays focused for optional extra context.
1398
+ // When the user clicks Send, continueRun sends { coachingJobId, instructions }.
1399
+ const label = managerJobId.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1400
+ // Strip any prior /fraim invocation text already in the textarea (backward-compat
1401
+ // for sessions that stored raw invocation text before this change).
1247
1402
  const textarea = els['coach-text'];
1248
- const prior = textarea.value;
1249
- let combined;
1250
- if (prior.trim().length === 0) {
1251
- combined = invocation;
1252
- } else {
1253
- const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
1254
- combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
1255
- }
1256
- textarea.value = combined;
1257
- // Caret at the end.
1258
- textarea.setSelectionRange(combined.length, combined.length);
1403
+ textarea.value = textarea.value
1404
+ .replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
1405
+ .replace(/\s+/g, ' ')
1406
+ .trim();
1259
1407
  textarea.focus();
1260
- syncSendButton();
1408
+ setPendingCoachingJob(managerJobId, label);
1261
1409
  closeTemplatePopover();
1262
1410
  }
1263
1411
 
@@ -1326,20 +1474,6 @@ function surfaceText(role, text, conv) {
1326
1474
  return raw;
1327
1475
  }
1328
1476
 
1329
- function extractExplicitFraimInvocation(text) {
1330
- const raw = String(text || '');
1331
- const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
1332
- if (!match || match.index == null) return null;
1333
- const before = raw.slice(0, match.index).trim();
1334
- const after = raw.slice(match.index + match[0].length).trim();
1335
- const remainder = [before, after].filter(Boolean).join('\n\n').trim();
1336
- return {
1337
- symbol: match[1],
1338
- jobId: match[2],
1339
- remainder,
1340
- };
1341
- }
1342
-
1343
1477
  function latestEmployeeSurfaceText(conv) {
1344
1478
  const messages = conv.messages || [];
1345
1479
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -1449,7 +1583,39 @@ function syncSendButton() {
1449
1583
  // coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
1450
1584
  // is for; the server only requires sessionId).
1451
1585
  const resumable = !!(conv && conv.sessionId);
1452
- els['send'].disabled = !(hasText && resumable);
1586
+ // A pending coaching job (set by quick-coach buttons or template picker)
1587
+ // is enough to enable Send even with an empty textarea — the user just
1588
+ // wants to fire that coaching directive, optionally with added context.
1589
+ const hasPendingJob = !!state.pendingCoachingJobId;
1590
+ els['send'].disabled = !((hasText || hasPendingJob) && resumable);
1591
+ }
1592
+
1593
+ // ---------------------------------------------------------------------------
1594
+ // Pending coaching job helpers
1595
+ // ---------------------------------------------------------------------------
1596
+
1597
+ // Set a pending coaching job selected via a quick-coach button or template
1598
+ // picker. The invocation is NOT put into the textarea — the server adds it
1599
+ // based on the active employee. The coach-note shows a human-readable label
1600
+ // so the user knows what will fire when they hit Send.
1601
+ function setPendingCoachingJob(jobId, label) {
1602
+ state.pendingCoachingJobId = jobId;
1603
+ state.pendingCoachingLabel = label;
1604
+ if (els['coach-note']) {
1605
+ els['coach-note'].textContent = `▶ ${label} — add context below (optional) then send`;
1606
+ els['coach-note'].classList.add('pending-job');
1607
+ }
1608
+ syncSendButton();
1609
+ }
1610
+
1611
+ function clearPendingCoachingJob() {
1612
+ state.pendingCoachingJobId = null;
1613
+ state.pendingCoachingLabel = null;
1614
+ if (els['coach-note']) {
1615
+ els['coach-note'].classList.remove('pending-job');
1616
+ // Note text is restored to normal status by the next renderActive call.
1617
+ }
1618
+ syncSendButton();
1453
1619
  }
1454
1620
 
1455
1621
  // ---------------------------------------------------------------------------
@@ -1897,50 +2063,22 @@ function deriveTitle(jobTitle, instructions) {
1897
2063
  // (per developers.openai.com/codex/skills: "type $ to mention a skill")
1898
2064
  // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
1899
2065
  //
1900
- // Every command typed by the manager is prefixed with the agent's FRAIM
1901
- // symbol so the host always sees that this is a FRAIM job, not a freeform
1902
- // prompt. Follow-up coaching keeps the jobId too; otherwise headless hosts
1903
- // lose which FRAIM workflow the manager meant to run.
1904
- const FRAIM_INVOCATION_SYMBOL = {
1905
- codex: '$fraim',
1906
- claude: '/fraim',
1907
- gemini: '/fraim',
1908
- };
1909
-
1910
- function fraimInvocationFor(employeeId, jobId, kind) {
1911
- if (jobId === '__freeform__') return null;
1912
- const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1913
- return `${symbol} ${jobId}`;
1914
- }
1915
-
1916
- // Wrap the manager's typed instructions with the host-appropriate FRAIM
1917
- // invocation. The wrapped text is what we ACTUALLY send to the host CLI
1918
- // AND what we show in the timeline so the manager sees what the agent
1919
- // received. For freeform jobs (no FRAIM job assigned), the instructions
1920
- // are sent verbatim with no invocation prefix.
1921
- function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1922
- const trimmed = (instructions || '').trim();
1923
- const explicit = extractExplicitFraimInvocation(trimmed);
1924
- const effectiveJobId = explicit?.jobId || jobId;
1925
- const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
1926
- // Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
1927
- if (!invocation) return explicit?.remainder || trimmed;
1928
- const remainder = explicit ? explicit.remainder : trimmed;
1929
- const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1930
- if (!remainder) return `${invocation}${stub}`;
1931
- return `${invocation}${stub}\n\n${remainder}`;
1932
- }
1933
-
2066
+ // The browser sends raw manager instructions. AI Hub normalizes assigned
2067
+ // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
2068
+ // share one start/continue contract.
1934
2069
  async function startRun(job, instructions, employeeId) {
1935
- // Prefix the manager's typed instructions with the FRAIM invocation so
1936
- // the underlying host actually launches the right job. The prefixed
1937
- // text is what the host receives AND what we show in the timeline.
1938
- // Freeform jobs carry no job stub and no FRAIM invocation prefix.
1939
2070
  const isFreeform = job.id === '__freeform__';
1940
- const absoluteStubPath = (!isFreeform && job.stubPath && state.projectPath)
1941
- ? [state.projectPath, job.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1942
- : undefined;
1943
- const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, absoluteStubPath);
2071
+ // In task-pane/extension mode: get a fresh selection snapshot and prepend
2072
+ // document context to the instructions so the agent knows what the user is
2073
+ // looking at. stubPath resolution and FRAIM invocation are now server-side.
2074
+ let effectiveInstructions = instructions || '';
2075
+ if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
2076
+ const fresh = await requestWordContext('get-selection').catch(() => null);
2077
+ const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
2078
+ const ctxBlock = buildWordContextBlock(wc);
2079
+ if (ctxBlock) effectiveInstructions = ctxBlock + '\n\n---\n\n' + effectiveInstructions;
2080
+ }
2081
+
1944
2082
  // Issue #442: read the A/B toggle state from the modal before it closes.
1945
2083
  const abToggle = document.getElementById('ab-toggle');
1946
2084
  const isAB = !isFreeform && abToggle && abToggle.checked;
@@ -1957,7 +2095,7 @@ async function startRun(job, instructions, employeeId) {
1957
2095
  runId: null,
1958
2096
  sessionId: null,
1959
2097
  status: 'running',
1960
- messages: [{ role: 'manager', text: agentMessage, at: Date.now() }],
2098
+ messages: [],
1961
2099
  events: [],
1962
2100
  artifacts: [],
1963
2101
  lastUpdatedAt: Date.now(),
@@ -1965,6 +2103,8 @@ async function startRun(job, instructions, employeeId) {
1965
2103
  compareMode: isAB ? 'ab' : undefined,
1966
2104
  compareRunId: null,
1967
2105
  compareRun: null,
2106
+ // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
2107
+ wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
1968
2108
  };
1969
2109
  upsertConversation(conv);
1970
2110
  state.activeId = conv.id;
@@ -1980,8 +2120,8 @@ async function startRun(job, instructions, employeeId) {
1980
2120
  projectPath: state.projectPath,
1981
2121
  hostId: employeeId,
1982
2122
  jobId: job.id,
1983
- message: agentMessage,
1984
- ...(isAB ? { compareMode: 'ab', directInstructions: instructions } : {}),
2123
+ instructions: effectiveInstructions,
2124
+ ...(isAB ? { compareMode: 'ab', directInstructions: effectiveInstructions } : {}),
1985
2125
  }),
1986
2126
  });
1987
2127
  conv.runId = run.id;
@@ -2008,10 +2148,14 @@ async function startRun(job, instructions, employeeId) {
2008
2148
  // Start a NEW run on an existing conversation with a different agent.
2009
2149
  // The conversation's message history stays intact; only the run restarts.
2010
2150
  async function restartConvWithAgent(conv, newAgentId, text) {
2011
- const agentMessage = buildAgentMessage(newAgentId, conv.jobId, 'start', text);
2151
+ // Snapshot both the message history and the event log so they survive the
2152
+ // restart. foldRunIntoConversation replaces these arrays on every call
2153
+ // (including poll ticks), so we must store them on the conv object itself —
2154
+ // not as local variables that only live until the first poll fires.
2155
+ conv._priorMessages = Array.isArray(conv.messages) ? [...conv.messages] : [];
2156
+ conv._priorEvents = Array.isArray(conv.events) ? [...conv.events] : [];
2012
2157
  conv.status = 'running';
2013
2158
  conv.sessionId = null;
2014
- conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
2015
2159
  upsertConversation(conv);
2016
2160
  renderRail();
2017
2161
  renderActive();
@@ -2023,11 +2167,13 @@ async function restartConvWithAgent(conv, newAgentId, text) {
2023
2167
  projectPath: state.projectPath,
2024
2168
  hostId: newAgentId,
2025
2169
  jobId: conv.jobId,
2026
- message: agentMessage,
2170
+ instructions: text,
2027
2171
  }),
2028
2172
  });
2029
2173
  conv.runId = run.id;
2030
2174
  foldRunIntoConversation(conv, run);
2175
+ // Note: foldRunIntoConversation now prepends _priorMessages/_priorEvents
2176
+ // on every call, so the history is preserved through poll ticks too.
2031
2177
  upsertConversation(conv);
2032
2178
  renderRail();
2033
2179
  renderActive();
@@ -2045,11 +2191,12 @@ async function restartConvWithAgent(conv, newAgentId, text) {
2045
2191
  async function continueRun(text) {
2046
2192
  const conv = activeConversation();
2047
2193
  if (!conv || !conv.runId) return;
2048
- // Prefix follow-up coaching with @fraim so the host knows this is a
2049
- // continuation of the active FRAIM session rather than a fresh prompt.
2050
- const agentMessage = buildAgentMessage(conv.employeeId, conv.jobId, 'continue', text);
2194
+ // Consume any pending coaching job selected via quick-coach or template picker.
2195
+ // The server adds the correct invocation prefix ($fraim / /fraim) based on
2196
+ // the active employee the user never sees raw invocation syntax.
2197
+ const coachingJobId = state.pendingCoachingJobId || undefined;
2198
+ clearPendingCoachingJob();
2051
2199
  conv.status = 'running';
2052
- conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
2053
2200
  upsertConversation(conv);
2054
2201
  renderRail();
2055
2202
  renderActive();
@@ -2057,7 +2204,7 @@ async function continueRun(text) {
2057
2204
  const run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
2058
2205
  method: 'POST',
2059
2206
  headers: { 'Content-Type': 'application/json' },
2060
- body: JSON.stringify({ message: agentMessage }),
2207
+ body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
2061
2208
  });
2062
2209
  foldRunIntoConversation(conv, run);
2063
2210
  upsertConversation(conv);
@@ -2086,15 +2233,19 @@ function foldRunIntoConversation(conv, run) {
2086
2233
  totals: run.totals || null,
2087
2234
  };
2088
2235
  // Replace the conversation's events with the run's events (single source of truth on the server).
2089
- conv.events = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
2090
- // Append any employee messages we don't already have.
2091
- const existingEmployeeTexts = new Set(conv.messages.filter((m) => m.role === 'employee').map((m) => m.text));
2092
- for (const m of run.messages || []) {
2093
- if (m.role === 'employee' && !existingEmployeeTexts.has(m.text)) {
2094
- conv.messages.push({ role: 'employee', text: m.text, at: Date.now() });
2095
- existingEmployeeTexts.add(m.text);
2096
- }
2097
- }
2236
+ // If this conversation was restarted on a different agent, prepend the prior run's events so
2237
+ // the micro-log shows the full diagnostic history across all run attempts (Issue #495).
2238
+ const newEvents = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
2239
+ const priorEvents = Array.isArray(conv._priorEvents) ? conv._priorEvents : [];
2240
+ conv.events = priorEvents.length > 0 ? [...priorEvents, ...newEvents] : newEvents;
2241
+ // Same treatment for messages: prepend any cross-run history captured at restart time.
2242
+ const newMessages = (run.messages || []).map((m) => ({
2243
+ role: m.role,
2244
+ text: m.text,
2245
+ at: Date.parse(m.createdAt) || Date.now(),
2246
+ }));
2247
+ const priorMessages = Array.isArray(conv._priorMessages) ? conv._priorMessages : [];
2248
+ conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
2098
2249
  // Track session for resumption.
2099
2250
  if (run.sessionId) conv.sessionId = run.sessionId;
2100
2251
  // R4: persist the persona key from the server-side run record.
@@ -2179,6 +2330,7 @@ function startPolling() {
2179
2330
  if (nowFraimDone && nowDirectDone) {
2180
2331
  window.clearInterval(state.pollHandle);
2181
2332
  state.pollHandle = null;
2333
+ tryWordWriteBack(conv);
2182
2334
  }
2183
2335
  } catch (error) {
2184
2336
  console.warn('Polling failed:', error);
@@ -2186,6 +2338,35 @@ function startPolling() {
2186
2338
  }, 1000);
2187
2339
  }
2188
2340
 
2341
+ // Issue #489: When a job completes in Word task-pane surface, write the final
2342
+ // agent output back into the document with tracked changes so the user can
2343
+ // accept/reject via Word's normal review flow.
2344
+ async function tryWordWriteBack(conv) {
2345
+ if (conv.wordWrittenBack) return;
2346
+ if (document.body.dataset.surface !== 'task-pane') return;
2347
+ if (!state.wordContext || !state.wordContext.docTitle) return;
2348
+ if (conv.status !== 'completed') return;
2349
+
2350
+ const employeeMessages = (conv.messages || []).filter(m => m.role === 'employee');
2351
+ if (employeeMessages.length === 0) return;
2352
+ const outputText = employeeMessages[employeeMessages.length - 1].text;
2353
+ if (!outputText || !outputText.trim()) return;
2354
+
2355
+ // Mark before async calls to guard against concurrent poll ticks.
2356
+ conv.wordWrittenBack = true;
2357
+ upsertConversation(conv);
2358
+
2359
+ try {
2360
+ await requestWordContext('track-changes-on');
2361
+ const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
2362
+ await requestWordContext(action, { text: outputText });
2363
+ await requestWordContext('track-changes-off');
2364
+ showStatus('Result written to document — review tracked changes in Word.');
2365
+ } catch (e) {
2366
+ console.warn('Word write-back failed:', e);
2367
+ }
2368
+ }
2369
+
2189
2370
  function convNeedsPolling(conv) {
2190
2371
  if (!conv || !conv.runId) return false;
2191
2372
  const fraimRunning = conv.status === 'running';
@@ -2218,11 +2399,9 @@ function showStatus(text, isError) {
2218
2399
 
2219
2400
  async function pickProject() {
2220
2401
  try {
2221
- const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
2222
- if (response.status === 204) return;
2223
- const payload = await response.json();
2224
- if (!response.ok) throw new Error(payload.error || 'Could not choose a folder.');
2225
- if (payload.path) {
2402
+ // 204 = user cancelled the OS folder picker; requestJson returns null for empty bodies.
2403
+ const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
2404
+ if (payload && payload.path) {
2226
2405
  await loadBootstrap(payload.path);
2227
2406
  }
2228
2407
  } catch (error) {
@@ -2252,6 +2431,7 @@ function showStep2(job) {
2252
2431
  els['start'].disabled = true;
2253
2432
  els['step1'].hidden = true;
2254
2433
  els['step2'].hidden = false;
2434
+ renderWordContextInModal();
2255
2435
  setTimeout(() => els['instructions'].focus(), 50);
2256
2436
  }
2257
2437
 
@@ -2307,7 +2487,9 @@ function wireEvents() {
2307
2487
  els['coach-text'].addEventListener('input', syncSendButton);
2308
2488
  els['send'].addEventListener('click', async () => {
2309
2489
  const text = els['coach-text'].value.trim();
2310
- if (!text) return;
2490
+ // Allow send when textarea is empty IFF a coaching job is pending — the
2491
+ // user clicked a quick-coach button and just wants to fire that job.
2492
+ if (!text && !state.pendingCoachingJobId) return;
2311
2493
  els['coach-text'].value = '';
2312
2494
  syncSendButton();
2313
2495
  // If the user changed the agent via the inline selector, restart with the
@@ -2402,22 +2584,19 @@ function wireEvents() {
2402
2584
  }
2403
2585
 
2404
2586
  // R5: quick-access coaching buttons.
2587
+ // The invocation syntax ($fraim / /fraim) stays out of the UI entirely —
2588
+ // the server adds it based on the active employee. Clicking a button sets a
2589
+ // pending coaching job (shown in #coach-note); the user can add optional
2590
+ // context in the textarea and then click Send.
2405
2591
  if (els['quick-coach-btns']) {
2406
2592
  els['quick-coach-btns'].addEventListener('click', (e) => {
2407
2593
  const btn = e.target.closest('.quick-coach-btn');
2408
2594
  if (!btn) return;
2409
2595
  const jobId = btn.dataset.job;
2410
2596
  if (!jobId) return;
2411
- const conv = activeConversation();
2412
- const prefix = conv && conv.employeeId && conv.employeeId.toLowerCase().includes('codex') ? '$fraim' : '/fraim';
2413
- const invocation = `${prefix} ${jobId}`;
2414
- const textarea = els['coach-text'];
2415
- const existing = textarea.value;
2416
- const sep = existing && !existing.endsWith(' ') ? ' ' : '';
2417
- textarea.value = existing + sep + invocation;
2418
- textarea.focus();
2419
- textarea.setSelectionRange(textarea.value.length, textarea.value.length);
2420
- syncSendButton();
2597
+ const label = btn.textContent.trim();
2598
+ setPendingCoachingJob(jobId, label);
2599
+ els['coach-text'].focus();
2421
2600
  });
2422
2601
  }
2423
2602
  if (els['other-manager-jobs-btn']) {
@@ -2459,6 +2638,24 @@ function wireEvents() {
2459
2638
  }
2460
2639
  bindEnterToButton(els['coach-text'], els['send']);
2461
2640
  bindEnterToButton(els['instructions'], els['start']);
2641
+
2642
+ // Word context bar refresh button.
2643
+ if (els['word-ctx-refresh']) {
2644
+ els['word-ctx-refresh'].addEventListener('click', async () => {
2645
+ const fresh = await requestWordContext('get-context');
2646
+ if (fresh) { state.wordContext = fresh; renderWordContextBar(); renderWordContextInModal(); }
2647
+ });
2648
+ }
2649
+ // Word context card expand/collapse toggle.
2650
+ if (els['word-ctx-card-toggle']) {
2651
+ els['word-ctx-card-toggle'].addEventListener('click', () => {
2652
+ const body = els['word-ctx-card-body'];
2653
+ if (!body) return;
2654
+ const expanded = !body.hidden;
2655
+ body.hidden = expanded;
2656
+ els['word-ctx-card-toggle'].textContent = expanded ? '▸' : '▾';
2657
+ });
2658
+ }
2462
2659
  }
2463
2660
 
2464
2661
  // ---------------------------------------------------------------------------
@@ -2466,6 +2663,9 @@ function wireEvents() {
2466
2663
  // ---------------------------------------------------------------------------
2467
2664
 
2468
2665
  (async function init() {
2666
+ // html.electron is already set by the inline script in <head> for zero-flash.
2667
+ // This is a belt-and-suspenders fallback in case the inline script was skipped.
2668
+
2469
2669
  gatherElements();
2470
2670
  loadConversationsFromStorage();
2471
2671
  wirePopovers();
@@ -2473,14 +2673,34 @@ function wireEvents() {
2473
2673
 
2474
2674
  const urlParams = new URLSearchParams(window.location.search);
2475
2675
  const isFirstRun = urlParams.get('firstRun') === 'true';
2676
+ const surface = urlParams.get('surface') || 'hub';
2677
+ const docUrl = urlParams.get('docUrl') || '';
2678
+
2679
+ // Apply surface class before bootstrap so CSS takes effect immediately.
2680
+ if (surface !== 'hub') document.body.dataset.surface = surface;
2681
+
2682
+ // Register the Word context bridge (no-op outside iframe context).
2683
+ setupWordBridge();
2476
2684
 
2477
2685
  try {
2478
- await loadBootstrap();
2686
+ await loadBootstrap(null, docUrl);
2479
2687
  } catch (error) {
2480
2688
  showStatus(error.message, true);
2481
2689
  return;
2482
2690
  }
2483
2691
 
2692
+ // Task-pane and extension surfaces: skip welcome/onboarding, show active job
2693
+ // or open the new-job picker. Layout is compact (rail hidden via CSS).
2694
+ if (surface === 'task-pane' || surface === 'extension') {
2695
+ const conv = activeConversation();
2696
+ if (conv && conv.projectPath === state.projectPath) {
2697
+ if (convNeedsPolling(conv)) startPolling();
2698
+ } else {
2699
+ openModal();
2700
+ }
2701
+ return;
2702
+ }
2703
+
2484
2704
  if (isFirstRun) {
2485
2705
  renderFirstRunLanding();
2486
2706
  return;
@@ -2621,11 +2841,8 @@ function renderFirstRunLanding(mode) {
2621
2841
  ].join('');
2622
2842
  browseBtn.addEventListener('click', async () => {
2623
2843
  try {
2624
- const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
2625
- if (response.status === 204) return;
2626
- const payload = await response.json();
2627
- if (!response.ok) throw new Error(payload.error || 'Could not pick folder.');
2628
- if (payload.path) {
2844
+ const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
2845
+ if (payload && payload.path) {
2629
2846
  await loadBootstrap(payload.path);
2630
2847
  folderInput.value = state.projectPath;
2631
2848
  folderInput.style.color = 'var(--text,#1f2a24)';