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 +2 -2
- package/dist/commands/dev.js +4 -2
- package/dist/lib/constants.js +7 -4
- package/dist/lib/dockerfile-builder.js +49 -4
- package/dist/lib/migrate-to-latest.js +27 -48
- package/package.json +1 -1
- package/templates/Dockerfile +2 -2
- package/templates/claude-statusline.sh +42 -62
- package/templates/git-readonly-wrapper.mjs +12 -2
- package/templates/npmrc +2 -0
- package/templates/skills/totopo-statusline/SKILL.md +6 -6
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
|
|
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
|
|
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
|
|
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
508
|
-
//
|
|
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
|
-
/**
|
|
511
|
-
export function isImageStale(containerName) {
|
|
512
|
-
|
|
513
|
-
|
|
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 (
|
|
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
|
-
|
|
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
package/templates/Dockerfile
CHANGED
|
@@ -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
|
|
9
|
-
# 2. <
|
|
10
|
-
# 3.
|
|
11
|
-
# 4.
|
|
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
|
-
(
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
118
|
-
# Light grey
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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="${
|
|
134
|
+
reset_label="${delta_m}m"
|
|
149
135
|
fi
|
|
150
136
|
else
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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 "$
|
|
226
|
-
model_seg="${BLUE}${
|
|
227
|
-
[ -n "$effort" ] && model_seg="${model_seg} ${
|
|
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:
|
|
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}(
|
|
213
|
+
quota_seg="${GREY}5h limit${RESET} ${bar_color}${bar}${RESET} ${GREY}(resets in ${reset_label})${RESET}"
|
|
236
214
|
fi
|
|
237
215
|
|
|
238
|
-
#
|
|
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" "$
|
|
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
|
|
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
|
}
|
package/templates/npmrc
ADDED
|
@@ -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
|
|
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
|
|
34
|
-
2. **Model** - name
|
|
35
|
-
3. **
|
|
36
|
-
4. **
|
|
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
|
-
-
|
|
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.
|