@harborgroup/my-team 0.2.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 +13 -1
- package/dist/init.js +18 -0
- package/dist/server/chat-history.js +39 -0
- package/dist/server/chat-session.js +27 -4
- package/dist/server/http-server.js +9 -1
- package/dist/server/profile.js +41 -3
- package/dist/server/server-pid.js +19 -0
- package/dist/server/start.js +26 -0
- package/dist/web/app.js +396 -19
- package/dist/web/index.html +21 -14
- package/dist/web/style.css +192 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,18 @@ On startup, `my-team` checks the npm registry in the background for a newer publ
|
|
|
46
46
|
|
|
47
47
|
Every raw request sent to the Claude Agent SDK (including the exact system prompt) and every raw message it streams back is logged verbatim to a JSON-lines file, one per `npm run team` run, under `~/.my-team/logs/` (outside the repo — old runs beyond a retention count are pruned automatically). The current run's log path is printed on startup and shown on the Settings page.
|
|
48
48
|
|
|
49
|
+
## Clarifying questions
|
|
50
|
+
|
|
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
|
+
|
|
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
|
+
|
|
49
61
|
## Status
|
|
50
62
|
|
|
51
|
-
v1.
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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();
|
package/dist/server/profile.js
CHANGED
|
@@ -100,6 +100,32 @@ export async function writeProfile(cwd, data) {
|
|
|
100
100
|
throw err;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Shared by both system prompts below. Custom MCP-tool elicitation was tried
|
|
105
|
+
* and confirmed (via a real round-trip test) not to work in the installed
|
|
106
|
+
* SDK version — the Claude Code CLI's MCP-client role doesn't currently
|
|
107
|
+
* declare elicitation capability, regardless of an onElicitation handler
|
|
108
|
+
* being registered. This text-convention approach sidesteps that entirely:
|
|
109
|
+
* the frontend parses this fenced block out of the normal streamed text and
|
|
110
|
+
* renders it as an interactive widget, and the user's answer becomes a
|
|
111
|
+
* completely ordinary next chat message — no protocol dependency at all.
|
|
112
|
+
*/
|
|
113
|
+
const QUESTION_WIDGET_INSTRUCTIONS = `When you want to ask a clarifying question and a structured answer would help more than free text, emit a fenced block with the language tag "question-widget" containing exactly one JSON object, then stop your message there and wait for the answer — don't also ask the same question in prose before or after the block, and don't include more than one such block per message. Shape: {"type": "text" | "single_select" | "multi_select", "question": "the question to ask", "options": ["..."]} — "options" is required for "single_select"/"multi_select" and must be omitted for "text". Example:
|
|
114
|
+
\`\`\`question-widget
|
|
115
|
+
{"type": "single_select", "question": "Which best fits?", "options": ["Option A", "Option B"]}
|
|
116
|
+
\`\`\`
|
|
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
|
+
}
|
|
103
129
|
/**
|
|
104
130
|
* Strong, first-person identity framing — deliberately not "you're also
|
|
105
131
|
* helping company X" phrasing. Weaker framing was tested and Claude treated
|
|
@@ -108,8 +134,12 @@ export async function writeProfile(cwd, data) {
|
|
|
108
134
|
* injection). Explicitly asserting this is configured identity, not
|
|
109
135
|
* external/injected content, fixed it in testing.
|
|
110
136
|
*/
|
|
111
|
-
export function formatProfileForSystemPrompt(profile) {
|
|
112
|
-
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
|
|
137
|
+
export function formatProfileForSystemPrompt(profile, cwd) {
|
|
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.
|
|
139
|
+
|
|
140
|
+
${QUESTION_WIDGET_INSTRUCTIONS}
|
|
141
|
+
|
|
142
|
+
${getProcessSafetyInstructions(cwd)}`;
|
|
113
143
|
}
|
|
114
144
|
/**
|
|
115
145
|
* Used instead of formatProfileForSystemPrompt while onboardingComplete is
|
|
@@ -121,5 +151,13 @@ export function formatProfileForSystemPrompt(profile) {
|
|
|
121
151
|
*/
|
|
122
152
|
export function getOnboardingSystemPrompt(cwd) {
|
|
123
153
|
const filePath = getProfilePath(cwd);
|
|
124
|
-
return `There is no company profile configured yet in this repository. Before doing anything else, have a brief, friendly conversation with the founder (you're talking to them right now) to learn: (1) their company's name, (2) its mission or what it does, (3) what they'd like to name you as their AI CEO, and (4) what personality or communication style you should have. Ask
|
|
154
|
+
return `There is no company profile configured yet in this repository. Before doing anything else, have a brief, friendly conversation with the founder (you're talking to them right now) to learn: (1) their company's name, (2) its mission or what it does, (3) what they'd like to name you as their AI CEO, and (4) what personality or communication style you should have. Ask one thing at a time — don't interrogate, and don't ask about anything else in the repo yet.
|
|
155
|
+
|
|
156
|
+
${QUESTION_WIDGET_INSTRUCTIONS}
|
|
157
|
+
|
|
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.
|
|
159
|
+
|
|
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)}`;
|
|
125
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
|
+
}
|
package/dist/server/start.js
CHANGED
|
@@ -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
|
@@ -43,6 +43,7 @@ const el = {
|
|
|
43
43
|
typingAvatar: document.getElementById('typing-avatar'),
|
|
44
44
|
form: document.getElementById('chat-form'),
|
|
45
45
|
input: document.getElementById('chat-input'),
|
|
46
|
+
sendButton: document.getElementById('chat-send'),
|
|
46
47
|
|
|
47
48
|
profileForm: document.getElementById('profile-form'),
|
|
48
49
|
profileCompany: document.getElementById('profile-company'),
|
|
@@ -294,7 +295,12 @@ async function checkStatus() {
|
|
|
294
295
|
selectPage('messages');
|
|
295
296
|
checkForUpdateBanner(1);
|
|
296
297
|
|
|
297
|
-
|
|
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) {
|
|
298
304
|
onboardingKickedOff = true;
|
|
299
305
|
kickoffOnboarding();
|
|
300
306
|
}
|
|
@@ -366,7 +372,7 @@ function renderMarkdown(text) {
|
|
|
366
372
|
return html;
|
|
367
373
|
}
|
|
368
374
|
|
|
369
|
-
function appendMessageRow(kind, name, avatarText, text) {
|
|
375
|
+
function appendMessageRow(kind, name, avatarText, text, tsOverride) {
|
|
370
376
|
const row = document.createElement('div');
|
|
371
377
|
row.className = `msg-row ${kind}`;
|
|
372
378
|
|
|
@@ -382,7 +388,8 @@ function appendMessageRow(kind, name, avatarText, text) {
|
|
|
382
388
|
meta.textContent = name;
|
|
383
389
|
const time = document.createElement('span');
|
|
384
390
|
time.className = 'msg-time';
|
|
385
|
-
|
|
391
|
+
const d = tsOverride ? new Date(tsOverride) : new Date();
|
|
392
|
+
time.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
386
393
|
meta.appendChild(time);
|
|
387
394
|
|
|
388
395
|
const textEl = document.createElement('div');
|
|
@@ -398,8 +405,251 @@ function appendMessageRow(kind, name, avatarText, text) {
|
|
|
398
405
|
return { row, textEl };
|
|
399
406
|
}
|
|
400
407
|
|
|
401
|
-
function appendUserMessage(text) {
|
|
402
|
-
appendMessageRow('user', 'You', 'Y', text);
|
|
408
|
+
function appendUserMessage(text, ts) {
|
|
409
|
+
appendMessageRow('user', 'You', 'Y', text, ts);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let pendingWidgetCount = 0;
|
|
413
|
+
|
|
414
|
+
function setPendingWidget(delta) {
|
|
415
|
+
pendingWidgetCount = Math.max(0, pendingWidgetCount + delta);
|
|
416
|
+
el.input.disabled = pendingWidgetCount > 0;
|
|
417
|
+
el.sendButton.disabled = pendingWidgetCount > 0;
|
|
418
|
+
if (pendingWidgetCount === 0) el.input.focus();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const WIDGET_OPEN_FENCE = '```question-widget';
|
|
422
|
+
|
|
423
|
+
// Incremental parser: processes streamed text chunks as they arrive rather
|
|
424
|
+
// than re-parsing the full accumulated text on every delta. This matters for
|
|
425
|
+
// two reasons — (1) a widget block mid-stream would otherwise flash raw JSON
|
|
426
|
+
// before its closing fence arrives, and (2) naively re-rendering the whole
|
|
427
|
+
// message on every delta would wipe out a widget the user already answered
|
|
428
|
+
// if more text streams in afterward.
|
|
429
|
+
class AssistantContentRenderer {
|
|
430
|
+
constructor(containerEl) {
|
|
431
|
+
this.container = containerEl;
|
|
432
|
+
this.state = 'text';
|
|
433
|
+
this.buffer = '';
|
|
434
|
+
this.textSegmentEl = null;
|
|
435
|
+
this.textRaw = '';
|
|
436
|
+
this.widgetPlaceholderEl = null;
|
|
437
|
+
this.widgetRaw = '';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
push(deltaText) {
|
|
441
|
+
this.buffer += deltaText;
|
|
442
|
+
for (;;) {
|
|
443
|
+
if (this.state === 'text') {
|
|
444
|
+
const fenceIdx = this.buffer.indexOf(WIDGET_OPEN_FENCE);
|
|
445
|
+
if (fenceIdx === -1) {
|
|
446
|
+
// Hold back a tail as long as the marker minus one character —
|
|
447
|
+
// deltas arrive token-by-token, so the marker itself can easily
|
|
448
|
+
// be split across chunks (e.g. one delta ending "``" and the next
|
|
449
|
+
// starting "`question-widget"). Flushing eagerly would render the
|
|
450
|
+
// first half as plain text before the second half ever confirms
|
|
451
|
+
// it was a fence, and the marker would never be recognized.
|
|
452
|
+
const safeLen = Math.max(0, this.buffer.length - (WIDGET_OPEN_FENCE.length - 1));
|
|
453
|
+
const toFlush = this.buffer.slice(0, safeLen);
|
|
454
|
+
this.buffer = this.buffer.slice(safeLen);
|
|
455
|
+
if (toFlush) {
|
|
456
|
+
if (!this.textSegmentEl) {
|
|
457
|
+
this.textSegmentEl = document.createElement('div');
|
|
458
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
459
|
+
this.container.appendChild(this.textSegmentEl);
|
|
460
|
+
}
|
|
461
|
+
this.textRaw += toFlush;
|
|
462
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (fenceIdx > 0 || this.textRaw) {
|
|
467
|
+
if (!this.textSegmentEl) {
|
|
468
|
+
this.textSegmentEl = document.createElement('div');
|
|
469
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
470
|
+
this.container.appendChild(this.textSegmentEl);
|
|
471
|
+
}
|
|
472
|
+
this.textRaw += this.buffer.slice(0, fenceIdx);
|
|
473
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
474
|
+
}
|
|
475
|
+
this.textSegmentEl = null;
|
|
476
|
+
this.textRaw = '';
|
|
477
|
+
this.buffer = this.buffer.slice(fenceIdx + WIDGET_OPEN_FENCE.length);
|
|
478
|
+
this.state = 'in-widget';
|
|
479
|
+
this.widgetRaw = '';
|
|
480
|
+
this.widgetPlaceholderEl = document.createElement('div');
|
|
481
|
+
this.widgetPlaceholderEl.className = 'question-widget-placeholder';
|
|
482
|
+
this.widgetPlaceholderEl.textContent = 'Preparing a question…';
|
|
483
|
+
this.container.appendChild(this.widgetPlaceholderEl);
|
|
484
|
+
} else {
|
|
485
|
+
const closeIdx = this.buffer.indexOf('```');
|
|
486
|
+
if (closeIdx === -1) {
|
|
487
|
+
// Same tail-holding logic for the closing fence (3 chars).
|
|
488
|
+
const safeLen = Math.max(0, this.buffer.length - 2);
|
|
489
|
+
this.widgetRaw += this.buffer.slice(0, safeLen);
|
|
490
|
+
this.buffer = this.buffer.slice(safeLen);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.widgetRaw += this.buffer.slice(0, closeIdx);
|
|
494
|
+
this.buffer = this.buffer.slice(closeIdx + 3);
|
|
495
|
+
this.state = 'text';
|
|
496
|
+
this.renderWidget(this.widgetRaw);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
renderWidget(rawJson) {
|
|
502
|
+
let data;
|
|
503
|
+
try {
|
|
504
|
+
data = JSON.parse(rawJson);
|
|
505
|
+
} catch {
|
|
506
|
+
// Malformed JSON from the model — show the raw text rather than
|
|
507
|
+
// silently dropping it, so a parsing failure is at least visible.
|
|
508
|
+
this.widgetPlaceholderEl.textContent = rawJson.trim();
|
|
509
|
+
this.widgetPlaceholderEl.className = 'msg-text-segment';
|
|
510
|
+
this.widgetPlaceholderEl = null;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
setPendingWidget(1);
|
|
514
|
+
let answered = false;
|
|
515
|
+
const widgetEl = createQuestionWidget(data, (messageText) => {
|
|
516
|
+
if (answered) return;
|
|
517
|
+
answered = true;
|
|
518
|
+
setPendingWidget(-1);
|
|
519
|
+
streamChat(messageText, { showUserBubble: false, isWidgetAnswer: true });
|
|
520
|
+
});
|
|
521
|
+
this.widgetPlaceholderEl.replaceWith(widgetEl);
|
|
522
|
+
this.widgetPlaceholderEl = null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
finish() {
|
|
526
|
+
// No more text is coming, so anything still held back as a possible
|
|
527
|
+
// partial fence match is definitely not one — flush it as plain text.
|
|
528
|
+
if (this.state === 'text' && this.buffer) {
|
|
529
|
+
if (!this.textSegmentEl) {
|
|
530
|
+
this.textSegmentEl = document.createElement('div');
|
|
531
|
+
this.textSegmentEl.className = 'msg-text-segment';
|
|
532
|
+
this.container.appendChild(this.textSegmentEl);
|
|
533
|
+
}
|
|
534
|
+
this.textRaw += this.buffer;
|
|
535
|
+
this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
|
|
536
|
+
this.buffer = '';
|
|
537
|
+
}
|
|
538
|
+
// A block left unterminated when the turn ends (shouldn't normally
|
|
539
|
+
// happen) — show what was received rather than leaving a permanent
|
|
540
|
+
// "Preparing a question…" placeholder with no way to proceed.
|
|
541
|
+
if (this.state === 'in-widget' && this.widgetPlaceholderEl) {
|
|
542
|
+
this.widgetPlaceholderEl.textContent = `${WIDGET_OPEN_FENCE}\n${this.widgetRaw}${this.buffer}`;
|
|
543
|
+
this.widgetPlaceholderEl.className = 'msg-text-segment';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
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) {
|
|
553
|
+
const card = document.createElement('div');
|
|
554
|
+
card.className = 'question-widget';
|
|
555
|
+
|
|
556
|
+
const questionEl = document.createElement('div');
|
|
557
|
+
questionEl.className = 'question-widget-question';
|
|
558
|
+
questionEl.textContent = data.question ?? 'Question';
|
|
559
|
+
card.appendChild(questionEl);
|
|
560
|
+
|
|
561
|
+
const body = document.createElement('div');
|
|
562
|
+
body.className = 'question-widget-body';
|
|
563
|
+
card.appendChild(body);
|
|
564
|
+
|
|
565
|
+
function submit(displayText, messageText) {
|
|
566
|
+
card.classList.add('answered');
|
|
567
|
+
body.innerHTML = '';
|
|
568
|
+
const answerEl = document.createElement('div');
|
|
569
|
+
answerEl.className = 'question-widget-answer';
|
|
570
|
+
answerEl.textContent = displayText;
|
|
571
|
+
body.appendChild(answerEl);
|
|
572
|
+
if (onAnswer) onAnswer(messageText ?? displayText);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (lockedAnswer != null) {
|
|
576
|
+
submit(lockedAnswer, lockedAnswer);
|
|
577
|
+
return card;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildOtherRow(placeholder) {
|
|
581
|
+
const row = document.createElement('div');
|
|
582
|
+
row.className = 'question-widget-row question-widget-other';
|
|
583
|
+
const input = document.createElement('input');
|
|
584
|
+
input.type = 'text';
|
|
585
|
+
input.placeholder = placeholder;
|
|
586
|
+
const btn = document.createElement('button');
|
|
587
|
+
btn.type = 'button';
|
|
588
|
+
btn.textContent = 'Send';
|
|
589
|
+
row.appendChild(input);
|
|
590
|
+
row.appendChild(btn);
|
|
591
|
+
const trigger = () => {
|
|
592
|
+
const val = input.value.trim();
|
|
593
|
+
if (val) submit(val, val);
|
|
594
|
+
};
|
|
595
|
+
btn.addEventListener('click', trigger);
|
|
596
|
+
input.addEventListener('keydown', (e) => {
|
|
597
|
+
if (e.key === 'Enter') trigger();
|
|
598
|
+
});
|
|
599
|
+
return row;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const type = data.type;
|
|
603
|
+
const options = Array.isArray(data.options) ? data.options : [];
|
|
604
|
+
|
|
605
|
+
if (type === 'single_select') {
|
|
606
|
+
for (const opt of options) {
|
|
607
|
+
const btn = document.createElement('button');
|
|
608
|
+
btn.type = 'button';
|
|
609
|
+
btn.className = 'question-widget-option';
|
|
610
|
+
btn.textContent = opt;
|
|
611
|
+
btn.addEventListener('click', () => submit(opt, opt));
|
|
612
|
+
body.appendChild(btn);
|
|
613
|
+
}
|
|
614
|
+
body.appendChild(buildOtherRow('Other…'));
|
|
615
|
+
} else if (type === 'multi_select') {
|
|
616
|
+
const checkboxes = [];
|
|
617
|
+
for (const opt of options) {
|
|
618
|
+
const label = document.createElement('label');
|
|
619
|
+
label.className = 'question-widget-checkbox-label';
|
|
620
|
+
const cb = document.createElement('input');
|
|
621
|
+
cb.type = 'checkbox';
|
|
622
|
+
cb.value = opt;
|
|
623
|
+
checkboxes.push(cb);
|
|
624
|
+
label.appendChild(cb);
|
|
625
|
+
label.appendChild(document.createTextNode(opt));
|
|
626
|
+
body.appendChild(label);
|
|
627
|
+
}
|
|
628
|
+
const otherInput = document.createElement('input');
|
|
629
|
+
otherInput.type = 'text';
|
|
630
|
+
otherInput.placeholder = 'Other (optional)…';
|
|
631
|
+
otherInput.className = 'question-widget-other-inline';
|
|
632
|
+
body.appendChild(otherInput);
|
|
633
|
+
|
|
634
|
+
const submitBtn = document.createElement('button');
|
|
635
|
+
submitBtn.type = 'button';
|
|
636
|
+
submitBtn.className = 'question-widget-submit';
|
|
637
|
+
submitBtn.textContent = 'Submit';
|
|
638
|
+
submitBtn.addEventListener('click', () => {
|
|
639
|
+
const selected = checkboxes.filter((c) => c.checked).map((c) => c.value);
|
|
640
|
+
const other = otherInput.value.trim();
|
|
641
|
+
if (other) selected.push(other);
|
|
642
|
+
if (!selected.length) return;
|
|
643
|
+
const display = selected.join(', ');
|
|
644
|
+
submit(display, display);
|
|
645
|
+
});
|
|
646
|
+
body.appendChild(submitBtn);
|
|
647
|
+
} else {
|
|
648
|
+
// 'text', or an unrecognized type — a free-text prompt never silently drops the question.
|
|
649
|
+
body.appendChild(buildOtherRow('Type your answer…'));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return card;
|
|
403
653
|
}
|
|
404
654
|
|
|
405
655
|
function appendAssistantRow(turnMeta) {
|
|
@@ -411,15 +661,131 @@ function appendAssistantRow(turnMeta) {
|
|
|
411
661
|
badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
|
|
412
662
|
result.row.querySelector('.msg-meta').appendChild(badge);
|
|
413
663
|
}
|
|
664
|
+
result.renderer = new AssistantContentRenderer(result.textEl);
|
|
414
665
|
return result;
|
|
415
666
|
}
|
|
416
667
|
|
|
417
|
-
function appendToolMessage(name) {
|
|
418
|
-
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
|
+
}
|
|
419
743
|
}
|
|
420
744
|
|
|
421
|
-
function
|
|
422
|
-
|
|
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;
|
|
423
789
|
}
|
|
424
790
|
|
|
425
791
|
function showTypingIndicator() {
|
|
@@ -432,20 +798,24 @@ function hideTypingIndicator() {
|
|
|
432
798
|
el.typingIndicator.hidden = true;
|
|
433
799
|
}
|
|
434
800
|
|
|
435
|
-
async function streamChat(message, { showUserBubble }) {
|
|
801
|
+
async function streamChat(message, { showUserBubble, isWidgetAnswer }) {
|
|
436
802
|
if (showUserBubble) appendUserMessage(message);
|
|
437
803
|
showTypingIndicator();
|
|
438
804
|
setStatus('typing');
|
|
439
805
|
|
|
440
|
-
let
|
|
441
|
-
let assistantText = '';
|
|
806
|
+
let assistantRenderer = null;
|
|
442
807
|
let sawContent = false;
|
|
443
808
|
let turnMeta = null;
|
|
444
809
|
|
|
445
810
|
const res = await api('/api/chat', {
|
|
446
811
|
method: 'POST',
|
|
447
812
|
headers: { 'Content-Type': 'application/json' },
|
|
448
|
-
body: JSON.stringify({
|
|
813
|
+
body: JSON.stringify({
|
|
814
|
+
message,
|
|
815
|
+
model: el.chatModelSelect.value,
|
|
816
|
+
effort: el.chatEffortSelect.value,
|
|
817
|
+
isWidgetAnswer: isWidgetAnswer === true,
|
|
818
|
+
}),
|
|
449
819
|
});
|
|
450
820
|
|
|
451
821
|
if (!res.ok || !res.body) {
|
|
@@ -479,11 +849,10 @@ async function streamChat(message, { showUserBubble }) {
|
|
|
479
849
|
hideTypingIndicator();
|
|
480
850
|
sawContent = true;
|
|
481
851
|
}
|
|
482
|
-
if (!
|
|
483
|
-
|
|
852
|
+
if (!assistantRenderer) {
|
|
853
|
+
assistantRenderer = appendAssistantRow(turnMeta).renderer;
|
|
484
854
|
}
|
|
485
|
-
|
|
486
|
-
assistantTextEl.innerHTML = renderMarkdown(assistantText);
|
|
855
|
+
assistantRenderer.push(event.text);
|
|
487
856
|
el.messages.scrollTop = el.messages.scrollHeight;
|
|
488
857
|
} else if (event.type === 'tool-use') {
|
|
489
858
|
if (!sawContent) {
|
|
@@ -502,6 +871,7 @@ async function streamChat(message, { showUserBubble }) {
|
|
|
502
871
|
}
|
|
503
872
|
}
|
|
504
873
|
|
|
874
|
+
assistantRenderer?.finish();
|
|
505
875
|
hideTypingIndicator();
|
|
506
876
|
if (statusState !== 'offline') setStatus('online');
|
|
507
877
|
await refreshProfileIfNeeded();
|
|
@@ -517,11 +887,18 @@ el.form.addEventListener('submit', async (e) => {
|
|
|
517
887
|
if (!message) return;
|
|
518
888
|
el.input.value = '';
|
|
519
889
|
el.input.disabled = true;
|
|
890
|
+
el.sendButton.disabled = true;
|
|
520
891
|
try {
|
|
521
892
|
await streamChat(message, { showUserBubble: true });
|
|
522
893
|
} finally {
|
|
523
|
-
|
|
524
|
-
|
|
894
|
+
// A widget the model asked as part of this same turn may still be
|
|
895
|
+
// unanswered — setPendingWidget already owns disabling in that case,
|
|
896
|
+
// so only re-enable here if nothing is pending.
|
|
897
|
+
if (pendingWidgetCount === 0) {
|
|
898
|
+
el.input.disabled = false;
|
|
899
|
+
el.sendButton.disabled = false;
|
|
900
|
+
el.input.focus();
|
|
901
|
+
}
|
|
525
902
|
}
|
|
526
903
|
});
|
|
527
904
|
|
package/dist/web/index.html
CHANGED
|
@@ -6,21 +6,28 @@
|
|
|
6
6
|
<link rel="stylesheet" href="/style.css" />
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
|
-
<
|
|
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…</p>
|
|
13
|
+
</div>
|
|
10
14
|
|
|
11
|
-
<section id="onboarding" hidden>
|
|
12
|
-
<
|
|
13
|
-
<div
|
|
14
|
-
<
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
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>
|
|
@@ -134,7 +141,7 @@
|
|
|
134
141
|
</div>
|
|
135
142
|
<form id="chat-form">
|
|
136
143
|
<input id="chat-input" type="text" placeholder="Message your CEO…" autocomplete="off" />
|
|
137
|
-
<button type="submit">Send</button>
|
|
144
|
+
<button type="submit" id="chat-send">Send</button>
|
|
138
145
|
</form>
|
|
139
146
|
</section>
|
|
140
147
|
</main>
|
package/dist/web/style.css
CHANGED
|
@@ -65,20 +65,80 @@ body {
|
|
|
65
65
|
display: none !important;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.splash-card button {
|
|
140
|
+
width: 100%;
|
|
141
|
+
margin-top: 0.4rem;
|
|
82
142
|
}
|
|
83
143
|
|
|
84
144
|
/* ---------- App shell ---------- */
|
|
@@ -502,6 +562,131 @@ body {
|
|
|
502
562
|
color: #e5484d;
|
|
503
563
|
}
|
|
504
564
|
|
|
565
|
+
.msg-text-segment:empty {
|
|
566
|
+
display: none;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.question-widget-placeholder {
|
|
570
|
+
display: inline-flex;
|
|
571
|
+
align-items: center;
|
|
572
|
+
gap: 0.4rem;
|
|
573
|
+
color: var(--muted);
|
|
574
|
+
font-style: italic;
|
|
575
|
+
font-size: 0.88rem;
|
|
576
|
+
padding: 0.3rem 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.question-widget {
|
|
580
|
+
border: 1px solid var(--border);
|
|
581
|
+
border-radius: 10px;
|
|
582
|
+
padding: 0.9rem 1rem;
|
|
583
|
+
margin: 0.5rem 0;
|
|
584
|
+
max-width: 420px;
|
|
585
|
+
background: var(--bg-subtle);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
.question-widget-question {
|
|
589
|
+
font-weight: 600;
|
|
590
|
+
margin-bottom: 0.7rem;
|
|
591
|
+
line-height: 1.4;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.question-widget-body {
|
|
595
|
+
display: flex;
|
|
596
|
+
flex-direction: column;
|
|
597
|
+
gap: 0.45rem;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.question-widget-option {
|
|
601
|
+
background: var(--bg);
|
|
602
|
+
color: var(--fg);
|
|
603
|
+
border: 1px solid var(--border);
|
|
604
|
+
text-align: left;
|
|
605
|
+
font-weight: 500;
|
|
606
|
+
padding: 0.55rem 0.8rem;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.question-widget-option:hover {
|
|
610
|
+
border-color: var(--accent);
|
|
611
|
+
background: color-mix(in srgb, var(--accent) 8%, var(--bg));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.question-widget-checkbox-label {
|
|
615
|
+
display: flex;
|
|
616
|
+
align-items: center;
|
|
617
|
+
gap: 0.5rem;
|
|
618
|
+
font-size: 0.92rem;
|
|
619
|
+
cursor: pointer;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.question-widget-checkbox-label input {
|
|
623
|
+
width: 16px;
|
|
624
|
+
height: 16px;
|
|
625
|
+
accent-color: var(--accent);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.question-widget-submit {
|
|
629
|
+
align-self: flex-start;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.question-widget-row {
|
|
633
|
+
display: flex;
|
|
634
|
+
gap: 0.5rem;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.question-widget-row input {
|
|
638
|
+
flex: 1;
|
|
639
|
+
padding: 0.5rem 0.7rem;
|
|
640
|
+
border-radius: 7px;
|
|
641
|
+
border: 1px solid var(--border);
|
|
642
|
+
background: var(--bg);
|
|
643
|
+
color: var(--fg);
|
|
644
|
+
font-size: 0.9rem;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.question-widget-row input:focus {
|
|
648
|
+
outline: none;
|
|
649
|
+
border-color: var(--accent);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.question-widget-row button {
|
|
653
|
+
flex-shrink: 0;
|
|
654
|
+
padding: 0.5rem 0.9rem;
|
|
655
|
+
font-size: 0.85rem;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.question-widget-other {
|
|
659
|
+
margin-top: 0.15rem;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.question-widget-other-inline {
|
|
663
|
+
padding: 0.5rem 0.7rem;
|
|
664
|
+
border-radius: 7px;
|
|
665
|
+
border: 1px solid var(--border);
|
|
666
|
+
background: var(--bg);
|
|
667
|
+
color: var(--fg);
|
|
668
|
+
font-size: 0.9rem;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.question-widget-other-inline:focus {
|
|
672
|
+
outline: none;
|
|
673
|
+
border-color: var(--accent);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.question-widget.answered {
|
|
677
|
+
background: transparent;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.question-widget-answer {
|
|
681
|
+
color: var(--accent);
|
|
682
|
+
font-weight: 600;
|
|
683
|
+
font-size: 0.92rem;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.question-widget-answer::before {
|
|
687
|
+
content: '✓ ';
|
|
688
|
+
}
|
|
689
|
+
|
|
505
690
|
#typing-indicator {
|
|
506
691
|
display: flex;
|
|
507
692
|
align-items: center;
|