chamba 0.5.0 → 0.6.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/dist/commands/onboard.js +2 -1
- package/dist/lib/agent-context.js +10 -3
- package/dist/lib/git-env.js +21 -0
- package/dist/lib/shadows.js +2 -0
- package/package.json +1 -1
- package/templates/claude-statusline.sh +7 -4
- package/templates/context/web-pane-craft.md +57 -0
- package/templates/context-usage.sh +30 -13
- package/templates/skills/chamba-statusline/SKILL.md +15 -5
- package/templates/skills/web-pane/SKILL.md +1 -55
- package/templates/webterm/README.md +6 -3
- package/templates/webterm/artifacts.js +14 -1
- package/templates/webterm/conversation.js +57 -13
- package/templates/webterm/pane.js +9 -1
- package/templates/webterm/public/app/composer.js +25 -4
- package/templates/webterm/public/app/dom.js +4 -0
- package/templates/webterm/public/app/frames.js +3 -1
- package/templates/webterm/public/app/main.js +5 -1
- package/templates/webterm/public/app/pane-arrival.js +36 -0
- package/templates/webterm/public/app/pane.js +98 -13
- package/templates/webterm/public/app/status-strip.js +26 -34
- package/templates/webterm/public/app/strip-format.js +36 -0
- package/templates/webterm/public/app/tabs.js +66 -4
- package/templates/webterm/public/index.html +23 -3
- package/templates/webterm/public/styles.css +159 -14
- package/templates/webterm/resume.js +14 -5
- package/templates/webterm/server.js +12 -2
- package/templates/webterm/snapshot.js +4 -2
- package/templates/skills/context-usage/SKILL.md +0 -53
package/dist/commands/onboard.js
CHANGED
|
@@ -9,6 +9,7 @@ import { basename, join } from "node:path";
|
|
|
9
9
|
import { cancel, confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts";
|
|
10
10
|
import { buildDefaultChambaYaml, readChambaYaml, slugifyForWorkspaceId, validateWorkspaceId, writeChambaYaml, } from "../lib/chamba-yaml.js";
|
|
11
11
|
import { CHAMBA_YAML } from "../lib/constants.js";
|
|
12
|
+
import { gitEnv } from "../lib/git-env.js";
|
|
12
13
|
import { readWebRange } from "../lib/global-config.js";
|
|
13
14
|
import { safeRmSync } from "../lib/safe-rm.js";
|
|
14
15
|
import { ensureWebPort } from "../lib/webterm.js";
|
|
@@ -26,7 +27,7 @@ function deriveUniqueWorkspaceId(baseId, workspaceRoot) {
|
|
|
26
27
|
}
|
|
27
28
|
function tryGetGitRoot(cwd) {
|
|
28
29
|
try {
|
|
29
|
-
return execSync("git rev-parse --show-toplevel", { encoding: "utf8", cwd, stdio: "pipe" }).trim();
|
|
30
|
+
return execSync("git rev-parse --show-toplevel", { encoding: "utf8", cwd, stdio: "pipe", env: gitEnv() }).trim();
|
|
30
31
|
}
|
|
31
32
|
catch {
|
|
32
33
|
return null;
|
|
@@ -108,12 +108,17 @@ export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE
|
|
|
108
108
|
];
|
|
109
109
|
return `${sections.join("\n")}\n`;
|
|
110
110
|
}
|
|
111
|
+
// The page-writing craft is claude's web-pane skill body, and this is the same file: a skill is a Claude
|
|
112
|
+
// Code mechanism, so an AGENTS.md section is the only channel codex and opencode have for it. Claude does
|
|
113
|
+
// not get it here - it reads it as the skill, and a second copy in its CLAUDE.md would say it twice.
|
|
114
|
+
// The leading newline: the sections above are one bullet list, and this one opens with a heading.
|
|
115
|
+
const craftSection = `\n${loadTemplate("web-pane-craft")}`;
|
|
111
116
|
// The context-usage section is Claude-only: the snapshot it points at is written by the status line
|
|
112
117
|
// script Claude Code runs, so other agents' sessions never refresh it.
|
|
113
118
|
return {
|
|
114
119
|
claude: build("~/.claude/CLAUDE.md", [loadTemplate("context-usage")]),
|
|
115
|
-
opencode: build("~/.config/opencode/AGENTS.md"),
|
|
116
|
-
codex: build("~/.codex/AGENTS.md"),
|
|
120
|
+
opencode: build("~/.config/opencode/AGENTS.md", [craftSection]),
|
|
121
|
+
codex: build("~/.codex/AGENTS.md", [craftSection]),
|
|
117
122
|
};
|
|
118
123
|
}
|
|
119
124
|
// --- Claude settings.json bootstrap ------------------------------------------------------------------------------------------------------
|
|
@@ -147,7 +152,9 @@ export function injectClaudeSkills(workspaceDir) {
|
|
|
147
152
|
if (!existsSync(templatesSkillsDir))
|
|
148
153
|
return;
|
|
149
154
|
const targetSkillsDir = join(workspaceDir, AGENTS_DIR, "claude", "skills");
|
|
150
|
-
|
|
155
|
+
// web_pane_craft is the page-writing advice codex and opencode also get, as a section of their AGENTS.md.
|
|
156
|
+
// One file, rendered into both channels, so the two cannot come to say different things.
|
|
157
|
+
const vars = { statusline_path: CLAUDE_STATUSLINE_PATH, web_pane_craft: loadTemplate("web-pane-craft") };
|
|
151
158
|
for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
|
|
152
159
|
if (!entry.isDirectory())
|
|
153
160
|
continue;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/git-env.ts - The environment every git call in chamba runs in
|
|
3
|
+
// git takes its repo from GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE before it takes it from -C or the working
|
|
4
|
+
// directory, so a call made from inside a git hook would answer about that hook's repo instead of this one.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
/**
|
|
7
|
+
* The environment to run git in when the repo is chosen by path.
|
|
8
|
+
*
|
|
9
|
+
* git takes its repo from the environment as well as from the command line, and GIT_DIR, GIT_WORK_TREE and
|
|
10
|
+
* GIT_INDEX_FILE win over `-C` and over the working directory. Anything chamba starts inherits whatever the
|
|
11
|
+
* caller had set - a git hook sets all three - so a question asked about the workspace could be answered about
|
|
12
|
+
* another repo entirely. Every git call here names its repo by path, so none of those variables is ever wanted.
|
|
13
|
+
*/
|
|
14
|
+
export function gitEnv() {
|
|
15
|
+
const env = { ...process.env };
|
|
16
|
+
for (const key of Object.keys(env)) {
|
|
17
|
+
if (key.startsWith("GIT_"))
|
|
18
|
+
delete env[key];
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
}
|
package/dist/lib/shadows.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "no
|
|
|
7
7
|
import { dirname, join, relative } from "node:path";
|
|
8
8
|
import fg from "fast-glob";
|
|
9
9
|
import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
|
|
10
|
+
import { gitEnv } from "./git-env.js";
|
|
10
11
|
import { safeRmSync } from "./safe-rm.js";
|
|
11
12
|
/**
|
|
12
13
|
* Expand gitignore-style patterns into concrete relative paths.
|
|
@@ -58,6 +59,7 @@ function filterGitTrackedPaths(paths, workspaceRoot) {
|
|
|
58
59
|
const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
|
|
59
60
|
encoding: "utf8",
|
|
60
61
|
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
env: gitEnv(),
|
|
61
63
|
});
|
|
62
64
|
if (result.status !== 0)
|
|
63
65
|
return { kept: paths, dropped: [] };
|
package/package.json
CHANGED
|
@@ -73,13 +73,15 @@ cc_version=""
|
|
|
73
73
|
|
|
74
74
|
# One jq pass over the session state, straight into the snapshot object.
|
|
75
75
|
# Tokens are floor-rounded and percentages rounded to integers, so a reader uses them as they are.
|
|
76
|
-
#
|
|
76
|
+
# A rate limit is inverted here: the API reports the percentage USED, and everything downstream
|
|
77
77
|
# shows the percentage REMAINING (an energy-left metaphor), clamped to 0..100 so an over-limit
|
|
78
|
-
# report never becomes a negative number.
|
|
78
|
+
# report never becomes a negative number. Both windows go through the same "remaining" definition,
|
|
79
|
+
# because two meters drawn side by side that meant opposite things would look identical.
|
|
79
80
|
snapshot_json=$(jq -rc \
|
|
80
81
|
--arg claude_pid "$claude_pid" \
|
|
81
82
|
--arg claude_pid_start "$claude_pid_start" \
|
|
82
83
|
--arg version "$cc_version" '
|
|
84
|
+
def remaining: if . == null then null else ((100 - round) | if . < 0 then 0 elif . > 100 then 100 else . end) end;
|
|
83
85
|
(.session_id // "") as $sid |
|
|
84
86
|
{
|
|
85
87
|
session_id: $sid,
|
|
@@ -91,9 +93,10 @@ snapshot_json=$(jq -rc \
|
|
|
91
93
|
context_window_size: (.context_window.context_window_size // 0),
|
|
92
94
|
model: ((.model.display_name // "") | split(" (") | .[0]),
|
|
93
95
|
effort: (.effort.level // ""),
|
|
94
|
-
quota_left_pct: (.rate_limits.five_hour.used_percentage
|
|
95
|
-
| if . == null then null else ((100 - round) | if . < 0 then 0 elif . > 100 then 100 else . end) end),
|
|
96
|
+
quota_left_pct: (.rate_limits.five_hour.used_percentage | remaining),
|
|
96
97
|
quota_resets_at: (.rate_limits.five_hour.resets_at // null),
|
|
98
|
+
quota_week_left_pct: (.rate_limits.seven_day.used_percentage | remaining),
|
|
99
|
+
quota_week_resets_at: (.rate_limits.seven_day.resets_at // null),
|
|
97
100
|
version: (if $version == "" then null else $version end)
|
|
98
101
|
}' 2>/dev/null)
|
|
99
102
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
## Publishing a page
|
|
2
|
+
|
|
3
|
+
The web pane is the panel beside the terminal in the chamba web interface. You publish a standalone HTML file into it and it opens at once, in front of the user, with a chip in the bar above the pane that they can come back to. A user who is looking at another session sees their tab marked instead, and reads the page when they get there.
|
|
4
|
+
|
|
5
|
+
Publish when the answer is bigger than the terminal carries well, and when the question is.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
webpane report.html --title "Publish-ports rework"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The file's own `<title>` is what the pane shows. `--title` names the file it is saved as, and defaults to the filename. The helper prints what it saved, or a plain message and a non-zero exit when it could not - the usual reason being a plain terminal session, which has no pane.
|
|
12
|
+
|
|
13
|
+
### When to reach for it
|
|
14
|
+
|
|
15
|
+
- **Long or structured output.** A plan, a comparison table, a diagram, a report, an explanation with sections. Anything a user would want to scroll back to, keep, or read twice.
|
|
16
|
+
- **A question with shape.** More options than a plain prompt holds, options that need a sentence each, several questions at once, or a free-text answer alongside a choice. Your own question tooling is right for a quick fork in the road; a page is right when the choice deserves a table.
|
|
17
|
+
- **Anything the user asked to see.** "Show me", "write it up", "as a page" - take those literally.
|
|
18
|
+
|
|
19
|
+
Do not publish a one-paragraph answer, or a wall of code that belongs in a file. A page nobody needed is a chip in the way.
|
|
20
|
+
|
|
21
|
+
After publishing, say in one line what you put there. The page is the answer; the terminal line is a pointer to it.
|
|
22
|
+
|
|
23
|
+
### Writing the page
|
|
24
|
+
|
|
25
|
+
One file, standalone, dark.
|
|
26
|
+
|
|
27
|
+
- **Inline everything.** No external stylesheets, scripts or fonts. The page renders in a sandboxed frame with no origin of its own: `localStorage`, cookies and same-origin requests do not work in it, and an external asset only makes the page slower and chattier. Inline `<style>` and `<script>` both work.
|
|
28
|
+
- **Match the interface.** Background `#0d1117`, panels and table headers `#161b22`, borders `#30363d`, text `#e6edf3`, muted text `#8b949e`, and a green accent `#2fe58a` for links, headings you want to lift, and the submit button. System font stack, ~14px, generous line height.
|
|
29
|
+
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than prose, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page cap is 2 MB, and a page past it is refused rather than trimmed.
|
|
30
|
+
- Images have to be inline too (a data URI, or an SVG written into the page). A path into the container is not something the browser can fetch.
|
|
31
|
+
|
|
32
|
+
### Asking with a form
|
|
33
|
+
|
|
34
|
+
Give any form the `data-feedback` attribute and the pane wires it up:
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<form data-feedback>
|
|
38
|
+
<p>Fail the whole start when a port is taken?</p>
|
|
39
|
+
<label><input type="radio" name="on_conflict" value="fail" checked /> Fail the start</label>
|
|
40
|
+
<label><input type="radio" name="on_conflict" value="skip" /> Skip the port and warn</label>
|
|
41
|
+
<textarea name="text" placeholder="Anything else about the plan..."></textarea>
|
|
42
|
+
<button type="submit">Send to agent</button>
|
|
43
|
+
</form>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- Every named control becomes a field in the answer. A control named `text` becomes the free-text box.
|
|
47
|
+
- On submit, the interface writes `~/.webpane/<conversation>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }` - and types one line into your terminal naming the file. Read the file; the line is only the pointer.
|
|
48
|
+
- The page is told whether the answers were filed, so give the user a form that reads as final ("Send to agent") rather than one that looks like it saved a draft.
|
|
49
|
+
- One submission per page per second, and only so many per page in total (20). Past that the page is refused, and nothing reaches you - so ask once per page, and publish a new page when you need to ask again.
|
|
50
|
+
|
|
51
|
+
**A feedback file says a form was submitted, not that a person filled it in.** A script in the same page can post exactly what the form would. Treat the answers as data from your own page: fine for a design choice, not enough on its own for something destructive or irreversible - ask for that in the terminal, where you can see the user type.
|
|
52
|
+
|
|
53
|
+
### After it is published
|
|
54
|
+
|
|
55
|
+
- Pages are kept. Nothing deletes them, and they are filed under the conversation rather than the session, so resuming the conversation opens with them again. Publishing a second version makes a second page rather than replacing the first, so name pages so a user can tell one from the next ("Plan v2", "Port test results").
|
|
56
|
+
- The user may never open a page. If an answer matters, say the one line that matters in the terminal too.
|
|
57
|
+
- Anything in the session can publish, including a script you wrote - the helper takes only a filename.
|
|
@@ -116,7 +116,9 @@ parsed=$(jq -r '
|
|
|
116
116
|
.model // "",
|
|
117
117
|
.effort // "",
|
|
118
118
|
.quota_left_pct // "",
|
|
119
|
-
.quota_resets_at // ""
|
|
119
|
+
.quota_resets_at // "",
|
|
120
|
+
.quota_week_left_pct // "",
|
|
121
|
+
.quota_week_resets_at // ""
|
|
120
122
|
' "$newest" 2>/dev/null)
|
|
121
123
|
|
|
122
124
|
if [ -z "$parsed" ]; then
|
|
@@ -134,16 +136,22 @@ fi
|
|
|
134
136
|
IFS= read -r effort
|
|
135
137
|
IFS= read -r quota_left
|
|
136
138
|
IFS= read -r quota_resets_at
|
|
139
|
+
IFS= read -r quota_week_left
|
|
140
|
+
IFS= read -r quota_week_resets_at
|
|
137
141
|
} <<EOF
|
|
138
142
|
$parsed
|
|
139
143
|
EOF
|
|
140
144
|
|
|
141
145
|
now=$(date +%s)
|
|
142
146
|
|
|
143
|
-
# Format a seconds delta as "Xh Ym", "Xm", or "Xs".
|
|
147
|
+
# Format a seconds delta as "Xd Yh", "Xh Ym", "Xm", or "Xs". The day unit is here for the seven-day
|
|
148
|
+
# rate-limit window, which is days away for most of its life; the hours are truncated rather than
|
|
149
|
+
# rounded so a delta of 4 days and 23h50m never reads "4d 24h".
|
|
144
150
|
fmt_delta() {
|
|
145
151
|
d=$1
|
|
146
|
-
if [ "$d" -ge
|
|
152
|
+
if [ "$d" -ge 86400 ]; then
|
|
153
|
+
echo "$(( d / 86400 ))d $(( (d % 86400) / 3600 ))h"
|
|
154
|
+
elif [ "$d" -ge 3600 ]; then
|
|
147
155
|
echo "$(( d / 3600 ))h $(( (d % 3600) / 60 ))m"
|
|
148
156
|
elif [ "$d" -ge 60 ]; then
|
|
149
157
|
echo "$(( d / 60 ))m"
|
|
@@ -199,22 +207,31 @@ else
|
|
|
199
207
|
fi
|
|
200
208
|
printf 'context: %s tokens (%s%%%s)\n' "$tokens_label" "${used_pct:-?}" "$window_label"
|
|
201
209
|
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
# One rate-limit window as a line, printed only when the snapshot carried that window. Claude Code
|
|
211
|
+
# reports two - the five-hour one, which "quota" has always meant here, and the seven-day one - and an
|
|
212
|
+
# account may have both, one, or neither. The label is padded to the width the other lines use.
|
|
213
|
+
quota_line() {
|
|
214
|
+
ql_label=$1
|
|
215
|
+
ql_left=$2
|
|
216
|
+
ql_resets=$3
|
|
217
|
+
[ -n "$ql_left" ] || return 0
|
|
218
|
+
ql_reset_label=""
|
|
219
|
+
case "$ql_resets" in
|
|
206
220
|
'' | *[!0-9]*) ;;
|
|
207
221
|
*)
|
|
208
|
-
|
|
209
|
-
if [ "$
|
|
210
|
-
|
|
222
|
+
ql_delta=$(( ql_resets - now ))
|
|
223
|
+
if [ "$ql_delta" -gt 0 ]; then
|
|
224
|
+
ql_reset_label=", resets in $(fmt_delta "$ql_delta")"
|
|
211
225
|
else
|
|
212
|
-
|
|
226
|
+
ql_reset_label=", resets now"
|
|
213
227
|
fi
|
|
214
228
|
;;
|
|
215
229
|
esac
|
|
216
|
-
printf '
|
|
217
|
-
|
|
230
|
+
printf '%-8s %s%% remaining%s\n' "$ql_label" "$ql_left" "$ql_reset_label"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
quota_line 'quota:' "$quota_left" "$quota_resets_at"
|
|
234
|
+
quota_line 'weekly:' "$quota_week_left" "$quota_week_resets_at"
|
|
218
235
|
|
|
219
236
|
if [ -n "$model" ]; then
|
|
220
237
|
effort_label=""
|
|
@@ -16,24 +16,34 @@ That is the answer to most questions this skill gets. The rest of it is the deta
|
|
|
16
16
|
|
|
17
17
|
## Step 1 - The strip, which is where the numbers are
|
|
18
18
|
|
|
19
|
-
The web interface shows the attached session's status in one line between the terminal and the composer.
|
|
19
|
+
The web interface shows the attached session's status in one line between the terminal and the composer. Its segments, left to right, separated by a mid-dot:
|
|
20
20
|
|
|
21
21
|
1. **Model** - the display name Claude Code reports, with any trailing parenthetical (such as "(1M context)") trimmed, and the reasoning effort beside it.
|
|
22
22
|
2. **Context** - tokens held, then the size of the window they sit in, a bar, and the percentage. The size half is dropped when Claude Code does not report a window size. The bar turns yellow as the window fills and red near the top.
|
|
23
|
-
3. **Quota** - what is LEFT of
|
|
23
|
+
3. **Quota** - one meter per rate-limit window Claude Code reports, tagged `5h` and `7d`: what is LEFT of that window, as a bar and a percentage, with the time until it recharges beside it. It is an energy meter: full and green when fresh, yellow at half, red near empty. A window the account does not have takes its whole meter off the strip, so free accounts and sessions before their first API response show none.
|
|
24
24
|
4. **Claude Code** - the installed CLI version.
|
|
25
25
|
|
|
26
26
|
The strip belongs to the session it is drawn for, and only a claude session has one - the snapshot is a Claude Code feature, so a codex or opencode tab has no strip at all.
|
|
27
27
|
|
|
28
28
|
## Step 2 - The snapshot behind it, and the helper
|
|
29
29
|
|
|
30
|
-
The script at `{{statusline_path}}` is what makes any of this exist. Claude Code runs it on every prompt render and hands it the session's state on stdin; the script parses it, resolves the pid of the claude process it was run by, and writes a small JSON file. Fields: `session_id`, `claude_pid`, `claude_pid_start`, `updated_at`, `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at`, `version`.
|
|
30
|
+
The script at `{{statusline_path}}` is what makes any of this exist. Claude Code runs it on every prompt render and hands it the session's state on stdin; the script parses it, resolves the pid of the claude process it was run by, and writes a small JSON file. Fields: `session_id`, `claude_pid`, `claude_pid_start`, `updated_at`, `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at`, `quota_week_left_pct`, `quota_week_resets_at`, `version`. The two `quota_*` pairs are the five-hour and seven-day rate-limit windows; both percentages are what is LEFT, inverted from the percentage used that Claude Code reports.
|
|
31
31
|
|
|
32
32
|
Two things read those files:
|
|
33
33
|
- The web interface, which matches a snapshot to a session by that pid and draws the strip from it.
|
|
34
|
-
- The `context-usage` command, which is how an agent inspects its own context and quota from the terminal. Run it rather than reading the files by hand
|
|
34
|
+
- The `context-usage` command, which is how an agent inspects its own context and quota from the terminal. Run it rather than reading the files by hand.
|
|
35
35
|
|
|
36
|
-
If the user wants the numbers without the browser, `context-usage` is the answer.
|
|
36
|
+
If the user wants the numbers without the browser, `context-usage` is the answer. It prints one labelled line per thing the snapshot carried:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
session: 78b4025b-... (this session, updated 3s ago)
|
|
40
|
+
context: 70.8k tokens (7% of 1M window)
|
|
41
|
+
quota: 84% remaining, resets in 21m
|
|
42
|
+
weekly: 59% remaining, resets in 4d 6h
|
|
43
|
+
model: Fable 5 (effort high)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`quota` is the five-hour window and `weekly` the seven-day one; a window the account does not have prints no line at all, and neither does a field the snapshot is missing. The `(this session, ...)` marker means the snapshot was matched to the asking session by pid and is certainly its own; without it the newest file was used instead, and a `warning:` line says why that may mislead. The numbers are as of the moment the current prompt was submitted, so the turn in flight is not in them yet.
|
|
37
47
|
|
|
38
48
|
## Step 3 - When the user wants a status line of their own
|
|
39
49
|
|
|
@@ -5,58 +5,4 @@ description: Publish an HTML page into the web pane beside the terminal - and as
|
|
|
5
5
|
|
|
6
6
|
# web-pane: publish a page, and ask with it
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Publish when the answer is bigger than the terminal carries well, and when the question is.
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
webpane report.html --title "Publish-ports rework"
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
The file's own `<title>` is what the pane shows. `--title` names the file it is saved as, and defaults to the filename. The helper prints what it saved, or a plain message and a non-zero exit when it could not - the usual reason being a plain terminal session, which has no pane.
|
|
17
|
-
|
|
18
|
-
## When to reach for it
|
|
19
|
-
|
|
20
|
-
- **Long or structured output.** A plan, a comparison table, a diagram, a report, an explanation with sections. Anything a user would want to scroll back to, keep, or read twice.
|
|
21
|
-
- **A question with shape.** More options than a plain prompt holds, options that need a sentence each, several questions at once, or a free-text answer alongside a choice. Your own question tooling is right for a quick fork in the road; a page is right when the choice deserves a table.
|
|
22
|
-
- **Anything the user asked to see.** "Show me", "write it up", "as a page" - take those literally.
|
|
23
|
-
|
|
24
|
-
Do not publish a one-paragraph answer, or a wall of code that belongs in a file. A page nobody needed is a chip in the way.
|
|
25
|
-
|
|
26
|
-
After publishing, say in one line what you put there. The page is the answer; the terminal line is a pointer to it.
|
|
27
|
-
|
|
28
|
-
## Writing the page
|
|
29
|
-
|
|
30
|
-
One file, standalone, dark.
|
|
31
|
-
|
|
32
|
-
- **Inline everything.** No external stylesheets, scripts or fonts. The page renders in a sandboxed frame with no origin of its own: `localStorage`, cookies and same-origin requests do not work in it, and an external asset only makes the page slower and chattier. Inline `<style>` and `<script>` both work.
|
|
33
|
-
- **Match the interface.** Background `#0d1117`, panels and table headers `#161b22`, borders `#30363d`, text `#e6edf3`, muted text `#8b949e`, and a green accent `#2fe58a` for links, headings you want to lift, and the submit button. System font stack, ~14px, generous line height.
|
|
34
|
-
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than prose, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page cap is 2 MB, and a page past it is refused rather than trimmed.
|
|
35
|
-
- Images have to be inline too (a data URI, or an SVG written into the page). A path into the container is not something the browser can fetch.
|
|
36
|
-
|
|
37
|
-
## Asking with a form
|
|
38
|
-
|
|
39
|
-
Give any form the `data-feedback` attribute and the pane wires it up:
|
|
40
|
-
|
|
41
|
-
```html
|
|
42
|
-
<form data-feedback>
|
|
43
|
-
<p>Fail the whole start when a port is taken?</p>
|
|
44
|
-
<label><input type="radio" name="on_conflict" value="fail" checked /> Fail the start</label>
|
|
45
|
-
<label><input type="radio" name="on_conflict" value="skip" /> Skip the port and warn</label>
|
|
46
|
-
<textarea name="text" placeholder="Anything else about the plan..."></textarea>
|
|
47
|
-
<button type="submit">Send to agent</button>
|
|
48
|
-
</form>
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
- Every named control becomes a field in the answer. A control named `text` becomes the free-text box.
|
|
52
|
-
- On submit, the interface writes `~/.webpane/<conversation>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }` - and types one line into your terminal naming the file. Read the file; the line is only the pointer.
|
|
53
|
-
- The page is told whether the answers were filed, so give the user a form that reads as final ("Send to agent") rather than one that looks like it saved a draft.
|
|
54
|
-
- One submission per page per second, and only so many per page in total (20). Past that the page is refused, and nothing reaches you - so ask once per page, and publish a new page when you need to ask again.
|
|
55
|
-
|
|
56
|
-
**A feedback file says a form was submitted, not that a person filled it in.** A script in the same page can post exactly what the form would. Treat the answers as data from your own page: fine for a design choice, not enough on its own for something destructive or irreversible - ask for that in the terminal, where you can see the user type.
|
|
57
|
-
|
|
58
|
-
## After it is published
|
|
59
|
-
|
|
60
|
-
- Pages are kept. Nothing deletes them, and they are filed under the conversation rather than the session, so resuming the conversation opens with them again. Publishing a second version makes a second page rather than replacing the first, so name pages so a user can tell one from the next ("Plan v2", "Port test results").
|
|
61
|
-
- The user may never open a page. If an answer matters, say the one line that matters in the terminal too.
|
|
62
|
-
- Anything in the session can publish, including a script you wrote - the helper takes only a filename.
|
|
8
|
+
{{web_pane_craft}}
|
|
@@ -34,6 +34,7 @@ The page is mission control for the container: the tab bar at the top is every a
|
|
|
34
34
|
- **A tab is named after what it runs** - `claude 3`, `codex 4` - so a bar with two agents in it reads without a legend. **Rename** by double-clicking the name, so a long-lived conversation reads by what it is about instead; the name shows in every window and clearing it brings the default back. Names last as long as the session; nothing is saved to disk.
|
|
35
35
|
- **Drag a tab to reorder the bar.** The order is the registry's, not the window's, so it moves in every window at once, and the number in `claude 3` stays with the session rather than with the position. Mouse and trackpad only - this is the browser's own drag and drop, which touch does not fire.
|
|
36
36
|
- **The composer belongs to the session you are in.** A half-written message stays with its conversation: switch tabs and the box holds the next session's draft, switch back and yours is as you left it, image attachments included. Ending a session throws its draft away with it. Drafts are per browser window - they survive a switch, a reload and a sleep, but they do not follow a session into another window, and nothing is saved to disk.
|
|
37
|
+
- **One button asks for a page.** The fourth button in the composer, above Send, sends `Put your last answer in the web pane as a page.` in one click - the one message that would otherwise be typed again every day. It goes through the same paste path as anything typed, so the agent cannot tell the two apart, and it does nothing else: a half-written message stays in the box, the draft is kept, the caret does not move, and the sentence is not added to the Up-arrow history, which is what *you* typed. It is in every session whatever agent it runs, and it is unavailable exactly when Send is - with no connection, or with no session attached.
|
|
37
38
|
- **Shift+Enter is a newline in both boxes.** It always was in the composer; in the terminal above it, Shift+Enter now sends the same ESC-then-Return that Alt+Enter does, which is what the agents read as "a newline, not send". A terminal has no Shift+Enter of its own - Enter is a carriage return whatever else is held - so this is a second key onto a sequence the agent already understands. Alt+Enter still works.
|
|
38
39
|
- **Up recalls what you already sent.** From an empty composer, Up brings back the last message sent to that session and keeps stepping back; Down comes forward, and past the newest is the empty box you started from. Once a message is showing the arrows only step on from its first and last line, so they still move the caret around a long one, and typing anything ends the walk. What is stored is what the agent actually received, image tokens already expanded, so sending a recalled message again means the same thing. History is per session and per browser window, it holds what went through the composer rather than what was typed straight into the terminal, and it goes when the session does.
|
|
39
40
|
- **One window drives a session at a time**, because a PTY has one size and two drivers would fight over it. Opening a session another window is watching offers to switch it to this one; the window that loses it can take it back.
|
|
@@ -58,6 +59,7 @@ That is why a session showing as working never also shows as waiting for you.
|
|
|
58
59
|
|
|
59
60
|
- **A light travels round a tab** while that session's agent is working.
|
|
60
61
|
- **A tab flashes and then stays lit** when its agent finishes. Every ending, whoever is watching: it costs nothing to say a thing twice on screen, and one rule for every session is easier to trust than one for the tab you have open and another for the rest. Touching the session, or arriving at it, is what puts it out.
|
|
62
|
+
- **A tab takes its session's colour and grows a small page** beside its age when a page is published into that session's pane while you are looking at another one, and flashes once as it arrives. It holds until you get to that session, where the newest page opens itself; anything older that piled up behind it keeps its chip badge, so the tab marks itself again - without a second flash - once you leave. Its own state, not the one above: that one is an agent finishing, and this one is a page waiting to be read.
|
|
61
63
|
- **The browser tab speaks too**, because that is all a window behind something else can do. The title counts the sessions waiting for you, and the favicon carries one mark: a white bar across its bottom edge, with a lit segment sweeping along it, while an agent is working, and a green dot in its top corner - pulsing until you go and look - when one is waiting. Two shapes at opposite ends of the icon rather than two colours in one place, because at 16px a hue is the first thing to go. White for working because every hue here belongs to a workspace or a session, and the frame around the icon is one of them.
|
|
62
64
|
- **And ten seconds later it says so out loud** - one soft chime, once, for an ending nobody came back to. A sound cannot be taken back and it reaches you in the next room, so it is the one thing here that asks whether you were there. Several sessions finishing together are one chime, and so are two windows open on the same container. The bell beside the power button mutes it, and remembers.
|
|
63
65
|
|
|
@@ -111,13 +113,14 @@ Beside the terminal, each session has a pane of pages the agent published - a pl
|
|
|
111
113
|
- **The files.** `~/.webpane/<conversation-id>/<NN>-<slug>.html`, bind-mounted from the host workspace cache. A page is a plain standalone HTML file, and a file copied into the directory by hand shows up in the pane within a second or two, exactly like a published one.
|
|
112
114
|
- **Per conversation, not per session.** The directory is named after the agent's own conversation id, so resuming a conversation - after a container restart, or by hand with `claude --resume <id>` - opens with its pages again. A session whose agent has not written its id down yet publishes into a `pending-...` directory, which is renamed the moment the id appears. Two live sessions never share a directory: a conversation already open elsewhere is refused, and the second session gets its own empty pane with a notice.
|
|
113
115
|
- **Feedback.** A page may carry `<form data-feedback>`. Submitting it writes `~/.webpane/<conversation-id>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }`, where a field named `text` becomes the free-text box - and types one line into the agent's terminal saying where to read it. An agent that has already exited still gets the file; only the line is skipped.
|
|
114
|
-
- **What you see.** The pane is beside the terminal from the start, as the vertical "Web pane" spine in a session that has published nothing - a pane that only appeared once an agent had used it was one nobody knew to ask for. Opening that empty pane says what it is for and gives three things to say to get a page. Once there are pages, the history is the chips bar above them: one chip per page with its title and age, newest at the end. A page that arrives
|
|
115
|
-
- **How an agent comes to use it.** Two layers, so it happens without being asked each time. Every agent's injected context carries the standing rule - prefer a page when the answer is longer or more structured than a terminal reply carries well, and when a question has more options or structure than the agent's own question tooling holds - plus the palette to match and the form contract.
|
|
116
|
+
- **What you see.** The pane is beside the terminal from the start, as the vertical "Web pane" spine in a session that has published nothing - a pane that only appeared once an agent had used it was one nobody knew to ask for. Opening that empty pane says what it is for and gives three things to say to get a page. Once there are pages, the history is the chips bar above them: one chip per page with its title and age, newest at the end. A page that arrives opens itself and pulses the pane's edge once, and takes nothing else: the caret stays where it was mid-sentence, so you keep typing and click into the page when you want it. "Arrives" is the server's own unread flag rather than "new to this window", so a reload opens nothing that was already read, and a page waiting in a session you have not visited still opens when you get there. A pane you put away comes back for it, at no less than a readable width - the one place this interface overrides a choice you made. A pane nobody has touched yet opens the same way, since collapsed has three answers (yes, no, and nobody has said) and only a click or a drag makes it one of the first two. A page published in a session you are *not* looking at changes nothing where you are: its tab takes that session's colour and a small page beside its age, flashes once, and holds until you get there. Drag the divider to set the width. The minus and plus beside the counter step the shown page through five text sizes, 80% to 150% with 100% the default, and nothing reloads on a press, so a scroll position and a half-filled form both survive it. That control is the browser's own furniture: the size is applied from outside the frame, no page has to account for it, and nothing an agent reads mentions it. The button at the end of the chips bar puts the pane away into the vertical "Web pane" spine, which carries the unread count and reopens on a click, and dragging the divider past the collapse threshold does the same. Reopening gives back the width the pane had, but never less than a third of the window - a pane that comes back as a sliver may as well have stayed shut. The width, the collapse and the text size belong to the window and survive switching tabs; the pages, the selection and the badges belong to the session and switch with it.
|
|
117
|
+
- **How an agent comes to use it.** Two layers, so it happens without being asked each time. Every agent's injected context carries the standing rule - prefer a page when the answer is longer or more structured than a terminal reply carries well, and when a question has more options or structure than the agent's own question tooling holds - plus the palette to match and the form contract. Every agent also gets the craft in full - a standalone dark page, inline everything, how to ask with a form, and what a submitted form does and does not prove - from one file, `templates/context/web-pane-craft.md`: claude reads it as the body of a `web-pane` skill, whose description is the phrases a user actually says ("show me", "as a page", "in the pane", "publish"), and codex and opencode read it as a section of the `AGENTS.md` chamba writes them, since a skill is a Claude Code mechanism and neither has one. The frontmatter and the title are claude's alone; the advice is written once. The session greeting names the pane too, so a user who has never heard of it learns it exists in the first sentence.
|
|
116
118
|
- **Nothing is deleted.** No age sweep, nothing removed when a session closes. Growth is bounded instead: a page over the per-page cap is refused, and once the whole directory reaches its total - in bytes or in number of files - new publishes are refused and what is there stays. Advanced > Clear agent memory in chamba is what clears it.
|
|
117
119
|
|
|
118
120
|
## The status strip
|
|
119
121
|
|
|
120
|
-
Between the terminal and the composer, a claude session shows its own numbers, live: the model and its effort, the context it is holding against the window it fits in, what is left of the five-hour
|
|
122
|
+
Between the terminal and the composer, a claude session shows its own numbers, live: the model and its effort, the context it is holding against the window it fits in, what is left of each rate-limit window - the five-hour one and the seven-day one, tagged `5h` and `7d` - with the time until each recharges, and the installed Claude Code version.
|
|
123
|
+
A window the account does not have takes its whole meter off the strip rather than showing an empty one.
|
|
121
124
|
|
|
122
125
|
- **Where the numbers come from.** The status line script chamba installs (`claude-statusline.sh`, baked into the image) prints nothing at all - in a browser a status line would scroll away with the output and cost a terminal row every prompt. What it does instead is write `~/.claude/context-usage/<session-id>.json` on every prompt render, and the strip is drawn from that file. The same file is what the `context-usage` helper reads, so an agent asking about its own context and the strip above it always agree.
|
|
123
126
|
- **Whose numbers they are.** A snapshot records the pid of the claude process that wrote it, and the server matches it to a session by walking up the process tree from that pid to the session's PTY leader - not by assuming the two are equal, since a wrapper or a shell may sit between them. The start time recorded beside the pid is the tiebreak: the snapshot directory is a chamba mount, so it holds files written by containers that are gone, and their pids can be live again as something else.
|
|
@@ -332,6 +332,19 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
332
332
|
return changed;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* How many of a key's pages nobody has opened. Answered from the last scan rather than from the directory:
|
|
337
|
+
* this rides on the session bar, which is broadcast to every window far more often than a pane changes,
|
|
338
|
+
* and reading the head of every page file to answer it would be paying a listing's price for a number.
|
|
339
|
+
*/
|
|
340
|
+
function unreadCount(key) {
|
|
341
|
+
let count = 0;
|
|
342
|
+
for (const entry of scanned.get(key)?.values() ?? []) {
|
|
343
|
+
if (entry.unread) count += 1;
|
|
344
|
+
}
|
|
345
|
+
return count;
|
|
346
|
+
}
|
|
347
|
+
|
|
335
348
|
/** The window opened this page, so it is no longer new. True when that actually changed something. */
|
|
336
349
|
function markRead(key, id) {
|
|
337
350
|
const entry = scanned.get(key)?.get(id);
|
|
@@ -559,7 +572,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
559
572
|
}
|
|
560
573
|
}
|
|
561
574
|
|
|
562
|
-
return { adopt, ensureDir, keyDir, list, markRead, publish, read, refresh, totalBytes, writeFeedback };
|
|
575
|
+
return { adopt, ensureDir, keyDir, list, markRead, publish, read, refresh, totalBytes, unreadCount, writeFeedback };
|
|
563
576
|
}
|
|
564
577
|
|
|
565
578
|
/**
|
|
@@ -35,11 +35,15 @@ const MAX_JSON_BYTES = 1024 * 1024;
|
|
|
35
35
|
const MAX_STORE_DEPTH = 5;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Every matching file under `dir`, in no particular order, each handed to `onFile`. Returning true from
|
|
39
|
+
* `onFile` stops the walk, which is what lets a caller that only needs to know whether there is one at all
|
|
40
|
+
* stop at the first instead of listing a whole store.
|
|
41
|
+
*
|
|
42
|
+
* Depth-limited, and unreadable dirs contribute nothing. Symlinks are skipped rather than followed: these
|
|
43
|
+
* stores are the agents' own, and a link in one of them would only ever lead somewhere this has no business
|
|
44
|
+
* reading - readdir reports one as neither a file nor a directory, which is what leaves it out.
|
|
40
45
|
*/
|
|
41
|
-
|
|
42
|
-
const found = [];
|
|
46
|
+
function walkStore(dir, pattern, depth, onFile) {
|
|
43
47
|
const queue = [{ dir, depth }];
|
|
44
48
|
while (queue.length > 0) {
|
|
45
49
|
const current = queue.shift();
|
|
@@ -51,25 +55,52 @@ export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
|
51
55
|
}
|
|
52
56
|
for (const entry of entries) {
|
|
53
57
|
const full = join(current.dir, entry.name);
|
|
54
|
-
// Symlinks are skipped rather than followed: these stores are the agents' own, and a link in one
|
|
55
|
-
// of them would only ever lead somewhere this lookup has no business reading.
|
|
56
58
|
if (entry.isDirectory() && current.depth > 0) {
|
|
57
59
|
queue.push({ dir: full, depth: current.depth - 1 });
|
|
58
|
-
} else if (entry.isFile() && pattern.test(entry.name)) {
|
|
59
|
-
|
|
60
|
-
found.push({ path: full, mtime: statSync(full).mtimeMs });
|
|
61
|
-
} catch {
|
|
62
|
-
// Vanished between the listing and the stat.
|
|
63
|
-
}
|
|
60
|
+
} else if (entry.isFile() && pattern.test(entry.name) && onFile(full)) {
|
|
61
|
+
return;
|
|
64
62
|
}
|
|
65
63
|
}
|
|
66
64
|
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Files under `dir` matching `pattern`, newest first, capped.
|
|
69
|
+
* Exported for resume.js, which reads the same stores to answer a different question.
|
|
70
|
+
*/
|
|
71
|
+
export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
72
|
+
const found = [];
|
|
73
|
+
walkStore(dir, pattern, depth, (path) => {
|
|
74
|
+
try {
|
|
75
|
+
found.push({ path, mtime: statSync(path).mtimeMs });
|
|
76
|
+
} catch {
|
|
77
|
+
// Vanished between the listing and the stat.
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
67
81
|
return found
|
|
68
82
|
.sort((a, b) => b.mtime - a.mtime)
|
|
69
83
|
.slice(0, MAX_CANDIDATES)
|
|
70
84
|
.map((entry) => entry.path);
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Whether the store holds anything matching at all, stopping at the first one it finds.
|
|
89
|
+
*
|
|
90
|
+
* The cheap half of the question above, and the only half resume.js asks of codex and opencode: they are
|
|
91
|
+
* reopened by their own flag rather than by id, so all that is wanted is whether there is a conversation
|
|
92
|
+
* there. Answering that by listing and stat-ing everything first is what would make a heavy opencode store -
|
|
93
|
+
* one file per message part - a pause on the way to a new session.
|
|
94
|
+
*/
|
|
95
|
+
export function hasAnyFile(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
96
|
+
let found = false;
|
|
97
|
+
walkStore(dir, pattern, depth, () => {
|
|
98
|
+
found = true;
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
/**
|
|
74
105
|
* The head of a file as text, or "" when it cannot be read.
|
|
75
106
|
*
|
|
@@ -196,6 +227,12 @@ export function claudeConversationId(session, stores) {
|
|
|
196
227
|
// rollout-<timestamp>-<uuid>.jsonl - the uuid is the conversation id codex resumes by.
|
|
197
228
|
export const CODEX_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
198
229
|
|
|
230
|
+
// The same rollout once codex has compressed it. Rollouts are compressed when they go cold, so a store whose
|
|
231
|
+
// history has all gone cold holds nothing matching the pattern above - which is why resume.js asks with this
|
|
232
|
+
// one instead. It is deliberately not what a live session is matched against below: compressing a rollout
|
|
233
|
+
// rewrites it, and the fresh mtime that leaves behind looks exactly like a session writing to it.
|
|
234
|
+
export const CODEX_ANY_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(\.zst)?$/;
|
|
235
|
+
|
|
199
236
|
/** Codex's conversation id for a session: the newest rollout file that could be this session's. */
|
|
200
237
|
export function codexConversationId(session, stores) {
|
|
201
238
|
for (const path of filesNewestFirst(stores.codexSessions, CODEX_ROLLOUT)) {
|
|
@@ -208,13 +245,20 @@ export function codexConversationId(session, stores) {
|
|
|
208
245
|
|
|
209
246
|
// --- OpenCode ----------------------------------------------------------------------------------------------------------------------------
|
|
210
247
|
|
|
248
|
+
// How far under opencode's store a session record sits. It nests everything under
|
|
249
|
+
// storage/session/{info,message,part}/, and only info/ holds one file per session - message/ and part/ hold
|
|
250
|
+
// one per message and one per message part, deeper down and far newer. So the bound is not a cost saving
|
|
251
|
+
// here but the whole of the answer: without it the newest .json in the store is a message part, and the pane
|
|
252
|
+
// would file a conversation's pages under an id that changes with every message.
|
|
253
|
+
const OPENCODE_SESSION_DEPTH = 1;
|
|
254
|
+
|
|
211
255
|
/**
|
|
212
256
|
* OpenCode's conversation id for a session: the newest session record in its local store that could be this
|
|
213
257
|
* session's. The id comes from the record when it carries one and from the filename otherwise, which is the
|
|
214
258
|
* same value - opencode names the file after the session.
|
|
215
259
|
*/
|
|
216
260
|
export function opencodeConversationId(session, stores) {
|
|
217
|
-
for (const path of filesNewestFirst(stores.opencodeSessions, /\.json
|
|
261
|
+
for (const path of filesNewestFirst(stores.opencodeSessions, /\.json$/, OPENCODE_SESSION_DEPTH)) {
|
|
218
262
|
if (mtimeOf(path) < session.since) continue;
|
|
219
263
|
const record = readJson(path);
|
|
220
264
|
const recorded = firstPathField(record);
|
|
@@ -127,6 +127,14 @@ export function createPane({ store, stores, sessions, onChange }) {
|
|
|
127
127
|
return { pages: store.list(keyFor(sid)), notice: noticeFor(sid) };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* How many pages of this session's pane nobody has opened. This is what a window hears about a session it
|
|
132
|
+
* is not driving: the pages themselves only ever reach the window that session belongs to.
|
|
133
|
+
*/
|
|
134
|
+
function unreadCount(sid) {
|
|
135
|
+
return store.unreadCount(keyFor(sid));
|
|
136
|
+
}
|
|
137
|
+
|
|
130
138
|
/** Publish into this session's pane. The page is new, so whoever is watching hears about it at once. */
|
|
131
139
|
function publish(sid, page) {
|
|
132
140
|
const result = store.publish(keyFor(sid), page);
|
|
@@ -152,5 +160,5 @@ export function createPane({ store, stores, sessions, onChange }) {
|
|
|
152
160
|
return bySession.has(sid);
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
return { discover, feedback, keyFor, knows, noticeFor, pages, publish, read, sweep };
|
|
163
|
+
return { discover, feedback, keyFor, knows, noticeFor, pages, publish, read, sweep, unreadCount };
|
|
156
164
|
}
|