@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 +9 -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 +18 -3
- package/dist/server/server-pid.js +19 -0
- package/dist/server/start.js +26 -0
- package/dist/web/app.js +149 -14
- package/dist/web/index.html +20 -13
- package/dist/web/style.css +67 -7
- package/package.json +1 -1
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.
|
|
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
|
@@ -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
|
+
}
|
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
|
@@ -295,7 +295,12 @@ async function checkStatus() {
|
|
|
295
295
|
selectPage('messages');
|
|
296
296
|
checkForUpdateBanner(1);
|
|
297
297
|
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
658
|
-
|
|
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({
|
|
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) {
|
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>
|
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 ---------- */
|