diff --git a/docker/plugin-setup/compose.yaml b/docker/plugin-setup/compose.yaml index 15678eaa6..919259e13 100644 --- a/docker/plugin-setup/compose.yaml +++ b/docker/plugin-setup/compose.yaml @@ -14,7 +14,7 @@ x-real-cli: &real-cli tmpfs: - /tmp:rw,nosuid,nodev,exec,size=${ECC_TMPFS_SIZE:-2g},uid=1000,gid=1000,mode=0700 - /workspace:rw,nosuid,nodev,noexec,size=${ECC_WORKSPACE_SIZE:-1g},uid=1000,gid=1000,mode=0700 - environment: + environment: &real-cli-environment CLAUDE_CONFIG_DIR: /tmp/ecc-claude-config DISABLE_AUTOUPDATER: "1" HOME: /tmp/ecc-home @@ -76,7 +76,12 @@ services: profiles: - networked network_mode: default + environment: + <<: *real-cli-environment + NPM_CONFIG_PREFIX: /tmp/ecc-npm + PATH: /tmp/ecc-npm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # Codex OAuth callback bridge: host browser -> codex-auth-relay.js -> loopback. + # compose run requires --service-ports to publish this mapping. ports: - "127.0.0.1:1455:1456" image: ecc-plugin-setup:debian diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index 11006a354..f57b348d2 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -40,14 +40,15 @@ function formatDuration(isoTimestamp) { * @param {number} remaining - Raw remaining percentage from Claude Code * @returns {string} Colored bar string */ -function buildContextBar(remaining) { +function buildContextBar(remaining, glyphs) { if (remaining === null || remaining === undefined) return ''; const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100); const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining))); const filled = Math.floor(used / 10); - const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); + const bar = (glyphs?.full || '\u2588').repeat(filled) + + (glyphs?.empty || '\u2591').repeat(10 - filled); if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`; if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`; @@ -95,6 +96,16 @@ function getEccVersion(plugins) { } } +function sessionDurationMs(cost, bridge) { + if (Number.isFinite(cost.total_duration_ms) && cost.total_duration_ms >= 0) { + return cost.total_duration_ms; + } + const elapsed = bridge?.first_timestamp + ? Date.now() - new Date(bridge.first_timestamp).getTime() + : 0; + return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : 0; +} + /** * Assemble statusline output lines from Claude Code stdin data. * @param {object} data - Parsed statusLine stdin JSON @@ -111,8 +122,10 @@ function buildUsageLines(data, bridge, task) { if (process.env.ECC_STATUSLINE_COMPACT === '1') { const segments = [`\x1b[2m${data.model?.display_name || 'Claude'}\x1b[0m`]; if (task) segments.push(`\x1b[1;97m${task}\x1b[0m`); + const usage = render.buildUsageLine(data.rate_limits, null, { compact: true }); + if (usage) segments.push(usage); segments.push(`\x1b[2m${dirname}\x1b[0m`); - return [segments.join(render.SEP) + buildContextBar(remaining)]; + return [segments.join(render.SEP) + buildContextBar(remaining, render.G)]; } const usageLine = render.buildUsageLine( @@ -120,14 +133,12 @@ function buildUsageLines(data, bridge, task) { render.computeCacheStats(data.context_window?.current_usage) ); - const durationMs = bridge?.first_timestamp - ? Date.now() - new Date(bridge.first_timestamp).getTime() - : 0; + const durationMs = sessionDurationMs(cost, bridge); const sessionLine = render.buildSessionLine({ model: data.model?.display_name, effort: data.effort?.level, fastMode: data.fast_mode === true, - ctxBar: buildContextBar(remaining), + ctxBar: buildContextBar(remaining, render.G), ctxWindowSize: data.context_window?.context_window_size, costUsd: cost.total_cost_usd ?? bridge?.total_cost_usd ?? 0, linesAdded: cost.total_lines_added || 0, diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js index 00abcde04..74cdd11b8 100644 --- a/scripts/lib/statusline-render.js +++ b/scripts/lib/statusline-render.js @@ -124,10 +124,11 @@ function computeCacheStats(currentUsage) { return { hitPct: Math.round((read / total) * 100) }; } -function usageSegment(label, labelColor, window) { - const pct = Math.round(window?.used_percentage ?? -1); - if (pct < 0) return ''; +function usageSegment(label, labelColor, window, compact = false) { + if (!Number.isFinite(window?.used_percentage) || window.used_percentage < 0) return ''; + const pct = Math.round(window.used_percentage); const color = pctColor(pct, labelColor); + if (compact) return `${labelColor}${label}${C.reset} ${color}${pct}%${C.reset}`; const reset = formatCountdown(window.resets_at); const resetStr = reset ? ` ${C.dim}${G.reset}${reset}${C.reset}` : ''; return `${labelColor}${label}${C.reset} ${color}${buildBar(pct)} ${pct}%${C.reset}${resetStr}`; @@ -137,12 +138,14 @@ function usageSegment(label, labelColor, window) { * Line 1: rate-limit windows + prompt cache. * Returns '' when no rate limit data is available (e.g. API-key billing). */ -function buildUsageLine(rateLimits, cacheStats) { +function buildUsageLine(rateLimits, cacheStats, { compact = false } = {}) { const parts = []; - const fiveHour = usageSegment('5h', C.amber, rateLimits?.five_hour); - const sevenDay = usageSegment('7d', C.terracotta, rateLimits?.seven_day); + const fiveHour = usageSegment('5h', C.amber, rateLimits?.five_hour, compact); + const sevenDay = usageSegment('7d', C.terracotta, rateLimits?.seven_day, compact); + const spendLimit = usageSegment('spend', C.amber, rateLimits?.spend_limit, compact); if (fiveHour) parts.push(fiveHour); if (sevenDay) parts.push(sevenDay); + if (spendLimit) parts.push(spendLimit); if (cacheStats && parts.length > 0) { const cacheColor = cacheStats.hitPct >= 50 ? C.green : C.dim; parts.push(`${C.dim}cache${C.reset} ${cacheColor}${cacheStats.hitPct}%${C.reset}`); diff --git a/skills/docker-patterns/SKILL.md b/skills/docker-patterns/SKILL.md index f5dbb4683..5f096dbe3 100644 --- a/skills/docker-patterns/SKILL.md +++ b/skills/docker-patterns/SKILL.md @@ -409,8 +409,10 @@ docker compose -p ecc-plugin-session \ Host credentials are absent by default and credential directories are never mounted. The default service also has no network access. When an authenticated provider session genuinely needs a network, build `real-cli` first and then opt -in visibly with `docker compose --profile networked run real-cli-networked -shell`. Prefer authenticating inside that disposable session. If a CI run must +in visibly with `docker compose -f docker/plugin-setup/compose.yaml +--profile networked run --service-ports real-cli-networked shell`. The +`--service-ports` flag publishes the service's loopback callback mapping. +Prefer authenticating inside that disposable session. If a CI run must inherit a host environment credential, make that opt-in at invocation with an explicit Compose `--env NAME` flag, understand that the value is inspectable and can be exfiltrated for the container lifetime, and remove the exact named @@ -426,13 +428,17 @@ without a bridge. The `real-cli-networked` service publishes host logging in: ```bash -npm install --global @openai/codex # the harness image does not ship codex +npm install --global @openai/codex@0.162.0 # the harness image does not ship codex node /ecc/docker/plugin-setup/codex-auth-relay.js & relay_pid=$! codex login kill "$relay_pid" ``` +The networked service sets `NPM_CONFIG_PREFIX=/tmp/ecc-npm` and adds its `bin` +directory to `PATH`. This keeps the pinned installation writable for UID 1000 +on the executable `/tmp` mount while the root filesystem remains read-only. + Complete the sign-in in the host browser. Each attempt mints a fresh `state`/`code`, so always follow the newest printed link. The token stays in the disposable container's `~/.codex/auth.json`; removing the named container diff --git a/tests/hooks/ecc-statusline-late.test.js b/tests/hooks/ecc-statusline-late.test.js new file mode 100644 index 000000000..bdbd3da45 --- /dev/null +++ b/tests/hooks/ecc-statusline-late.test.js @@ -0,0 +1,115 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const { buildUsageLines } = require('../../scripts/hooks/ecc-statusline'); +const { buildUsageLine } = require('../../scripts/lib/statusline-render'); +const shellQuote = value => "'" + value.replace(/'/g, "'\\''") + "'"; +const repoRoot = path.resolve(__dirname, '../..'); +// eslint-disable-next-line no-control-regex -- Statusline ANSI output is the fixture boundary. +const stripAnsi = value => value.replace(/\x1b\[[0-9;]*m/g, ''); + +function registeredDisplay(t, payload, options = {}, bridge) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-statusline-late-')); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + const configDir = path.join(home, 'selected profile'); + fs.mkdirSync(configDir); + const temporaryDir = path.join(home, 'tmp'); + fs.mkdirSync(temporaryDir); + if (bridge) fs.writeFileSync(path.join(temporaryDir, `ecc-metrics-${payload.session_id}.json`), JSON.stringify(bridge)); + const example = JSON.parse(fs.readFileSync(path.join(repoRoot, 'examples/statusline.json'), 'utf8')); + const command = example.statusLine.command.replace(/^node /, `${shellQuote(process.execPath)} `) + .replace('"/scripts/hooks/ecc-statusline.js"', + shellQuote(path.join(repoRoot, 'scripts/hooks/ecc-statusline.js'))); + const settingsPath = path.join(configDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ statusLine: { ...example.statusLine, command } })); + const before = fs.readFileSync(settingsPath); + const settings = JSON.parse(before); + const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: configDir, TMPDIR: temporaryDir, + ECC_STATUSLINE_USAGE: '1', ECC_STATUSLINE_COMPACT: '0', ECC_BAR_GLYPHS: 'ascii', ...options }; + if (env.ECC_STATUSLINE_USAGE === undefined) delete env.ECC_STATUSLINE_USAGE; + const result = spawnSync('/bin/sh', ['-c', settings.statusLine.command], { + input: JSON.stringify(payload), encoding: 'utf8', env, timeout: 5000, cwd: home, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(settingsPath), before, 'display must not rewrite its registration'); + return stripAnsi(result.stdout); +} + +for (const [window, label] of [['five_hour', '5h'], ['seven_day', '7d'], ['spend_limit', 'spend']]) { + test(`registered compact display preserves independently supplied ${window}`, t => { + const output = registeredDisplay(t, { model: { display_name: 'Fixture' }, workspace: { current_dir: '/fixture/project' }, + rate_limits: { [window]: { used_percentage: 23.5 } } }, { ECC_STATUSLINE_COMPACT: '1' }); + assert.match(output, new RegExp(`${label} 24%`)); + assert.equal(output.split('\n').length, 1); + assert.doesNotMatch(output, /[_\u2588\u2591]/, 'abbreviated usage has no bar'); + }); +} + +for (const compact of ['0', '1']) { + test(`registered opt-in display honors ASCII context glyphs, compact=${compact}`, t => { + const output = registeredDisplay(t, { context_window: { remaining_percentage: 80 } }, + { ECC_STATUSLINE_COMPACT: compact }); + assert.match(output, /[|_]{10} 24%/); + assert.doesNotMatch(output, /[\u2588\u2591]/); + }); +} + +test('registered default display keeps its original exact output despite optional settings/data', t => { + const payload = { model: { display_name: 'Fixture' }, workspace: { current_dir: '/fixture/project' }, + context_window: { remaining_percentage: 80 }, cost: { total_duration_ms: 125000 }, + rate_limits: { spend_limit: { used_percentage: 50 }, five_hour: { used_percentage: 30 } } }; + const expected = `Fixture \u2502 project ${'\u2588'.repeat(2)}${'\u2591'.repeat(8)} 24%`; + assert.equal(registeredDisplay(t, payload, { ECC_STATUSLINE_USAGE: undefined, ECC_STATUSLINE_COMPACT: '1' }), expected); + assert.equal(registeredDisplay(t, payload, { ECC_STATUSLINE_USAGE: '0' }), expected); +}); + +test('registered opt-in display uses stdin session duration without bridge metrics', t => { + assert.match(registeredDisplay(t, { cost: { total_duration_ms: 125000 } }), /2m/); +}); + +test('stdin duration takes precedence over bridge elapsed time, including explicit zero', () => { + const bridge = { first_timestamp: new Date(Date.now() - 600000).toISOString() }; + assert.match(stripAnsi(buildUsageLines({ cost: { total_duration_ms: 125000 } }, bridge, '').join('\n')), /2m/); + assert.doesNotMatch(stripAnsi(buildUsageLines({ cost: { total_duration_ms: 0 } }, bridge, '').join('\n')), /10m/); +}); + +test('invalid stdin duration falls back to a finite nonnegative bridge duration', () => { + const bridge = { first_timestamp: new Date(Date.now() - 600000).toISOString() }; + for (const duration of [undefined, null, -1, '125000', NaN, Infinity]) { + const output = stripAnsi(buildUsageLines({ cost: { total_duration_ms: duration } }, bridge, '').join('\n')); + assert.match(output, /10m/); + assert.doesNotMatch(output, /NaN|Infinity/); + } + for (const firstTimestamp of ['invalid', new Date(Date.now() + 600000).toISOString()]) { + const output = stripAnsi(buildUsageLines({}, { first_timestamp: firstTimestamp }, '').join('\n')); + assert.doesNotMatch(output, /NaN|Infinity|10m/); + } +}); + +test('gateway spend limit renders independently and malformed windows stay absent', () => { + assert.match(stripAnsi(buildUsageLine({ spend_limit: { used_percentage: 62.8, + resets_at: Math.floor(Date.now() / 1000) + 3600 } })), /spend.*63%.*1h/); + for (const percentage of [undefined, null, -1, '25', true, NaN, Infinity]) { + assert.equal(buildUsageLine({ spend_limit: { used_percentage: percentage } }), ''); + } + assert.match(stripAnsi(buildUsageLine({ spend_limit: { used_percentage: 125 } })), /125%/); +}); + + +test('registered command prefers stdin duration over bridge and preserves legacy bridge output', t => { + const bridge = { first_timestamp: new Date(Date.now() - 600000).toISOString(), + total_cost_usd: 1.23, tool_count: 2, files_modified_count: 1 }; + const data = { session_id: 'duration-fixture', model: { display_name: 'Fixture' }, + workspace: { current_dir: '/fixture/project' }, cost: { total_duration_ms: 125000 } }; + const enabled = registeredDisplay(t, data, {}, bridge); + assert.match(enabled, /2m/); + assert.doesNotMatch(enabled, /10m/); + assert.doesNotMatch(registeredDisplay(t, { ...data, cost: { total_duration_ms: 0 } }, {}, bridge), /10m/); + assert.match(registeredDisplay(t, data, { ECC_STATUSLINE_USAGE: '0' }, bridge), /\$1\.23 2t 1f 10m/); + assert.match(registeredDisplay(t, { ...data, cost: { total_duration_ms: -1 } }, {}, bridge), /10m/); +});