totopo 3.5.1 → 3.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/README.md CHANGED
@@ -194,10 +194,10 @@ totopo keeps all three CLIs on their latest published versions, checking for upd
194
194
  For convenience, every Claude session opens with a status line at the bottom of the terminal:
195
195
 
196
196
  ```
197
- 174k / 1M (17%) · Opus 4.7 high · Claude Code v2.1.132 · ▓░░░░░░░░░ (12% used, resets in 3 hr 35 min)
197
+ 174k (17%) · Opus 4.7 (1M context) xhigh · 5h limit ▓░░░░░░░░░ (resets in 2h 15m) · Claude Code v2.1.132
198
198
  ```
199
199
 
200
- Four segments: current context usage (count / window / percentage), model and reasoning effort, the installed Claude Code CLI version with a freshness hint that escalates as the install ages, and a 10-block gauge of the 5-hour rate-limit window with current usage and time-to-reset (subscriber accounts only). The line stays on a calm grey baseline and only escalates to yellow or red when something genuinely warrants attention. Ask Claude `/totopo-statusline` to customize or restore the default.
200
+ Four segments: current context usage (count and percentage), the model display name as provided by Claude Code followed by reasoning effort in purple, a 10-block gauge of the 5-hour rate-limit window with a relative countdown to reset (subscriber accounts only), and the installed Claude Code CLI version with a freshness hint that escalates as the install ages. The line stays on a calm grey baseline and only escalates to yellow or red when something genuinely warrants attention. Ask Claude `/totopo-statusline` to customize or restore the default.
201
201
 
202
202
  ### Persistent Agent Memory
203
203
 
@@ -9,7 +9,7 @@ import { join, relative } from "node:path";
9
9
  import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
10
10
  import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
11
11
  import { CONTAINER_STARTUP, CONTAINER_WORKSPACE, GIT_MODE, LABEL_GIT_MODE, LABEL_MANAGED, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, PROFILE, RUNTIME_ENV, } from "../lib/constants.js";
12
- import { buildDockerfile, buildImageWithTempfile } from "../lib/dockerfile-builder.js";
12
+ import { buildDockerfile, buildImageWithTempfile, computeBuildHash } from "../lib/dockerfile-builder.js";
13
13
  import { isImageStale } from "../lib/migrate-to-latest.js";
14
14
  import { buildPnpmStoreMountArgs } from "../lib/pnpm-store.js";
15
15
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
@@ -299,7 +299,9 @@ export async function run(packageDir, ctx, options) {
299
299
  };
300
300
  startContainer(containerOpts);
301
301
  // --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
