feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
@@ -9,6 +9,14 @@
|
||||
* $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so
|
||||
* curl no-ops (|| true) — the hooks are harmless in normal terminals.
|
||||
*
|
||||
* v0.7 additions (T-hooks-installer):
|
||||
* H1 PostToolUse added to FF_EVENTS (A4 timeline completeness, AC-A4.5)
|
||||
* L5 --max-time computed from PERM_TIMEOUT_MS env (not hardcoded 350, SEC-M4)
|
||||
* H3 ntfy/Pushover bridge: only installed when WEBTERM_NTFY_URL+TOPIC set;
|
||||
* token always via $WEBTERM_NTFY_TOKEN env — never a literal (SEC-C6)
|
||||
* B2 statusLine handler: scripts/statusline.mjs installed idempotently;
|
||||
* --remove cleans the entry; user's own statusLine is not overwritten
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/setup-hooks.mjs # install (idempotent)
|
||||
* node scripts/setup-hooks.mjs --remove # uninstall
|
||||
@@ -19,69 +27,277 @@
|
||||
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
|
||||
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fire-and-forget status events: POST and discard the response.
|
||||
const FF_COMMAND =
|
||||
/** Appears in every web-terminal hook command — used by cleanup to identify our entries. */
|
||||
export const MARKER = 'WEBTERM_HOOK_URL'
|
||||
|
||||
/** Appears in our statusLine command — used to detect and remove our entry idempotently. */
|
||||
export const STATUSLINE_MARKER = 'statusline.mjs'
|
||||
|
||||
/** Safety buffer added to PERM_TIMEOUT_MS/1000 for --max-time (L5/SEC-M4). */
|
||||
const CURL_BUFFER_SEC = 5
|
||||
|
||||
/** Default value for PERM_TIMEOUT_MS when env is unset or invalid (5 minutes in ms). */
|
||||
const DEFAULT_PERM_TIMEOUT_MS = 300_000
|
||||
|
||||
// ── Pure helpers (exported for unit tests) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the --max-time value (seconds) for the held PermissionRequest curl.
|
||||
* Result is always > permTimeoutMs/1000 so curl never races the server (L5/SEC-M4).
|
||||
* Falls back to DEFAULT_PERM_TIMEOUT_MS when the input is missing, zero, or negative.
|
||||
*
|
||||
* @param {number} [permTimeoutMs]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function computeMaxTimeSec(permTimeoutMs = DEFAULT_PERM_TIMEOUT_MS) {
|
||||
const safeMs =
|
||||
Number.isFinite(permTimeoutMs) && permTimeoutMs > 0 ? permTimeoutMs : DEFAULT_PERM_TIMEOUT_MS
|
||||
return Math.ceil(safeMs / 1000) + CURL_BUFFER_SEC
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the held PermissionRequest curl command string.
|
||||
* --max-time is supplied by the caller (from computeMaxTimeSec) — never hardcoded (L5).
|
||||
*
|
||||
* @param {number} maxTimeSec
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildPermCommand(maxTimeSec) {
|
||||
return (
|
||||
'curl -s --max-time ' +
|
||||
maxTimeSec +
|
||||
' -X POST "${WEBTERM_HOOK_URL}/permission"' +
|
||||
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fire-and-forget ntfy notification curl command (H3).
|
||||
*
|
||||
* Security guarantees (SEC-C6):
|
||||
* - $WEBTERM_NTFY_TOKEN is a shell variable reference, NEVER a literal value.
|
||||
* - The guard `[ -n "$WEBTERM_HOOK_URL" ]` makes this a no-op outside web-terminal
|
||||
* (WEBTERM_HOOK_URL is injected by the server spawn, not available globally).
|
||||
* - Because the command contains MARKER (WEBTERM_HOOK_URL), the existing cleanup
|
||||
* loop removes it correctly on --remove / re-install.
|
||||
*
|
||||
* @param {'high' | 'low'} priority - ntfy Priority header value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildNtfyCommand(priority) {
|
||||
return (
|
||||
'[ -n "$WEBTERM_HOOK_URL" ] && ' +
|
||||
'curl -s -X POST "${WEBTERM_NTFY_URL}/${WEBTERM_NTFY_TOPIC}"' +
|
||||
' -H "Authorization: Bearer $WEBTERM_NTFY_TOKEN"' +
|
||||
' -H "Priority: ' +
|
||||
priority +
|
||||
'"' +
|
||||
' >/dev/null 2>&1 || true'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the statusLine command Claude Code runs on each status update (B2).
|
||||
* Uses an absolute path so it works regardless of the shell's CWD.
|
||||
*
|
||||
* @param {string} scriptDir - directory that contains statusline.mjs
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildStatusLineCommand(scriptDir) {
|
||||
return 'node ' + path.join(scriptDir, 'statusline.mjs')
|
||||
}
|
||||
|
||||
// ── Hook event lists ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fire-and-forget hook events.
|
||||
* H1: PostToolUse added so the A4 timeline captures "task complete" events
|
||||
* (AC-A4.5, A4-FR1). These all POST asynchronously; Claude doesn't wait.
|
||||
*/
|
||||
export const FF_EVENTS = [
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PostToolUse', // H1 — added for A4 timeline completeness
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
]
|
||||
|
||||
/** Fire-and-forget: POST hook body to web-terminal, discard response. */
|
||||
export const FF_COMMAND =
|
||||
'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' +
|
||||
' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true'
|
||||
const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd']
|
||||
|
||||
// PermissionRequest is HELD: curl waits for the server's response (the decision
|
||||
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude.
|
||||
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt.
|
||||
const PERM_COMMAND =
|
||||
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' +
|
||||
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
|
||||
/** Held PermissionRequest command — computed at module load from PERM_TIMEOUT_MS env. */
|
||||
export const PERM_COMMAND = buildPermCommand(
|
||||
computeMaxTimeSec(
|
||||
(() => {
|
||||
const raw = process.env['PERM_TIMEOUT_MS']
|
||||
if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS
|
||||
})(),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = process.argv.includes('--remove')
|
||||
// ── Core install/remove logic (exported for tests) ───────────────────────────
|
||||
|
||||
let settings = {}
|
||||
if (existsSync(SETTINGS)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(SETTINGS, 'utf8'))
|
||||
} catch {
|
||||
console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`)
|
||||
/**
|
||||
* Apply (install or remove) web-terminal hooks to a settings.json file.
|
||||
* This is the testable core — the main script entry calls it with real env values.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.settingsPath - absolute path to settings.json
|
||||
* @param {boolean} opts.remove - true = remove our entries, false = install
|
||||
* @param {boolean} opts.hasNtfy - true = add ntfy bridge hook commands (H3)
|
||||
* @param {number} opts.permTimeoutMs - used to compute --max-time (L5/SEC-M4)
|
||||
* @param {string} opts.scriptDir - directory of statusline.mjs (for absolute path)
|
||||
* @returns {{ ok: boolean; error?: string; settings?: object }}
|
||||
*/
|
||||
export function applyHooks({ settingsPath, remove, hasNtfy, permTimeoutMs, scriptDir }) {
|
||||
const maxTimeSec = computeMaxTimeSec(permTimeoutMs)
|
||||
const permCommand = buildPermCommand(maxTimeSec)
|
||||
const statusLineCommand = buildStatusLineCommand(scriptDir)
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
let settings = {}
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
} catch {
|
||||
return { ok: false, error: `${settingsPath} is not valid JSON — aborting` }
|
||||
}
|
||||
copyFileSync(settingsPath, `${settingsPath}.webterm-bak`)
|
||||
}
|
||||
|
||||
if (typeof settings['hooks'] !== 'object' || settings['hooks'] === null) {
|
||||
settings['hooks'] = {}
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown[]>} */
|
||||
const hooks = /** @type {Record<string, unknown[]>} */ (settings['hooks'])
|
||||
|
||||
// ── Strip existing web-terminal hook groups (idempotent + --remove) ─────────
|
||||
// Any hook group whose command contains MARKER is ours (FF_COMMAND, PERM_COMMAND,
|
||||
// and ntfy commands all contain WEBTERM_HOOK_URL, so one cleanup pass covers all).
|
||||
for (const ev of Object.keys(hooks)) {
|
||||
const groups = hooks[ev]
|
||||
if (!Array.isArray(groups)) continue
|
||||
hooks[ev] = groups.filter(
|
||||
(g) =>
|
||||
!(
|
||||
g != null &&
|
||||
typeof g === 'object' &&
|
||||
Array.isArray(/** @type {Record<string,unknown>}*/ (g)['hooks']) &&
|
||||
/** @type {Array<Record<string,unknown>>} */ (
|
||||
/** @type {Record<string,unknown>} */ (g)['hooks']
|
||||
).some(
|
||||
(h) => typeof h['command'] === 'string' && h['command'].includes(MARKER),
|
||||
)
|
||||
),
|
||||
)
|
||||
if (hooks[ev].length === 0) delete hooks[ev]
|
||||
}
|
||||
|
||||
// ── Strip statusLine if it's ours (idempotent + --remove) ───────────────────
|
||||
if (
|
||||
typeof settings['statusLine'] === 'string' &&
|
||||
settings['statusLine'].includes(STATUSLINE_MARKER)
|
||||
) {
|
||||
delete settings['statusLine']
|
||||
}
|
||||
|
||||
// ── Install (skip when --remove) ────────────────────────────────────────────
|
||||
if (!remove) {
|
||||
// Fire-and-forget events
|
||||
for (const ev of FF_EVENTS) {
|
||||
if (!Array.isArray(hooks[ev])) hooks[ev] = []
|
||||
hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
|
||||
}
|
||||
|
||||
// Held PermissionRequest (waits for server decision JSON)
|
||||
if (!Array.isArray(hooks['PermissionRequest'])) hooks['PermissionRequest'] = []
|
||||
hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: permCommand }] })
|
||||
|
||||
// H3: ntfy bridge — only when URL + TOPIC are configured at install time.
|
||||
// The token ($WEBTERM_NTFY_TOKEN) is NEVER a literal here (SEC-C6).
|
||||
if (hasNtfy) {
|
||||
// NEEDS-INPUT signal: PermissionRequest → high priority
|
||||
hooks['PermissionRequest'].push({
|
||||
hooks: [{ type: 'command', command: buildNtfyCommand('high') }],
|
||||
})
|
||||
// DONE signal: Stop + SessionEnd → low priority
|
||||
for (const ev of ['Stop', 'SessionEnd']) {
|
||||
hooks[ev].push({ hooks: [{ type: 'command', command: buildNtfyCommand('low') }] })
|
||||
}
|
||||
}
|
||||
|
||||
// B2: statusLine handler — only when the key is currently absent, so we never
|
||||
// overwrite a statusLine the user configured independently.
|
||||
if (!settings['statusLine']) {
|
||||
settings['statusLine'] = statusLineCommand
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(settingsPath), { recursive: true })
|
||||
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
|
||||
|
||||
return { ok: true, settings }
|
||||
}
|
||||
|
||||
// ── Main script entry (runs only when executed directly, not when imported) ──
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const remove = process.argv.includes('--remove')
|
||||
|
||||
// H3: ntfy bridge is installed only when WEBTERM_NTFY_URL + TOPIC are in env
|
||||
const ntfyUrl = process.env['WEBTERM_NTFY_URL']
|
||||
const ntfyTopic = process.env['WEBTERM_NTFY_TOPIC']
|
||||
const hasNtfy = Boolean(ntfyUrl && ntfyTopic)
|
||||
|
||||
// L5/SEC-M4: --max-time computed from PERM_TIMEOUT_MS (never a bare literal)
|
||||
const permTimeoutMs = (() => {
|
||||
const raw = process.env['PERM_TIMEOUT_MS']
|
||||
if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS
|
||||
})()
|
||||
|
||||
const result = applyHooks({
|
||||
settingsPath: SETTINGS,
|
||||
remove,
|
||||
hasNtfy,
|
||||
permTimeoutMs,
|
||||
scriptDir: SCRIPT_DIR,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(result.error)
|
||||
process.exit(1)
|
||||
}
|
||||
copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`)
|
||||
}
|
||||
|
||||
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
|
||||
|
||||
// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove).
|
||||
for (const ev of Object.keys(settings.hooks)) {
|
||||
const groups = settings.hooks[ev]
|
||||
if (!Array.isArray(groups)) continue
|
||||
settings.hooks[ev] = groups.filter(
|
||||
(g) =>
|
||||
!(
|
||||
g &&
|
||||
Array.isArray(g.hooks) &&
|
||||
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER))
|
||||
),
|
||||
const allEvents = [...FF_EVENTS, 'PermissionRequest']
|
||||
console.log(
|
||||
remove
|
||||
? `Removed web-terminal hooks from ${SETTINGS}`
|
||||
: `Installed web-terminal hooks into ${SETTINGS} (events: ${allEvents.join(', ')})`,
|
||||
)
|
||||
if (settings.hooks[ev].length === 0) delete settings.hooks[ev]
|
||||
}
|
||||
|
||||
if (!remove) {
|
||||
for (const ev of FF_EVENTS) {
|
||||
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = []
|
||||
settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
|
||||
if (!remove) {
|
||||
console.log(`Installed statusLine handler: ${buildStatusLineCommand(SCRIPT_DIR)}`)
|
||||
if (hasNtfy) {
|
||||
console.log(`Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → ${ntfyUrl}/${ntfyTopic}`)
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = []
|
||||
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] })
|
||||
|
||||
if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`)
|
||||
console.log('Restart any running `claude` sessions for the hooks to take effect.')
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(SETTINGS), { recursive: true })
|
||||
writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`)
|
||||
|
||||
console.log(
|
||||
remove
|
||||
? `Removed web-terminal hooks from ${SETTINGS}`
|
||||
: `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`,
|
||||
)
|
||||
if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`)
|
||||
console.log('Restart any running `claude` sessions for the hooks to take effect.')
|
||||
|
||||
186
scripts/statusline.mjs
Executable file
186
scripts/statusline.mjs
Executable file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/statusline.mjs — Claude Code statusLine callback for web-terminal (B2).
|
||||
*
|
||||
* Claude Code pipes statusLine JSON to stdin when this script is configured
|
||||
* as a statusLine handler. The script:
|
||||
* 1. POSTs the raw JSON to $WEBTERM_STATUSLINE_URL with X-Webterm-Session header
|
||||
* (server parses + broadcasts to attached clients via parseStatusLine)
|
||||
* 2. Prints a one-line summary (ctx% · $cost · model) to stdout for Claude's status bar
|
||||
*
|
||||
* Outside web-terminal ($WEBTERM_STATUSLINE_URL not set) → prints summary only, no POST.
|
||||
* Never throws; all network errors are silently absorbed.
|
||||
*
|
||||
* Field mapping from raw statusLine JSON (R0-confirmed, see src/types.ts StatusTelemetry):
|
||||
* context_window.used_percentage → contextUsedPct
|
||||
* cost.total_cost_usd → costUsd
|
||||
* model.display_name (or model) → model
|
||||
*
|
||||
* Installed via scripts/setup-hooks.mjs (T-hooks-installer).
|
||||
* Idempotent marker: WEBTERM_STATUSLINE_URL env var set by spawn env (T-spawn-env).
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Safety cap on stdin reads (64 KB). */
|
||||
const MAX_STDIN_BYTES = 64 * 1024
|
||||
|
||||
/** Max length for model display name (prevents runaway strings). */
|
||||
const MAX_MODEL_LEN = 100
|
||||
|
||||
/**
|
||||
* Read all stdin into a string (up to maxBytes). Never throws.
|
||||
*
|
||||
* @param {number} [maxBytes]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function readStdin(maxBytes = MAX_STDIN_BYTES) {
|
||||
return new Promise((resolve) => {
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = []
|
||||
let total = 0
|
||||
process.stdin.on('data', (/** @type {Buffer} */ chunk) => {
|
||||
total += chunk.length
|
||||
if (total <= maxBytes) chunks.push(chunk)
|
||||
})
|
||||
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
process.stdin.on('error', () => resolve(''))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw Claude Code statusLine JSON into display fields.
|
||||
* Tolerant: missing/invalid fields → undefined, never throws.
|
||||
*
|
||||
* @param {string} raw - raw stdin JSON string
|
||||
* @returns {{ contextUsedPct?: number; costUsd?: number; model?: string } | null}
|
||||
* Returns null for unparseable or non-object JSON; returns object (with possible
|
||||
* undefined fields) for valid JSON objects.
|
||||
*/
|
||||
export function parseRaw(raw) {
|
||||
/** @type {unknown} */
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof json !== 'object' || json === null || Array.isArray(json)) return null
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const obj = /** @type {Record<string, unknown>} */ (json)
|
||||
|
||||
/**
|
||||
* Extract a finite number from an unknown value.
|
||||
* @param {unknown} v
|
||||
* @returns {number | undefined}
|
||||
*/
|
||||
const safeNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
|
||||
|
||||
// context_window.used_percentage
|
||||
const ctxWin =
|
||||
obj['context_window'] != null &&
|
||||
typeof obj['context_window'] === 'object' &&
|
||||
!Array.isArray(obj['context_window'])
|
||||
? /** @type {Record<string, unknown>} */ (obj['context_window'])
|
||||
: null
|
||||
const contextUsedPct = ctxWin ? safeNum(ctxWin['used_percentage']) : undefined
|
||||
|
||||
// cost.total_cost_usd
|
||||
const costObj =
|
||||
obj['cost'] != null &&
|
||||
typeof obj['cost'] === 'object' &&
|
||||
!Array.isArray(obj['cost'])
|
||||
? /** @type {Record<string, unknown>} */ (obj['cost'])
|
||||
: null
|
||||
const costUsd = costObj ? safeNum(costObj['total_cost_usd']) : undefined
|
||||
|
||||
// model.display_name OR model (string fallback)
|
||||
const rawModel =
|
||||
obj['model'] != null &&
|
||||
typeof obj['model'] === 'object' &&
|
||||
!Array.isArray(obj['model'])
|
||||
? /** @type {Record<string, unknown>} */ (obj['model'])['display_name']
|
||||
: obj['model']
|
||||
const model =
|
||||
typeof rawModel === 'string' && rawModel.length > 0
|
||||
? rawModel.slice(0, MAX_MODEL_LEN)
|
||||
: undefined
|
||||
|
||||
return { contextUsedPct, costUsd, model }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-line summary for Claude's status bar.
|
||||
* Format: "ctx:45% · $0.53 · claude-opus-4-5"
|
||||
* Returns empty string if no fields are available.
|
||||
*
|
||||
* @param {{ contextUsedPct?: number; costUsd?: number; model?: string } | null} parsed
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSummary(parsed) {
|
||||
if (!parsed) return ''
|
||||
/** @type {string[]} */
|
||||
const parts = []
|
||||
if (typeof parsed.contextUsedPct === 'number') {
|
||||
parts.push(`ctx:${Math.round(parsed.contextUsedPct)}%`)
|
||||
}
|
||||
if (typeof parsed.costUsd === 'number') {
|
||||
parts.push(`$${parsed.costUsd.toFixed(2)}`)
|
||||
}
|
||||
if (typeof parsed.model === 'string' && parsed.model.length > 0) {
|
||||
parts.push(parsed.model)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the raw JSON body to the statusLine URL with the session header.
|
||||
* Resolves when complete or on any error. Never throws.
|
||||
* Uses AbortController to enforce a deadline.
|
||||
*
|
||||
* @param {string} url - $WEBTERM_STATUSLINE_URL (loopback http://127.0.0.1:<port>/hook/status)
|
||||
* @param {string} sessionId - $WEBTERM_SESSION (identifies the Claude session)
|
||||
* @param {string} body - raw stdin JSON to forward verbatim
|
||||
* @param {number} [timeoutMs] - abort after this many ms (default 5000)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function postToServer(url, sessionId, body, timeoutMs = 5000) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webterm-Session': sessionId,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// silent: server down, wrong URL, timeout, outside web-terminal — all absorbed
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main execution (only when run directly, not when imported for testing) ──
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
void (async () => {
|
||||
const raw = await readStdin()
|
||||
const parsed = parseRaw(raw)
|
||||
const summary = buildSummary(parsed)
|
||||
|
||||
// Print summary to stdout — Claude uses this as the status line text.
|
||||
// Empty summary (no data or unparseable) → print nothing (Claude keeps its default).
|
||||
if (summary) process.stdout.write(summary + '\n')
|
||||
|
||||
// POST to server only when inside web-terminal (env var injected by spawn env).
|
||||
const url = process.env['WEBTERM_STATUSLINE_URL']
|
||||
const sessionId = process.env['WEBTERM_SESSION'] ?? ''
|
||||
if (url) {
|
||||
await postToServer(url, sessionId, raw)
|
||||
}
|
||||
})()
|
||||
}
|
||||
Reference in New Issue
Block a user