@harborgroup/my-team 0.1.0 → 0.2.0
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 +23 -1
- package/dist/init.js +4 -20
- package/dist/npm-install.js +13 -0
- package/dist/own-package.js +9 -0
- package/dist/server/auth-check.js +25 -14
- package/dist/server/chat-session.js +45 -15
- package/dist/server/http-server.js +50 -5
- package/dist/server/logger.js +68 -0
- package/dist/server/profile.js +125 -0
- package/dist/server/start.js +12 -1
- package/dist/server/update-check.js +32 -0
- package/dist/web/app.js +437 -27
- package/dist/web/index.html +171 -30
- package/dist/web/style.css +627 -52
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,28 @@ Opens a local, token-protected dashboard in your browser (bound to `127.0.0.1` o
|
|
|
24
24
|
|
|
25
25
|
Chat runs with this repo as Claude's working directory, and tool calls (file edits, shell commands) are auto-approved via Claude's own safety classifier (`permissionMode: 'auto'`) rather than prompted per action — there's no terminal in this flow to answer y/n prompts.
|
|
26
26
|
|
|
27
|
+
## Your AI CEO
|
|
28
|
+
|
|
29
|
+
The first time you run `npm run team` in a repo, Claude opens the conversation itself — it introduces itself and asks about your company (name, mission), what to call itself, and what personality it should have. Once it has answers, it writes `.my-team/profile.json` itself and adopts that identity from then on: not "an assistant with some context," but your company's AI CEO by name, on every chat turn (via a strong identity system prompt — weak "you're also helping company X" framing gets treated as low-trust incidental context and can get second-guessed; asserting it as configured identity doesn't).
|
|
30
|
+
|
|
31
|
+
**Commit `.my-team/profile.json`** — don't gitignore it. It's shared, non-sensitive team config, the same category as a checked-in `CLAUDE.md`: the whole point of it living per-repo is that teammates cloning the repo share the same CEO persona without re-onboarding. Use the "Edit profile" button on the dashboard to adjust the company name, mission, CEO name, or personality later; changes take effect on the next chat turn without restarting the server.
|
|
32
|
+
|
|
33
|
+
## Interface
|
|
34
|
+
|
|
35
|
+
An icon rail (Home / Messages / Profile / Settings) plus a Slack-style Messages page: a `#general` channel (placeholder for now) and a DM with your CEO, real token-by-token streaming (via the SDK's `includePartialMessages`), a typing indicator, and a status dot on the CEO's avatar reflecting connection state. It's plain static HTML/CSS/JS talking to the backend over local HTTP + SSE — no browser-only assumptions — so a future native (Tauri) shell can point at the same server without a rewrite; there's no Tauri wrapper yet.
|
|
36
|
+
|
|
37
|
+
## Model & effort
|
|
38
|
+
|
|
39
|
+
Set a default model and reasoning effort for your CEO on the Profile page (fetched live from the SDK's `supportedModels()` — the effort dropdown only ever offers levels the chosen model actually supports, e.g. Haiku offers none). Override either per message from a small selector above the chat input; each response shows a small badge with the model actually used.
|
|
40
|
+
|
|
41
|
+
## Updates
|
|
42
|
+
|
|
43
|
+
On startup, `my-team` checks the npm registry in the background for a newer published version. If one exists, a banner appears with an "Update" button that runs the install for you (`npm install --save-dev` the new version) — restart `npm run team` afterward to pick it up. No auto-restart; that's a deliberate simplicity/safety tradeoff over self-respawning the running server process.
|
|
44
|
+
|
|
45
|
+
## Logs
|
|
46
|
+
|
|
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
|
+
|
|
27
49
|
## Status
|
|
28
50
|
|
|
29
|
-
v1: onboarding +
|
|
51
|
+
v1.4: CLI-auth onboarding + conversational company/CEO-persona onboarding + Slack-style chat shell with real streaming + per-message model/effort selection + update checker + structured logging. No persisted chat history across restarts, no multi-user support, no `#general` backend (sidebar placeholder only).
|
package/dist/init.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
// dist/init.js -> package root is one level up.
|
|
7
|
-
const OWN_PACKAGE_ROOT = path.join(__dirname, '..');
|
|
8
|
-
const OWN_PACKAGE_JSON = JSON.parse(readFileSync(path.join(OWN_PACKAGE_ROOT, 'package.json'), 'utf-8'));
|
|
9
|
-
function npmInstall(cwd, spec) {
|
|
10
|
-
// On Windows, npm is a .cmd batch file that spawnSync can't execute
|
|
11
|
-
// directly (EINVAL). Routing through cmd.exe /c as the actual child
|
|
12
|
-
// (rather than shell: true) avoids that without the argument-escaping
|
|
13
|
-
// risk Node warns about (DEP0190) for shell: true + an args array.
|
|
14
|
-
const result = process.platform === 'win32'
|
|
15
|
-
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm', 'install', '--save-dev', spec], { cwd, stdio: 'inherit' })
|
|
16
|
-
: spawnSync('npm', ['install', '--save-dev', spec], { cwd, stdio: 'inherit' });
|
|
17
|
-
return result.status === 0;
|
|
18
|
-
}
|
|
3
|
+
import { npmInstall } from './npm-install.js';
|
|
4
|
+
import { OWN_PACKAGE_NAME, OWN_PACKAGE_ROOT, OWN_PACKAGE_VERSION } from './own-package.js';
|
|
19
5
|
function addTeamScript(repoPackageJsonPath) {
|
|
20
6
|
const raw = readFileSync(repoPackageJsonPath, 'utf-8');
|
|
21
7
|
const pkg = JSON.parse(raw);
|
|
@@ -32,14 +18,12 @@ function addTeamScript(repoPackageJsonPath) {
|
|
|
32
18
|
}
|
|
33
19
|
export async function runInit(cwd) {
|
|
34
20
|
const repoPackageJsonPath = path.join(cwd, 'package.json');
|
|
35
|
-
const name = OWN_PACKAGE_JSON.name;
|
|
36
21
|
if (!existsSync(repoPackageJsonPath)) {
|
|
37
|
-
console.error(`No package.json found at ${cwd}. Run "npm init" first, then re-run "npx ${
|
|
22
|
+
console.error(`No package.json found at ${cwd}. Run "npm init" first, then re-run "npx ${OWN_PACKAGE_NAME} init".`);
|
|
38
23
|
process.exitCode = 1;
|
|
39
24
|
return;
|
|
40
25
|
}
|
|
41
|
-
const
|
|
42
|
-
const installed = npmInstall(cwd, `${name}@${version}`) || npmInstall(cwd, OWN_PACKAGE_ROOT);
|
|
26
|
+
const installed = npmInstall(cwd, `${OWN_PACKAGE_NAME}@${OWN_PACKAGE_VERSION}`) || npmInstall(cwd, OWN_PACKAGE_ROOT);
|
|
43
27
|
if (!installed) {
|
|
44
28
|
console.error('Failed to install my-team as a devDependency.');
|
|
45
29
|
process.exitCode = 1;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* On Windows, npm is a .cmd batch file that spawnSync can't execute
|
|
4
|
+
* directly (EINVAL). Routing through cmd.exe /c as the actual child
|
|
5
|
+
* (rather than shell: true) avoids that without the argument-escaping
|
|
6
|
+
* risk Node warns about (DEP0190) for shell: true + an args array.
|
|
7
|
+
*/
|
|
8
|
+
export function npmInstall(cwd, spec) {
|
|
9
|
+
const result = process.platform === 'win32'
|
|
10
|
+
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm', 'install', '--save-dev', spec], { cwd, stdio: 'inherit' })
|
|
11
|
+
: spawnSync('npm', ['install', '--save-dev', spec], { cwd, stdio: 'inherit' });
|
|
12
|
+
return result.status === 0;
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
// dist/own-package.js -> package root is one level up.
|
|
6
|
+
export const OWN_PACKAGE_ROOT = path.join(__dirname, '..');
|
|
7
|
+
export const OWN_PACKAGE_JSON = JSON.parse(readFileSync(path.join(OWN_PACKAGE_ROOT, 'package.json'), 'utf-8'));
|
|
8
|
+
export const OWN_PACKAGE_NAME = OWN_PACKAGE_JSON.name;
|
|
9
|
+
export const OWN_PACKAGE_VERSION = OWN_PACKAGE_JSON.version;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { sanitizeEnv } from './env.js';
|
|
3
|
+
import { forLog } from './logger.js';
|
|
3
4
|
/**
|
|
4
5
|
* Live probe, run fresh on every /api/status call rather than caching an
|
|
5
6
|
* "onboarded" flag, so it can't go stale.
|
|
@@ -9,34 +10,44 @@ import { sanitizeEnv } from './env.js';
|
|
|
9
10
|
* `system init` message arrives is enough to call accountInfo() without ever
|
|
10
11
|
* dispatching a turn.
|
|
11
12
|
*/
|
|
12
|
-
export async function checkAuth(cwd) {
|
|
13
|
+
export async function checkAuth(cwd, logger) {
|
|
13
14
|
const controller = new AbortController();
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
});
|
|
15
|
+
const options = {
|
|
16
|
+
cwd,
|
|
17
|
+
env: sanitizeEnv(process.env),
|
|
18
|
+
permissionMode: 'plan',
|
|
19
|
+
abortController: controller,
|
|
20
|
+
maxTurns: 1,
|
|
21
|
+
};
|
|
22
|
+
logger.log({ type: 'auth-check-request', options: forLog(options) });
|
|
23
|
+
const q = query({ prompt: '', options });
|
|
24
24
|
try {
|
|
25
25
|
for await (const msg of q) {
|
|
26
|
+
logger.log({ type: 'auth-check-sdk-message', message: msg });
|
|
26
27
|
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
27
28
|
const accountInfo = await q.accountInfo();
|
|
29
|
+
const models = await q.supportedModels();
|
|
30
|
+
logger.log({ type: 'auth-check-account-info', accountInfo });
|
|
31
|
+
logger.log({ type: 'auth-check-models', models });
|
|
28
32
|
controller.abort();
|
|
29
33
|
if (!accountInfo.email && !accountInfo.apiProvider) {
|
|
30
|
-
|
|
34
|
+
const result = { ok: false, reason: 'not-authenticated' };
|
|
35
|
+
logger.log({ type: 'auth-check-result', result });
|
|
36
|
+
return result;
|
|
31
37
|
}
|
|
32
|
-
|
|
38
|
+
const result = { ok: true, accountInfo, models };
|
|
39
|
+
logger.log({ type: 'auth-check-result', result });
|
|
40
|
+
return result;
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
|
-
|
|
43
|
+
const result = { ok: false, reason: 'not-authenticated' };
|
|
44
|
+
logger.log({ type: 'auth-check-result', result });
|
|
45
|
+
return result;
|
|
36
46
|
}
|
|
37
47
|
catch (err) {
|
|
38
48
|
controller.abort();
|
|
39
49
|
const message = String(err?.message ?? err);
|
|
50
|
+
logger.log({ type: 'auth-check-error', error: message, stack: err?.stack });
|
|
40
51
|
if (/native binary not found|ENOENT|not recognized as an internal/i.test(message)) {
|
|
41
52
|
return { ok: false, reason: 'cli-missing' };
|
|
42
53
|
}
|
|
@@ -1,43 +1,72 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { sanitizeEnv } from './env.js';
|
|
3
|
+
import { forLog } from './logger.js';
|
|
4
|
+
import { formatProfileForSystemPrompt, getOnboardingSystemPrompt, readProfile } from './profile.js';
|
|
3
5
|
/**
|
|
4
6
|
* One ongoing SDK session per running server process. Conversation history
|
|
5
7
|
* lives only in the CLI's own session store (resumed via session_id) — no
|
|
6
8
|
* disk persistence of our own, lost on server restart (acceptable for v1).
|
|
7
9
|
*/
|
|
8
10
|
export class ChatSession {
|
|
11
|
+
logger;
|
|
9
12
|
sessionId;
|
|
10
13
|
turnInFlight = false;
|
|
14
|
+
constructor(logger) {
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
}
|
|
11
17
|
isBusy() {
|
|
12
18
|
return this.turnInFlight;
|
|
13
19
|
}
|
|
14
|
-
async *sendTurn(message, cwd) {
|
|
20
|
+
async *sendTurn(message, cwd, overrides = {}) {
|
|
15
21
|
if (this.turnInFlight) {
|
|
16
22
|
yield { type: 'error', message: 'A previous turn is still in progress.' };
|
|
17
23
|
return;
|
|
18
24
|
}
|
|
19
25
|
this.turnInFlight = true;
|
|
20
26
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const profile = await readProfile(cwd);
|
|
28
|
+
const onboarded = profile?.onboardingComplete === true;
|
|
29
|
+
const systemPromptAppend = onboarded
|
|
30
|
+
? formatProfileForSystemPrompt(profile)
|
|
31
|
+
: getOnboardingSystemPrompt(cwd);
|
|
32
|
+
// Per-message override wins, then the CEO's configured default, then no
|
|
33
|
+
// override at all (Claude Code picks its own default).
|
|
34
|
+
const model = overrides.model || profile?.defaultModel || undefined;
|
|
35
|
+
const effort = overrides.effort || profile?.defaultEffort || undefined;
|
|
36
|
+
const options = {
|
|
37
|
+
cwd,
|
|
38
|
+
env: sanitizeEnv(process.env),
|
|
39
|
+
permissionMode: 'auto',
|
|
40
|
+
includePartialMessages: true,
|
|
41
|
+
...(this.sessionId ? { resume: this.sessionId } : {}),
|
|
42
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append: systemPromptAppend },
|
|
43
|
+
...(model ? { model } : {}),
|
|
44
|
+
...(effort ? { effort: effort } : {}),
|
|
45
|
+
};
|
|
46
|
+
this.logger.log({ type: 'chat-request', message, options: forLog(options) });
|
|
47
|
+
const q = query({ prompt: message, options });
|
|
30
48
|
for await (const msg of q) {
|
|
49
|
+
this.logger.log({ type: 'chat-sdk-message', message: msg });
|
|
31
50
|
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
32
51
|
this.sessionId = msg.session_id;
|
|
33
|
-
|
|
52
|
+
// msg.model reflects what the SDK actually resolved and used for
|
|
53
|
+
// this turn; effort isn't echoed back on the message stream, so we
|
|
54
|
+
// report what we requested (the UI only ever offers effort levels
|
|
55
|
+
// the chosen model's own supportedEffortLevels confirms are valid,
|
|
56
|
+
// so a silent downgrade in practice shouldn't occur).
|
|
57
|
+
yield { type: 'meta', sessionId: msg.session_id, model: msg.model, effort: effort ?? '' };
|
|
58
|
+
}
|
|
59
|
+
else if (msg.type === 'stream_event') {
|
|
60
|
+
const event = msg.event;
|
|
61
|
+
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
|
|
62
|
+
yield { type: 'text-delta', text: event.delta.text };
|
|
63
|
+
}
|
|
34
64
|
}
|
|
35
65
|
else if (msg.type === 'assistant') {
|
|
66
|
+
// Text was already streamed via stream_event deltas above — only tool
|
|
67
|
+
// calls are new information here (tool inputs don't render usefully).
|
|
36
68
|
for (const block of msg.message.content) {
|
|
37
|
-
if (block.type === '
|
|
38
|
-
yield { type: 'text', text: block.text };
|
|
39
|
-
}
|
|
40
|
-
else if (block.type === 'tool_use') {
|
|
69
|
+
if (block.type === 'tool_use') {
|
|
41
70
|
yield { type: 'tool-use', name: block.name };
|
|
42
71
|
}
|
|
43
72
|
}
|
|
@@ -51,6 +80,7 @@ export class ChatSession {
|
|
|
51
80
|
}
|
|
52
81
|
}
|
|
53
82
|
catch (err) {
|
|
83
|
+
this.logger.log({ type: 'chat-error', error: String(err?.message ?? err), stack: err?.stack });
|
|
54
84
|
yield { type: 'error', message: String(err?.message ?? err) };
|
|
55
85
|
}
|
|
56
86
|
finally {
|
|
@@ -2,8 +2,11 @@ import http from 'node:http';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { OWN_PACKAGE_NAME } from '../own-package.js';
|
|
6
|
+
import { npmInstall } from '../npm-install.js';
|
|
5
7
|
import { checkAuth } from './auth-check.js';
|
|
6
8
|
import { ChatSession } from './chat-session.js';
|
|
9
|
+
import { readProfile, validateProfileInput, writeProfile } from './profile.js';
|
|
7
10
|
import { tokensMatch } from './token.js';
|
|
8
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
12
|
const WEB_DIR = path.join(__dirname, '..', 'web');
|
|
@@ -28,8 +31,8 @@ async function readJsonBody(req) {
|
|
|
28
31
|
return raw ? JSON.parse(raw) : {};
|
|
29
32
|
}
|
|
30
33
|
export function createServer(options) {
|
|
31
|
-
const { cwd, token, portRef } = options;
|
|
32
|
-
const chatSession = new ChatSession();
|
|
34
|
+
const { cwd, token, portRef, logger, updateInfoRef } = options;
|
|
35
|
+
const chatSession = new ChatSession(logger);
|
|
33
36
|
return http.createServer(async (req, res) => {
|
|
34
37
|
if (!isAllowedHost(req.headers.host, portRef.port)) {
|
|
35
38
|
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Host header');
|
|
@@ -56,8 +59,46 @@ export function createServer(options) {
|
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
59
|
-
const result = await checkAuth(cwd);
|
|
60
|
-
|
|
62
|
+
const result = await checkAuth(cwd, logger);
|
|
63
|
+
const body = result.ok ? { ...result, profile: await readProfile(cwd) } : result;
|
|
64
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === 'GET' && url.pathname === '/api/meta') {
|
|
68
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ logFilePath: logger.filePath, updateInfo: updateInfoRef.current }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'POST' && url.pathname === '/api/update') {
|
|
72
|
+
const info = updateInfoRef.current;
|
|
73
|
+
if (!info?.updateAvailable || !info.latestVersion) {
|
|
74
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'no update available' }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const ok = npmInstall(cwd, `${OWN_PACKAGE_NAME}@${info.latestVersion}`);
|
|
78
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (req.method === 'GET' && url.pathname === '/api/profile') {
|
|
82
|
+
const profile = await readProfile(cwd);
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ profile }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (req.method === 'POST' && url.pathname === '/api/profile') {
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = await readJsonBody(req);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'invalid body' }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const validated = validateProfileInput(body);
|
|
96
|
+
if ('error' in validated) {
|
|
97
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(validated));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await writeProfile(cwd, validated);
|
|
101
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
|
61
102
|
return;
|
|
62
103
|
}
|
|
63
104
|
if (req.method === 'POST' && url.pathname === '/api/chat') {
|
|
@@ -66,9 +107,13 @@ export function createServer(options) {
|
|
|
66
107
|
return;
|
|
67
108
|
}
|
|
68
109
|
let message;
|
|
110
|
+
let model;
|
|
111
|
+
let effort;
|
|
69
112
|
try {
|
|
70
113
|
const body = await readJsonBody(req);
|
|
71
114
|
message = String(body.message ?? '');
|
|
115
|
+
model = typeof body.model === 'string' && body.model ? body.model : undefined;
|
|
116
|
+
effort = typeof body.effort === 'string' && body.effort ? body.effort : undefined;
|
|
72
117
|
}
|
|
73
118
|
catch {
|
|
74
119
|
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'invalid body' }));
|
|
@@ -83,7 +128,7 @@ export function createServer(options) {
|
|
|
83
128
|
'Cache-Control': 'no-cache',
|
|
84
129
|
Connection: 'keep-alive',
|
|
85
130
|
});
|
|
86
|
-
for await (const event of chatSession.sendTurn(message, cwd)) {
|
|
131
|
+
for await (const event of chatSession.sendTurn(message, cwd, { model, effort })) {
|
|
87
132
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
88
133
|
}
|
|
89
134
|
res.end();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Every request sent to the Claude Agent SDK and every raw message it
|
|
7
|
+
* streams back (init, deltas, tool calls, results, errors) gets logged
|
|
8
|
+
* verbatim as one JSON-lines file per server run.
|
|
9
|
+
*
|
|
10
|
+
* Rotation is per-run rather than byte-size-based: each `npm run team`
|
|
11
|
+
* invocation gets its own file, and old runs beyond RETENTION_COUNT are
|
|
12
|
+
* pruned at startup. This sidesteps the concurrent-append/rotate-mid-write
|
|
13
|
+
* hazards of size-based rotation, and maps naturally onto how you'd actually
|
|
14
|
+
* want to debug this tool ("show me the log from my last session").
|
|
15
|
+
*
|
|
16
|
+
* Logs live outside the repo entirely (under the user's home directory),
|
|
17
|
+
* not in .my-team/ alongside profile.json — they're ephemeral machine/debug
|
|
18
|
+
* state, not shared project config, and may contain full file contents or
|
|
19
|
+
* other repo material flowing through tool calls, so they should never risk
|
|
20
|
+
* ending up committed.
|
|
21
|
+
*/
|
|
22
|
+
const RETENTION_COUNT = 20;
|
|
23
|
+
/**
|
|
24
|
+
* Every SDK request carries the full (sanitized) process environment as
|
|
25
|
+
* `options.env` — dozens of unrelated OS/session variables that are noise,
|
|
26
|
+
* not signal, for debugging model behavior. Logging that in full would
|
|
27
|
+
* bloat every single entry with ~2KB of irrelevant PATH/OneDrive/etc.
|
|
28
|
+
* clutter. Replace it with a count before logging; everything else in the
|
|
29
|
+
* request (cwd, systemPrompt, permissionMode, resume) is logged verbatim.
|
|
30
|
+
*/
|
|
31
|
+
export function forLog(options) {
|
|
32
|
+
if (!options.env)
|
|
33
|
+
return options;
|
|
34
|
+
return { ...options, env: `[${Object.keys(options.env).length} vars, omitted from log]` };
|
|
35
|
+
}
|
|
36
|
+
function repoKey(cwd) {
|
|
37
|
+
const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
|
|
38
|
+
const base = path.basename(cwd).replace(/[^a-zA-Z0-9-_]/g, '_');
|
|
39
|
+
return `${base}-${hash}`;
|
|
40
|
+
}
|
|
41
|
+
export function createLogger(cwd) {
|
|
42
|
+
const dir = path.join(os.homedir(), '.my-team', 'logs');
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
const key = repoKey(cwd);
|
|
45
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
46
|
+
const filePath = path.join(dir, `${key}-${timestamp}.jsonl`);
|
|
47
|
+
try {
|
|
48
|
+
const existing = readdirSync(dir)
|
|
49
|
+
.filter((f) => f.startsWith(`${key}-`) && f.endsWith('.jsonl'))
|
|
50
|
+
.map((f) => ({ f, mtime: statSync(path.join(dir, f)).mtimeMs }))
|
|
51
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
52
|
+
for (const { f } of existing.slice(RETENTION_COUNT - 1)) {
|
|
53
|
+
unlinkSync(path.join(dir, f));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Pruning is best-effort; never block startup on it.
|
|
58
|
+
}
|
|
59
|
+
function log(entry) {
|
|
60
|
+
try {
|
|
61
|
+
appendFileSync(filePath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Logging must never crash the thing it's trying to help debug.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { log, filePath };
|
|
68
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
4
|
+
const MAX_FIELD_LENGTH = 2000;
|
|
5
|
+
const DEFAULT_CEO_NAME = 'your AI CEO';
|
|
6
|
+
const DEFAULT_CEO_PERSONALITY = 'direct, pragmatic, and personally invested in the company succeeding';
|
|
7
|
+
export function getProfileDir(cwd) {
|
|
8
|
+
return path.join(cwd, '.my-team');
|
|
9
|
+
}
|
|
10
|
+
export function getProfilePath(cwd) {
|
|
11
|
+
return path.join(getProfileDir(cwd), 'profile.json');
|
|
12
|
+
}
|
|
13
|
+
function stringOrDefault(value, fallback) {
|
|
14
|
+
return typeof value === 'string' && value.trim() ? value : fallback;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Never throws. Returns null only when the file doesn't exist or isn't
|
|
18
|
+
* parseable JSON at all — the "nothing written yet" case. Once the file
|
|
19
|
+
* exists, every field degrades gracefully to a default rather than
|
|
20
|
+
* invalidating the whole profile, because Claude writes this file itself
|
|
21
|
+
* incrementally during the onboarding conversation (e.g. companyName known,
|
|
22
|
+
* mission not yet) so the UI can reflect progress as it happens.
|
|
23
|
+
*/
|
|
24
|
+
export async function readProfile(cwd) {
|
|
25
|
+
const filePath = getProfilePath(cwd);
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(filePath, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.warn(`my-team: ${filePath} exists but isn't valid JSON — treating as unconfigured.`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
42
|
+
console.warn(`my-team: ${filePath} exists but has an unexpected shape — treating as unconfigured.`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
companyName: stringOrDefault(parsed.companyName, ''),
|
|
47
|
+
mission: stringOrDefault(parsed.mission, ''),
|
|
48
|
+
ceoName: stringOrDefault(parsed.ceoName, DEFAULT_CEO_NAME),
|
|
49
|
+
ceoPersonality: stringOrDefault(parsed.ceoPersonality, DEFAULT_CEO_PERSONALITY),
|
|
50
|
+
onboardingComplete: parsed.onboardingComplete === true,
|
|
51
|
+
defaultModel: typeof parsed.defaultModel === 'string' ? parsed.defaultModel : '',
|
|
52
|
+
defaultEffort: EFFORT_LEVELS.includes(parsed.defaultEffort) ? parsed.defaultEffort : '',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function clampField(value, fallback) {
|
|
56
|
+
const str = typeof value === 'string' ? value.trim() : '';
|
|
57
|
+
if (!str)
|
|
58
|
+
return fallback;
|
|
59
|
+
if (str.length > MAX_FIELD_LENGTH)
|
|
60
|
+
return { error: `fields must be ${MAX_FIELD_LENGTH} characters or fewer` };
|
|
61
|
+
return str;
|
|
62
|
+
}
|
|
63
|
+
/** Used by the manual "Edit profile" form — companyName/mission required, ceoName/ceoPersonality optional. */
|
|
64
|
+
export function validateProfileInput(body) {
|
|
65
|
+
const companyName = typeof body?.companyName === 'string' ? body.companyName.trim() : '';
|
|
66
|
+
const mission = typeof body?.mission === 'string' ? body.mission.trim() : '';
|
|
67
|
+
if (!companyName || !mission) {
|
|
68
|
+
return { error: 'companyName and mission are both required' };
|
|
69
|
+
}
|
|
70
|
+
if (companyName.length > MAX_FIELD_LENGTH || mission.length > MAX_FIELD_LENGTH) {
|
|
71
|
+
return { error: `fields must be ${MAX_FIELD_LENGTH} characters or fewer` };
|
|
72
|
+
}
|
|
73
|
+
const ceoName = clampField(body?.ceoName, DEFAULT_CEO_NAME);
|
|
74
|
+
if (typeof ceoName === 'object')
|
|
75
|
+
return ceoName;
|
|
76
|
+
const ceoPersonality = clampField(body?.ceoPersonality, DEFAULT_CEO_PERSONALITY);
|
|
77
|
+
if (typeof ceoPersonality === 'object')
|
|
78
|
+
return ceoPersonality;
|
|
79
|
+
const defaultModel = clampField(body?.defaultModel, '');
|
|
80
|
+
if (typeof defaultModel === 'object')
|
|
81
|
+
return defaultModel;
|
|
82
|
+
const requestedEffort = typeof body?.defaultEffort === 'string' ? body.defaultEffort.trim() : '';
|
|
83
|
+
const defaultEffort = EFFORT_LEVELS.includes(requestedEffort)
|
|
84
|
+
? requestedEffort
|
|
85
|
+
: '';
|
|
86
|
+
// A manual submission is a deliberate, complete configuration — never partial.
|
|
87
|
+
return { companyName, mission, ceoName, ceoPersonality, onboardingComplete: true, defaultModel, defaultEffort };
|
|
88
|
+
}
|
|
89
|
+
export async function writeProfile(cwd, data) {
|
|
90
|
+
const dir = getProfileDir(cwd);
|
|
91
|
+
await mkdir(dir, { recursive: true });
|
|
92
|
+
const filePath = getProfilePath(cwd);
|
|
93
|
+
const tmpPath = path.join(dir, `.profile.json.${process.pid}.tmp`);
|
|
94
|
+
await writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
95
|
+
try {
|
|
96
|
+
await rename(tmpPath, filePath);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
await unlink(tmpPath).catch(() => { });
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Strong, first-person identity framing — deliberately not "you're also
|
|
105
|
+
* helping company X" phrasing. Weaker framing was tested and Claude treated
|
|
106
|
+
* it as low-trust incidental context (correctly suspicious when the value
|
|
107
|
+
* changed between turns, since that's exactly the shape of a prompt
|
|
108
|
+
* injection). Explicitly asserting this is configured identity, not
|
|
109
|
+
* external/injected content, fixed it in testing.
|
|
110
|
+
*/
|
|
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.`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Used instead of formatProfileForSystemPrompt while onboardingComplete is
|
|
116
|
+
* false. Turns the conversation itself into the onboarding step, rather than
|
|
117
|
+
* gating the dashboard behind a form — Claude asks the questions and
|
|
118
|
+
* persists the answers itself via its own file-write access, updating the
|
|
119
|
+
* file after each answer (not just once at the end) so the UI can reflect
|
|
120
|
+
* progress live as the conversation happens.
|
|
121
|
+
*/
|
|
122
|
+
export function getOnboardingSystemPrompt(cwd) {
|
|
123
|
+
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 naturally, a question or two at a time — don't interrogate, and don't ask about anything else in the repo yet. 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.`;
|
|
125
|
+
}
|
package/dist/server/start.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { OWN_PACKAGE_NAME, OWN_PACKAGE_VERSION } from '../own-package.js';
|
|
2
3
|
import { createServer } from './http-server.js';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
3
5
|
import { generateToken } from './token.js';
|
|
6
|
+
import { checkForUpdate } from './update-check.js';
|
|
4
7
|
function openBrowser(url) {
|
|
5
8
|
const platform = process.platform;
|
|
6
9
|
try {
|
|
@@ -21,14 +24,22 @@ function openBrowser(url) {
|
|
|
21
24
|
export async function startServer(cwd) {
|
|
22
25
|
const token = generateToken();
|
|
23
26
|
const portRef = { port: 0 };
|
|
27
|
+
const logger = createLogger(cwd);
|
|
28
|
+
// Fire-and-forget: never block startup on a network round-trip. /api/meta
|
|
29
|
+
// just returns null until this resolves.
|
|
30
|
+
const updateInfoRef = { current: null };
|
|
31
|
+
checkForUpdate(OWN_PACKAGE_NAME, OWN_PACKAGE_VERSION).then((info) => {
|
|
32
|
+
updateInfoRef.current = info;
|
|
33
|
+
});
|
|
24
34
|
await new Promise((resolve) => {
|
|
25
|
-
const server = createServer({ cwd, token, portRef });
|
|
35
|
+
const server = createServer({ cwd, token, portRef, logger, updateInfoRef });
|
|
26
36
|
server.listen(0, '127.0.0.1', () => {
|
|
27
37
|
const address = server.address();
|
|
28
38
|
portRef.port = typeof address === 'object' && address ? address.port : 0;
|
|
29
39
|
const url = `http://127.0.0.1:${portRef.port}/?token=${token}`;
|
|
30
40
|
console.log(`my-team is running for ${cwd}`);
|
|
31
41
|
console.log(`Open: ${url}`);
|
|
42
|
+
console.log(`Logging raw requests/responses to: ${logger.filePath}`);
|
|
32
43
|
openBrowser(url);
|
|
33
44
|
resolve();
|
|
34
45
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function isNewerVersion(latest, current) {
|
|
2
|
+
const a = latest.split('.').map(Number);
|
|
3
|
+
const b = current.split('.').map(Number);
|
|
4
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
5
|
+
const x = a[i] ?? 0;
|
|
6
|
+
const y = b[i] ?? 0;
|
|
7
|
+
if (x !== y)
|
|
8
|
+
return x > y;
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Plain registry GET, no npm CLI involved. Never throws — a failed check
|
|
14
|
+
* (offline, registry down) just means "no update info available," not an
|
|
15
|
+
* error worth surfacing to the user.
|
|
16
|
+
*/
|
|
17
|
+
export async function checkForUpdate(packageName, currentVersion) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
|
|
20
|
+
signal: AbortSignal.timeout(4000),
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
return { currentVersion, latestVersion: null, updateAvailable: false };
|
|
24
|
+
const data = (await res.json());
|
|
25
|
+
if (!data.version)
|
|
26
|
+
return { currentVersion, latestVersion: null, updateAvailable: false };
|
|
27
|
+
return { currentVersion, latestVersion: data.version, updateAvailable: isNewerVersion(data.version, currentVersion) };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { currentVersion, latestVersion: null, updateAvailable: false };
|
|
31
|
+
}
|
|
32
|
+
}
|