302
- let stale = isImageStale(containerName);
302
+ const dockerfileContent = buildDockerfile(join(templatesDir, "Dockerfile"), profileHook);
303
+ const expectedBuildHash = computeBuildHash(dockerfileContent, templatesDir);
304
+ let stale = isImageStale(containerName, expectedBuildHash);
303
305
  if (stale) {
304
306
  log.warn("totopo's latest release includes an updated container image.\n Please rebuild to update — this will not affect agent memory, settings, or your data.");
305
307
  const rebuild = await confirm({
@@ -38,6 +38,7 @@ export const LABEL_SHADOWS = "totopo.shadows";
38
38
  export const LABEL_PROFILE = "totopo.profile";
39
39
  export const LABEL_RUNTIME_ENV = "totopo.runtime-env";
40
40
  export const LABEL_GIT_MODE = "totopo.git-mode";
41
+ export const LABEL_BUILD_HASH = "totopo.build-hash";
41
42
  // Built-in profile names (must match keys in buildDefaultTotopoYaml in totopo-yaml.ts)
42
43
  export const PROFILE = {
43
44
  default: "default",
@@ -52,10 +53,12 @@ import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "../../templates/
52
53
  export { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE };
53
54
  export const GIT_MODES = Object.values(GIT_MODE);
54
55
  // Runtime env vars injected into every container via docker run -e.
55
- // DISABLE_AUTOUPDATER suppresses Claude Code's in-process auto-updater, which always fails inside
56
- // the container (devuser can't write to the root-owned global npm prefix). totopo's startup script
57
- // already keeps Claude on the latest version - see templates/startup.mjs.
56
+ // Each flag suppresses a Claude Code feature that is inapplicable or disruptive inside the container.
58
57
  export const RUNTIME_ENV = {
59
58
  CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1",
60
- DISABLE_AUTOUPDATER: "1",
59
+ DISABLE_AUTOUPDATER: "1", // In-process updater fails (root-owned prefix); startup.mjs handles updates
60
+ DISABLE_ERROR_REPORTING: "1", // Container errors include sandbox paths not useful to Anthropic
61
+ DISABLE_INSTALLATION_CHECKS: "1", // npm install is by design; native installer is not applicable
62
+ DISABLE_TELEMETRY: "1", // Container sessions should not phone home
63
+ DISABLE_UPGRADE_COMMAND: "1", // /upgrade is wrong path inside container; totopo manages CLI version
61
64
  };
@@ -3,11 +3,11 @@
3
3
  // Combines base template + profile hook + USER instruction at build time
4
4
  // =========================================================================================================================================
5
5
  import { spawnSync } from "node:child_process";
6
- import { randomBytes } from "node:crypto";
7
- import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
- import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_MANAGED } from "./constants.js";
10
+ import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_BUILD_HASH, LABEL_MANAGED } from "./constants.js";
11
11
  // --- User shell config appended after USER instruction -----------------------------------------------------------------------------------
12
12
  const USER_SHELL_CONFIG = `
13
13
  # ---------------------------------------------------------------------------
@@ -42,6 +42,40 @@ export function buildDockerfile(baseTemplatePath, profileHook) {
42
42
  content += USER_SHELL_CONFIG;
43
43
  return content;
44
44
  }
45
+ // --- Build hash for image staleness detection --------------------------------------------------------------------------------------------
46
+ // Filenames (relative to the templates dir) of every artifact that the Dockerfile bakes into the image
47
+ // via COPY. Edits to any of these change the build hash and trigger a rebuild prompt at session start.
48
+ // Kept in sync with templates/Dockerfile by the bidirectional test in tests/dockerfile-builder.test.ts.
49
+ export const BAKED_TEMPLATE_FILES = [
50
+ "claude-statusline.sh",
51
+ "git-readonly-wrapper.mjs",
52
+ "npmrc",
53
+ "runtime-constants.mjs",
54
+ "startup-git-mode.mjs",
55
+ "startup.mjs",
56
+ ];
57
+ /**
58
+ * Fingerprint everything the package contributes to the image: the assembled Dockerfile content
59
+ * plus every file in BAKED_TEMPLATE_FILES, hashed in deterministic order.
60
+ *
61
+ * Tolerates missing files in buildContextDir. In production (real package install) all baked files
62
+ * exist, so the existsSync branch never fires. In tests that build minimal images from a temp
63
+ * contextDir, missing files naturally produce a different hash than production - exactly the
64
+ * contract the staleness tests rely on.
65
+ */
66
+ export function computeBuildHash(dockerfileContent, buildContextDir) {
67
+ const h = createHash("sha256");
68
+ h.update("dockerfile:\n");
69
+ h.update(dockerfileContent);
70
+ for (const name of [...BAKED_TEMPLATE_FILES].sort()) {
71
+ h.update(`\nfile:${name}\n`);
72
+ const path = join(buildContextDir, name);
73
+ if (existsSync(path)) {
74
+ h.update(readFileSync(path));
75
+ }
76
+ }
77
+ return h.digest("hex");
78
+ }
45
79
  // --- Build image with temp file ----------------------------------------------------------------------------------------------------------
46
80
  /**
47
81
  * Write Dockerfile content to a temp file, run docker build, then clean up.
@@ -51,7 +85,18 @@ export function buildImageWithTempfile(dockerfileContent, buildContextDir, image
51
85
  const tmpFile = join(tmpdir(), `${CONTAINER_NAME_PREFIX}Dockerfile-${randomBytes(8).toString("hex")}`);
52
86
  try {
53
87
  writeFileSync(tmpFile, dockerfileContent);
54
- const buildArgs = ["build", "--label", `${LABEL_MANAGED}=true`, "-f", tmpFile, "-t", imageName];
88
+ const buildHash = computeBuildHash(dockerfileContent, buildContextDir);
89
+ const buildArgs = [
90
+ "build",
91
+ "--label",
92
+ `${LABEL_MANAGED}=true`,
93
+ "--label",
94
+ `${LABEL_BUILD_HASH}=${buildHash}`,
95
+ "-f",
96
+ tmpFile,
97
+ "-t",
98
+ imageName,
99
+ ];
55
100
  if (noCache)
56
101
  buildArgs.push("--no-cache");
57
102
  buildArgs.push(buildContextDir);
@@ -22,13 +22,12 @@
22
22
  // All migrations are idempotent - each checks if needed and skips if not.
23
23
  // =========================================================================================================================================
24
24
  import { spawnSync } from "node:child_process";
25
- import { createHash } from "node:crypto";
26
25
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
27
26
  import { homedir } from "node:os";
28
27
  import { join } from "node:path";
29
28
  import { confirm, isCancel, log, note } from "@clack/prompts";
30
29
  import { load as loadYaml } from "js-yaml";
31
- import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_STARTUP, GIT_MODE, GIT_WRAPPER_SOURCE, LOCK_FILE, PACKAGE_ROOT, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
30
+ import { AGENTS_DIR, GIT_MODE, LABEL_BUILD_HASH, LOCK_FILE, PROFILE, SHADOWS_DIR, TOTOPO_DIR, TOTOPO_YAML, WORKSPACES_DIR, } from "./constants.js";
32
31
  import { safeRmSync } from "./safe-rm.js";
33
32
  import { buildDefaultTotopoYaml, readTotopoYaml, slugifyForWorkspaceId, validateWorkspaceId, writeTotopoYaml, } from "./totopo-yaml.js";
34
33
  import { findTotopoYamlDir, getWorkspacesBaseDir, initWorkspaceDir, LOCK_KEYS } from "./workspace-identity.js";
@@ -504,54 +503,34 @@ export async function runMigration(cwd, skipAnyConfirmations = true) {
504
503
  }
505
504
  // =========================================================================================================================================
506
505
  // Image staleness detection
507
- // Called at session start to detect outdated container images that need rebuilding.
508
- // Add new conditions here when the base image changes in future releases.
506
+ //
507
+ // Called at session start; returns true if the running container's image is older than the current
508
+ // package and needs a rebuild prompt.
509
+ //
510
+ // Mechanism: at build time, dockerfile-builder.ts stamps every image with a totopo.build-hash label
511
+ // that fingerprints the assembled Dockerfile + every baked template file. At session start we
512
+ // recompute the expected hash from current package sources and compare. Any change to
513
+ // templates/Dockerfile, the active profile hook, or any baked template file produces a different
514
+ // hash -> rebuild prompt fires.
515
+ //
516
+ // When shipping a new bake-time artifact:
517
+ // - Editing an existing template file or the Dockerfile -> auto-detected. No action.
518
+ // - Adding a NEW templated COPY -> add the filename to BAKED_TEMPLATE_FILES in
519
+ // dockerfile-builder.ts. The unit test in tests/dockerfile-builder.test.ts will fail until
520
+ // you do.
521
+ //
522
+ // Cosmetic Dockerfile edits (comment-only, whitespace) DO trigger a rebuild for users on prior
523
+ // images. This is intentional: the Dockerfile and template files are rarely edited, so any change
524
+ // is treated as meaningful. The release skill warns when a release contains diffs to these files
525
+ // that look cosmetic, so the author can decide whether the rebuild cost is worth it.
509
526
  // =========================================================================================================================================
510
- /** Check if a running container's image is stale (missing expected files/features). */
511
- export function isImageStale(containerName) {
512
- // v3.2.0: startup.mjs replaced post-start.mjs + update-ai-clis.mjs
513
- const startupCheck = spawnSync("docker", ["exec", containerName, "test", "-f", CONTAINER_STARTUP], { stdio: "pipe" });
514
- if (startupCheck.status !== 0)
515
- return true;
516
- // v3.3.0: file added to the base image for artifact inspection
517
- const fileCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/file"], { stdio: "pipe" });
518
- if (fileCheck.status !== 0)
519
- return true;
520
- // v3.3.0: bubblewrap added for Codex sandboxing prerequisites
521
- const bubblewrapCheck = spawnSync("docker", ["exec", containerName, "test", "-x", "/usr/bin/bwrap"], { stdio: "pipe" });
522
- if (bubblewrapCheck.status !== 0)
523
- return true;
524
- // v3.4.0: git read-only wrapper baked in for strict git mode
525
- const wrapperCheck = spawnSync("docker", ["exec", containerName, "test", "-x", GIT_WRAPPER_SOURCE], {
526
- stdio: "pipe",
527
- });
528
- if (wrapperCheck.status !== 0)
529
- return true;
530
- // v3.4.0: runtime-constants module imported by startup-git-mode.mjs
531
- const constantsCheck = spawnSync("docker", ["exec", containerName, "test", "-f", "/home/devuser/runtime-constants.mjs"], {
527
+ /** Returns true if the container's stamped totopo.build-hash label does not match the expected hash. */
528
+ export function isImageStale(containerName, expectedBuildHash) {
529
+ const result = spawnSync("docker", ["inspect", "--format", `{{ index .Config.Labels "${LABEL_BUILD_HASH}" }}`, containerName], {
530
+ encoding: "utf8",
532
531
  stdio: "pipe",
533
532
  });
534
- if (constantsCheck.status !== 0)
535
- return true;
536
- // v3.5.1: explicit pnpm store-dir baked into ~/.npmrc to prevent pnpm's volume-check
537
- // relocation, which would otherwise create .pnpm-store at the repo root.
538
- const npmrcCheck = spawnSync("docker", ["exec", containerName, "grep", "-q", "^store-dir=", "/home/devuser/.npmrc"], { stdio: "pipe" });
539
- if (npmrcCheck.status !== 0)
533
+ if (result.status !== 0)
540
534
  return true;
541
- // SHA-256 of the host claude-statusline.sh vs the file inside the container - any refinement to
542
- // the shipped script triggers an automatic rebuild prompt on next session.
543
- const hostScriptPath = join(PACKAGE_ROOT, "templates", "claude-statusline.sh");
544
- if (existsSync(hostScriptPath)) {
545
- const expectedHash = createHash("sha256").update(readFileSync(hostScriptPath)).digest("hex");
546
- const sumResult = spawnSync("docker", ["exec", containerName, "sha256sum", CLAUDE_STATUSLINE_PATH], {
547
- encoding: "utf8",
548
- stdio: "pipe",
549
- });
550
- if (sumResult.status !== 0)
551
- return true;
552
- const actualHash = sumResult.stdout.trim().split(/\s+/)[0] ?? "";
553
- if (actualHash !== expectedHash)
554
- return true;
555
- }
556
- return false;
535
+ return result.stdout.trim() !== expectedBuildHash;
557
536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "3.5.1",
3
+ "version": "3.6.0",
4
4
  "description": "Run AI coding agents safely in your local codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,16 +87,16 @@ RUN groupadd --gid 1001 devuser && \
87
87
  useradd --uid 1001 --gid devuser --shell /bin/bash --create-home devuser && \
88
88
  mkdir -p /home/devuser/.local/state /home/devuser/.local/share /home/devuser/.local/share/pnpm /home/devuser/.local/bin && \
89
89
  date -u +"%Y-%m-%dT%H:%M:%S.000Z" > /home/devuser/.ai-cli-updated && \
90
- printf 'store-dir=/home/devuser/.local/share/pnpm/store\npackage-import-method=copy\n' > /home/devuser/.npmrc && \
91
90
  chown -R devuser:devuser /home/devuser
92
91
 
93
92
  # ---------------------------------------------------------------------------
94
93
  # Layer 9 — Bake startup script + git mode helper + shared constants into image
95
94
  # ---------------------------------------------------------------------------
95
+ COPY npmrc /home/devuser/.npmrc
96
96
  COPY startup.mjs /home/devuser/startup.mjs
97
97
  COPY startup-git-mode.mjs /home/devuser/startup-git-mode.mjs
98
98
  COPY runtime-constants.mjs /home/devuser/runtime-constants.mjs
99
- RUN chown devuser:devuser /home/devuser/startup.mjs /home/devuser/startup-git-mode.mjs /home/devuser/runtime-constants.mjs
99
+ RUN chown devuser:devuser /home/devuser/.npmrc /home/devuser/startup.mjs /home/devuser/startup-git-mode.mjs /home/devuser/runtime-constants.mjs
100
100
 
101
101
  # ---------------------------------------------------------------------------
102
102
  # Layer 10 — Bake git read-only wrapper into image
@@ -5,10 +5,10 @@
5
5
  # Referenced from ~/.claude/settings.json -> statusLine.command
6
6
  #
7
7
  # Render pattern (4 segments separated by mid-dot):
8
- # 1. <tokens>k / <ctx> (<pct>%)
9
- # 2. <model> <effort>
10
- # 3. Claude Code v<version> (<age>[, hint])
11
- # 4. <bar> (<rate>% used, resets in <hr> hr <min> min)
8
+ # 1. <tokens>k (<pct>%)
9
+ # 2. <model_full> <effort>
10
+ # 3. <bar> (resets in <Xh Ym>)
11
+ # 4. Claude Code v<version> (<age>[, hint])
12
12
  #
13
13
  # Designed to degrade gracefully: every field uses jq's // fallback, and any field that goes
14
14
  # missing in a future Claude Code release is silently skipped rather than failing the script.
@@ -24,9 +24,7 @@
24
24
  parsed=$(jq -r '
25
25
  .model.display_name // "",
26
26
  ((.context_window.used_percentage // 0) | round),
27
- (((.context_window.current_usage.input_tokens // 0)
28
- + (.context_window.current_usage.cache_creation_input_tokens // 0)
29
- + (.context_window.current_usage.cache_read_input_tokens // 0)) | floor),
27
+ (.context_window.total_input_tokens // 0),
30
28
  .effort.level // "",
31
29
  (.rate_limits.five_hour.used_percentage | if . == null then "" else round end),
32
30
  (.rate_limits.five_hour.resets_at // "")
@@ -43,30 +41,24 @@ parsed=$(jq -r '
43
41
  $parsed
44
42
  EOF
45
43
 
46
- # Split "Opus 4.7 (1M context)" into model "Opus 4.7" + ctx_size "1M" via POSIX parameter expansion.
47
- ctx_size=""
48
- model_display="$model_full"
49
- case "$model_full" in
50
- *"("*")"*)
51
- inside=${model_full#*\(}
52
- inside=${inside%%\)*}
53
- ctx_size=${inside% context}
54
- model_display=${model_full%% \(*}
55
- ;;
56
- esac
44
+ # Capture "now" once for both the rate-limit countdown and the Claude Code freshness check.
45
+ # Status line runs on every prompt render, so avoid spawning `date` twice.
46
+ now_epoch=$(date +%s)
57
47
 
58
48
  # Colors
59
49
  GREEN='\033[32m'
60
50
  YELLOW='\033[33m'
61
51
  RED='\033[31m'
62
52
  BLUE='\033[34m'
53
+ PURPLE='\033[38;5;177m'
63
54
  GREY='\033[90m'
64
55
  GREY_LIGHT='\033[38;5;245m'
65
56
  RESET='\033[0m'
66
57
 
67
- # Normalize tokens & pct (may arrive empty when current_usage is null).
68
- [ -z "$used_tokens" ] && used_tokens=0
69
- [ -z "$used_pct" ] && used_pct=0
58
+ # Normalize tokens & pct to integers (may arrive empty, non-numeric, or fractional when
59
+ # current_usage is null or the JSON schema changes in a future Claude Code release).
60
+ case "$used_tokens" in ''|*[!0-9]*) used_tokens=0 ;; esac
61
+ case "$used_pct" in ''|*[!0-9]*) used_pct=0 ;; esac
70
62
 
71
63
  # Token color by absolute count: <100k green, 100k-500k yellow, >500k red.
72
64
  if [ "$used_tokens" -lt 100000 ]; then
@@ -114,47 +106,35 @@ awk_out=$(awk -v t="$used_tokens" -v r="$rate_pct" 'BEGIN {
114
106
  $awk_out
115
107
  EOF
116
108
 
117
- # Bar color by rate-limit usage: <50% light grey, <80% yellow, >=80% red.
118
- # Light grey at the calm baseline keeps the line quiet until usage actually warrants attention.
109
+ # Bar color by rate-limit usage: <50% light grey (calm baseline), 50-79% yellow, >=80% red.
110
+ # Light grey keeps the line quiet until usage actually warrants attention.
119
111
  bar_color="$GREY_LIGHT"
120
- if [ -n "$rate_pct" ]; then
121
- if [ "$rate_pct" -lt 50 ]; then
122
- bar_color="$GREY_LIGHT"
123
- elif [ "$rate_pct" -lt 80 ]; then
112
+ if [ -n "$rate_pct" ] && [ "$rate_pct" -ge 50 ]; then
113
+ if [ "$rate_pct" -lt 80 ]; then
124
114
  bar_color="$YELLOW"
125
115
  else
126
116
  bar_color="$RED"
127
117
  fi
128
118
  fi
129
119
 
130
- # Time until the rate-limit window resets, formatted as "X hr Y min" / "Y min" / "X day Y hr".
120
+ # Relative countdown until the rate-limit window resets (e.g. "2h 15m", "43m").
121
+ # Uses a delta from now so the display is correct regardless of container timezone.
131
122
  # Skipped silently when resets_at is missing or non-numeric (forward compat with future schemas).
132
123
  reset_label=""
133
124
  case "$rate_resets_at" in
134
125
  '' | *[!0-9]*) ;;
135
126
  *)
136
- now=$(date +%s)
137
- secs=$((rate_resets_at - now))
138
- [ "$secs" -lt 60 ] && secs=60
139
- total_min=$(((secs + 30) / 60))
140
- if [ "$total_min" -lt 60 ]; then
141
- reset_label="${total_min} min"
142
- elif [ "$total_min" -lt 1440 ]; then
143
- hr=$((total_min / 60))
144
- min=$((total_min % 60))
145
- if [ "$min" -eq 0 ]; then
146
- reset_label="${hr} hr"
127
+ delta=$(( rate_resets_at - now_epoch ))
128
+ if [ "$delta" -gt 0 ]; then
129
+ delta_h=$(( delta / 3600 ))
130
+ delta_m=$(( (delta % 3600) / 60 ))
131
+ if [ "$delta_h" -gt 0 ]; then
132
+ reset_label="${delta_h}h ${delta_m}m"
147
133
  else
148
- reset_label="${hr} hr ${min} min"
134
+ reset_label="${delta_m}m"
149
135
  fi
150
136
  else
151
- day=$((total_min / 1440))
152
- hr=$(((total_min % 1440) / 60))
153
- if [ "$hr" -eq 0 ]; then
154
- reset_label="${day} day"
155
- else
156
- reset_label="${day} day ${hr} hr"
157
- fi
137
+ reset_label="now"
158
138
  fi
159
139
  ;;
160
140
  esac
@@ -177,8 +157,7 @@ if [ -r "$cc_ts_file" ]; then
177
157
  if [ -n "$cc_iso" ]; then
178
158
  cc_secs=$(date -d "$cc_iso" +%s 2>/dev/null)
179
159
  if [ -n "$cc_secs" ]; then
180
- cc_now=$(date +%s)
181
- cc_age_days=$(( (cc_now - cc_secs) / 86400 ))
160
+ cc_age_days=$(( (now_epoch - cc_secs) / 86400 ))
182
161
  [ "$cc_age_days" -lt 0 ] && cc_age_days=0
183
162
  fi
184
163
  fi
@@ -216,28 +195,29 @@ fi
216
195
  SEP=" ${GREY}·${RESET} "
217
196
 
218
197
  # Segment 1: tokens (always rendered; used_tokens defaults to 0).
219
- ctx_seg="${tokens_color}${tokens_label}${RESET}"
220
- [ -n "$ctx_size" ] && ctx_seg="${ctx_seg} ${GREY}/ ${ctx_size}${RESET}"
221
- ctx_seg="${ctx_seg} ${GREY}(${used_pct}%)${RESET}"
198
+ ctx_seg="${tokens_color}${tokens_label}${RESET} ${GREY}(${used_pct}%)${RESET}"
222
199
 
223
- # Segment 2: model + effort (rendered only when model name is present; effort shares model color).
200
+ # Segment 2: model display name + effort (rendered only when model name is present).
201
+ # model_full is rendered as-is (e.g. "Opus 4.7 (1M context)") -- future-proof and requires no parsing.
202
+ # Effort renders unparenthesized in purple to avoid double-parens when the model name itself
203
+ # already contains a parenthetical (e.g. "Opus 4.7 (1M context)") -- the color shift is the cue.
224
204
  model_seg=""
225
- if [ -n "$model_display" ]; then
226
- model_seg="${BLUE}${model_display}${RESET}"
227
- [ -n "$effort" ] && model_seg="${model_seg} ${BLUE}${effort}${RESET}"
205
+ if [ -n "$model_full" ]; then
206
+ model_seg="${BLUE}${model_full}${RESET}"
207
+ [ -n "$effort" ] && model_seg="${model_seg} ${PURPLE}${effort}${RESET}"
228
208
  fi
229
209
 
230
- # Segment 3: cc_seg (computed above; may be empty).
231
-
232
- # Segment 4: rate-limit gauge (rendered only when rate-limit data is present).
210
+ # Segment 3: rate-limit gauge (rendered only when rate-limit data is present).
233
211
  quota_seg=""
234
212
  if [ -n "$bar" ] && [ -n "$reset_label" ]; then
235
- quota_seg="${bar_color}${bar}${RESET} ${GREY}(${rate_pct}% used, resets in ${reset_label})${RESET}"
213
+ quota_seg="${GREY}5h limit${RESET} ${bar_color}${bar}${RESET} ${GREY}(resets in ${reset_label})${RESET}"
236
214
  fi
237
215
 
238
- # Join non-empty segments with SEP, in order: tokens -> model -> claude-code -> gauge.
216
+ # Segment 4: cc_seg (computed above; may be empty).
217
+
218
+ # Join non-empty segments with SEP, in order: tokens -> model -> gauge -> claude-code.
239
219
  out=""
240
- for seg in "$ctx_seg" "$model_seg" "$cc_seg" "$quota_seg"; do
220
+ for seg in "$ctx_seg" "$model_seg" "$quota_seg" "$cc_seg"; do
241
221
  [ -z "$seg" ] && continue
242
222
  if [ -z "$out" ]; then
243
223
  out="$seg"
@@ -130,6 +130,11 @@ const CONFIG_WRITE_FLAGS = new Set([
130
130
  // Flags that take their next arg as a value -- skip the value when counting tokens.
131
131
  // --file/-f/--blob = scope; --type = value coercion; --default = fallback for --get.
132
132
  const CONFIG_TWO_ARG_FLAGS = new Set(["--file", "-f", "--blob", "--type", "--default"]);
133
+ // Keys whose write form is allowed in strict mode. Narrow by design: each entry must be
134
+ // safe (cannot escalate to remote ops or weaken protocol.allow). core.hooksPath unblocks
135
+ // `pnpm install` for repos whose prepare script points git at a tracked hooks directory.
136
+ // Compared lowercased: git treats section/variable names case-insensitively.
137
+ const CONFIG_WRITE_ALLOWLIST = new Set(["core.hookspath"]);
133
138
 
134
139
  // -- remote: block these subactions, allow the rest (default = list) ----------
135
140
  const REMOTE_MUTATING_ACTIONS = new Set(["add", "remove", "rm", "rename", "set-url", "prune", "update", "set-head", "set-branches"]);
@@ -213,9 +218,10 @@ export function classify(argv) {
213
218
  }
214
219
  // Otherwise count non-flag tokens after the subcommand:
215
220
  // `config <key>` -> 1 token -> read
216
- // `config <key> <value>` -> 2 tokens -> write
221
+ // `config <key> <value>` -> 2 tokens -> write (allowed only if key is in allowlist)
217
222
  // Scope flags (--system, --global, ...) are flags and don't count.
218
223
  let nonFlagCount = 0;
224
+ let firstKey = null;
219
225
  for (let i = 0; i < rest.length; i++) {
220
226
  const a = rest[i];
221
227
  if (a.startsWith("-")) {
@@ -223,7 +229,11 @@ export function classify(argv) {
223
229
  continue;
224
230
  }
225
231
  nonFlagCount++;
226
- if (nonFlagCount >= 2) return blocked("config (write)");
232
+ if (nonFlagCount === 1) firstKey = a;
233
+ if (nonFlagCount >= 2) {
234
+ if (firstKey !== null && CONFIG_WRITE_ALLOWLIST.has(firstKey.toLowerCase())) return { allow: true };
235
+ return blocked("config (write)");
236
+ }
227
237
  }
228
238
  return { allow: true };
229
239
  }
@@ -0,0 +1,2 @@
1
+ store-dir=/home/devuser/.local/share/pnpm/store
2
+ package-import-method=copy
@@ -27,13 +27,13 @@ Tell the user which state they are in, and the exact command path if custom.
27
27
  Four segments, left to right, separated by a mid-dot:
28
28
 
29
29
  ```
30
- 174k / 1M (17%) · Opus 4.7 high · Claude Code v2.1.132 · ▓░░░░░░░░░ (12% used, resets in 3 hr 35 min)
30
+ 174k (17%) · Opus 4.7 (1M context) high · 5h limit ▓░░░░░░░░░ (resets in 2h 15m) · Claude Code v2.1.132
31
31
  ```
32
32
 
33
- 1. **Tokens** - current context-window usage (count / window size / percentage).
34
- 2. **Model** - name and reasoning effort.
35
- 3. **Claude Code** - installed CLI version, with a freshness hint shown only once the install starts to age.
36
- 4. **Rate-limit gauge** - share of the 5-hour window used and time until it resets. Hidden on free accounts and before the first API response.
33
+ 1. **Tokens** - current context-window usage (count and percentage).
34
+ 2. **Model** - display name as provided by Claude Code (rendered as-is), followed by reasoning effort in purple (no parentheses, since the model name itself may already contain a parenthetical).
35
+ 3. **Rate-limit gauge** - bar showing share of the 5-hour window used, plus a relative countdown until it resets. Hidden on free accounts and before the first API response.
36
+ 4. **Claude Code** - installed CLI version, with a freshness hint shown only once the install starts to age.
37
37
 
38
38
  Most of the line stays calm; individual segments turn **yellow** or **red** when something deserves attention - typically a nudge to *clear or compact the context*, *update the harness by opening a new totopo session*, or *watch the rate-limit window*.
39
39
 
@@ -106,4 +106,4 @@ If the user wants something completely different:
106
106
 
107
107
  - Always preserve other fields in `settings.json` when editing. The file may contain unrelated user settings.
108
108
  - If `settings.json` does not exist or is unparseable, create it fresh as `{ "statusLine": { ... } }`.
109
- - The format Claude Code passes via stdin includes `workspace.current_dir`, `model.display_name`, `context_window.used_percentage`, and `context_window.current_usage.{input_tokens, cache_creation_input_tokens, cache_read_input_tokens}`. Use these as the data sources for any custom script.
109
+ - Any statusline changes must use only the fields documented in the official Claude Code statusline reference: https://code.claude.com/docs/en/statusline#available-data -- consult that page for the full list of available JSON fields, their types, nullability, and conditional presence.