@harborgroup/my-team 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,14 @@ Every raw request sent to the Claude Agent SDK (including the exact system promp
50
50
 
51
51
  When Iris (or your CEO) wants a structured answer rather than free text, it asks via a real inline widget — text input, single-select, or multi-select (with an automatic "Other" option) — instead of plain prose. This is a text convention the model follows (a fenced `question-widget` block the frontend parses out of the stream and replaces with an interactive card), not an MCP tool call: a real custom-tool + elicitation round trip was tested directly against the installed SDK and confirmed not to work yet (Claude Code's MCP-client role doesn't currently declare elicitation capability), so this achieves the same UX without that dependency. The main composer is disabled while a widget is awaiting an answer.
52
52
 
53
+ ## Chat history
54
+
55
+ The visible conversation — not just Claude's own session context — is persisted to `.my-team/chat-history.jsonl` and replayed on load, so both a page reload and a full server restart show prior messages instead of starting over. A widget that was already answered replays locked to that answer; a widget left unanswered when the server stopped (e.g. it was killed mid-conversation) replays as a genuinely live, answerable widget, composer gating included, picking up exactly where things left off. Unlike `profile.json`, this file is gitignored automatically (it can contain arbitrary conversation content) — `init` sets this up, and a plain `npm run team` self-heals the `.gitignore` too, so upgrading an existing install doesn't require re-running `init`.
56
+
57
+ ## Staying out of its own way
58
+
59
+ This dashboard is itself an ordinary Node process running inside the repo it manages. If your CEO ever needs to restart a dev server, broadly killing every node process (`taskkill /F /IM node.exe`, `killall node`) would take the dashboard down with it — so the system prompt explicitly warns against that and points at `.my-team/server.pid` (also gitignored) to identify and exclude the dashboard's own process. The server also logs and continues past uncaught exceptions rather than crashing the whole session.
60
+
53
61
  ## Status
54
62
 
55
- v1.5: CLI-auth onboarding + conversational company/CEO-persona onboarding + Slack-style chat shell with real streaming + per-message model/effort selection + update checker + structured logging + inline clarifying-question widgets. No persisted chat history across restarts, no multi-user support, no `#general` backend (sidebar placeholder only).
63
+ v1.6: CLI-auth onboarding + conversational company/CEO-persona onboarding + Slack-style chat shell with real streaming + per-message model/effort selection + update checker + structured logging + inline clarifying-question widgets + persisted chat history + dev-server-restart safety guardrail. No multi-user support, no `#general` backend (sidebar placeholder only).
package/dist/init.js CHANGED
@@ -16,6 +16,23 @@ function addTeamScript(repoPackageJsonPath) {
16
16
  writeFileSync(repoPackageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
17
17
  console.log('Added "npm run team" script to package.json.');
18
18
  }
19
+ // Only these two files are ignored — .my-team/profile.json is deliberately
20
+ // committed (see README: it's shared, non-sensitive team config, the same
21
+ // category as CLAUDE.md). Chat history can contain arbitrary conversation
22
+ // content and server.pid is per-machine-run ephemeral state; neither
23
+ // belongs in git.
24
+ const GITIGNORE_ENTRIES = ['.my-team/chat-history.jsonl', '.my-team/server.pid'];
25
+ export function ensureGitignore(cwd) {
26
+ const gitignorePath = path.join(cwd, '.gitignore');
27
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
28
+ const existingLines = new Set(existing.split('\n').map((l) => l.trim()));
29
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
30
+ if (!missing.length)
31
+ return;
32
+ const separator = existing && !existing.endsWith('\n') ? '\n' : '';
33
+ writeFileSync(gitignorePath, existing + separator + missing.join('\n') + '\n');
34
+ console.log(`Added ${missing.join(', ')} to .gitignore.`);
35
+ }
19
36
  export async function runInit(cwd) {
20
37
  const repoPackageJsonPath = path.join(cwd, 'package.json');
21
38
  if (!existsSync(repoPackageJsonPath)) {
@@ -30,5 +47,6 @@ export async function runInit(cwd) {
30
47
  return;
31
48
  }
32
49
  addTeamScript(repoPackageJsonPath);
50
+ ensureGitignore(cwd);
33
51
  console.log('Done — run "npm run team" to launch.');
34
52
  }
@@ -0,0 +1,39 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getProfileDir } from './profile.js';
4
+ function getHistoryPath(cwd) {
5
+ return path.join(getProfileDir(cwd), 'chat-history.jsonl');
6
+ }
7
+ export async function appendHistory(cwd, entry) {
8
+ const dir = getProfileDir(cwd);
9
+ try {
10
+ await mkdir(dir, { recursive: true });
11
+ await appendFile(getHistoryPath(cwd), JSON.stringify(entry) + '\n', 'utf-8');
12
+ }
13
+ catch {
14
+ // History is a nice-to-have replay, not a source of truth — never let a
15
+ // disk error here interrupt the actual conversation.
16
+ }
17
+ }
18
+ export async function readHistory(cwd) {
19
+ let raw;
20
+ try {
21
+ raw = await readFile(getHistoryPath(cwd), 'utf-8');
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const entries = [];
27
+ for (const line of raw.split('\n')) {
28
+ if (!line.trim())
29
+ continue;
30
+ try {
31
+ entries.push(JSON.parse(line));
32
+ }
33
+ catch {
34
+ // Skip a corrupted line (e.g. a torn write from a crash) rather than
35
+ // discarding the entire history.
36
+ }
37
+ }
38
+ return entries;
39
+ }
@@ -1,11 +1,14 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { appendHistory } from './chat-history.js';
2
3
  import { sanitizeEnv } from './env.js';
3
4
  import { forLog } from './logger.js';
4
5
  import { formatProfileForSystemPrompt, getOnboardingSystemPrompt, readProfile } from './profile.js';
5
6
  /**
6
- * One ongoing SDK session per running server process. Conversation history
7
- * lives only in the CLI's own session store (resumed via session_id) — no
8
- * disk persistence of our own, lost on server restart (acceptable for v1).
7
+ * One ongoing SDK session per running server process. Claude's own
8
+ * conversation context survives via `resume`; the *visible* transcript is
9
+ * additionally persisted to chat-history.jsonl (see chat-history.ts) so a
10
+ * page reload or a full server restart both show prior messages instead of
11
+ * an empty thread.
9
12
  */
10
13
  export class ChatSession {
11
14
  logger;
@@ -23,11 +26,16 @@ export class ChatSession {
23
26
  return;
24
27
  }
25
28
  this.turnInFlight = true;
29
+ const ts = new Date().toISOString();
30
+ await appendHistory(cwd, { kind: overrides.isWidgetAnswer ? 'widget-answer' : 'user', text: message, ts });
31
+ let assistantText = '';
32
+ let resultModel = '';
33
+ let resultEffort = '';
26
34
  try {
27
35
  const profile = await readProfile(cwd);
28
36
  const onboarded = profile?.onboardingComplete === true;
29
37
  const systemPromptAppend = onboarded
30
- ? formatProfileForSystemPrompt(profile)
38
+ ? formatProfileForSystemPrompt(profile, cwd)
31
39
  : getOnboardingSystemPrompt(cwd);
32
40
  // Per-message override wins, then the CEO's configured default, then no
33
41
  // override at all (Claude Code picks its own default).
@@ -54,11 +62,14 @@ export class ChatSession {
54
62
  // report what we requested (the UI only ever offers effort levels
55
63
  // the chosen model's own supportedEffortLevels confirms are valid,
56
64
  // so a silent downgrade in practice shouldn't occur).
65
+ resultModel = msg.model;
66
+ resultEffort = effort ?? '';
57
67
  yield { type: 'meta', sessionId: msg.session_id, model: msg.model, effort: effort ?? '' };
58
68
  }
59
69
  else if (msg.type === 'stream_event') {
60
70
  const event = msg.event;
61
71
  if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
72
+ assistantText += event.delta.text;
62
73
  yield { type: 'text-delta', text: event.delta.text };
63
74
  }
64
75
  }
@@ -67,20 +78,32 @@ export class ChatSession {
67
78
  // calls are new information here (tool inputs don't render usefully).
68
79
  for (const block of msg.message.content) {
69
80
  if (block.type === 'tool_use') {
81
+ await appendHistory(cwd, { kind: 'tool', name: block.name, ts: new Date().toISOString() });
70
82
  yield { type: 'tool-use', name: block.name };
71
83
  }
72
84
  }
73
85
  }
74
86
  else if (msg.type === 'result') {
75
87
  if (msg.subtype !== 'success') {
88
+ await appendHistory(cwd, { kind: 'error', message: msg.subtype, ts: new Date().toISOString() });
76
89
  yield { type: 'error', message: msg.subtype };
77
90
  }
78
91
  yield { type: 'done' };
79
92
  }
80
93
  }
94
+ if (assistantText) {
95
+ await appendHistory(cwd, {
96
+ kind: 'assistant',
97
+ text: assistantText,
98
+ model: resultModel,
99
+ effort: resultEffort,
100
+ ts: new Date().toISOString(),
101
+ });
102
+ }
81
103
  }
82
104
  catch (err) {
83
105
  this.logger.log({ type: 'chat-error', error: String(err?.message ?? err), stack: err?.stack });
106
+ await appendHistory(cwd, { kind: 'error', message: String(err?.message ?? err), ts: new Date().toISOString() });
84
107
  yield { type: 'error', message: String(err?.message ?? err) };
85
108
  }
86
109
  finally {
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { OWN_PACKAGE_NAME } from '../own-package.js';
6
6
  import { npmInstall } from '../npm-install.js';
7
7
  import { checkAuth } from './auth-check.js';
8
+ import { readHistory } from './chat-history.js';
8
9
  import { ChatSession } from './chat-session.js';
9
10
  import { readProfile, validateProfileInput, writeProfile } from './profile.js';
10
11
  import { tokensMatch } from './token.js';
@@ -78,6 +79,11 @@ export function createServer(options) {
78
79
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok }));
79
80
  return;
80
81
  }
