@usenexus/cli 0.0.1 → 0.1.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 +60 -2
- package/bin/nexus.mjs +72 -3
- package/dist/assets/index-Bj0JERnz.css +1 -0
- package/dist/assets/index-DycaEuDy.js +10 -0
- package/dist/index.html +14 -0
- package/package.json +45 -7
- package/prompts/adopt.md +119 -0
- package/prompts/pitch.md +93 -0
- package/server/gh.mjs +89 -0
- package/server/hub.mjs +104 -0
- package/server/normalize.mjs +89 -0
- package/server/preflight.mjs +59 -0
- package/server/provider.mjs +218 -0
- package/server/repo.mjs +192 -0
- package/server/server.mjs +279 -0
- package/server/sh.mjs +47 -0
- package/shared/beats.mjs +190 -0
- package/shared/route.mjs +60 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="referrer" content="no-referrer" />
|
|
7
|
+
<title>nexus</title>
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-DycaEuDy.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Bj0JERnz.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,13 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usenexus/cli",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "nexus: a
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "nexus: a local GUI for Claude Code that runs the nexus loop on your repo. npx @usenexus/cli",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
7
|
+
"bin": {
|
|
8
|
+
"nexus": "bin/nexus.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"server",
|
|
13
|
+
"shared",
|
|
14
|
+
"dist",
|
|
15
|
+
"prompts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node scripts/bundle-prompts.mjs && vite build",
|
|
24
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
25
|
+
"start": "node bin/nexus.mjs",
|
|
26
|
+
"prepublishOnly": "npm run build && npm test"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.305"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
33
|
+
"react": "19.3.1",
|
|
34
|
+
"react-dom": "19.3.1",
|
|
35
|
+
"vite": "8.3.4"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/daretodave/nexus.git",
|
|
40
|
+
"directory": "packages/cli"
|
|
41
|
+
},
|
|
11
42
|
"homepage": "https://github.com/daretodave/nexus#readme",
|
|
12
|
-
"keywords": [
|
|
43
|
+
"keywords": [
|
|
44
|
+
"claude-code",
|
|
45
|
+
"nexus",
|
|
46
|
+
"autonomous",
|
|
47
|
+
"agents",
|
|
48
|
+
"methodology",
|
|
49
|
+
"gui"
|
|
50
|
+
]
|
|
13
51
|
}
|
package/prompts/adopt.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Adopt nexus — agent prompt
|
|
2
|
+
|
|
3
|
+
> Clone nexus next to your repo, then paste the short version
|
|
4
|
+
> from README.md's
|
|
5
|
+
> [TL;DR — clone + delegate the adoption](../README.md#tldr--clone--delegate-the-adoption)
|
|
6
|
+
> into your agent — it points here. This file is the full
|
|
7
|
+
> instruction set the agent then follows; read it yourself
|
|
8
|
+
> first if you want to know exactly what you're authorizing.
|
|
9
|
+
>
|
|
10
|
+
> Use this if you already have a `spec.md`. If you only have a
|
|
11
|
+
> verbal pitch, use [`pitch.md`](./pitch.md) instead.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The prompt
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Adopt nexus.
|
|
19
|
+
|
|
20
|
+
nexus is at ../nexus (or ./.nexus if submoduled). It is a
|
|
21
|
+
methodology + template kit for turning this repo into an
|
|
22
|
+
autonomous-loop project. Read the following, in order, before
|
|
23
|
+
making a single change:
|
|
24
|
+
|
|
25
|
+
1. ../nexus/README.md — entry point
|
|
26
|
+
2. ../nexus/concepts/architecture.md — the whole system
|
|
27
|
+
3. ../nexus/concepts/skills-anatomy.md — how skills work
|
|
28
|
+
4. ../nexus/playbooks/new-project.md AND
|
|
29
|
+
../nexus/playbooks/existing-project.md
|
|
30
|
+
— pick the one that applies
|
|
31
|
+
4a. ../nexus/playbooks/polyrepo.md AND/OR
|
|
32
|
+
../nexus/playbooks/workspace.md
|
|
33
|
+
— only if the topology check
|
|
34
|
+
under "Then:" below (in
|
|
35
|
+
this same prompt) lands on
|
|
36
|
+
either
|
|
37
|
+
5. ../nexus/playbooks/ci-providers.md — for the deploy gate
|
|
38
|
+
6. ../nexus/intervention-spectrum.md — how the loop scales
|
|
39
|
+
7. ../nexus/customization/*.md — e.g. verify gate, hermetic
|
|
40
|
+
e2e, data layer, sub-agents;
|
|
41
|
+
skim the rest as needed
|
|
42
|
+
|
|
43
|
+
Then:
|
|
44
|
+
|
|
45
|
+
- Decide whether this is greenfield or brownfield by reading
|
|
46
|
+
`git log --oneline | wc -l`, looking at the file tree, and
|
|
47
|
+
checking for an existing spec.md.
|
|
48
|
+
- Check the topology: is there already a sibling `plan/`
|
|
49
|
+
directory next to this repo, or multiple sibling repos
|
|
50
|
+
under one un-versioned root? Infer a default from that
|
|
51
|
+
(single-repo if neither), but always confirm it with the
|
|
52
|
+
user before proceeding — see the ask list below. Getting
|
|
53
|
+
this wrong reshapes file locations from phase 1 onward.
|
|
54
|
+
- Follow the matching playbook end-to-end. Do not skip steps.
|
|
55
|
+
- Copy templates from ../nexus/templates/ into this repo.
|
|
56
|
+
- Replace placeholders (<PROJECT>, <PROJECT_LOWER>,
|
|
57
|
+
<PROJECT_TAGLINE>, <HOSTING_URL>, <HOSTING_PROVIDER>,
|
|
58
|
+
<REPO_SLUG>, <DEFAULT_BRANCH>, <PROJECT_PKG_PREFIX>) with
|
|
59
|
+
values you derive from the existing repo state. If a value
|
|
60
|
+
is genuinely unknowable, surface it in plan/AUDIT.md as a
|
|
61
|
+
[needs-user-call] row and continue with a defensible
|
|
62
|
+
default.
|
|
63
|
+
- Ask the user ONLY for: (a) the hosting provider name and
|
|
64
|
+
auth token if not visibly configured, (b) the project's
|
|
65
|
+
canonical name + tagline if no spec exists, (c) the
|
|
66
|
+
URL/API/CLI contract if it cannot be inferred, (d)
|
|
67
|
+
confirmation of the topology guess above — single-repo,
|
|
68
|
+
polyrepo (`plan/` split into a sibling repo), or workspace
|
|
69
|
+
(3+ sibling repos under one un-versioned root). For
|
|
70
|
+
everything else, decide.
|
|
71
|
+
- Adapt bearings.md to reflect the actual stack present in
|
|
72
|
+
this repo. Do not assume Next.js, Tailwind, or any
|
|
73
|
+
specific framework — read package.json / Cargo.toml /
|
|
74
|
+
pyproject.toml / go.mod and adapt.
|
|
75
|
+
- Write the plan as specs and chunks (nexus/concepts/
|
|
76
|
+
plan-shape.md): a plan/README.md roadmap with one row per
|
|
77
|
+
spec drawn from the spec (or from CURRENT-STATE.md for
|
|
78
|
+
brownfield), the first spec or two chunked (S/M/L, every
|
|
79
|
+
acceptance ID covered), the rest `Not chunked yet.`.
|
|
80
|
+
Copy templates/plan/nexus.lock and run
|
|
81
|
+
scripts/plan-check.mjs until it is green.
|
|
82
|
+
- Wire the verify gate against this repo's actual test
|
|
83
|
+
setup. Wire the deploy gate against the chosen provider.
|
|
84
|
+
- At the end: produce a single commit titled
|
|
85
|
+
"chore: adopt nexus methodology" with a body listing every
|
|
86
|
+
file added/modified, every placeholder you resolved, and
|
|
87
|
+
every [needs-user-call] you logged. Push.
|
|
88
|
+
- Then stop. Do not invoke /ship-a-chunk yourself; let the
|
|
89
|
+
user do that as the first conscious step.
|
|
90
|
+
|
|
91
|
+
Standing rules carried from agents.md:
|
|
92
|
+
- Commit and push as a single atomic act.
|
|
93
|
+
- No Co-Authored-By trailers, no emojis. If your harness
|
|
94
|
+
auto-appends one of these by default, strip it before every
|
|
95
|
+
commit — this rule overrides that convention here.
|
|
96
|
+
- No --no-verify, no force-push, no destructive resets.
|
|
97
|
+
- Tests alongside code.
|
|
98
|
+
- When in doubt: decide, document the call in the commit body,
|
|
99
|
+
proceed.
|
|
100
|
+
|
|
101
|
+
Estimated time: well under an hour, agent-paced (no
|
|
102
|
+
read-and-decide pauses) — new-project.md's own header quotes
|
|
103
|
+
2–3 hours for a human working the same playbook manually and
|
|
104
|
+
notes the delegated-agent case finishes faster; see its note
|
|
105
|
+
on the difference. Begin.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## See also
|
|
111
|
+
|
|
112
|
+
- [`README.md`](../README.md) — the short paste that points
|
|
113
|
+
here, plus what to expect when the agent returns and when
|
|
114
|
+
this path is the wrong one.
|
|
115
|
+
- [`playbooks/new-project.md`](../playbooks/new-project.md) /
|
|
116
|
+
[`playbooks/existing-project.md`](../playbooks/existing-project.md)
|
|
117
|
+
— the playbooks this prompt tells the agent to follow.
|
|
118
|
+
- [`pitch.md`](./pitch.md) — the pre-spec variant, for when
|
|
119
|
+
there's no `spec.md` yet.
|
package/prompts/pitch.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Pitch-to-adopted — agent prompt
|
|
2
|
+
|
|
3
|
+
> Clone nexus next to your repo, then paste the short version
|
|
4
|
+
> from README.md's
|
|
5
|
+
> [TL;DR — I have a pitch, no spec yet](../README.md#tldr--i-have-a-pitch-no-spec-yet)
|
|
6
|
+
> into your agent — it points here, with your pitch inline.
|
|
7
|
+
> This file is the full instruction set the agent then
|
|
8
|
+
> follows; read it yourself first if you want to know exactly
|
|
9
|
+
> what you're authorizing.
|
|
10
|
+
>
|
|
11
|
+
> Use this if you have a working name and a one-paragraph
|
|
12
|
+
> pitch, but no `spec.md` yet. If you already have a `spec.md`,
|
|
13
|
+
> use [`adopt.md`](./adopt.md) instead.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The prompt
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Run nexus's pitch-to-adopted flow.
|
|
21
|
+
|
|
22
|
+
nexus is at ../nexus (or ./.nexus if submoduled). The plan
|
|
23
|
+
is: pre-spec interview → write spec.md → adopt nexus.
|
|
24
|
+
|
|
25
|
+
Phase A — pre-spec interview (30-45 minutes)
|
|
26
|
+
|
|
27
|
+
Read ../nexus/playbooks/pre-spec.md end-to-end. Read
|
|
28
|
+
../nexus/concepts/asking-well.md for question shape. Then
|
|
29
|
+
run the three question batches (foundation, spine, surface)
|
|
30
|
+
per the playbook. AskUserQuestion is allowed during this
|
|
31
|
+
phase — it's pre-spec, not in-skill. Once spec.md is
|
|
32
|
+
committed at the end of Phase A, that rule reverts.
|
|
33
|
+
|
|
34
|
+
Here's my pitch in my own words:
|
|
35
|
+
|
|
36
|
+
<ONE-PARAGRAPH PITCH — what the product is, who it's for,
|
|
37
|
+
what makes it interesting. Don't polish.>
|
|
38
|
+
|
|
39
|
+
Phase A deliverables (committed before Phase B starts):
|
|
40
|
+
- spec.md (at least one page, persona named, v1 scope)
|
|
41
|
+
- plan/bearings.md stub (Surface, Auth, Stack, hosting,
|
|
42
|
+
voice, hard rules)
|
|
43
|
+
- (optional) claude-design.prompt.md if Batch 3 Q2 landed
|
|
44
|
+
on "commission a visual system" (see
|
|
45
|
+
../nexus/customization/visual-system.md)
|
|
46
|
+
- (optional) NEXUS_LESSONS.md scratch — capture nexus gaps
|
|
47
|
+
you hit during the interview, for a later /lessons-pr
|
|
48
|
+
pass back to the nexus repo
|
|
49
|
+
|
|
50
|
+
Phase B — adoption
|
|
51
|
+
|
|
52
|
+
Once Phase A's commits land, switch to the standard adoption
|
|
53
|
+
prompt: read and follow ../nexus/prompts/adopt.md exactly,
|
|
54
|
+
including its own narrow four-item ask list (hosting
|
|
55
|
+
provider/token, project name/tagline, URL/API/CLI contract,
|
|
56
|
+
topology confirmation) — that list is adopt.md's own
|
|
57
|
+
carve-out, the same class as /oversight's. Beyond those four
|
|
58
|
+
items, AskUserQuestion is not allowed. Decide and document;
|
|
59
|
+
don't ask.
|
|
60
|
+
|
|
61
|
+
End-state:
|
|
62
|
+
- chore: adopt nexus methodology commit landed and pushed
|
|
63
|
+
- Day 1 checklist in new-project.md passes
|
|
64
|
+
- Ready for /ship-a-chunk as the first conscious step.
|
|
65
|
+
- Do NOT invoke /ship-a-chunk yourself; let the user do
|
|
66
|
+
that.
|
|
67
|
+
|
|
68
|
+
Standing rules:
|
|
69
|
+
- Commit and push as a single atomic act per logical chunk.
|
|
70
|
+
- No Co-Authored-By trailers, no emojis.
|
|
71
|
+
- No --no-verify, no force-push.
|
|
72
|
+
|
|
73
|
+
Estimated time: 60-90 minutes total, agent-paced for Phase B
|
|
74
|
+
(Phase A is genuinely interactive, per pre-spec.md's own
|
|
75
|
+
30-45 minute estimate; Phase B's 2-3 hour playbook estimate
|
|
76
|
+
is the human-paced figure — see new-project.md's note).
|
|
77
|
+
Begin with Phase A.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Pitch quality matters. If you can't describe the product in
|
|
81
|
+
one paragraph in plain language, come back when you can. The
|
|
82
|
+
agent will not invent product direction for you.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## See also
|
|
87
|
+
|
|
88
|
+
- [`README.md`](../README.md) — the short paste that points
|
|
89
|
+
here.
|
|
90
|
+
- [`adopt.md`](./adopt.md) — the Phase B prompt this one
|
|
91
|
+
hands off to.
|
|
92
|
+
- [`playbooks/pre-spec.md`](../playbooks/pre-spec.md) — what
|
|
93
|
+
Phase A tells the agent to follow.
|
package/server/gh.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// GitHub Actions setup and live runs, through the person's own `gh` login.
|
|
2
|
+
// Run state is observed only (gh run list / view); nothing here infers
|
|
3
|
+
// progress from a clock. A run still queued after STALL_MS reads "stalled".
|
|
4
|
+
import { run, json } from './sh.mjs';
|
|
5
|
+
|
|
6
|
+
export const STALL_MS = 2 * 60 * 1000;
|
|
7
|
+
export const POLL_MS = 8000;
|
|
8
|
+
export const SECRET = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
9
|
+
|
|
10
|
+
export async function actionsSetup(repo) {
|
|
11
|
+
const auth = await run('gh', ['auth', 'status'], { cwd: repo });
|
|
12
|
+
if (!auth.ok) return { gh: false, note: auth.missing ? 'gh is not installed.' : 'gh is not signed in. Run `gh auth login`.' };
|
|
13
|
+
const [secrets, workflows, perms, rulesets] = await Promise.all([
|
|
14
|
+
run('gh', ['secret', 'list', '--json', 'name'], { cwd: repo }),
|
|
15
|
+
run('gh', ['workflow', 'list', '--all', '--json', 'id,name,path,state'], { cwd: repo }),
|
|
16
|
+
run('gh', ['api', 'repos/{owner}/{repo}/actions/permissions/workflow'], { cwd: repo }),
|
|
17
|
+
run('gh', ['api', 'repos/{owner}/{repo}/rulesets'], { cwd: repo }),
|
|
18
|
+
]);
|
|
19
|
+
const names = (json(secrets.stdout) || []).map((s) => s.name);
|
|
20
|
+
const p = json(perms.stdout) || {};
|
|
21
|
+
const rs = json(rulesets.stdout);
|
|
22
|
+
return {
|
|
23
|
+
gh: true,
|
|
24
|
+
steps: [
|
|
25
|
+
{
|
|
26
|
+
id: 'secret',
|
|
27
|
+
label: `${SECRET} repo secret`,
|
|
28
|
+
ok: names.includes(SECRET),
|
|
29
|
+
detail: secrets.ok ? (names.length ? `secrets: ${names.join(', ')}` : 'no repo secrets') : 'could not list secrets (needs admin on the repo)',
|
|
30
|
+
fix: `Run \`claude setup-token\`, then \`gh secret set ${SECRET}\` and paste the token.`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'permissions',
|
|
34
|
+
label: 'Workflow token can write',
|
|
35
|
+
ok: p.default_workflow_permissions === 'write',
|
|
36
|
+
detail: perms.ok ? `default: ${p.default_workflow_permissions || 'unknown'}; may approve PRs: ${p.can_approve_pull_request_reviews ? 'yes' : 'no'}` : 'could not read workflow permissions',
|
|
37
|
+
fix: 'Settings -> Actions -> General -> Workflow permissions: Read and write.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'rulesets',
|
|
41
|
+
label: 'Rulesets on main and claims',
|
|
42
|
+
ok: Array.isArray(rs) && rs.length > 0,
|
|
43
|
+
optional: true,
|
|
44
|
+
detail: Array.isArray(rs) ? (rs.length ? rs.map((r) => `${r.name} (${r.enforcement})`).join(', ') : 'none') : 'could not read rulesets',
|
|
45
|
+
fix: 'Optional in owner mode. In PR mode, protect main so only pull requests land.',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
workflows: (json(workflows.stdout) || []).map((w) => ({ id: w.id, name: w.name, path: w.path, enabled: w.state === 'active', state: w.state })),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function setWorkflow(repo, id, enabled) {
|
|
53
|
+
if (!/^[0-9]+$/.test(String(id))) return { ok: false, error: 'bad workflow id' };
|
|
54
|
+
const r = await run('gh', ['workflow', enabled ? 'enable' : 'disable', String(id)], { cwd: repo });
|
|
55
|
+
return { ok: r.ok, out: (r.stdout + r.stderr).trim() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listRuns(repo) {
|
|
59
|
+
const r = await run('gh', ['run', 'list', '-L', '20', '--json', 'databaseId,displayTitle,workflowName,status,conclusion,createdAt,updatedAt,url,event'], { cwd: repo });
|
|
60
|
+
if (!r.ok) return { ok: false, runs: [], error: (r.stderr || '').trim().slice(0, 300) };
|
|
61
|
+
return { ok: true, runs: (json(r.stdout) || []).map((x) => markStall(x, Date.now())) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function markStall(runRow, now) {
|
|
65
|
+
const queuedFor = now - Date.parse(runRow.createdAt || 0);
|
|
66
|
+
return { ...runRow, stalled: runRow.status === 'queued' && queuedFor > STALL_MS };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turn a fresh run list into loop beats against what was seen before.
|
|
71
|
+
* `seen` maps run id -> last status. A run first seen already completed
|
|
72
|
+
* is history, not news: it is recorded without a beat.
|
|
73
|
+
*/
|
|
74
|
+
export function runBeats(runs, seen) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const r of runs) {
|
|
77
|
+
const id = String(r.databaseId);
|
|
78
|
+
const before = seen.get(id);
|
|
79
|
+
const now = r.status;
|
|
80
|
+
seen.set(id, now);
|
|
81
|
+
if (before === now) continue;
|
|
82
|
+
if (before === undefined && now === 'completed') continue;
|
|
83
|
+
const name = r.workflowName || r.displayTitle || '';
|
|
84
|
+
if (now === 'queued' || now === 'waiting' || now === 'requested' || now === 'pending') out.push({ k: 'loop', p: 'dispatch', id, name });
|
|
85
|
+
else if (now === 'in_progress') out.push({ k: 'loop', p: 'run', id, name });
|
|
86
|
+
else if (now === 'completed') out.push({ k: 'loop', p: 'conclude', id, name, conclusion: r.conclusion || 'unknown' });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/server/hub.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The event hub behind GET /api/events (one SSE stream per tab).
|
|
2
|
+
//
|
|
3
|
+
// - On connect: a `hello` with server time and the full replay (beats and
|
|
4
|
+
// transcript), so a late tab reduces to the same state as an early one.
|
|
5
|
+
// - A `: ping` comment every 20 s keeps proxies and sleeping tabs honest.
|
|
6
|
+
// - Writes are coalesced into 300 ms flushes; consecutive text deltas for
|
|
7
|
+
// the same transcript item merge into one event.
|
|
8
|
+
|
|
9
|
+
export const PING_MS = 20000;
|
|
10
|
+
export const FLUSH_MS = 300;
|
|
11
|
+
const MAX_BEATS = 5000;
|
|
12
|
+
const MAX_TRANSCRIPT = 2000;
|
|
13
|
+
|
|
14
|
+
export function createHub({ pingMs = PING_MS, flushMs = FLUSH_MS } = {}) {
|
|
15
|
+
const clients = new Set();
|
|
16
|
+
const beats = [];
|
|
17
|
+
const transcript = [];
|
|
18
|
+
let queue = [];
|
|
19
|
+
let timer = null;
|
|
20
|
+
let status = { state: 'idle' };
|
|
21
|
+
const extras = {};
|
|
22
|
+
|
|
23
|
+
function frame(type, data) {
|
|
24
|
+
return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function flush() {
|
|
28
|
+
timer = null;
|
|
29
|
+
if (!queue.length) return;
|
|
30
|
+
const out = coalesce(queue).map((e) => frame(e.type, e.data)).join('');
|
|
31
|
+
queue = [];
|
|
32
|
+
for (const res of clients) res.write(out);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emit(type, data) {
|
|
36
|
+
queue.push({ type, data });
|
|
37
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function beat(b) {
|
|
41
|
+
beats.push(b);
|
|
42
|
+
if (beats.length > MAX_BEATS) beats.splice(0, beats.length - MAX_BEATS);
|
|
43
|
+
emit('beat', b);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tx(item) {
|
|
47
|
+
if (item.kind === 'delta') {
|
|
48
|
+
const last = transcript[transcript.length - 1];
|
|
49
|
+
if (last && last.kind === 'text' && last.id === item.id) last.text += item.text;
|
|
50
|
+
else transcript.push({ kind: 'text', id: item.id, agent: item.agent, text: item.text });
|
|
51
|
+
} else transcript.push(item);
|
|
52
|
+
if (transcript.length > MAX_TRANSCRIPT) transcript.splice(0, transcript.length - MAX_TRANSCRIPT);
|
|
53
|
+
emit('tx', item);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setStatus(next) {
|
|
57
|
+
status = { ...status, ...next };
|
|
58
|
+
emit('status', status);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function set(key, value) {
|
|
62
|
+
extras[key] = value;
|
|
63
|
+
emit(key, value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function attach(req, res) {
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
69
|
+
'cache-control': 'no-cache, no-transform',
|
|
70
|
+
connection: 'keep-alive',
|
|
71
|
+
'x-accel-buffering': 'no',
|
|
72
|
+
});
|
|
73
|
+
res.write(frame('hello', { time: Date.now(), beats, transcript, status, ...extras }));
|
|
74
|
+
clients.add(res);
|
|
75
|
+
const ping = setInterval(() => res.write(': ping\n\n'), pingMs);
|
|
76
|
+
req.on('close', () => { clearInterval(ping); clients.delete(res); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reset() {
|
|
80
|
+
beats.length = 0;
|
|
81
|
+
transcript.length = 0;
|
|
82
|
+
emit('reset', {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function close() {
|
|
86
|
+
if (timer) { clearTimeout(timer); flush(); }
|
|
87
|
+
for (const res of clients) res.end();
|
|
88
|
+
clients.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { emit, beat, tx, setStatus, set, attach, reset, close, flush, get clients() { return clients.size; }, get beats() { return beats; }, get transcript() { return transcript; } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Merge consecutive `tx` deltas for the same item; everything else passes in order. */
|
|
95
|
+
export function coalesce(events) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const e of events) {
|
|
98
|
+
const last = out[out.length - 1];
|
|
99
|
+
if (e.type === 'tx' && e.data.kind === 'delta' && last && last.type === 'tx' && last.data.kind === 'delta' && last.data.id === e.data.id) {
|
|
100
|
+
last.data = { ...last.data, text: last.data.text + e.data.text };
|
|
101
|
+
} else out.push({ type: e.type, data: e.data });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SDK / stream-json messages -> transcript items for the prompt pane.
|
|
2
|
+
// Pure apart from `ctx`, which remembers which assistant messages already
|
|
3
|
+
// streamed as partials (so the full message that follows is not repeated).
|
|
4
|
+
|
|
5
|
+
export function newTxContext() {
|
|
6
|
+
return { msgId: null, streamed: new Set() };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function summarize(name, input = {}) {
|
|
10
|
+
const pick = input.command || input.file_path || input.pattern || input.url || input.description || input.prompt || input.query || '';
|
|
11
|
+
const s = String(pick).replace(/\s+/g, ' ').trim();
|
|
12
|
+
return s.length > 140 ? `${s.slice(0, 137)}...` : s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Copy for account errors, keyed by the SDK's assistant error codes. */
|
|
16
|
+
export function friendlyError(code, fallback) {
|
|
17
|
+
switch (code) {
|
|
18
|
+
case 'authentication_failed':
|
|
19
|
+
return 'Claude Code is not logged in. Run `claude` in a terminal, then /login, and try again.';
|
|
20
|
+
case 'billing_error':
|
|
21
|
+
return 'Your Claude account has a billing problem. Check your plan, then try again.';
|
|
22
|
+
case 'rate_limit':
|
|
23
|
+
return 'Rate limited. Wait a minute and send again.';
|
|
24
|
+
case 'overloaded':
|
|
25
|
+
return 'The service is overloaded right now. Try again shortly.';
|
|
26
|
+
default:
|
|
27
|
+
return fallback || `Claude Code reported: ${code}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function txFromMessage(msg, ctx) {
|
|
32
|
+
const out = [];
|
|
33
|
+
if (!msg || typeof msg !== 'object') return out;
|
|
34
|
+
const agent = msg.parent_tool_use_id || 'main';
|
|
35
|
+
switch (msg.type) {
|
|
36
|
+
case 'system':
|
|
37
|
+
if (msg.subtype === 'init') out.push({ kind: 'system', text: `session ${String(msg.session_id || '').slice(0, 8)} · ${msg.model || 'model'} · ${(msg.slash_commands || []).length} commands`, session: msg.session_id });
|
|
38
|
+
if (msg.subtype === 'task_notification' && msg.summary) out.push({ kind: 'system', agent: msg.tool_use_id || agent, text: `task ${msg.status}: ${msg.summary}` });
|
|
39
|
+
break;
|
|
40
|
+
case 'stream_event': {
|
|
41
|
+
if (msg.parent_tool_use_id) break;
|
|
42
|
+
const ev = msg.event || {};
|
|
43
|
+
if (ev.type === 'message_start' && ev.message) ctx.msgId = ev.message.id;
|
|
44
|
+
if (ev.type === 'content_block_delta' && ev.delta && ev.delta.type === 'text_delta') {
|
|
45
|
+
if (ctx.msgId) ctx.streamed.add(ctx.msgId);
|
|
46
|
+
out.push({ kind: 'delta', id: `${ctx.msgId || 'm'}:${ev.index ?? 0}`, agent, text: ev.delta.text });
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'assistant': {
|
|
51
|
+
const m = msg.message || {};
|
|
52
|
+
if (msg.error) out.push({ kind: 'error', code: msg.error, text: friendlyError(msg.error) });
|
|
53
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
54
|
+
content.forEach((block, i) => {
|
|
55
|
+
if (block.type === 'text' && !msg.parent_tool_use_id && !ctx.streamed.has(m.id) && block.text) {
|
|
56
|
+
out.push({ kind: 'text', id: `${m.id || 'm'}:${i}`, agent, text: block.text });
|
|
57
|
+
}
|
|
58
|
+
if (block.type === 'tool_use') out.push({ kind: 'tool', id: block.id, agent, name: block.name, summary: summarize(block.name, block.input) });
|
|
59
|
+
});
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'user': {
|
|
63
|
+
const content = msg.message && Array.isArray(msg.message.content) ? msg.message.content : [];
|
|
64
|
+
for (const block of content) {
|
|
65
|
+
if (block && block.type === 'tool_result' && block.is_error) out.push({ kind: 'tool-error', id: block.tool_use_id, agent, text: toText(block.content).slice(0, 400) });
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'result':
|
|
70
|
+
out.push({
|
|
71
|
+
kind: 'result',
|
|
72
|
+
ok: !msg.is_error,
|
|
73
|
+
subtype: msg.subtype,
|
|
74
|
+
cost: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
|
|
75
|
+
turns: msg.num_turns ?? null,
|
|
76
|
+
ms: msg.duration_ms ?? null,
|
|
77
|
+
session: msg.session_id,
|
|
78
|
+
text: msg.is_error ? toText(msg.result || msg.errors || msg.subtype) : '',
|
|
79
|
+
});
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toText(c) {
|
|
86
|
+
if (typeof c === 'string') return c;
|
|
87
|
+
if (Array.isArray(c)) return c.map((x) => (typeof x === 'string' ? x : x && x.text ? x.text : '')).join(' ');
|
|
88
|
+
return c == null ? '' : JSON.stringify(c);
|
|
89
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Onboarding preflight: is this machine ready to drive Claude Code here?
|
|
2
|
+
import { run, json } from './sh.mjs';
|
|
3
|
+
import { repoInfo } from './repo.mjs';
|
|
4
|
+
|
|
5
|
+
export async function preflight(repo) {
|
|
6
|
+
const [ver, auth, gh, info] = await Promise.all([
|
|
7
|
+
run('claude', ['--version']),
|
|
8
|
+
run('claude', ['auth', 'status', '--json'], { timeout: 15000 }),
|
|
9
|
+
run('gh', ['auth', 'status']),
|
|
10
|
+
repoInfo(repo),
|
|
11
|
+
]);
|
|
12
|
+
const a = json(auth.stdout) || {};
|
|
13
|
+
const checks = [
|
|
14
|
+
{
|
|
15
|
+
id: 'claude',
|
|
16
|
+
label: 'Claude Code installed',
|
|
17
|
+
ok: ver.ok,
|
|
18
|
+
detail: ver.ok ? ver.stdout.trim() : 'Not found on PATH.',
|
|
19
|
+
fix: ver.ok ? null : 'Install Claude Code (https://claude.com/claude-code), then reopen this page.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'login',
|
|
23
|
+
label: 'Logged in',
|
|
24
|
+
ok: !!a.loggedIn,
|
|
25
|
+
detail: a.loggedIn ? [a.authMethod, a.subscriptionType].filter(Boolean).join(' · ') : 'Not logged in.',
|
|
26
|
+
fix: a.loggedIn ? null : 'Run `claude` in a terminal, then /login.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'gh',
|
|
30
|
+
label: 'GitHub CLI signed in',
|
|
31
|
+
ok: gh.ok,
|
|
32
|
+
optional: true,
|
|
33
|
+
detail: gh.ok ? firstLine(gh.stdout + gh.stderr, /Logged in to .*/) : gh.missing ? 'gh not installed.' : 'Not signed in.',
|
|
34
|
+
fix: gh.ok ? null : 'Optional, for the cloud loop and PRs: install gh, then `gh auth login`.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'remote',
|
|
38
|
+
label: 'Git remote',
|
|
39
|
+
ok: !!info.remote,
|
|
40
|
+
optional: true,
|
|
41
|
+
detail: info.remote || (info.isGit ? 'No origin remote.' : 'This folder is not a git repo.'),
|
|
42
|
+
fix: info.remote ? null : info.isGit ? 'Add one: `git remote add origin <url>`.' : 'Run the GUI from inside your project, or `git init` first.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'adopted',
|
|
46
|
+
label: 'nexus adopted',
|
|
47
|
+
ok: info.adopted,
|
|
48
|
+
optional: true,
|
|
49
|
+
detail: info.adopted ? 'agents.md, plan/ and .claude/commands present.' : 'Not yet.',
|
|
50
|
+
fix: info.adopted ? null : 'Use "Adopt nexus" below, or give the prompt to your agent.',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
return { checks, info, ready: checks.filter((c) => !c.optional).every((c) => c.ok) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstLine(text, re) {
|
|
57
|
+
const m = re.exec(text);
|
|
58
|
+
return m ? m[0].trim() : text.trim().split(/\r?\n/)[0] || '';
|
|
59
|
+
}
|