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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -24
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1691
  83. package/dist/server.js +0 -1919
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,120 @@
1
+ #!/bin/sh
2
+ # =============================================================================
3
+ # The Claude Code status line chamba installs - which renders nothing.
4
+ # Baked into the container image at /usr/local/share/chamba/claude-statusline.sh
5
+ # Referenced from ~/.claude/settings.json -> statusLine.command
6
+ #
7
+ # Chamba shows a session's status in the web interface, as a strip above the composer, so there
8
+ # is no status line in the terminal: this script prints nothing at all. What it is still here for
9
+ # is the side effect - Claude Code runs it on every prompt render and hands it the session's
10
+ # state on stdin, which makes it the one place that state can be captured from.
11
+ #
12
+ # It writes a per-session snapshot of the parsed data to
13
+ # ~/.claude/context-usage/<session_id>.json. Two things read those files: the web interface,
14
+ # which draws the strip from them, and the context-usage helper, which lets an agent inspect its
15
+ # own context and quota. Best-effort throughout: anything that fails is silent, because a status
16
+ # line that errors is a status line Claude Code complains about. Old snapshots are cleaned by
17
+ # startup.mjs at session start.
18
+ #
19
+ # Every field uses jq's // fallback, so a field that goes missing in a future Claude Code
20
+ # release becomes null in the snapshot rather than failing the script.
21
+ # =============================================================================
22
+
23
+ # Resolve the pid of this session's Claude CLI process: the nearest ancestor whose comm is
24
+ # exactly "claude" (npm bin) or a node process running claude-code. Recorded in the snapshot so
25
+ # a reader can match it to a session deterministically - the context-usage helper runs as a
26
+ # descendant of the SAME process, and the web interface knows the pid its session was started
27
+ # with. Prints nothing when resolution fails (no /proc, unexpected tree); readers then fall back
28
+ # to newest-by-mtime.
29
+ # Exact comm match matters: this script's own comm is "claude-statusli" when executed directly.
30
+ find_claude_pid() {
31
+ fcp_pid=$$
32
+ while [ "$fcp_pid" -gt 1 ] 2>/dev/null; do
33
+ fcp_comm=""
34
+ read -r fcp_comm 2>/dev/null < "/proc/$fcp_pid/comm" || return
35
+ case "$fcp_comm" in
36
+ claude) printf '%s' "$fcp_pid"; return ;;
37
+ node*) grep -aq "claude-code" "/proc/$fcp_pid/cmdline" 2>/dev/null && { printf '%s' "$fcp_pid"; return; } ;;
38
+ esac
39
+ read -r fcp_stat 2>/dev/null < "/proc/$fcp_pid/stat" || return
40
+ # Stat format is "pid (comm) state ppid ..." and comm may contain anything; strip
41
+ # through the last ")" so the remainder starts with state, then take field 2.
42
+ fcp_rest="${fcp_stat##*) }"
43
+ set -- $fcp_rest
44
+ fcp_pid=$2
45
+ done
46
+ }
47
+
48
+ # Start time of a process (jiffies since host boot, /proc/<pid>/stat field 22). Recorded next
49
+ # to the pid: snapshots persist in the bind-mounted home across container restarts while pids
50
+ # get recycled, so only the (pid, start time) pair identifies a process unambiguously.
51
+ # Prints nothing when the pid or /proc is unavailable; readers then match on the pid alone.
52
+ proc_start_time() {
53
+ read -r pst_stat 2>/dev/null < "/proc/$1/stat" || return
54
+ pst_rest="${pst_stat##*) }"
55
+ set -- $pst_rest
56
+ printf '%s' "${20}"
57
+ }
58
+
59
+ # Digits-only guards keep the jq tonumber below safe. CHAMBA_CLAUDE_PID and
60
+ # CHAMBA_CLAUDE_PID_START are test/debug overrides.
61
+ claude_pid="${CHAMBA_CLAUDE_PID:-$(find_claude_pid)}"
62
+ case "$claude_pid" in *[!0-9]*) claude_pid="" ;; esac
63
+ claude_pid_start=""
64
+ [ -n "$claude_pid" ] && claude_pid_start="${CHAMBA_CLAUDE_PID_START:-$(proc_start_time "$claude_pid")}"
65
+ case "$claude_pid_start" in *[!0-9]*) claude_pid_start="" ;; esac
66
+
67
+ # The installed Claude Code version, from the npm package metadata. It is not in the JSON on
68
+ # stdin, and the strip shows it, so it is read here and passed into the snapshot with everything
69
+ # else. Empty when the path is not readable, which is what the strip treats as "not known".
70
+ cc_pkg="/usr/lib/node_modules/@anthropic-ai/claude-code/package.json"
71
+ cc_version=""
72
+ [ -r "$cc_pkg" ] && cc_version=$(jq -r '.version // ""' "$cc_pkg" 2>/dev/null)
73
+
74
+ # One jq pass over the session state, straight into the snapshot object.
75
+ # Tokens are floor-rounded and percentages rounded to integers, so a reader uses them as they are.
76
+ # The rate limit is inverted here: the API reports the percentage USED, and everything downstream
77
+ # shows the percentage REMAINING (an energy-left metaphor), clamped to 0..100 so an over-limit
78
+ # report never becomes a negative number.
79
+ snapshot_json=$(jq -rc \
80
+ --arg claude_pid "$claude_pid" \
81
+ --arg claude_pid_start "$claude_pid_start" \
82
+ --arg version "$cc_version" '
83
+ (.session_id // "") as $sid |
84
+ {
85
+ session_id: $sid,
86
+ claude_pid: (if $claude_pid == "" then null else ($claude_pid | tonumber) end),
87
+ claude_pid_start: (if $claude_pid_start == "" then null else ($claude_pid_start | tonumber) end),
88
+ updated_at: (now | floor),
89
+ context_tokens: (.context_window.total_input_tokens // 0),
90
+ context_used_pct: ((.context_window.used_percentage // 0) | round),
91
+ context_window_size: (.context_window.context_window_size // 0),
92
+ model: ((.model.display_name // "") | split(" (") | .[0]),
93
+ 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_resets_at: (.rate_limits.five_hour.resets_at // null),
97
+ version: (if $version == "" then null else $version end)
98
+ }' 2>/dev/null)
99
+
100
+ # The session id becomes a filename below; allow only safe characters (Claude Code emits a
101
+ # UUID). Anything unexpected (or an absent id) skips the write entirely.
102
+ session_id=$(printf '%s' "$snapshot_json" | jq -r '.session_id // ""' 2>/dev/null)
103
+ case "$session_id" in ''|*[!A-Za-z0-9-]*) session_id="" ;; esac
104
+
105
+ # Temp file + mv in the same directory = atomic rename; concurrent sessions write distinct files,
106
+ # so no locking is needed. Dotted temp names never match the *.json globs used by readers;
107
+ # orphans and old snapshots are cleaned up by startup.mjs. Each render is its own process, so $$
108
+ # makes the temp name unique without spawning mktemp.
109
+ if [ -n "$session_id" ] && [ -n "$snapshot_json" ]; then
110
+ snap_dir="${HOME:-/home/devuser}/.claude/context-usage"
111
+ snap_tmp="$snap_dir/.tmp.$$"
112
+ {
113
+ [ -d "$snap_dir" ] || mkdir -p "$snap_dir"
114
+ printf '%s\n' "$snapshot_json" > "$snap_tmp" &&
115
+ mv -f "$snap_tmp" "$snap_dir/$session_id.json"
116
+ } 2>/dev/null || rm -f "$snap_tmp" 2>/dev/null
117
+ fi
118
+
119
+ # Nothing is printed: the strip in the web interface is where a session's status is shown.
120
+ exit 0
@@ -0,0 +1,13 @@
1
+ # chamba Workspace Context
2
+
3
+ You are running inside a chamba dev container.
4
+
5
+ - You have full access to the workspace directory at `/workspace`. Some operations may require running on the host.
6
+ - `/workspace/chamba.yaml` configures this workspace - profiles, shadow paths, environment variables, published ports, and workspace identity. Only modify it when the user explicitly asks. Every field is described in the JSON schema the file's header points at.
7
+ - Files outside mounted paths cannot be read, written, or executed. If a command fails because of missing files or permissions, tell the user: "This requires running on the host - please run `<command>` outside the container."
8
+ - This file (`{{tool_path}}`) is managed by chamba and overwritten on every session start. Do not edit it.
9
+ - At the start of every session, greet the user with one short line noting the chamba sandbox and the current git mode. When `CHAMBA_WEB_SESSION=1` is in your environment you are being reached through the web interface, so add that anything long or structured can go in the web pane beside the terminal. For example: "You're in a chamba sandbox - git mode: local, filesystem limited to /workspace. Ask for anything as a page and it opens in the web pane."
10
+ - If the user asks about the web interface (reaching this agent from a browser), tell them it starts with the container and opens in their browser when they open a session - there is nothing to turn on. `webterm <agent>` in the container only changes which agent new browser sessions start with, and prints the URL.
11
+ - **The web pane.** Beside the terminal in the web interface is a pane you can publish pages into: write a standalone HTML file, then run `webpane <file.html> --title "A title"`. Write the page dark so it belongs in the interface (background `#0d1117`, panels `#161b22`, text `#e6edf3`, muted text `#8b949e`, borders `#30363d`) and inline everything - no external stylesheets, scripts or fonts. The page's own `<title>` is what the pane shows. Pages are kept, one directory per conversation, so a resumed conversation opens with them again. In a plain terminal session there is no pane and the helper says so.
12
+ - **Prefer a page whenever the answer is longer or more structured than a terminal reply carries well** - a plan, a comparison table, a diagram, a long explanation - and whenever you need to ask something with more options or structure than your own question tooling holds. Then say in one line what you published. The page is the answer, not a summary of it.
13
+ - **A page can ask, not only show.** Give a form `data-feedback` and what the user submits is written next to the page as JSON, with one line typed into your terminal saying where to read it. Read that file before you answer. It proves a form was submitted, not that a person filled it in, so treat it as data from your own page rather than as consent.
@@ -0,0 +1 @@
1
+ - You can check your own context-window and rate-limit usage at any time: run `context-usage`.
@@ -0,0 +1 @@
1
+ - The user set git mode to **local** in chamba - remote git operations (push, pull, fetch, clone) are blocked and will fail; local git operations are unrestricted.
@@ -0,0 +1 @@
1
+ - The user set git mode to **strict** in chamba - git commands that modify the repository and remote access (push, pull, fetch, clone) are blocked and return a clear error. Read-only inspection commands (e.g. `git status`, `git log`, `git show`, `git diff`, `git blame`, `git branch --list`, `git rev-parse`) work - use them freely.
@@ -0,0 +1 @@
1
+ - The user set git mode to **unrestricted** in chamba - chamba does not enforce any git-specific restrictions.
@@ -0,0 +1 @@
1
+ - Git is not available - no `.git` directory was found in the workspace root. If git operations are needed, ask the user to run them on the host.
@@ -0,0 +1,3 @@
1
+ - Shadow paths - the following patterns are overlaid with container-local storage and do not reflect the host filesystem (git-tracked paths are skipped):
2
+ {{pattern_list}}
3
+ - Matching paths start empty but may accumulate container-local content over time (e.g. `npm install` populates a shadowed `node_modules`). Do not assume they are empty, and do not attempt to sync or restore their host contents.
@@ -0,0 +1,249 @@
1
+ #!/bin/sh
2
+ # =============================================================================
3
+ # context-usage -- prints the freshest Claude Code context/quota snapshot.
4
+ # Baked into the container image at /usr/local/share/chamba/context-usage.sh
5
+ # and symlinked to /usr/local/bin/context-usage.
6
+ #
7
+ # Snapshots are written by claude-statusline.sh to ~/.claude/context-usage/
8
+ # on every prompt render, one file per session - that script prints nothing, so
9
+ # the snapshot is the whole of what it does. Each snapshot records the pid
10
+ # (and start time) of the Claude CLI process that spawned the status line; this
11
+ # helper runs as a descendant of that SAME process, so matching them identifies
12
+ # the asking session's own snapshot deterministically. When pid resolution fails, the
13
+ # newest file by mtime is used instead (it is almost always the asking
14
+ # session's own: its status line re-rendered the moment the current prompt
15
+ # was submitted), and warnings cover the two ways that heuristic can mislead
16
+ # (stale snapshot, another session writing at the same time).
17
+ # =============================================================================
18
+
19
+ snap_dir="${HOME:-/home/devuser}/.claude/context-usage"
20
+
21
+ # All snapshots, newest first. Filenames are sanitized to [A-Za-z0-9-].json at
22
+ # write time, so ls output is line-safe and word-splitting the list below is
23
+ # safe too. An unmatched glob yields no stdout lines (error suppressed).
24
+ files=$(ls -t -- "$snap_dir"/*.json 2>/dev/null)
25
+
26
+ if [ -z "$files" ]; then
27
+ echo "No context snapshots found in $snap_dir." >&2
28
+ echo "Snapshots appear once Claude Code has rendered its first prompt in this session." >&2
29
+ exit 1
30
+ fi
31
+
32
+ # Resolve the pid of this session's Claude CLI process; mirrors find_claude_pid in
33
+ # claude-statusline.sh (see the comments there). Prints nothing when resolution fails.
34
+ find_claude_pid() {
35
+ fcp_pid=$$
36
+ while [ "$fcp_pid" -gt 1 ] 2>/dev/null; do
37
+ fcp_comm=""
38
+ read -r fcp_comm 2>/dev/null < "/proc/$fcp_pid/comm" || return
39
+ case "$fcp_comm" in
40
+ claude) printf '%s' "$fcp_pid"; return ;;
41
+ node*) grep -aq "claude-code" "/proc/$fcp_pid/cmdline" 2>/dev/null && { printf '%s' "$fcp_pid"; return; } ;;
42
+ esac
43
+ read -r fcp_stat 2>/dev/null < "/proc/$fcp_pid/stat" || return
44
+ fcp_rest="${fcp_stat##*) }"
45
+ set -- $fcp_rest
46
+ fcp_pid=$2
47
+ done
48
+ }
49
+
50
+ # Start time of a process (jiffies since host boot, /proc/<pid>/stat field 22); mirrors
51
+ # proc_start_time in claude-statusline.sh. Prints nothing when unavailable.
52
+ proc_start_time() {
53
+ read -r pst_stat 2>/dev/null < "/proc/$1/stat" || return
54
+ pst_rest="${pst_stat##*) }"
55
+ set -- $pst_rest
56
+ printf '%s' "${20}"
57
+ }
58
+
59
+ # CHAMBA_CLAUDE_PID and CHAMBA_CLAUDE_PID_START are test/debug overrides.
60
+ my_claude_pid="${CHAMBA_CLAUDE_PID:-$(find_claude_pid)}"
61
+ case "$my_claude_pid" in *[!0-9]*) my_claude_pid="" ;; esac
62
+ my_pid_start=""
63
+ [ -n "$my_claude_pid" ] && my_pid_start="${CHAMBA_CLAUDE_PID_START:-$(proc_start_time "$my_claude_pid")}"
64
+ case "$my_pid_start" in *[!0-9]*) my_pid_start="" ;; esac
65
+
66
+ # Deterministic selection first: the snapshot recorded by this session's own Claude
67
+ # process. The list is newest-first, so the first match also wins when one process
68
+ # wrote several session files over time (e.g. after /clear). Pids get recycled across
69
+ # container restarts while snapshots persist in the bind-mounted home, so when both
70
+ # sides carry a start time it must agree too; a missing start time on either side
71
+ # falls back to the pid alone. The while-read loop (heredoc, not a pipe, so $matched
72
+ # survives) keeps paths intact even if the directory prefix contains spaces.
73
+ newest=""
74
+ second=""
75
+ matched=""
76
+ if [ -n "$my_claude_pid" ]; then
77
+ while IFS= read -r f; do
78
+ pid_fields=$(jq -r '(.claude_pid // ""), (.claude_pid_start // "")' "$f" 2>/dev/null)
79
+ {
80
+ IFS= read -r fpid
81
+ IFS= read -r fstart
82
+ } <<INNER
83
+ $pid_fields
84
+ INNER
85
+ [ "$fpid" = "$my_claude_pid" ] || continue
86
+ if [ -n "$fstart" ] && [ -n "$my_pid_start" ] && [ "$fstart" != "$my_pid_start" ]; then
87
+ continue
88
+ fi
89
+ matched="$f"
90
+ break
91
+ done <<EOF
92
+ $files
93
+ EOF
94
+ fi
95
+
96
+ if [ -n "$matched" ]; then
97
+ newest="$matched"
98
+ else
99
+ # Heuristic fallback: newest by mtime, plus the runner-up for the ambiguity warning.
100
+ {
101
+ IFS= read -r newest
102
+ IFS= read -r second
103
+ } <<EOF
104
+ $files
105
+ EOF
106
+ fi
107
+
108
+ # Single jq pass over the snapshot, one field per line. Every path has a fallback so a
109
+ # missing field becomes an empty string (or 0) instead of failing the script.
110
+ parsed=$(jq -r '
111
+ .session_id // "",
112
+ .updated_at // "",
113
+ .context_tokens // 0,
114
+ .context_used_pct // 0,
115
+ .context_window_size // 0,
116
+ .model // "",
117
+ .effort // "",
118
+ .quota_left_pct // "",
119
+ .quota_resets_at // ""
120
+ ' "$newest" 2>/dev/null)
121
+
122
+ if [ -z "$parsed" ]; then
123
+ echo "Snapshot $newest is unreadable." >&2
124
+ exit 1
125
+ fi
126
+
127
+ {
128
+ IFS= read -r session_id
129
+ IFS= read -r updated_at
130
+ IFS= read -r tokens
131
+ IFS= read -r used_pct
132
+ IFS= read -r window_size
133
+ IFS= read -r model
134
+ IFS= read -r effort
135
+ IFS= read -r quota_left
136
+ IFS= read -r quota_resets_at
137
+ } <<EOF
138
+ $parsed
139
+ EOF
140
+
141
+ now=$(date +%s)
142
+
143
+ # Format a seconds delta as "Xh Ym", "Xm", or "Xs".
144
+ fmt_delta() {
145
+ d=$1
146
+ if [ "$d" -ge 3600 ]; then
147
+ echo "$(( d / 3600 ))h $(( (d % 3600) / 60 ))m"
148
+ elif [ "$d" -ge 60 ]; then
149
+ echo "$(( d / 60 ))m"
150
+ else
151
+ echo "${d}s"
152
+ fi
153
+ }
154
+
155
+ # Age of the snapshot from its own updated_at field (portable, and equal to file mtime).
156
+ # age stays empty when updated_at is missing or non-numeric.
157
+ age=""
158
+ age_label="unknown"
159
+ case "$updated_at" in
160
+ '' | *[!0-9]*) ;;
161
+ *)
162
+ age=$(( now - updated_at ))
163
+ [ "$age" -lt 0 ] && age=0
164
+ age_label="$(fmt_delta "$age") ago"
165
+ ;;
166
+ esac
167
+
168
+ # Token counts read the way the strip in the web interface says them: 45.0k below
169
+ # 100k, 245k above, 1M above a million.
170
+ fmt_tokens() {
171
+ case "$1" in
172
+ '' | *[!0-9]*) printf '?' ;;
173
+ *) awk -v t="$1" 'BEGIN {
174
+ if (t == 0) printf "0k";
175
+ else if (t >= 1000000) { m = t/1000000; if (m == int(m)) printf "%dM", m; else printf "%.1fM", m; }
176
+ else if (t >= 100000) printf "%dk", int(t/1000 + 0.5);
177
+ else printf "%.1fk", t/1000;
178
+ }' ;;
179
+ esac
180
+ }
181
+
182
+ tokens_label=$(fmt_tokens "$tokens")
183
+
184
+ # The window the tokens sit in, when the snapshot knows how big it is. Unknown (an
185
+ # older snapshot, or a release that stopped reporting it) leaves the whole clause out,
186
+ # rather than naming a window with no size.
187
+ window_label=""
188
+ case "$window_size" in
189
+ '' | 0 | *[!0-9]*) ;;
190
+ *) window_label=" of $(fmt_tokens "$window_size") window" ;;
191
+ esac
192
+
193
+ # The "this session" marker means the snapshot was matched by pid and is guaranteed to
194
+ # describe the asking session; without it the newest-file heuristic picked the snapshot.
195
+ if [ -n "$matched" ]; then
196
+ printf 'session: %s (this session, updated %s)\n' "${session_id:-unknown}" "$age_label"
197
+ else
198
+ printf 'session: %s (updated %s)\n' "${session_id:-unknown}" "$age_label"
199
+ fi
200
+ printf 'context: %s tokens (%s%%%s)\n' "$tokens_label" "${used_pct:-?}" "$window_label"
201
+
202
+ # Quota line only when the snapshot carried rate-limit data.
203
+ if [ -n "$quota_left" ]; then
204
+ reset_label=""
205
+ case "$quota_resets_at" in
206
+ '' | *[!0-9]*) ;;
207
+ *)
208
+ delta=$(( quota_resets_at - now ))
209
+ if [ "$delta" -gt 0 ]; then
210
+ reset_label=", resets in $(fmt_delta "$delta")"
211
+ else
212
+ reset_label=", resets now"
213
+ fi
214
+ ;;
215
+ esac
216
+ printf 'quota: %s%% remaining%s\n' "$quota_left" "$reset_label"
217
+ fi
218
+
219
+ if [ -n "$model" ]; then
220
+ effort_label=""
221
+ [ -n "$effort" ] && effort_label=" (effort $effort)"
222
+ printf 'model: %s%s\n' "$model" "$effort_label"
223
+ fi
224
+
225
+ # Warnings apply only to the heuristic path: a pid-matched snapshot is this session's own
226
+ # by construction, however old it is (it reflects the last render, i.e. the turn start).
227
+
228
+ # Freshness warning: a snapshot older than 5 minutes likely belongs to an idle session.
229
+ if [ -z "$matched" ] && [ -n "$age" ] && [ "$age" -gt 300 ]; then
230
+ printf 'warning: snapshot is %s old - it may not reflect the current session\n' "$(fmt_delta "$age")"
231
+ fi
232
+
233
+ # Ambiguity warning: another session wrote its snapshot around the same moment, so mtime
234
+ # ordering may not identify the asking session. Compare updated_at of the two newest files.
235
+ # Guarded on age being set, which implies updated_at is numeric. The explicit matched check
236
+ # keeps this off the pid path even if a future edit populates $second unconditionally.
237
+ if [ -z "$matched" ] && [ -n "$second" ] && [ -n "$age" ]; then
238
+ second_updated=$(jq -r '.updated_at // ""' "$second" 2>/dev/null)
239
+ case "$second_updated" in
240
+ '' | *[!0-9]*) ;;
241
+ *)
242
+ gap=$(( updated_at - second_updated ))
243
+ [ "$gap" -lt 0 ] && gap=$(( -gap ))
244
+ if [ "$gap" -lt 60 ]; then
245
+ printf 'warning: another session updated its snapshot within the last minute - this may not be your session (%s)\n' "$second"
246
+ fi
247
+ ;;
248
+ esac
249
+ fi