82
+ if (req.method === 'GET' && url.pathname === '/api/chat/history') {
83
+ const history = await readHistory(cwd);
84
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ history }));
85
+ return;
86
+ }
81
87
  if (req.method === 'GET' && url.pathname === '/api/profile') {
82
88
  const profile = await readProfile(cwd);
83
89
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ profile }));
@@ -109,11 +115,13 @@ export function createServer(options) {
109
115
  let message;
110
116
  let model;
111
117
  let effort;
118
+ let isWidgetAnswer;
112
119
  try {
113
120
  const body = await readJsonBody(req);
114
121
  message = String(body.message ?? '');
115
122
  model = typeof body.model === 'string' && body.model ? body.model : undefined;
116
123
  effort = typeof body.effort === 'string' && body.effort ? body.effort : undefined;
124
+ isWidgetAnswer = body.isWidgetAnswer === true;
117
125
  }
118
126
  catch {
119
127
  res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'invalid body' }));
@@ -128,7 +136,7 @@ export function createServer(options) {
128
136
  'Cache-Control': 'no-cache',
129
137
  Connection: 'keep-alive',
130
138
  });
131
- for await (const event of chatSession.sendTurn(message, cwd, { model, effort })) {
139
+ for await (const event of chatSession.sendTurn(message, cwd, { model, effort, isWidgetAnswer })) {
132
140
  res.write(`data: ${JSON.stringify(event)}\n\n`);
133
141
  }
134
142
  res.end();
@@ -115,6 +115,17 @@ const QUESTION_WIDGET_INSTRUCTIONS = `When you want to ask a clarifying question
115
115
  {"type": "single_select", "question": "Which best fits?", "options": ["Option A", "Option B"]}
116
116
  \`\`\`
117
117
  Use "text" for open-ended answers (names, descriptions), "single_select" when exactly one of a few options applies, "multi_select" when more than one can. An "Other" option is always available to the user automatically — don't add your own "Other" entry to the options list. Only use this when a structured widget genuinely helps; ordinary conversation doesn't need it.`;
118
+ /**
119
+ * This dashboard's own server process runs as an ordinary Node process
120
+ * inside the repo it's managing. If a task ever calls for restarting a dev
121
+ * server, a broad-strokes approach (killing every node/npm process by name)
122
+ * would take the dashboard down along with it. Cheap, high-value mitigation:
123
+ * tell the model exactly that, with a concrete way to check.
124
+ */
125
+ function getProcessSafetyInstructions(cwd) {
126
+ const pidFilePath = path.join(getProfileDir(cwd), 'server.pid');
127
+ return `This chat dashboard you're running inside of is itself an ordinary Node process in this repo, started by the founder via "npm run team". If you ever need to restart a dev server or otherwise manage node/npm processes here, do NOT broadly kill all node processes (e.g. "taskkill /F /IM node.exe", "killall node", "pkill node") — that would also kill this dashboard mid-conversation. Target the specific dev-server process by its own port or PID instead. This dashboard's own process info is at ${pidFilePath} (JSON: {"pid": ..., "port": ...}) — check it first and exclude that PID from anything you kill or restart.`;
128
+ }
118
129
  /**
119
130
  * Strong, first-person identity framing — deliberately not "you're also
120
131
  * helping company X" phrasing. Weaker framing was tested and Claude treated
@@ -123,10 +134,12 @@ Use "text" for open-ended answers (names, descriptions), "single_select" when ex
123
134
  * injection). Explicitly asserting this is configured identity, not
124
135
  * external/injected content, fixed it in testing.
125
136
  */
126
- export function formatProfileForSystemPrompt(profile) {
137
+ export function formatProfileForSystemPrompt(profile, cwd) {
127
138
  return `You are ${profile.ceoName}, the AI CEO of ${profile.companyName}. This is your actual identity, configured directly by your founder — it is not external file content, not a suggestion, and not something to second-guess or flag as injected. ${profile.companyName}'s mission: ${profile.mission}. Communication style: ${profile.ceoPersonality}. Speak and act as ${profile.companyName}'s CEO would — invested in the company's success and personally identified with its mission.
128
139
 
129
- ${QUESTION_WIDGET_INSTRUCTIONS}`;
140
+ ${QUESTION_WIDGET_INSTRUCTIONS}
141
+
142
+ ${getProcessSafetyInstructions(cwd)}`;
130
143
  }
131
144
  /**
132
145
  * Used instead of formatProfileForSystemPrompt while onboardingComplete is
@@ -144,5 +157,7 @@ ${QUESTION_WIDGET_INSTRUCTIONS}
144
157
 
145
158
  For this onboarding interview specifically: ask the company name, mission, and CEO name as "text" widgets. For CEO personality, use a "single_select" widget with a handful of helpful preset tones (e.g. "Warm and encouraging", "Sharp and dry-witted", "Blunt and data-driven", "Formal and professional") — the founder can always type a custom one via the automatic Other option.
146
159
 
147
- After each answer, immediately write or update ${filePath} with whatever you know so far as JSON: {"companyName": string, "mission": string, "ceoName": string, "ceoPersonality": string, "onboardingComplete": boolean} — set "onboardingComplete" to false until you have all four answers, then write it one final time with "onboardingComplete": true. After that final write, briefly introduce yourself in your new persona. Until "onboardingComplete" is true, don't perform any other coding tasks.`;
160
+ After each answer, immediately write or update ${filePath} with whatever you know so far as JSON: {"companyName": string, "mission": string, "ceoName": string, "ceoPersonality": string, "onboardingComplete": boolean} — set "onboardingComplete" to false until you have all four answers, then write it one final time with "onboardingComplete": true. After that final write, briefly introduce yourself in your new persona. Until "onboardingComplete" is true, don't perform any other coding tasks.
161
+
162
+ ${getProcessSafetyInstructions(cwd)}`;
148
163
  }
@@ -0,0 +1,19 @@
1
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getProfileDir } from './profile.js';
4
+ /**
5
+ * Lets Claude (or the user) identify the dashboard's own process before
6
+ * doing anything that might broadly restart or kill node processes in this
7
+ * repo (e.g. restarting a dev server) — see the system-prompt guardrail in
8
+ * profile.ts. Written at startup, removed on graceful shutdown.
9
+ */
10
+ export function getServerPidPath(cwd) {
11
+ return path.join(getProfileDir(cwd), 'server.pid');
12
+ }
13
+ export async function writeServerPid(cwd, port) {
14
+ await mkdir(getProfileDir(cwd), { recursive: true });
15
+ await writeFile(getServerPidPath(cwd), JSON.stringify({ pid: process.pid, port }, null, 2) + '\n', 'utf-8');
16
+ }
17
+ export async function removeServerPid(cwd) {
18
+ await unlink(getServerPidPath(cwd)).catch(() => { });
19
+ }
@@ -1,7 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { ensureGitignore } from '../init.js';
2
3
  import { OWN_PACKAGE_NAME, OWN_PACKAGE_VERSION } from '../own-package.js';
3
4
  import { createServer } from './http-server.js';
4
5
  import { createLogger } from './logger.js';
6
+ import { removeServerPid, writeServerPid } from './server-pid.js';
5
7
  import { generateToken } from './token.js';
6
8
  import { checkForUpdate } from './update-check.js';
7
9
  function openBrowser(url) {
@@ -21,10 +23,28 @@ function openBrowser(url) {
21
23
  // Fall through — the URL is printed to the terminal regardless.
22
24
  }
23
25
  }
26
+ function installResilienceHandlers(logger) {
27
+ // A local dev dashboard staying up through an unexpected error is more
28
+ // useful than crashing the whole session over it — log and continue
29
+ // rather than let Node's default "exit on uncaught exception" behavior
30
+ // take down the UI mid-conversation.
31
+ process.on('uncaughtException', (err) => {
32
+ logger.log({ type: 'uncaught-exception', error: String(err?.message ?? err), stack: err?.stack });
33
+ console.error('my-team: uncaught exception (continuing):', err);
34
+ });
35
+ process.on('unhandledRejection', (reason) => {
36
+ logger.log({ type: 'unhandled-rejection', reason: String(reason) });
37
+ console.error('my-team: unhandled rejection (continuing):', reason);
38
+ });
39
+ }
24
40
  export async function startServer(cwd) {
25
41
  const token = generateToken();
26
42
  const portRef = { port: 0 };
27
43
  const logger = createLogger(cwd);
44
+ installResilienceHandlers(logger);
45
+ // Idempotent — also fixes up repos that ran `init` before this existed,
46
+ // not just fresh ones, without needing them to re-run init.
47
+ ensureGitignore(cwd);
28
48
  // Fire-and-forget: never block startup on a network round-trip. /api/meta
29
49
  // just returns null until this resolves.
30
50
  const updateInfoRef = { current: null };
@@ -40,6 +60,12 @@ export async function startServer(cwd) {
40
60
  console.log(`my-team is running for ${cwd}`);
41
61
  console.log(`Open: ${url}`);
42
62
  console.log(`Logging raw requests/responses to: ${logger.filePath}`);
63
+ writeServerPid(cwd, portRef.port);
64
+ const shutdown = () => {
65
+ removeServerPid(cwd).finally(() => process.exit(0));
66
+ };
67
+ process.on('SIGINT', shutdown);
68
+ process.on('SIGTERM', shutdown);
43
69
  openBrowser(url);
44
70
  resolve();
45
71
  });
package/dist/web/app.js CHANGED
@@ -295,7 +295,12 @@ async function checkStatus() {
295
295
  selectPage('messages');
296
296
  checkForUpdateBanner(1);
297
297
 
298
- if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
298
+ const hasHistory = await loadHistory();
299
+ // Only auto-kick off onboarding on a genuinely blank slate — if history
300
+ // replay already left an open, answerable widget (the conversation
301
+ // stopped mid-question last time), let the user respond to that instead
302
+ // of firing a second, duplicate kickoff on top of it.
303
+ if (!lastProfile?.onboardingComplete && !onboardingKickedOff && !hasHistory) {
299
304
  onboardingKickedOff = true;
300
305
  kickoffOnboarding();
301
306
  }
@@ -367,7 +372,7 @@ function renderMarkdown(text) {
367
372
  return html;
368
373
  }
369
374
 
370
- function appendMessageRow(kind, name, avatarText, text) {
375
+ function appendMessageRow(kind, name, avatarText, text, tsOverride) {
371
376
  const row = document.createElement('div');
372
377
  row.className = `msg-row ${kind}`;
373
378
 
@@ -383,7 +388,8 @@ function appendMessageRow(kind, name, avatarText, text) {
383
388
  meta.textContent = name;
384
389
  const time = document.createElement('span');
385
390
  time.className = 'msg-time';
386
- time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
391
+ const d = tsOverride ? new Date(tsOverride) : new Date();
392
+ time.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
387
393
  meta.appendChild(time);
388
394
 
389
395
  const textEl = document.createElement('div');
@@ -399,8 +405,8 @@ function appendMessageRow(kind, name, avatarText, text) {
399
405
  return { row, textEl };
400
406
  }
401
407
 
402
- function appendUserMessage(text) {
403
- appendMessageRow('user', 'You', 'Y', text);
408
+ function appendUserMessage(text, ts) {
409
+ appendMessageRow('user', 'You', 'Y', text, ts);
404
410
  }
405
411
 
406
412
  let pendingWidgetCount = 0;
@@ -510,7 +516,7 @@ class AssistantContentRenderer {
510
516
  if (answered) return;
511
517
  answered = true;
512
518
  setPendingWidget(-1);
513
- streamChat(messageText, { showUserBubble: false });
519
+ streamChat(messageText, { showUserBubble: false, isWidgetAnswer: true });
514
520
  });
515
521
  this.widgetPlaceholderEl.replaceWith(widgetEl);
516
522
  this.widgetPlaceholderEl = null;
@@ -539,7 +545,11 @@ class AssistantContentRenderer {
539
545
  }
540
546
  }
541
547
 
542
- function createQuestionWidget(data, onAnswer) {
548
+ // lockedAnswer renders the widget immediately in its answered state with no
549
+ // interactive controls — used when replaying history, since the underlying
550
+ // SDK session has already moved past that turn and re-answering it live
551
+ // would be meaningless (or worse, confusing) rather than just cosmetic.
552
+ function createQuestionWidget(data, onAnswer, lockedAnswer) {
543
553
  const card = document.createElement('div');
544
554
  card.className = 'question-widget';
545
555
 
@@ -559,7 +569,12 @@ function createQuestionWidget(data, onAnswer) {
559
569
  answerEl.className = 'question-widget-answer';
560
570
  answerEl.textContent = displayText;
561
571
  body.appendChild(answerEl);
562
- onAnswer(messageText ?? displayText);
572
+ if (onAnswer) onAnswer(messageText ?? displayText);
573
+ }
574
+
575
+ if (lockedAnswer != null) {
576
+ submit(lockedAnswer, lockedAnswer);
577
+ return card;
563
578
  }
564
579
 
565
580
  function buildOtherRow(placeholder) {
@@ -650,12 +665,127 @@ function appendAssistantRow(turnMeta) {
650
665
  return result;
651
666
  }
652
667
 
653
- function appendToolMessage(name) {
654
- appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
668
+ function appendToolMessage(name, ts) {
669
+ appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`, ts);
670
+ }
671
+
672
+ function appendErrorMessage(message, ts) {
673
+ appendMessageRow('error', 'Error', '!', message, ts);
674
+ }
675
+
676
+ // One-shot counterpart to AssistantContentRenderer for replaying a
677
+ // message whose full text is already known (history) rather than
678
+ // streaming in — a single regex pass is fine here since there's no
679
+ // "flash of raw JSON" or "wipe answered state" risk on a fully-formed string.
680
+ // liveOnAnswer is only passed for a trailing, never-answered widget at the
681
+ // very end of history (the conversation was interrupted — e.g. server
682
+ // restarted — before the founder responded). That one genuinely needs to
683
+ // keep working exactly like a live pending widget, composer gating
684
+ // included, rather than being either dead-looking (locked) or clickable but
685
+ // silently doing nothing (no answer handler).
686
+ function renderStaticAssistantText(container, fullText, lockedAnswer, liveOnAnswer) {
687
+ const re = /```question-widget\s*\n([\s\S]*?)\n```/;
688
+ const match = fullText.match(re);
689
+ if (!match) {
690
+ const seg = document.createElement('div');
691
+ seg.className = 'msg-text-segment';
692
+ seg.innerHTML = renderMarkdown(fullText);
693
+ container.appendChild(seg);
694
+ return;
695
+ }
696
+
697
+ const before = fullText.slice(0, match.index);
698
+ const after = fullText.slice(match.index + match[0].length);
699
+
700
+ if (before.trim()) {
701
+ const seg = document.createElement('div');
702
+ seg.className = 'msg-text-segment';
703
+ seg.innerHTML = renderMarkdown(before);
704
+ container.appendChild(seg);
705
+ }
706
+
707
+ let data = null;
708
+ try {
709
+ data = JSON.parse(match[1]);
710
+ } catch {
711
+ const seg = document.createElement('div');
712
+ seg.className = 'msg-text-segment';
713
+ seg.textContent = match[1].trim();
714
+ container.appendChild(seg);
715
+ }
716
+ if (data) {
717
+ if (lockedAnswer != null) {
718
+ container.appendChild(createQuestionWidget(data, null, lockedAnswer));
719
+ } else if (liveOnAnswer) {
720
+ setPendingWidget(1);
721
+ let answered = false;
722
+ const widgetEl = createQuestionWidget(data, (messageText) => {
723
+ if (answered) return;
724
+ answered = true;
725
+ setPendingWidget(-1);
726
+ liveOnAnswer(messageText);
727
+ });
728
+ container.appendChild(widgetEl);
729
+ } else {
730
+ // Shouldn't normally happen (an unanswered widget anywhere but the
731
+ // last entry would mean the conversation continued without it ever
732
+ // being answered) — render inert rather than a dead-looking control.
733
+ container.appendChild(createQuestionWidget(data, null, '(not answered)'));
734
+ }
735
+ }
736
+
737
+ if (after.trim()) {
738
+ const seg = document.createElement('div');
739
+ seg.className = 'msg-text-segment';
740
+ seg.innerHTML = renderMarkdown(after);
741
+ container.appendChild(seg);
742
+ }
655
743
  }
656
744
 
657
- function appendErrorMessage(message) {
658
- appendMessageRow('error', 'Error', '!', message);
745
+ async function loadHistory() {
746
+ const res = await api('/api/chat/history');
747
+ const { history } = await res.json();
748
+
749
+ for (let i = 0; i < history.length; i++) {
750
+ const entry = history[i];
751
+ if (entry.kind === 'user') {
752
+ appendUserMessage(entry.text, entry.ts);
753
+ } else if (entry.kind === 'widget-answer') {
754
+ // Consumed by the preceding assistant entry's widget below — a live
755
+ // widget answer never gets its own bubble either.
756
+ continue;
757
+ } else if (entry.kind === 'tool') {
758
+ appendToolMessage(entry.name, entry.ts);
759
+ } else if (entry.kind === 'error') {
760
+ appendErrorMessage(entry.message, entry.ts);
761
+ } else if (entry.kind === 'assistant') {
762
+ const { row, textEl } = appendMessageRow(
763
+ 'assistant',
764
+ lastProfile?.ceoName || 'your AI CEO',
765
+ getCeoInitial(),
766
+ '',
767
+ entry.ts,
768
+ );
769
+ if (entry.model) {
770
+ const badge = document.createElement('span');
771
+ badge.className = 'msg-model-badge';
772
+ const modelLabel = modelDisplayName(entry.model);
773
+ badge.textContent = entry.effort ? `${modelLabel} · ${entry.effort}` : modelLabel;
774
+ row.querySelector('.msg-meta').appendChild(badge);
775
+ }
776
+ const next = history[i + 1];
777
+ const lockedAnswer = next?.kind === 'widget-answer' ? next.text : null;
778
+ const isTrailingUnanswered = i === history.length - 1 && !lockedAnswer;
779
+ renderStaticAssistantText(
780
+ textEl,
781
+ entry.text,
782
+ lockedAnswer,
783
+ isTrailingUnanswered ? (messageText) => streamChat(messageText, { showUserBubble: false, isWidgetAnswer: true }) : null,
784
+ );
785
+ }
786
+ }
787
+
788
+ return history.length > 0;
659
789
  }
660
790
 
661
791
  function showTypingIndicator() {
@@ -668,7 +798,7 @@ function hideTypingIndicator() {
668
798
  el.typingIndicator.hidden = true;
669
799
  }
670
800
 
671
- async function streamChat(message, { showUserBubble }) {
801
+ async function streamChat(message, { showUserBubble, isWidgetAnswer }) {
672
802
  if (showUserBubble) appendUserMessage(message);
673
803
  showTypingIndicator();
674
804
  setStatus('typing');
@@ -680,7 +810,12 @@ async function streamChat(message, { showUserBubble }) {
680
810
  const res = await api('/api/chat', {
681
811
  method: 'POST',
682
812
  headers: { 'Content-Type': 'application/json' },
683
- body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
813
+ body: JSON.stringify({
814
+ message,
815
+ model: el.chatModelSelect.value,
816
+ effort: el.chatEffortSelect.value,
817
+ isWidgetAnswer: isWidgetAnswer === true,
818
+ }),
684
819
  });
685
820
 
686
821
  if (!res.ok || !res.body) {
@@ -6,21 +6,28 @@
6
6
  <link rel="stylesheet" href="/style.css" />
7
7
  </head>
8
8
  <body>
9
- <p id="loading">Checking Claude Code status&hellip;</p>
9
+ <div id="loading" class="splash">
10
+ <div class="splash-logo"></div>
11
+ <div class="splash-spinner"></div>
12
+ <p class="splash-text">Checking Claude Code status&hellip;</p>
13
+ </div>
10
14
 
11
- <section id="onboarding" hidden>
12
- <h1>Set up Claude Code</h1>
13
- <div id="onboarding-cli-missing" hidden>
14
- <p>The Claude Code CLI binary couldn't be found or run.</p>
15
- <p>Try installing it globally, then reload this page:</p>
16
- <pre>npm install -g @anthropic-ai/claude-code</pre>
17
- </div>
18
- <div id="onboarding-not-authenticated" hidden>
19
- <p>Claude Code isn't logged in yet. In a terminal, run:</p>
20
- <pre>claude login</pre>
21
- <p>Then come back here.</p>
15
+ <section id="onboarding" class="splash" hidden>
16
+ <div class="splash-logo"></div>
17
+ <div class="splash-card">
18
+ <h1>Set up Claude Code</h1>
19
+ <div id="onboarding-cli-missing" hidden>
20
+ <p>The Claude Code CLI binary couldn't be found or run.</p>
21
+ <p>Try installing it globally, then reload this page:</p>
22
+ <pre>npm install -g @anthropic-ai/claude-code</pre>
23
+ </div>
24
+ <div id="onboarding-not-authenticated" hidden>
25
+ <p>Claude Code isn't logged in yet. In a terminal, run:</p>
26
+ <pre>claude login</pre>
27
+ <p>Then come back here.</p>
28
+ </div>
29
+ <button id="check-again">Check again</button>
22
30
  </div>
23
- <button id="check-again">Check again</button>
24
31
  </section>
25
32
 
26
33
  <div id="app-shell" hidden>
@@ -65,20 +65,80 @@ body {
65
65
  display: none !important;
66
66
  }
67
67
 
68
- #loading,
69
- #onboarding {
70
- max-width: 480px;
71
- margin: 4rem auto;
72
- padding: 0 1rem;
68
+ .splash {
69
+ position: fixed;
70
+ inset: 0;
71
+ display: flex;
72
+ flex-direction: column;
73
+ align-items: center;
74
+ justify-content: center;
75
+ gap: 1.1rem;
76
+ padding: 1.5rem;
77
+ background: radial-gradient(circle at 50% 35%, #241f3d 0%, #131120 65%, #0d0c17 100%);
78
+ color: #eceafc;
79
+ text-align: center;
80
+ }
81
+
82
+ .splash-logo {
83
+ width: 56px;
84
+ height: 56px;
85
+ border-radius: 16px;
86
+ background: linear-gradient(135deg, var(--accent), #b45cff);
87
+ box-shadow: 0 8px 32px rgba(124, 116, 255, 0.35);
88
+ }
89
+
90
+ .splash-spinner {
91
+ width: 26px;
92
+ height: 26px;
93
+ border-radius: 50%;
94
+ border: 3px solid rgba(255, 255, 255, 0.15);
95
+ border-top-color: #b8b2ff;
96
+ animation: splash-spin 0.8s linear infinite;
97
+ }
98
+
99
+ @keyframes splash-spin {
100
+ to {
101
+ transform: rotate(360deg);
102
+ }
103
+ }
104
+
105
+ .splash-text {
106
+ margin: 0;
107
+ color: #a9a5cf;
108
+ font-size: 0.92rem;
109
+ }
110
+
111
+ .splash-card {
112
+ width: 100%;
113
+ max-width: 440px;
114
+ background: var(--bg);
115
+ color: var(--fg);
116
+ border-radius: 14px;
117
+ padding: 1.75rem;
118
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
119
+ text-align: left;
120
+ }
121
+
122
+ .splash-card h1 {
123
+ margin: 0 0 0.9rem;
124
+ font-size: 1.15rem;
73
125
  text-align: center;
74
126
  }
75
127
 
76
- #onboarding pre {
128
+ .splash-card p {
129
+ line-height: 1.5;
130
+ }
131
+
132
+ .splash-card pre {
77
133
  background: var(--bg-subtle);
78
134
  padding: 0.75rem 1rem;
79
135
  border-radius: 8px;
80
136
  overflow-x: auto;
81
- text-align: left;
137
+ }
138
+
139
+ .splash-card button {
140
+ width: 100%;
141
+ margin-top: 0.4rem;
82
142
  }
83
143
 
84
144
  /* ---------- App shell ---------- */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborgroup/my-team",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Chat with your local Claude Code subscription from a web dashboard, scoped to the current repo. Dev-only.",
5
5
  "type": "module",
6
6
  "bin": {