chamba 0.3.0 → 0.4.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/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -24
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1691
- package/dist/server.js +0 -1919
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webterm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Browser front-end for the interactive agent CLIs (claude, opencode, codex) inside a chamba container (PTY relay + rich composer).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
12
|
+
"@xterm/xterm": "^5.5.0",
|
|
13
|
+
"express": "^4.21.2",
|
|
14
|
+
"node-pty": "^1.0.0",
|
|
15
|
+
"ws": "^8.18.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// pane.js - Which conversation's pages a session is looking at.
|
|
2
|
+
//
|
|
3
|
+
// The store (artifacts.js) knows about directories and the discovery (conversation.js) knows about agents.
|
|
4
|
+
// This is the piece between them: every live session gets a key, the key becomes the agent's own conversation
|
|
5
|
+
// id as soon as the agent has written one down, and the pages follow.
|
|
6
|
+
//
|
|
7
|
+
// A session that has just started has no id yet - the agent writes its first record a moment later - so it
|
|
8
|
+
// gets a provisional key and can publish immediately. When the id turns up, the provisional directory is
|
|
9
|
+
// adopted into the id's own, and from then on the conversation carries its pages wherever it is resumed.
|
|
10
|
+
//
|
|
11
|
+
// Two sessions never share a directory. If a conversation is resumed while another live session already holds
|
|
12
|
+
// its key, the second session is refused the adoption and keeps its provisional key for the rest of its life,
|
|
13
|
+
// with a notice the pane can show - two writers on one directory would interleave the page numbering, and the
|
|
14
|
+
// user would be reading two conversations as if they were one.
|
|
15
|
+
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { isPaneKey } from "./artifacts.js";
|
|
18
|
+
import { conversationId } from "./conversation.js";
|
|
19
|
+
|
|
20
|
+
// The shape a key has before an agent has told us its real one. Distinguishable on disk at a glance, and
|
|
21
|
+
// narrow enough to pass the store's own name check.
|
|
22
|
+
const PROVISIONAL_PREFIX = "pending-";
|
|
23
|
+
|
|
24
|
+
// How many sessions the key map remembers. Entries outlive their session on purpose: feedback submitted after
|
|
25
|
+
// an agent exited still has to land in the right directory. The cap only keeps a container that has been up
|
|
26
|
+
// for weeks from growing the map without end.
|
|
27
|
+
const MAX_REMEMBERED = 64;
|
|
28
|
+
|
|
29
|
+
/** True for a key this server invented rather than one an agent owns. */
|
|
30
|
+
export function isProvisional(key) {
|
|
31
|
+
return typeof key === "string" && key.startsWith(PROVISIONAL_PREFIX);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The pane's session bookkeeping.
|
|
36
|
+
*
|
|
37
|
+
* - `store` is the artifacts store.
|
|
38
|
+
* - `stores` is where each agent writes its conversation id (config's AGENT_STORES).
|
|
39
|
+
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since }` - everything the discovery
|
|
40
|
+
* needs, handed in rather than reached for, so this file needs no registry and no PTYs to be tested.
|
|
41
|
+
* - `onChange(sid)` is called whenever that session's page list moved, for whatever reason.
|
|
42
|
+
*/
|
|
43
|
+
export function createPane({ store, stores, sessions, onChange }) {
|
|
44
|
+
// sid -> { key, adopted, refused }. `adopted` means the key is the agent's own conversation id, so the
|
|
45
|
+
// discovery is done with this session; `refused` means it found one and could not have it.
|
|
46
|
+
const bySession = new Map();
|
|
47
|
+
|
|
48
|
+
function forget() {
|
|
49
|
+
if (bySession.size <= MAX_REMEMBERED) return;
|
|
50
|
+
const live = new Set(sessions().map((session) => session.sid));
|
|
51
|
+
for (const sid of bySession.keys()) {
|
|
52
|
+
if (bySession.size <= MAX_REMEMBERED) return;
|
|
53
|
+
if (!live.has(sid)) bySession.delete(sid);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The key this session's pages are filed under, assigning a provisional one the first time it is asked. */
|
|
58
|
+
function keyFor(sid) {
|
|
59
|
+
const known = bySession.get(sid);
|
|
60
|
+
if (known) return known.key;
|
|
61
|
+
const entry = { key: `${PROVISIONAL_PREFIX}${randomBytes(4).toString("hex")}`, adopted: false, refused: false };
|
|
62
|
+
bySession.set(sid, entry);
|
|
63
|
+
forget();
|
|
64
|
+
return entry.key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why this session's pane is not the conversation's own, when it is not: "shared", else null. */
|
|
68
|
+
function noticeFor(sid) {
|
|
69
|
+
return bySession.get(sid)?.refused === true ? "shared" : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every key a live session currently holds, so an adoption can tell a free conversation from a taken one. */
|
|
73
|
+
function liveKeys(exceptSid) {
|
|
74
|
+
const taken = new Set();
|
|
75
|
+
for (const session of sessions()) {
|
|
76
|
+
if (session.sid === exceptSid) continue;
|
|
77
|
+
const entry = bySession.get(session.sid);
|
|
78
|
+
if (entry) taken.add(entry.key);
|
|
79
|
+
}
|
|
80
|
+
return taken;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask the agent, once more, which conversation this session is having, and move its pages under that id
|
|
85
|
+
* when the answer arrives. Returns true when the session's pane changed.
|
|
86
|
+
*/
|
|
87
|
+
function discover(session) {
|
|
88
|
+
const entry = bySession.get(session.sid);
|
|
89
|
+
if (!entry || entry.adopted || entry.refused) return false;
|
|
90
|
+
const id = conversationId(session, stores);
|
|
91
|
+
if (id === null || id === entry.key) return false;
|
|
92
|
+
// An id that cannot be a directory name is not one we can file under. The name only: creating the
|
|
93
|
+
// directory here would leave the adoption nothing to rename into, and it makes its own directory.
|
|
94
|
+
if (!isPaneKey(id)) return false;
|
|
95
|
+
if (liveKeys(session.sid).has(id)) {
|
|
96
|
+
// Someone else is already having this conversation in another tab. Say so once and stop asking.
|
|
97
|
+
entry.refused = true;
|
|
98
|
+
console.log(
|
|
99
|
+
`[webterm] pane: ${session.sid} resumed a conversation another session already holds - keeping its own pages apart`,
|
|
100
|
+
);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (!store.adopt(entry.key, id)) return false;
|
|
104
|
+
entry.key = id;
|
|
105
|
+
entry.adopted = true;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One pass over the live sessions: find the conversation ids that have appeared, and look at the
|
|
111
|
+
* directories again for pages written by hand. Called on a timer by the server; every session whose pane
|
|
112
|
+
* moved is reported through `onChange`.
|
|
113
|
+
*/
|
|
114
|
+
function sweep() {
|
|
115
|
+
for (const session of sessions()) {
|
|
116
|
+
// A session that has never published has no key yet, and the discovery works on keys - so this is
|
|
117
|
+
// also where a session started a moment ago joins the pane at all.
|
|
118
|
+
keyFor(session.sid);
|
|
119
|
+
const changed = discover(session);
|
|
120
|
+
const rescanned = store.refresh(keyFor(session.sid));
|
|
121
|
+
if (changed || rescanned) onChange?.(session.sid);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The pages of the session's pane, and the notice that goes with it. */
|
|
126
|
+
function pages(sid) {
|
|
127
|
+
return { pages: store.list(keyFor(sid)), notice: noticeFor(sid) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Publish into this session's pane. The page is new, so whoever is watching hears about it at once. */
|
|
131
|
+
function publish(sid, page) {
|
|
132
|
+
const result = store.publish(keyFor(sid), page);
|
|
133
|
+
if (result.ok) onChange?.(sid);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read one page of this session's pane, and count it as read. */
|
|
138
|
+
function read(sid, id) {
|
|
139
|
+
const key = keyFor(sid);
|
|
140
|
+
const result = store.read(key, id);
|
|
141
|
+
if (result.ok && store.markRead(key, id)) onChange?.(sid);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** File a submission from one of this session's pages. The caller has already checked the page exists. */
|
|
146
|
+
function feedback(sid, id, submission) {
|
|
147
|
+
return store.writeFeedback(keyFor(sid), id, submission);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether this session has a pane at all. A sid nobody has seen gets no directory made for it. */
|
|
151
|
+
function knows(sid) {
|
|
152
|
+
return bySession.has(sid);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { discover, feedback, keyFor, knows, noticeFor, pages, publish, read, sweep };
|
|
156
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// proc.js - The few things the server needs to know about processes, read from /proc.
|
|
2
|
+
//
|
|
3
|
+
// Two questions come up, and both are about identity rather than about process control. "Did this publish
|
|
4
|
+
// come from inside a session?" - a helper hands over its own pid and the server checks whether one of the
|
|
5
|
+
// pids it already knows is above it in the tree, because a caller naming a session directly would be a value
|
|
6
|
+
// the server has to trust. "Is this snapshot this session's?" - the same walk, plus the start time, which is
|
|
7
|
+
// what tells a live pid from a recycled one on a bind-mounted directory that outlived the container that
|
|
8
|
+
// wrote it.
|
|
9
|
+
//
|
|
10
|
+
// Everything here fails towards "no": an unreadable /proc entry, a pid that is gone, a tree that is deeper
|
|
11
|
+
// than any real one - all of them answer null or false rather than throwing, because the caller's next move
|
|
12
|
+
// is always to refuse.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
// A process tree in a container is a handful of levels deep. The cap is what keeps a malformed /proc, or a
|
|
17
|
+
// cycle that should not exist, from spinning here.
|
|
18
|
+
const MAX_ANCESTRY_DEPTH = 64;
|
|
19
|
+
|
|
20
|
+
// Where the process table is read from. Always /proc in the container; a fixture tree in the tests, which is
|
|
21
|
+
// the only way to exercise a tree shape the test host does not happen to have.
|
|
22
|
+
const PROC_ROOT = process.env.WEBTERM_PROC_ROOT || "/proc";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The fields of /proc/<pid>/stat after the command name, or null when the process is gone.
|
|
26
|
+
*
|
|
27
|
+
* The command name is the reason this is not a plain split: it is wrapped in parentheses and may itself hold
|
|
28
|
+
* spaces and parentheses ("(node (deleted))"), so everything up to the LAST ") " is skipped. What is left
|
|
29
|
+
* starts at the state field, which makes the parent pid field 2 and the start time field 20 - the same
|
|
30
|
+
* offsets the shell helpers use, counted the same way.
|
|
31
|
+
*/
|
|
32
|
+
function statFields(pid) {
|
|
33
|
+
let line;
|
|
34
|
+
try {
|
|
35
|
+
line = readFileSync(`${PROC_ROOT}/${pid}/stat`, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const end = line.lastIndexOf(") ");
|
|
40
|
+
if (end === -1) return null;
|
|
41
|
+
return line
|
|
42
|
+
.slice(end + 2)
|
|
43
|
+
.trim()
|
|
44
|
+
.split(/\s+/);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pid that started this one, or null when it is gone or unreadable. */
|
|
48
|
+
export function parentPid(pid) {
|
|
49
|
+
const fields = statFields(pid);
|
|
50
|
+
const parent = Number(fields?.[1]);
|
|
51
|
+
return Number.isInteger(parent) && parent > 0 ? parent : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* When a process started, in jiffies since boot (/proc/<pid>/stat field 22), or null when it is gone.
|
|
56
|
+
* Pids are recycled; a pid plus its start time is not, which is what makes this worth reading.
|
|
57
|
+
*/
|
|
58
|
+
export function processStart(pid) {
|
|
59
|
+
const fields = statFields(pid);
|
|
60
|
+
const started = Number(fields?.[19]);
|
|
61
|
+
return Number.isInteger(started) ? started : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when `pid` is `ancestor` itself or anything it started, however many levels down. */
|
|
65
|
+
export function isSelfOrDescendant(pid, ancestor) {
|
|
66
|
+
if (!Number.isInteger(pid) || !Number.isInteger(ancestor) || pid < 1 || ancestor < 1) return false;
|
|
67
|
+
let current = pid;
|
|
68
|
+
for (let depth = 0; depth < MAX_ANCESTRY_DEPTH; depth++) {
|
|
69
|
+
if (current === ancestor) return true;
|
|
70
|
+
if (current <= 1) return false;
|
|
71
|
+
const parent = parentPid(current);
|
|
72
|
+
if (parent === null) return false;
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a process that recorded itself as `pid` started at `start` is still that same process. A snapshot
|
|
80
|
+
* written before a container restart can name a pid that something else holds now, and the start time is
|
|
81
|
+
* what tells the two apart. A record with no start time is taken at face value: it is older than the field,
|
|
82
|
+
* not suspect.
|
|
83
|
+
*/
|
|
84
|
+
export function isSameProcess(pid, start) {
|
|
85
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
86
|
+
if (start === null || start === undefined) return processStart(pid) !== null;
|
|
87
|
+
const live = processStart(pid);
|
|
88
|
+
return live !== null && live === Number(start);
|
|
89
|
+
}
|