diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7247c13..cd5446b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "usage-limits", "displayName": "Usage Limits", - "version": "1.41.1", + "version": "1.43.0", "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.", "author": { "name": "Ridelink", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6daf7a8..589f643 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "usage-limits", - "version": "1.41.1", + "version": "1.43.0", "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.", "author": { "name": "Ridelink", diff --git a/README.md b/README.md index e3b1804..121050a 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,31 @@ The 5-hour limit is the binding one. At the current pace it runs out in about The clock time matters more than the countdown. "Resume after 03:00" is a plan; "4h 12m" is a number you still have to do arithmetic on. +**The cloud-session credit is a third case.** Claude Code can offer a dollar +credit for cloud sessions (claude.ai/code), on top of the plan, and names it at +the wall itself: "While you wait, start a new cloud session with a $250 +credit". It is not usage credits - the account this was built on has those off +at the org level and has this - and a cloud session on it was seen still +working with its 5-hour window "rejected". When the account carries the offer +the report says so on a `Cloud credit` line, with the amount read from the +offer, and at the wall adds the way on: + +``` + Work stops when it does. Nothing carries on into paid credits. + To carry on now instead of waiting, start a cloud session on the $250 + cloud-session credit: relay cloud hands it this work as claude --cloud. + Land what exists, write the handoff, and resume after 03:00. +``` + +`relay cloud` builds the hand-off the wake would deliver and prints the +`claude --cloud` command with it inline; the cloud session clones from GitHub, +so it warns about anything not pushed. On Windows it also warns when the +`claude` on PATH is a `.cmd` shim, because cmd.exe cuts the hand-off at its +first line break even when the command is pasted into PowerShell. Inside a cloud session the report does +not ask for `/usage` - the container keeps no snapshot - and says what the +session has cost so far at list prices instead. What is left of the credit is +not readable anywhere, so it is never claimed. + The skill also requires Claude to say up front when a job will not fit in what is left, name what it is doing now, what it is leaving, and when the rest can happen, rather than starting and stopping halfway through an edit. @@ -498,6 +523,15 @@ re-checks the meter and hands your continuation back. /usage-limits:relay permission acceptEdits ``` +With `mode resume` the wake resumes the same conversation (`claude --resume`). +`relay fresh on` makes it start a new one instead: `claude` in the project +with the whole hand-off as its first prompt, Remote Control on as +`usage-limits relay (new session)`, and the relay's permission mode - +so a session that has grown long hands its work on rather than carrying all of +it into the next window. It is off by default, `/usage-limits:defer` fires +through the same wake, and in both modes the wake stands down if the old +session is still open and working again. + What changes while it is armed is what Claude is told at the wall. Instead of "write the handoff and stop", the budget line says the relay has it, that being cut off now costs the wait rather than the work, and that the continuation is a diff --git a/commands/defer.md b/commands/defer.md index cde1855..ec2ec12 100644 --- a/commands/defer.md +++ b/commands/defer.md @@ -38,9 +38,35 @@ Other forms: The work is saved as a continuation and a real scheduled task is registered (Windows Task Scheduler, or `at`/launchd elsewhere). At the named time the same -wake script the usage relay uses starts a fresh session in the original -directory and hands it the saved plan. If the launch fails - a machine whose -network is not up yet is the common one - it retries rather than giving up. +wake script the usage relay uses runs, and what it does depends on the relay's +settings (`/usage-limits:relay`). The one-line reply names which of these it +will be: + +- `relay mode notify` - the default. It raises a notification with the plan + ready to pick up and **starts nothing**. With `relay fresh on` the + notification names the hand-off file to give a new session. +- `relay mode resume` with `relay fresh off` (the default) - it + resumes **this same conversation** (`claude --resume `) in a + window, with Remote Control on, and the first prompt points it at the + hand-off file. This needs the session id this command ran under; a deferral + made where none is known has nothing to resume, and the reply says so. +- `relay mode resume` with `relay fresh on` - it starts a **new conversation**: + `claude` in the original directory, with the whole hand-off (this work, and + the relay's thinking, voice and bug-check lines) as its first prompt, Remote + Control on under the name `usage-limits relay (new session)`, and + the relay's `permission` mode and `model`. It needs no session id. On Windows + the hand-off goes inline only through the native `claude.exe`; with only the + npm `claude.cmd` shim, the first prompt points at the hand-off file instead, + because cmd.exe cuts an argument at its first line break. +- `relay show off` - either kind runs headless (`claude -p`, the prompt on + stdin) with no window and no Remote Control; its output is kept in + `relay log --run`. + +Either way one terminal: if the original session is still open and working +again when the time comes, the wake stands down and opens nothing. If the +launch fails - a machine whose network is not up yet is the common one - it +retries rather than giving up. Under Codex the thread is resumed whatever +`fresh` says. If the time is unreadable or ambiguous, the script refuses and says so. It never picks a reading: a deferral that fires at the wrong hour while nobody is awake diff --git a/commands/relay.md b/commands/relay.md index 35f68e5..86641f7 100644 --- a/commands/relay.md +++ b/commands/relay.md @@ -23,12 +23,27 @@ The rest: when the window opens; a request one second later has been refused before. - `mode notify` - raise a notification with the continuation ready to open. This is the default and it starts nothing by itself. -- `mode resume` - at the wake, resume the same conversation in a window you can - see, with Remote Control on, so it is also on claude.ai/code and the phone; - the first prompt points it at the hand-off file. Say `permission acceptEdits` - (or whichever mode you want) as well: a resume does **not** inherit the - session's permission mode, so without one it will sit waiting for an - approval nobody is there to give. `show off` makes it a headless run instead. +- `mode resume` - at the wake, run the CLI itself in a window you can see, + with Remote Control on, so it is also on claude.ai/code and the phone. What + it runs is `fresh`'s choice (below). Say `permission acceptEdits` (or + whichever mode you want) as well: neither a resume nor a new session inherits + the session's permission mode, so without one it will sit waiting for an + approval nobody is there to give. `show off` makes it a headless `claude -p` + run instead, the prompt on stdin, with no window and no Remote Control. +- `fresh off` / `fresh on` - which conversation `mode resume` continues in. + Off, the default: the **same** conversation, `claude --resume `, named + `usage-limits relay ` on Remote Control, and its first prompt points + at the hand-off file. On: a **new** conversation, `claude` in the project + directory with the whole hand-off as its first prompt, inline, named + `usage-limits relay (new session)`, with the same permission mode + and model - for a session that has grown long, so the next window starts + clean instead of carrying all of it. The old conversation is left as it was. + On Windows the inline hand-off needs the native `claude.exe`; with only the + npm `claude.cmd` shim the first prompt points at the file instead. Both + modes keep one terminal: if the old session is open and working again at the + wake, the wake stands down; if it is open but quiet for three minutes, the + wake opens its one window anyway. Under `mode notify` neither applies, and + under Codex the thread is resumed either way. - `model ` - the `--model` a resumed run starts with; `model` alone goes back to the default. It is separate from `/model` in the session, so a wake pinned to an older model stays on it until this is changed. @@ -47,6 +62,16 @@ The rest: - `note ""` (or `note --file `) - store the continuation. This is the text that gets delivered, so write it to be acted on: what is done, what is next in order, which files are mid-change, what to verify first. +- `cloud [--session ] [--go] [""]` - the same hand-off, into a + cloud session now instead of this one after the reset. It prints the exact + `claude --cloud '...'` command with the hand-off inline (a cloud session + cannot read a file here), and warns about uncommitted changes and unpushed + commits, because the cloud session clones from GitHub. On Windows, if the + `claude` there is a `.cmd`/`.bat` shim, it also warns that cmd.exe cuts the + hand-off at its first line break, even from PowerShell, and to run it with + the native `claude.exe`. `--go` starts it, + detached, and names the log file its output goes to. It runs on the account's + cloud-session credit when there is one, so starting it is the user's call. - `cancel` - drop the wake and remove the scheduled task. - `log` - the last twenty lines of what the relay actually did. diff --git a/package.json b/package.json index 7f30ee2..5ccbec9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-usage-limits", - "version": "1.41.1", + "version": "1.43.0", "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.", "keywords": [ "claude", diff --git a/skills/usage-limits/SKILL.md b/skills/usage-limits/SKILL.md index 34b883d..e4f62d4 100644 --- a/skills/usage-limits/SKILL.md +++ b/skills/usage-limits/SKILL.md @@ -179,6 +179,15 @@ yourself: Claude Code announces it and asks before drawing on credits, and repeating that only adds noise. Just factor it into the plan, and take the user's answer to that prompt as the decision. +**Cloud credit.** A separate line, when the account is offered a credit for +cloud sessions. It is not usage credits and it can be there while they are +off. At the wall it is the way on now rather than after the reset: `relay +cloud` prints the `claude --cloud` command carrying the hand-off, and warns +about anything not pushed, because the cloud session clones from GitHub. Offer +it in one line; it spends the user's credit, so starting it is their call. +Inside a cloud session there is no usage snapshot, so do not send the user to +`/usage`; the report gives this session's spend at list prices instead. + ## Opening with the budget Start every reply with one line saying where the budget stands and whether what @@ -734,6 +743,11 @@ them, and do not promise behaviour it does not have: Control on (named `usage-limits relay ` on claude.ai/code and the phone), and its first prompt points it at the hand-off file. `relay show off` makes it a headless `claude -p` run instead; the output is kept either way. + `relay fresh on` makes the wake start a **new** conversation instead of + resuming the old one: `claude` in the project with the whole hand-off as its + first prompt, named `usage-limits relay (new session)`. Off, the + default, it resumes the same conversation. `defer` fires through the same + wake, so the same settings decide what a deferral does. macOS and Linux open the same window in Terminal (unverified there). A saved continuation counts as work to carry, so a session with a note but no todo list still arms; and a wake that never reports back is reaped as "lost". @@ -919,11 +933,11 @@ stop. | `scripts/live.js` | The usage reading itself, taken the way Claude Code takes it for `/usage`, kept in `usage-limits-live.json` where `collect()` prefers it when newer than the cache. | | `scripts/drift.js` | How wrong the reading was: each correction written down against the one it replaced. Run it with no arguments for the median and worst gap measured so far, `--json` for the fields. Answer "how far behind does this plugin actually run" from here rather than from memory. | | `scripts/view.js`, `scripts/bars.js`, `scripts/activity.js` | The display model, the drawing in Claude's colours, and the working/idle marks the hooks leave for the panel. Not meant to be called by hand. | -| `scripts/relay.js` | The relay: `status`, `on`/`off`, `at N`, `grace N`, `mode notify\|resume`, `permission MODE`, `thinking off\|resume\|always`, `armon threshold\|completion`, `backstop N`, `show on\|off`, `onfailure rearm\|stop`, `rearms N`, `offline N`, `doctor`, `note ""`, `cancel`, `log [--run]`. | +| `scripts/relay.js` | The relay: `status`, `on`/`off`, `at N`, `grace N`, `mode notify\|resume`, `permission MODE`, `thinking off\|resume\|always`, `armon threshold\|completion`, `backstop N`, `show on\|off`, `fresh on\|off`, `onfailure rearm\|stop`, `rearms N`, `offline N`, `doctor`, `note ""`, `cancel`, `log [--run]`. | | `scripts/net.js` | Can this machine reach the API, and was a failed run the network's fault. Three probes, TLS-interception detection, and the backoff the offline retries use. | | `scripts/wake.js` | What the scheduler runs after the reset: re-checks the meter, then notifies or resumes. Never called by hand. | | `scripts/voice.js` | The local writing profile: `show`, `card`, `set ""`, `clear`, `off`/`on`, `forget`. | -| `scripts/lowpri.js` | What Claude Code's own features at the wall are readable as: whether this account is provisioned for `/low-priority`, whether a manual session reset grant exists, whether the CLI injects its own wrap-up note here, whether usage credits are available, whether `autoContinueAtUsageLimit` is on, and the record of the user saying they switched low-priority on. Reads only; never writes anything but that one record. | +| `scripts/lowpri.js` | What Claude Code's own features at the wall are readable as: whether this account is provisioned for `/low-priority`, whether a manual session reset grant exists, whether the CLI injects its own wrap-up note here, whether usage credits are available, the cloud-session credit on offer and whether this is a cloud session, whether `autoContinueAtUsageLimit` is on, and the record of the user saying they switched low-priority on. Reads only; never writes anything but that one record. | | `references/how-it-works.md` | Where the numbers come from and where they are soft. | ## Codex controls diff --git a/skills/usage-limits/scripts/brief.js b/skills/usage-limits/scripts/brief.js index 72f7bfd..9169b72 100644 --- a/skills/usage-limits/scripts/brief.js +++ b/skills/usage-limits/scripts/brief.js @@ -160,7 +160,7 @@ function readSaid() { } } function keepSaidFor(key) { - return /#(standing|cachemiss|stale|relaylast|newer)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000; + return /#(standing|cachemiss|stale|relaylast|newer|cloud)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000; } // A plugin update takes effect when Claude Code restarts, so a session that @@ -218,6 +218,21 @@ function newerModelFor(sessionId, advice, now) { writeSaid(all, at); return advice.text; } +// The cloud-session credit, said once a session near the wall, by the same +// rule: the offer does not change from prompt to prompt. Keyed on the amount, +// so a different offer is news. +function cloudCreditFor(sessionId, credit, now) { + const amount = lowpri.creditText(credit); + if (!amount) return null; + const at = Number.isFinite(now) ? now : Date.now(); + const key = String(sessionId || '_') + '#cloud'; + const all = readSaid(); + const entry = all[key]; + if (entry && entry.seen === amount && Number.isFinite(entry.at) && at - entry.at < keepSaidFor(key)) return null; + all[key] = { at, seen: amount }; + writeSaid(all, at); + return amount; +} // The model the relay resumes with, when one is set: wake.js passes it as --model. function relayModelNow() { try { @@ -1148,6 +1163,18 @@ function briefText(input) { ' Like /low-priority, only the user can type it; you cannot run a slash command.' ); } + // The cloud-session credit: the one lever that carries work past the wall + // on an account with usage credits off. Only near the wall, only once a + // session (run() decides), and it is the user's credit, so it is offered, + // never taken. + const cloudSentence = + parts.cloudCredit && (parts.pressure === 'tight' || parts.pressure === 'gone') + ? 'A cloud session is the way past this wall now rather than after the reset: the account has a ' + + parts.cloudCredit + ' cloud-session credit on top of the plan, and relay cloud prints the claude --cloud ' + + 'command that hands it this work (it clones from GitHub, so push first). Offer it in one line; it spends ' + + "the user's credit, so starting it is their call." + : null; + if (cloudSentence) sentences.push(cloudSentence); if (parts.session) { sentences.push( 'This session: ' + parts.session.turns + ' turns, ' + @@ -1438,6 +1465,7 @@ function briefText(input) { sentences[0] + caveat + (parts.tier ? ' ' + parts.tier : '') + (bounded ? ' ' + bounded : '') + adviceText + (escapeSentence ? ' ' + escapeSentence : '') + (lowPrioritySentence ? ' ' + lowPrioritySentence : '') + + (cloudSentence ? ' ' + cloudSentence : '') + (parts.pressure !== 'roomy' && relaySentences.length ? ' ' + relaySentences.join(' ') : '') + '\n' + instruction + directive ); @@ -1595,6 +1623,9 @@ function wallFeatures(now, binding, windows, hostName) { out.lowPriority = info; out.hostWrapsUp = lowpri.wrapUp(account).hostWrapsUp; out.autoContinue = info.autoContinue; + // Only where it is the way past the wall: usage credits not on, and not + // already inside a cloud session, which is running on it. + out.cloudCredit = info.cloudCredit && !info.inCloud && info.credits.available !== true ? info.cloudCredit : null; if (info.state === 'acknowledged' && binding && binding.key === 'five_hour') { const weekly = lowpri.weeklyOf(windows); if (weekly && Number.isFinite(weekly.percentUsed) && !weekly.stale && Number.isFinite(weekly.resetsAt)) { @@ -1950,6 +1981,7 @@ async function run(now, hookInput, opts) { if (offering) mode.adviceOffer(advice.id, sessionId, now); const pressureNow = pressure(binding, now, config, Number.isFinite(yourTurnsLeft) ? yourTurnsLeft : turnsLeftNow); + const cloudCredit = pressureNow === 'tight' || pressureNow === 'gone' ? cloudCreditFor(sessionId, wall.cloudCredit, now) : null; // Fast mode changes what the window's figures mean, so a toggle is a change // worth saying even when nothing else has moved. const fastMode = fastModeFor(sessionId); @@ -2045,6 +2077,7 @@ async function run(now, hookInput, opts) { lowPriority: wall.lowPriority, hostWrapsUp: wall.hostWrapsUp, autoContinue: wall.autoContinue || null, + cloudCredit, // The turn count that matters for this session is its share of a shared // budget, not the whole window's. Escalating on the whole window meant a // count that looked comfortable while the part actually available here was @@ -2099,7 +2132,7 @@ function withBugcheck(text) { return text; } -module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, newerModelFor, relayNewsFor, installedVersion, runningVersion, +module.exports = { wallFeatures, sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, newerModelFor, cloudCreditFor, relayNewsFor, installedVersion, runningVersion, readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS, DEFAULTS, HOOK_BUDGET_MS, diff --git a/skills/usage-limits/scripts/defer.js b/skills/usage-limits/scripts/defer.js index 638213c..59ed37e 100644 --- a/skills/usage-limits/scripts/defer.js +++ b/skills/usage-limits/scripts/defer.js @@ -140,6 +140,7 @@ function confirmation(parts) { const tail = []; tail.push('Nothing has been started'); if (parts.items) tail.push(parts.items + ' saved'); + if (parts.fires) tail.push(parts.fires); // Every note, not only the first: two can apply at once. for (const note of parts.notes && parts.notes.length ? parts.notes : [parts.resetNote]) { if (note) tail.push(note); @@ -187,6 +188,22 @@ function lowPriorityAcknowledged(now) { } } +// What the wake will do at that time, in the words of the relay settings that +// decide it. Said in the line because the three are very different things to +// come back to: a notification, the same conversation, or a new one. +function whatFires(config, hostName, hasSession) { + if (!config || config.mode !== 'resume') return 'then it only raises a notification with the plan (relay mode notify)'; + if (hostName === host.CODEX) return 'then it resumes the Codex thread'; + if (config.fresh) return 'then it starts a new claude session in this folder with the saved work as its first prompt (relay fresh on)'; + return hasSession + ? 'then it resumes this same conversation (relay fresh off)' + : 'then it would resume a conversation, but no session id is known here, so it will fail: relay fresh on starts a new session instead'; +} + +function hasSessionId(argv, env) { + return Boolean(argOf(argv, '--session-id') || env.CLAUDE_CODE_SESSION_ID || env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID); +} + function sessionId(argv, env) { const at = argv.indexOf('--session-id'); if (at !== -1 && argv[at + 1]) return argv[at + 1]; @@ -291,6 +308,7 @@ function main(argv, now) { if (!decided.ok) return decided.error; const id = sessionId(args, process.env); + const hostName = host.detect(args, process.env); const work = argOf(args, '--work'); const cwd = argOf(args, '--cwd') || process.cwd(); @@ -307,7 +325,7 @@ function main(argv, now) { now: at, sessionId: id, cwd, - hostName: host.detect(args, process.env), + hostName, at: decided.at, binding: { percentUsed: binding.percent, resetsAt: binding.resetsAt }, work: { hasWork: true, pending: 1, source: 'defer', todos: [] }, @@ -318,7 +336,10 @@ function main(argv, now) { // session can tell the two apart - they read the same record. try { const held = relay.read(); - const mine = (typeof sessionId !== 'undefined' && relay.armedFor(held, sessionId)) || held.armed; + // By this deferral's own id. It used to pass the sessionId FUNCTION here, + // which matched nothing, so with another relay armed first the label and + // the continuation flag went on that relay instead of this one. + const mine = relay.armedFor(held, id); if (mine) { mine.deferred = true; mine.continuation = Boolean(work); @@ -334,6 +355,7 @@ function main(argv, now) { clock: decided.clock, in: decided.in, items, + fires: whatFires(relay.settings(), hostName, hasSessionId(args, process.env)), resetNote: decided.resetNote, notes: decided.notes, }); @@ -349,4 +371,4 @@ if (require.main === module) { } } -module.exports = { lowPriorityAcknowledged, parseWhen, formatClock, formatSpan, confirmation, plan, status, cancel, main, MINUTE, HOUR }; +module.exports = { whatFires, lowPriorityAcknowledged, parseWhen, formatClock, formatSpan, confirmation, plan, status, cancel, main, MINUTE, HOUR }; diff --git a/skills/usage-limits/scripts/lowpri.js b/skills/usage-limits/scripts/lowpri.js index f1b9a01..a658ed9 100644 --- a/skills/usage-limits/scripts/lowpri.js +++ b/skills/usage-limits/scripts/lowpri.js @@ -73,6 +73,18 @@ // waits out the reset and continues the same open session. That is better // than a scheduled wake when the terminal stays open, and it is why the relay // has to say so rather than presenting its wake as the only route. +// +// The cloud-session credit - a dollar credit for claude.ai/code sessions, on +// top of the plan, which the CLI itself advertises AT the limit wall ("While +// you wait, start a new cloud session with a $250 credit"). Read on +// 2026-09-28 (2.1.284) from cachedGrowthBookFeatures.tengu_swift_lynx, +// version 2, with endpoint /v1/code/promo/cloud_credit and the claim page at +// webPath /code/claim-credit. It is not usage credits: this account has those +// switched off at the org level and still has this. A cloud session on it was +// seen working with its own server record saying five_hour "rejected", so it +// is the one lever here that carries work past the 5-hour wall today. The +// amount is only ever parsed out of the server's own copy, and the balance is +// served nowhere a file can see, so neither is invented. const fs = require('fs'); const os = require('os'); @@ -86,6 +98,8 @@ const SESSION_RESET_FLAG = 'tengu_nifty_lemur'; const WRAPUP_MODE_FLAG = 'tengu_lantern_wick_mode'; const WRAPUP_TEXT_FLAG = 'tengu_lantern_wick_text'; const NEAR_WALL_FLAG = 'tengu_vellum_anchor'; +const CLOUD_CREDIT_FLAG = 'tengu_swift_lynx'; +const CLAUDE_AI = 'https://claude.ai'; // The only two mode values the CLI's own normalizer keeps. const WRAPUP_MODES = new Set(['wrap-up', 'next-steps']); @@ -303,6 +317,68 @@ function credits(account) { return { available: null, reason: reason }; } +// The first amount of money in a line of the server's copy: "$250" is 250 USD. +const CURRENCY_SIGNS = { $: 'USD', '€': 'EUR', '£': 'GBP' }; +function moneyIn(text) { + const found = typeof text === 'string' ? /([$€£])\s?(\d[\d,]*(?:\.\d+)?)/.exec(text) : null; + if (!found) return null; + const amount = Number(found[2].replace(/,/g, '')); + return Number.isFinite(amount) && amount > 0 ? { amount, currency: CURRENCY_SIGNS[found[1]] } : null; +} + +// "$250", from what cloudCredit() read. The sign goes back on the way out. +function creditText(credit) { + if (!credit || !Number.isFinite(credit.amount)) return null; + const sign = Object.keys(CURRENCY_SIGNS).find((key) => CURRENCY_SIGNS[key] === credit.currency); + const amount = Number.isInteger(credit.amount) ? String(credit.amount) : credit.amount.toFixed(2); + return sign ? sign + amount : amount + ' ' + credit.currency; +} + +// The cloud-session credit this account is offered, or null. +// +// Null when the flag is absent, is not a plain object, says enabled:false, or +// carries no amount in any of its lines: an offer this cannot put a number on +// is not one it can repeat. The startup line comes first because it states the +// grant; the wall lines only say "a $250 credit". Whether it has been claimed, +// and what is left of it, are not in any file, so nothing here says either. +function cloudCredit(account) { + const gb = features(account); + const flag = gb && Object.prototype.hasOwnProperty.call(gb, CLOUD_CREDIT_FLAG) ? gb[CLOUD_CREDIT_FLAG] : null; + if (!flag || typeof flag !== 'object' || Array.isArray(flag) || flag.enabled === false) return null; + const part = (key) => (flag[key] && typeof flag[key] === 'object' && !Array.isArray(flag[key]) ? flag[key] : {}); + const wall = part('limitWall'); + const lines = [part('startup').text, wall.noticeLine, wall.label, wall.claimPageLine, part('startup').claimPageLine, part('ide').text]; + const money = lines.map(moneyIn).find(Boolean); + if (!money) return null; + const webPath = typeof flag.webPath === 'string' && /^\/[\w\-/]*$/.test(flag.webPath) ? flag.webPath : null; + return { + amount: money.amount, + currency: money.currency, + claimUrl: webPath ? CLAUDE_AI + webPath : null, + // Whether the CLI names it at the wall itself, which is where the report + // and the brief name it too. + wallNotice: Boolean(copy(wall.noticeLine) || copy(wall.label)), + version: Number.isFinite(Number(flag.version)) ? Number(flag.version) : null, + }; +} + +// Whether this process runs inside a claude.ai/code cloud container. +// +// From the environment only, because that is all a cloud container has: its +// ~/.claude.json carries no usage snapshot at all (checked 2026-09-28), so +// every reading of the plan's windows there is absent, not zero. The container +// id names the product (container_..--claude_code_remote--..); the pair below +// is the same container seen without it. The session id is the one Claude Code +// hands every command it runs, which is how a report finds its own transcript. +function cloudSession(env) { + const e = env || process.env; + const container = String(e.CLAUDE_CODE_CONTAINER_ID || ''); + const cloud = + container.indexOf('claude_code_remote') !== -1 || + (e.CLAUDE_CODE_REMOTE === 'true' && e.CCR_AGENT_PROXY_ENABLED === '1'); + return cloud ? { cloud: true, sessionId: e.CLAUDE_CODE_SESSION_ID || null } : null; +} + function settingsFiles() { return [path.join(configDir(), 'settings.local.json'), path.join(configDir(), 'settings.json')]; } @@ -472,6 +548,8 @@ function forBrief(input) { resetGrant: sessionReset(account).present, resetVariant: sessionReset(account).variant, credits: credits(account), + cloudCredit: cloudCredit(account), + inCloud: Boolean(cloudSession(options.env)), autoContinue: autoContinue(), }; } @@ -483,6 +561,7 @@ module.exports = { WRAPUP_MODE_FLAG, WRAPUP_TEXT_FLAG, NEAR_WALL_FLAG, + CLOUD_CREDIT_FLAG, WRAPUP_MODES, DEFAULT_COOLOFF_MINUTES, MAX_COOLOFF_MINUTES, @@ -499,6 +578,9 @@ module.exports = { flagEnabled, wrapUp, credits, + cloudCredit, + creditText, + cloudSession, autoContinue, acknowledge, readAck, diff --git a/skills/usage-limits/scripts/relay.js b/skills/usage-limits/scripts/relay.js index 23c2f38..fca84ea 100644 --- a/skills/usage-limits/scripts/relay.js +++ b/skills/usage-limits/scripts/relay.js @@ -96,6 +96,15 @@ const DEFAULTS = { // it is also how you find out in the morning that nothing happened and have // no idea why. show: true, + // Hand the work to a NEW conversation instead of resuming the old one. + // + // Off, the wake runs `claude --resume `: the same conversation, with + // everything it already holds. That is today's behaviour and stays the + // default. On, it starts `claude` in the project with the whole hand-off as + // its first prompt, so a session that has grown long hands its work on + // instead of dragging its whole context into the next window (Gev, + // 2026-09-29). Claude Code only; a Codex wake resumes its thread either way. + fresh: false, // The hand-off carries how the user writes, so the resumed session answers // in their voice without being reminded; and it asks for the two bug passes // the user asks for on nearly every request. Both can be turned off. @@ -405,6 +414,7 @@ function settings(state) { offlineAttempts: Math.min(48, Math.max(1, number(stored.offlineAttempts, DEFAULTS.offlineAttempts))), offlineRetryMinutes: Math.min(120, Math.max(2, number(stored.offlineRetryMinutes, DEFAULTS.offlineRetryMinutes))), show: bool(stored.show, DEFAULTS.show), + fresh: bool(stored.fresh, DEFAULTS.fresh), voice: bool(stored.voice, DEFAULTS.voice), bugcheck: pick(stored.bugcheck, ['on', 'always', 'off'], DEFAULTS.bugcheck), onFailure: pick(stored.onFailure, ['rearm', 'stop'], DEFAULTS.onFailure), @@ -553,13 +563,44 @@ const BUGCHECK_LINE = 'the first missed something. The user asks for this on nearly every request, so do it without being asked, ' + 'unless the last turn already did both.'; +// The opening for a new local session (relay fresh on). Unlike the resume it +// has none of the conversation, and unlike the cloud it is on this machine, +// in the same folder, with every uncommitted change still there. +function freshOpening(options) { + return ( + (options.deferred + ? 'The time this work was deferred to has come, and this is the plugin handing it to a new session, not a new request. ' + : 'The usage window has reset, and this is the plugin handing an earlier session\'s work to this new one, not a new request. ') + + 'You have none of that conversation: what follows is what it left. Carry on from where it stopped, at full quality, ' + + 'without re-asking what to do. ' + + (options.projectDir + ? 'The project is ' + options.projectDir + ', added to this session, which was started from a trusted folder on purpose: use absolute paths.' + : 'You are in its project folder, with the files as it left them, uncommitted changes included.') + ); +} + function compose(input) { const parts = []; const options = input || {}; if (options.thinking) parts.push('ultrathink'); + // A cloud session is not the same conversation and the window has not reset, + // so it is told what it is: a fresh clone, with only what reached GitHub. parts.push( - 'The usage window has reset and this is the plugin picking the work back up, not a new request. ' + - 'Carry on from where the last turn stopped, at full quality, without re-asking what to do.' + options.fresh + ? freshOpening(options) + : options.cloud + ? 'A local session reached its usage limit and this is the plugin handing its work to a cloud session ' + + 'rather than waiting for the reset, not a new request. You have none of that conversation: what ' + + 'follows is what it left. Carry on from where it stopped, at full quality, without re-asking what to ' + + 'do. This is a fresh clone from GitHub, so only what was pushed is here; if something below is ' + + 'missing, say so rather than rebuilding it from the description.' + + // Which branch the clone starts on is not the plugin's to choose, so + // the one the work was pushed to is named. + (options.branch && options.branch !== 'HEAD' + ? ' The work is on branch ' + options.branch + ': check it out first if the clone is on another.' + : '') + : 'The usage window has reset and this is the plugin picking the work back up, not a new request. ' + + 'Carry on from where the last turn stopped, at full quality, without re-asking what to do.' ); const continuation = String(options.continuation || '').trim(); if (continuation) { @@ -729,8 +770,11 @@ function taskName(id, at) { ); } +// PowerShell also ends a single-quoted string at a curly single quote +// (U+2018-U+201B), so those are doubled too: a typographic apostrophe in a +// hand-off must not close its own argument. function psQuote(value) { - return "'" + String(value).replace(/'/g, "''") + "'"; + return "'" + String(value).replace(/['\u2018-\u201B]/g, '$&$&') + "'"; } // One argument, quoted the way CommandLineToArgvW will unquote it. The rule @@ -1482,7 +1526,8 @@ function status(now) { const lines = []; lines.push( 'Relay is ' + (config.enabled ? 'ON' : 'OFF') + ', arming at ' + config.at + ' per cent, waking ' + - config.graceMinutes + ' min after the reset, delivery ' + config.mode + '.' + config.graceMinutes + ' min after the reset, delivery ' + config.mode + + (config.mode === 'resume' ? (config.fresh ? ' into a new session (fresh on)' : ' of the same conversation (fresh off)') : '') + '.' ); const all = records(state); for (const armed of all) { @@ -1603,6 +1648,325 @@ async function armByHand(rest) { ); } +/* ------------------------------------------------------------ the cloud --- */ + +// Carrying the work into a cloud session now, instead of waiting for the reset. +// +// With usage credits off (org_level_disabled on this account) the 5-hour wall +// stops a local session dead, and the wake can only pick the work up after the +// reset. The CLI offers a second way at that same wall: a cloud-session credit +// (lowpri.cloudCredit), and `claude --cloud ` starts a +// claude.ai/code session. One was seen on 2026-09-28 still working with its own +// server record saying five_hour "rejected". +// +// Two things make it unlike the wake, and both are said rather than assumed. +// The cloud session clones the repository from GitHub, so it has none of this +// machine's uncommitted changes or unpushed commits; and it cannot read a file +// here, so the hand-off rides inline as the description instead of as a +// pointer to relay-plan-.md. +// +// `relay cloud` prints the command and starts nothing. `--go` starts it, and is +// guarded the way the scheduler is: under node --test, with +// USAGE_LIMITS_CLOUD=dry-run, or with the home folder redirected it launches +// nothing and says so; USAGE_LIMITS_CLOUD=real overrides all three. A cloud +// session spends a real credit, so no test may ever start one. +function cloudLaunchMode() { + const said = String(process.env.USAGE_LIMITS_CLOUD || '').trim().toLowerCase(); + if (said === 'real') return { dry: false, why: null }; + if (said === 'dry-run' || said === 'dry') return { dry: true, why: 'USAGE_LIMITS_CLOUD=' + said }; + if (process.env.NODE_TEST_CONTEXT) return { dry: true, why: 'running under node --test' }; + if (sandboxedHome()) return { dry: true, why: 'the home folder is redirected to ' + os.homedir() }; + return { dry: false, why: null }; +} + +// The longest description worth passing as one argument. Linux refuses a +// single argument past 128 KiB (MAX_ARG_STRLEN); Windows caps a whole command +// line at 32,767 characters, and at 8,191 once cmd.exe is in the way. +const CLOUD_ARG_MAX = { win32: 32000, posix: 120000 }; + +function runGit(args, cwd) { + const run = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10000, windowsHide: true }); + return { status: run.status, stdout: String(run.stdout || '').trim() }; +} + +// What the cloud clone will and will not have. `git` is injectable so the +// states can be tested without a repository in each of them. +function gitState(cwd, git) { + const run = git || runGit; + const inside = run(['rev-parse', '--is-inside-work-tree'], cwd); + if (inside.status !== 0 || inside.stdout !== 'true') return { repo: false }; + const status = run(['status', '--porcelain'], cwd); + const dirty = status.status === 0 && status.stdout ? status.stdout.split('\n').filter(Boolean).length : 0; + const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'], cwd).stdout || null; + const tracking = run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd); + const upstream = tracking.status === 0 && tracking.stdout ? tracking.stdout : null; + let unpushed = null; + if (upstream) { + const count = run(['rev-list', '--count', '@{u}..HEAD'], cwd); + unpushed = count.status === 0 ? Number(count.stdout) || 0 : null; + } + const remotes = run(['remote'], cwd); + return { repo: true, dirty, branch, upstream, unpushed, remote: remotes.status === 0 && Boolean(remotes.stdout) }; +} + +function plural(n, word) { + return n + ' ' + word + (n === 1 ? '' : 's'); +} + +// `cli` is the claude a person on Windows would be running the printed command +// with. A batch shim (claude.cmd from npm) runs through cmd.exe even from +// PowerShell, and cmd.exe ends a command at the first line break, so the cloud +// session would get only the hand-off's first line. `native` is a claude.exe +// found elsewhere on the machine, if there is one: --go already uses it. +function cloudWarnings(git, prompt, platform, cli, native) { + const out = []; + if (!git.repo) { + out.push('This folder is not a git repository. A cloud session starts from a clone of a GitHub repository, so there is nothing here for it to start from.'); + } else { + if (!git.remote) out.push('The repository has no remote, so there is nothing on GitHub for the cloud session to clone.'); + if (git.dirty) { + out.push(plural(git.dirty, 'uncommitted change') + ' in the working tree. The cloud session clones from GitHub and will not see ' + + (git.dirty === 1 ? 'it' : 'them') + ': commit and push first.'); + } + if (git.remote && !git.upstream) { + out.push('Branch ' + git.branch + ' has no upstream, so the cloud session cannot clone it: git push -u origin ' + git.branch + ' first.'); + } + if (git.unpushed) { + out.push(plural(git.unpushed, 'commit') + ' on ' + git.branch + ' not pushed to ' + git.upstream + ': push first, or the cloud session starts without ' + + (git.unpushed === 1 ? 'it' : 'them') + '.'); + } + } + const limit = platform === 'win32' ? CLOUD_ARG_MAX.win32 : CLOUD_ARG_MAX.posix; + if (prompt.length > limit) { + out.push('The hand-off is ' + prompt.length + ' characters, more than one command-line argument holds here (' + limit + '): shorten the continuation (relay note) and run this again.'); + } + if (platform === 'win32' && cli && /\.(cmd|bat)$/i.test(cli)) { + out.push('claude here is ' + cli + ', a batch shim. PowerShell runs it through cmd.exe, and cmd.exe cuts the hand-off at its first line break, ' + + 'so the cloud session would get one line of it: ' + + (native && !/\.(cmd|bat)$/i.test(native) + ? 'run it with the native claude.exe instead (& ' + psQuote(native) + ' in place of claude), or use --go, which does.' + : 'run it where claude is the native claude.exe (claude install puts one in ~/.local/bin).')); + } + if (platform === 'win32' && prompt.indexOf('"') !== -1) { + out.push('The hand-off holds double quotes, which Windows PowerShell before 7.3 drops from an argument: paste it into PowerShell 7.3 or later (pwsh), or use --go.'); + } + return out; +} + +// The command a person pastes. PowerShell on Windows, where the relay already +// lives, and sh everywhere else; both keep line breaks inside single quotes. +function cloudCommand(prompt, platform) { + const quoted = platform === 'win32' ? psQuote(prompt) : require('./wake.js').shQuote(prompt); + return 'claude --cloud ' + quoted; +} + +// The same prompt the wake would deliver - continuation, outstanding todos or +// the approved plan, thinking, voice, bug check - opened for a cloud session. +// The session is this one (CLAUDE_CODE_SESSION_ID) unless --session names +// another; with neither, the one armed relay. Text given on the command line +// stands in for the stored continuation. +function cloudHandoff(input) { + const options = input || {}; + const env = options.env || process.env; + const state = read(); + const config = settings(state); + const me = options.sessionId || env.CLAUDE_CODE_SESSION_ID || env.CLAUDE_SESSION_ID || null; + const all = records(state); + const record = armedFor(state, me) || (!me && all.length === 1 ? all[0] : null); + const id = me || (record && record.id) || null; + const given = String(options.text || '').trim().slice(0, CONTINUATION_MAX); + const continuation = given || (id ? readContinuation(id) : ''); + let work = record && record.work && Array.isArray(record.work.todos) ? record.work : null; + if (!work && id) { + const transcript = require('./wake.js').transcriptFile(id); + const found = transcript ? detectWork(transcript) : null; + if (found && found.hasWork) work = found; + } + const outstanding = work ? work.todos.filter((todo) => todo.status !== 'completed').length : 0; + if (!continuation && !outstanding && !(work && work.plan)) { + return { + ok: false, + error: + 'Nothing to hand over' + (id ? ' for session ' + id.slice(0, 8) : '') + ': no continuation, no outstanding todos, no approved plan. ' + + 'A cloud session has none of this conversation, so write what it needs first: relay cloud "", or relay note "" and run this again.', + }; + } + const platform = options.platform || process.platform; + const cwd = (record && record.cwd) || options.cwd || process.cwd(); + const git = gitState(cwd, options.git); + const prompt = compose({ + cloud: true, + branch: git.repo ? git.branch : null, + continuation, + work, + thinking: config.thinking !== 'off', + voice: config.voice === false ? null : voice.card(), + bugcheck: config.bugcheck, + }); + return { + ok: true, + id, + cwd, + platform, + prompt, + source: given ? 'the text given' : continuation ? 'the saved continuation' : outstanding ? plural(outstanding, 'outstanding todo') : 'the approved plan', + command: cloudCommand(prompt, platform), + git, + warnings: cloudWarnings(git, prompt, platform, options.cli || null, options.nativeCli || null), + }; +} + +// The CLI to launch. A batch shim cannot carry the hand-off - cmd.exe ends a +// command at the first line break - so on Windows the native claude.exe is +// looked for first. +function cloudCli(env, platform) { + const environment = env || process.env; + if (environment.USAGE_LIMITS_CLAUDE_CLI) return firstExisting([environment.USAGE_LIMITS_CLAUDE_CLI]); + if (platform === 'win32') { + const native = firstExisting([path.join(os.homedir(), '.local', 'bin', 'claude.exe')]); + if (native) return native; + const where = spawnSync('where', ['claude.exe'], { encoding: 'utf8', timeout: 10000, windowsHide: true }); + const first = where.status === 0 ? String(where.stdout || '').split(/\r?\n/)[0].trim() : ''; + return first || findClaude(environment); + } + const found = findClaude(environment); + if (found) return found; + const which = spawnSync('sh', ['-c', 'command -v claude'], { encoding: 'utf8', timeout: 10000 }); + return which.status === 0 ? String(which.stdout || '').trim() || null : null; +} + +// The claude that `claude` typed into a Windows shell runs: the first match on +// PATH, which is what `where` lists first. Only asked on Windows. +function shellClaude(env) { + const environment = env || process.env; + if (environment.USAGE_LIMITS_CLAUDE_CLI) return firstExisting([environment.USAGE_LIMITS_CLAUDE_CLI]); + const where = spawnSync('where', ['claude'], { encoding: 'utf8', timeout: 10000, windowsHide: true }); + const first = where.status === 0 ? String(where.stdout || '').split(/\r?\n/)[0].trim() : ''; + return first || findClaude(environment); +} + +// `deps` holds what the launch does to the world - mode, cli, spawn - so it can +// be tested without starting anything. Detached, with its output in a run log, +// because the command outlives this process and somebody will ask what it did. +function launchCloud(handoff, deps) { + const d = deps || {}; + const mode = d.mode || cloudLaunchMode(); + const who = handoff.id ? 'session ' + handoff.id.slice(0, 8) : 'the text given'; + if (mode.dry) { + note('cloud: dry run for ' + who + ', nothing launched (' + mode.why + ')'); + return { ok: true, dry: true, text: 'Dry run: nothing was launched (' + mode.why + ').' }; + } + if (!handoff.git.repo) return { ok: false, text: 'Not launched: there is no repository here for a cloud session to clone.' }; + const cli = d.cli || cloudCli(d.env, handoff.platform); + if (!cli) return { ok: false, text: 'Not launched: the claude CLI was not found. Run the command above yourself.' }; + if (handoff.platform === 'win32' && /\.(cmd|bat)$/i.test(cli)) { + return { + ok: false, + text: 'Not launched: claude here is ' + cli + ', a batch shim, and cmd.exe ends a command at the first line break, so the hand-off cannot pass through it. ' + + 'PowerShell runs a shim through cmd.exe too, so run the command above where claude is the native claude.exe (claude install puts one in ~/.local/bin).', + }; + } + const now = Date.now(); + const log = writeRunLog(runLogFile('cloud', now), '--- claude --cloud from ' + handoff.cwd + ' for ' + who + ', ' + new Date(now).toISOString() + ' ---\n'); + let fd = null; + try { + if (log) fd = fs.openSync(log, 'a'); + } catch (err) { + fd = null; + } + // Nobody's child: launched from inside a session, it would otherwise inherit + // that session's markers (see the wake's launcher). + const env = Object.assign({}, d.env || process.env); + delete env.CLAUDE_CODE_CHILD_SESSION; + delete env.CLAUDE_CODE_SESSION_ID; + try { + const child = (d.spawn || spawn)(cli, ['--cloud', handoff.prompt], { + cwd: handoff.cwd, + detached: true, + stdio: fd === null ? 'ignore' : ['ignore', fd, fd], + windowsHide: true, + env, + }); + if (child && typeof child.on === 'function') { + child.on('error', (err) => { + note('cloud: claude --cloud for ' + who + ' failed to start: ' + err.message); + }); + } + if (child && typeof child.unref === 'function') child.unref(); + const pid = child && child.pid ? child.pid : null; + note('cloud: launched claude --cloud for ' + who + ' from ' + handoff.cwd + (pid ? ' (pid ' + pid + ')' : '') + (log ? ', output in ' + log : '')); + return { + ok: true, + pid, + log, + text: 'Started claude --cloud' + (pid ? ' (pid ' + pid + ')' : '') + '.' + (log ? ' What it prints goes to ' + log + '.' : '') + + ' The session appears on claude.ai/code.', + }; + } catch (err) { + note('cloud: could not launch claude --cloud for ' + who + ': ' + err.message); + return { ok: false, text: 'Could not start claude --cloud: ' + err.message }; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch (err) { + // The child has its own copy. + } + } + } +} + +function cloud(rest, deps) { + const args = rest || []; + const d = deps || {}; + const at = args.indexOf('--session'); + const named = at !== -1 ? args[at + 1] || null : null; + const go = args.includes('--go'); + // --host is the plugin's own flag (relay.md: add --host codex under Codex), + // and a hand-off that ended in "--host codex" would be telling the cloud + // session something nobody meant. + const skip = new Set(); + for (const flag of ['--session', '--host']) { + const i = args.indexOf(flag); + if (i !== -1) skip.add(i).add(i + 1); + } + const text = args.filter((item, i) => item !== '--go' && !skip.has(i)).join(' '); + // Which claude the printed command would meet, asked only for Windows, where + // a batch shim cannot carry it: the first on PATH, as the shell finds it. + const platform = d.platform || process.platform; + let cli = null; + let nativeCli = null; + if (platform === 'win32') { + try { + cli = d.cli !== undefined ? d.cli : shellClaude(d.env); + if (/\.(cmd|bat)$/i.test(String(cli || ''))) nativeCli = d.nativeCli !== undefined ? d.nativeCli : cloudCli(d.env, platform); + } catch (err) { + cli = null; + } + } + const handoff = cloudHandoff({ sessionId: named, text, env: d.env, git: d.git, platform, cwd: d.cwd, cli, nativeCli }); + if (!handoff.ok) return handoff.error; + let amount = null; + try { + amount = lowpri.creditText(lowpri.cloudCredit(d.account !== undefined ? d.account : lowpri.snapshot())); + } catch (err) { + amount = null; + } + const lines = [ + 'Cloud hand-off' + (handoff.id ? ' for session ' + handoff.id.slice(0, 8) : '') + ', from ' + handoff.source + ': ' + + handoff.prompt.length + ' characters, inline, because a cloud session cannot read a file on this machine.' + + (amount ? ' It runs on the ' + amount + ' cloud-session credit, not the plan\'s 5-hour window.' : ''), + ]; + for (const warning of handoff.warnings) lines.push('Warning: ' + warning); + lines.push('', 'Run this from ' + handoff.cwd + (handoff.platform === 'win32' ? ', in PowerShell' : '') + ':', '', handoff.command, ''); + if (!go) { + lines.push('relay cloud --go starts it from here.'); + return lines.join('\n'); + } + lines.push(launchCloud(handoff, d).text); + return lines.join('\n'); +} + /* ------------------------------------------------------------- doctor ----- */ // Everything that has to be true hours from now, checked while somebody is @@ -1622,7 +1986,9 @@ async function doctor(now) { const add = (name, ok, detail, severity) => checks.push({ name, ok, detail, severity: severity || (ok ? 'ok' : 'error') }); add('relay enabled', config.enabled, config.enabled ? 'on, arming at ' + config.at + ' per cent' : 'off - nothing will ever be scheduled'); - add('delivery mode', true, config.mode === 'resume' ? 'resume: it starts the CLI itself' : 'notify: it raises a toast and leaves the plan on disk', 'ok'); + add('delivery mode', true, config.mode === 'resume' + ? 'resume: it starts the CLI itself, ' + (config.fresh ? 'as a new session with the hand-off as its first prompt (fresh on)' : 'resuming the same conversation (fresh off)') + : 'notify: it raises a toast and leaves the plan on disk', 'ok'); add('arming point', true, config.armOn === 'completion' ? 'completion - it waits for the reply to finish, and stops waiting past ' + config.backstopAt + ' per cent' : 'threshold - it arms the moment the window crosses ' + config.at + ' per cent, even mid-reply', 'ok'); @@ -1794,7 +2160,9 @@ function main(argv) { return ( 'Delivery is now "' + config.mode + '".' + (config.mode === 'resume' - ? '\nAt the wake it will run the CLI itself in the project directory. Set a permission mode ' + + ? '\nAt the wake it will run the CLI itself in the project directory, ' + + (config.fresh ? 'as a new session (relay fresh on)' : 'resuming the same conversation (relay fresh on starts a new one instead)') + + '. Set a permission mode ' + '(relay permission acceptEdits) or the resumed run will sit waiting for an approval nobody is there to give.' : '') ); @@ -1857,6 +2225,9 @@ function main(argv) { // tool into its rollouts, so nothing there ever reads as work to carry; and // a person can have a project in their head that is in no todo list. if (command === 'arm') return armByHand(rest); + // Past the wall now rather than after it: the same hand-off, into a cloud + // session on the cloud-session credit. + if (command === 'cloud') return cloud(rest); if (command === 'armon' || command === 'arm-on') { if (!['threshold', 'completion'].includes(String(value))) { return 'Arming is "threshold" (the moment the window crosses the mark, mid-reply) or "completion" (the end of the reply).'; @@ -1895,6 +2266,19 @@ function main(argv) { ? 'The resumed run will be a session in a window you can see, with Remote Control on; a headless run keeps its output in relay log --run.' : 'The resumed run will be invisible. Its output is still kept: relay log --run.'; } + if (command === 'fresh') { + const wanted = String(value || '').toLowerCase(); + if (!['on', 'off', 'true', 'false', 'yes', 'no', '1', '0'].includes(wanted)) { + return 'Fresh is ' + (settings(read()).fresh ? 'ON' : 'OFF') + ' now. Say relay fresh on (the wake starts a new session with the hand-off) or relay fresh off (it resumes the same conversation).'; + } + const config = configure({ fresh: ['on', 'true', 'yes', '1'].includes(wanted) }); + const when = config.mode === 'resume' ? '' : ' It applies once relay mode resume is set; with mode notify nothing is started either way.'; + return config.fresh + ? 'Fresh ON: the wake starts a new claude session in the project, with the whole hand-off as its first prompt, Remote Control on and ' + + (config.permissionMode ? '--permission-mode ' + config.permissionMode : 'no permission mode set (relay permission acceptEdits)') + + '. The old conversation is left as it was; if it is still open and working again, the wake stands down.' + when + : 'Fresh OFF: the wake resumes the same conversation (claude --resume).' + when; + } if (command === 'onfailure' || command === 'on-failure') { if (!['rearm', 'stop'].includes(String(value))) return 'On failure: "rearm" (try again next window) or "stop" (leave it to a person).'; const config = configure({ onFailure: value }); @@ -1942,8 +2326,9 @@ function main(argv) { return [ 'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|', ' thinking off|resume|always|armon threshold|completion|backstop N|', - ' show on|off|onfailure rearm|stop|offline N|doctor|preflight [cwd]|', - ' arm [--session ID] [TEXT]|note TEXT|cancel|log [--run]]', + ' show on|off|fresh on|off|onfailure rearm|stop|offline N|doctor|preflight [cwd]|', + ' arm [--session ID] [TEXT]|note TEXT|cloud [--session ID] [--go] [TEXT]|', + ' cancel|log [--run]]', '', status(Date.now()), ].join('\n'); @@ -1964,7 +2349,8 @@ if (require.main === module) { ); } -module.exports = { workWithContinuation, reapLost, schedulerMode, sandboxedHome, hiddenAction, headlessAvailable, HEADLESS_HOST, HIDDEN_HOST, BUGCHECK_LINE, +module.exports = { shellClaude, cloud, cloudHandoff, cloudCommand, cloudWarnings, cloudLaunchMode, cloudCli, launchCloud, gitState, CLOUD_ARG_MAX, + workWithContinuation, reapLost, schedulerMode, sandboxedHome, hiddenAction, headlessAvailable, HEADLESS_HOST, HIDDEN_HOST, BUGCHECK_LINE, taskAction, wakeLauncherFile, wakeLauncherScript, writeWakeLauncher, sweepWakeLaunchers, batchArg, describeSpawn, PS_ROUTE_MS, SCHTASKS_MS, records, armedFor, putRecord, dropRecord, preflightPrompts, claudeJsonFile, projectKeys, isHome, launchDirFor, applySetting, settingIs, DEFAULTS, diff --git a/skills/usage-limits/scripts/usage.js b/skills/usage-limits/scripts/usage.js index 3fc094c..27a071c 100644 --- a/skills/usage-limits/scripts/usage.js +++ b/skills/usage-limits/scripts/usage.js @@ -26,6 +26,7 @@ const host = require('./host.js'); const codex = require('./codex.js'); const live = require('./live.js'); const reading = require('./reading.js'); +const lowpri = require('./lowpri.js'); // Which agent's meter to read. Resolved once from the command line or the // environment, because a process that changed its mind halfway through would @@ -2975,6 +2976,9 @@ function collectClaude(now) { effortLevel: settings.effortLevel || 'default', }, extraUsage: utilization && utilization.extra_usage ? utilization.extra_usage : null, + // The dollar credit Claude Code offers for cloud sessions, which is a + // different thing from usage credits and outlives them being switched off. + cloudCredit: lowpri.cloudCredit(account), }; } @@ -3375,6 +3379,14 @@ async function report(now, options) { // reasoning inside it. const scopedModels = byModel(scoped); + // Inside a claude.ai/code container there is no snapshot to read, and the + // figure that means something is what this session has cost so far. The + // session is the one Claude Code names in the environment of whatever it + // runs; its subagents' turns carry the same id and are counted with it. + const cloud = isCodex() || isGemini() ? null : lowpri.cloudSession(options && options.env); + const cloudId = cloud ? (options && options.sessionId) || cloud.sessionId : null; + const cloudSession = cloud ? Object.assign({}, cloud, { sessionId: cloudId, spent: sessionSpend(events, cloudId) }) : null; + return Object.assign({}, base, { windows, binding, @@ -3393,6 +3405,8 @@ async function report(now, options) { // nothing happened. lastRefusal: [...rejections.values()].sort((a, b) => b.at - a.at)[0] || null, credits: base.codexCredits || creditsFrom(base.utilization), + cloudCredit: base.cloudCredit || null, + cloudSession, sessions: activeSessions(events, now, CONCURRENT_WINDOW_MS), session: sessionSpend(events, options && options.sessionId), // A per-model weekly for a model that is not running could be a week past @@ -3616,8 +3630,45 @@ function render(data) { ); } } + // Not usage credits: a dollar credit for claude.ai/code sessions that the + // CLI itself offers at the wall, and which this account had with usage + // credits switched off. Inside a cloud session the block below says it. + const cloudAmount = lowpri.creditText(data.cloudCredit); + if (cloudAmount && !data.cloudSession) { + lines.push(' Cloud credit ' + cloudAmount + ' for cloud sessions, on top of the plan: claude --cloud, or claude.ai/code'); + } lines.push(''); + // A claude.ai/code container keeps no usage snapshot, so "run /usage" is + // advice that cannot work there. What can be said is what kind of session + // this is, and what it has cost so far at list prices. + if (data.cloudSession) { + if (cloudAmount) { + lines.push(' ' + pad('Cloud session', 15) + 'a claude.ai/code session, billed to the cloud-session credit'); + lines.push(' rather than the plan\'s 5-hour window. Its container keeps no'); + lines.push(' usage snapshot, so there is no window to read and /usage has'); + lines.push(' nothing to add here.'); + } else { + lines.push(' ' + pad('Cloud session', 15) + 'a claude.ai/code session. Its container keeps no usage'); + lines.push(' snapshot, so the plan\'s windows are not readable here; the'); + lines.push(' report on the machine you usually work from has them.'); + } + const spent = data.cloudSession.spent; + if (!spent) { + lines.push(' ' + pad('So far', 15) + 'nothing this session has spent is on record yet.'); + } else { + lines.push( + ' ' + pad('So far', 15) + 'about ' + formatUSD(spent.cost) + ' this session at API list prices, ' + + spent.turns + ' turn' + (spent.turns === 1 ? '' : 's') + (cloudAmount ? ';' : '.') + ); + } + if (cloudAmount) { + lines.push(' the credit is ' + cloudAmount + ' across all cloud sessions, and what is'); + lines.push(' left of it is not readable here.'); + } + lines.push(''); + } + if (!data.windows.length) { // Two different situations, and telling them apart matters. Nothing to read // is a setup problem. Nothing to report is the correct answer on a plan @@ -3636,6 +3687,7 @@ function render(data) { } return lines.join('\n'); } + if (data.cloudSession) return lines.join('\n').replace(/\n+$/, ''); lines.push(' No usage snapshot in ' + (data.accountFile || '~/.claude.json') + '.'); lines.push( data.host === host.CODEX @@ -3961,6 +4013,12 @@ function render(data) { const credits = data.credits; if (!credits || !credits.enabled || credits.limitReached) { lines.push(' Work stops when it does. Nothing carries on into paid credits.'); + // The one way past the wall this account has today, and not a way to + // spend more of the plan: a cloud session runs on its own credit. + if (cloudAmount && !data.cloudSession) { + lines.push(' To carry on now instead of waiting, start a cloud session on the ' + cloudAmount); + lines.push(' cloud-session credit: relay cloud hands it this work as claude --cloud.'); + } } if (Number.isFinite(data.resumeAt)) { lines.push( diff --git a/skills/usage-limits/scripts/wake.js b/skills/usage-limits/scripts/wake.js index 3569a4f..e265ef6 100644 --- a/skills/usage-limits/scripts/wake.js +++ b/skills/usage-limits/scripts/wake.js @@ -157,7 +157,9 @@ function userIsPresent(cli) { // that way and answered before this change was made. The prompt parameter is // kept in the signature so callers and tests do not change shape. function claudeArgs(record, prompt, config, fallback) { - const args = fallback ? ['--continue', '-p'] : ['--resume', record.id, '-p']; + // relay fresh on: a new conversation, so no id to resume. The hand-off is + // still the stdin prompt, whole. + const args = fallback ? ['--continue', '-p'] : freshFor(config) ? ['-p'] : ['--resume', record.id, '-p']; if (config.permissionMode) args.push('--permission-mode', config.permissionMode); // Nobody is awake to answer a prompt. Deny it rather than stall on it: a // resumed run that sits waiting for a keystroke burns its whole timeout and @@ -229,12 +231,112 @@ function spawnOptionsFor(record, config, cli) { // would never be written and a failure would close the window unread. const LAUNCH_GRACE_MS = 20000; +// relay fresh: a new conversation instead of `claude --resume`. +// +// Resuming carries the whole conversation into the next window, and a long +// session is exactly the one that runs out. With fresh on, the wake starts +// `claude` in the project with the hand-off itself as the first prompt - +// inline, so the new session has all of it without a tool call - Remote +// Control on under a name that says it is a new session, and the relay's +// permission mode and model. The ONE TERMINAL rule is the same: an old +// session that is open and working again makes the wake stand down. +function freshFor(config) { + return Boolean(config && config.fresh); +} + +// One argument has to hold the hand-off. Linux refuses a single argument past +// 128 KiB; Windows caps a whole command line at 32,767 characters, and there +// each double quote and backslash can cost one more when it is quoted. +const FRESH_ARG_MAX = { win32: 30000, posix: 120000 }; + +function argCost(text, platform) { + return platform === 'win32' ? text.length + (text.match(/["\\]/g) || []).length : text.length; +} + +function isShim(cli) { + return /\.(cmd|bat)$/i.test(String(cli || '')); +} + +function freshName(record) { + return 'usage-limits relay ' + (record.project || path.basename(record.cwd)) + ' (new session)'; +} + +// When the hand-off cannot ride inline, the first prompt points at the file +// holding it, the way the resume does. Short and free of quotes, for cmd. +function freshPointerPrompt(file) { + return ( + 'This is a new session taking over work that the usage-limits plugin handed on from an earlier one, not a new request. ' + + 'Everything it left is in the file ' + file + ' - read it with the Read tool now and carry on from it at full quality, ' + + 'without re-asking what to do.' + ); +} + +function freshArgs(record, config, first) { + const args = []; + if (record.launchCwd) args.push('--add-dir', record.cwd); + if (config && config.permissionMode) args.push('--permission-mode', config.permissionMode); + if (config && config.model) args.push('--model', config.model); + args.push('--remote-control', freshName(record)); + args.push(first); + return args; +} + +// The new session's command line: the hand-off inline when one argument can +// carry it, and the pointer when it cannot - a batch shim on Windows (cmd.exe +// ends a command at the first line break) or a hand-off too long for argv. +function freshLaunch(record, config, cli, prompt, promptFile, platform) { + const text = String(prompt == null ? '' : prompt); + const limit = platform === 'win32' ? FRESH_ARG_MAX.win32 : FRESH_ARG_MAX.posix; + let why = null; + if (platform === 'win32' && isShim(cli)) { + why = cli + ' is a batch shim and cmd.exe ends a command at the first line break'; + } else if (argCost(text, platform) > limit) { + why = 'the hand-off is ' + text.length + ' characters, more than one argument holds here (' + limit + ')'; + } + return { cli, args: freshArgs(record, config, why ? freshPointerPrompt(promptFile) : text), inline: !why, why }; +} + +// On Windows the launcher is a .cmd, and cmd cannot carry a line break in an +// argument, so an inline hand-off goes through node instead: the launcher +// calls this script with --exec-spec, and it starts claude with the arguments +// from the spec as an array, which Node quotes for CreateProcess. Ctrl+C is +// claude's to handle; this process only waits and passes the exit code on. +// UNVERIFIED on a real Windows console: written from Node's documented spawn +// behaviour, with the argument passing itself under test. +function specFile(id) { + return path.join(relay.configDir(), 'relay-wake-' + String(id) + '.spec.json'); +} + +function execSpec(file, io) { + const ops = Object.assign({ spawn, exit: (code) => process.exit(code), onSigint: (fn) => process.on('SIGINT', fn) }, io || null); + let spec; + try { + spec = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + process.stderr.write('[usage-limits relay] could not read ' + file + ': ' + err.message + '\n'); + ops.exit(1); + return null; + } + ops.onSigint(() => {}); + const child = ops.spawn(spec.cli, spec.args, { stdio: 'inherit' }); + child.on('error', (err) => { + process.stderr.write('[usage-limits relay] could not start ' + spec.cli + ': ' + err.message + '\n'); + ops.exit(1); + }); + child.on('exit', (code) => ops.exit(code === null ? 1 : code)); + return child; +} + +function bridgeCommand(file) { + return cmdArg(process.execPath) + ' ' + cmdArg(path.join(__dirname, 'wake.js')) + ' ' + cmdArg('--exec-spec') + ' ' + cmdArg(file); +} + function wakePromptFile(id) { return path.join(relay.configDir(), 'relay-wake-' + id + '.md'); } -function launcherFile(id) { - return path.join(relay.configDir(), 'relay-wake-' + id + (process.platform === 'win32' ? '.cmd' : '.sh')); +function launcherFile(id, platform) { + return path.join(relay.configDir(), 'relay-wake-' + id + ((platform || process.platform) === 'win32' ? '.cmd' : '.sh')); } function exitFile(id) { @@ -267,7 +369,8 @@ function visibleArgs(record, config, promptFile) { return args; } -function launcherScript(record, config, cli, promptFile, exitPath) { +// `command`, when given, is what follows `call`: the fresh launch's own line. +function launcherScript(record, config, cli, promptFile, exitPath, command) { const name = String(record.project || path.basename(record.cwd)).replace(/[&|<>^%"]/g, ''); return [ '@echo off', @@ -280,7 +383,7 @@ function launcherScript(record, config, cli, promptFile, exitPath) { // The resumed session is nobody's child, whoever opened the window. 'set CLAUDE_CODE_CHILD_SESSION=', 'set CLAUDE_CODE_SESSION_ID=', - 'call ' + cmdArg(cli) + ' ' + visibleArgs(record, config, promptFile).map(cmdArg).join(' '), + 'call ' + (command || cmdArg(cli) + ' ' + visibleArgs(record, config, promptFile).map(cmdArg).join(' ')), 'set CODE=%ERRORLEVEL%', '> ' + cmdArg(exitPath) + ' echo %CODE%', 'if not "%CODE%"=="0" (', @@ -443,12 +546,13 @@ function shQuote(text) { return Q + String(text).split(Q).join(Q + String.fromCharCode(92) + Q + Q) + Q; } -function launcherScriptPosix(record, config, cli, promptFile, exitPath) { +// `args`, when given, replaces the resume's: the fresh launch's own. +function launcherScriptPosix(record, config, cli, promptFile, exitPath, args) { return [ '#!/bin/sh', 'cd ' + shQuote(record.cwd) + ' || exit 1', 'unset CLAUDE_CODE_CHILD_SESSION CLAUDE_CODE_SESSION_ID', - shQuote(cli) + ' ' + visibleArgs(record, config, promptFile).map(shQuote).join(' '), + shQuote(cli) + ' ' + (args || visibleArgs(record, config, promptFile)).map(shQuote).join(' '), 'code=$?', 'echo "$code" > ' + shQuote(exitPath), 'if [ "$code" -ne 0 ]; then', @@ -489,10 +593,20 @@ function openWindowPosix(launcher) { } // `io` exists so the window path can be tested without a window: open, sleep -// and now are the three things it does to the world. +// and now are the three things it does to the world. `platform` picks the +// launcher, and `nativeCli` is where a fresh launch on Windows looks for +// claude.exe when the CLI it was given is a batch shim. function deliverVisible(record, prompt, config, cli, runs, io) { - const ops = Object.assign({ open: process.platform === 'win32' ? openWindow : openWindowPosix, sleep: sleepMs, now: Date.now }, io || null); - if (!transcriptExists(record.id)) { + const platform = (io && io.platform) || process.platform; + const ops = Object.assign({ + open: platform === 'win32' ? openWindow : openWindowPosix, + sleep: sleepMs, + now: Date.now, + nativeCli: () => relay.cloudCli(process.env, 'win32'), + }, io || null); + const fresh = freshFor(config); + // A new session needs no transcript; only a resume does. + if (!fresh && !transcriptExists(record.id)) { return { ok: false, permanent: true, @@ -501,15 +615,44 @@ function deliverVisible(record, prompt, config, cli, runs, io) { 'The plan is still on disk: run "claude" in ' + record.cwd + ' and paste it in.', }; } + const label = fresh ? 'claude, a new session (window)' : 'claude --resume (window)'; const promptFile = wakePromptFile(record.id); - const launcher = launcherFile(record.id); + const launcher = launcherFile(record.id, platform); const exitPath = exitFile(record.id); + let launch = null; + if (fresh) { + let use = cli; + if (platform === 'win32' && isShim(cli)) { + let native = null; + try { + native = ops.nativeCli(); + } catch (err) { + native = null; + } + if (native && !isShim(native)) use = native; + } + launch = freshLaunch(record, config, use, prompt, promptFile, platform); + } try { fs.mkdirSync(relay.configDir(), { recursive: true }); fs.writeFileSync(promptFile, String(prompt == null ? '' : prompt) + '\n'); - fs.writeFileSync(launcher, process.platform === 'win32' - ? launcherScript(record, config, cli, promptFile, exitPath) - : launcherScriptPosix(record, config, cli, promptFile, exitPath)); + let script; + if (platform === 'win32') { + let command = null; + if (launch && launch.inline) { + const spec = specFile(record.id); + fs.writeFileSync(spec, JSON.stringify({ cli: launch.cli, args: launch.args })); + command = bridgeCommand(spec); + } else if (launch) { + command = cmdArg(launch.cli) + ' ' + launch.args.map(cmdArg).join(' '); + } + script = launcherScript(record, config, cli, promptFile, exitPath, command); + } else { + script = launch + ? launcherScriptPosix(record, config, launch.cli, promptFile, exitPath, launch.args) + : launcherScriptPosix(record, config, cli, promptFile, exitPath); + } + fs.writeFileSync(launcher, script); try { fs.unlinkSync(exitPath); } catch (err) { @@ -518,10 +661,13 @@ function deliverVisible(record, prompt, config, cli, runs, io) { } catch (err) { return { ok: false, error: 'could not write the launcher: ' + err.message }; } + if (launch && !launch.inline) { + relay.note('wake ' + record.id + ': the hand-off is not inline (' + launch.why + '); the first prompt points at ' + promptFile); + } try { ops.open(launcher, exitPath, record); } catch (err) { - appendRun(runs, 'claude --resume (window)', { status: 1, stdout: '', stderr: err.message }); + appendRun(runs, label, { status: 1, stdout: '', stderr: err.message }); return { ok: false, openFailed: true, error: 'could not open a window: ' + err.message }; } const started = ops.now(); @@ -530,23 +676,24 @@ function deliverVisible(record, prompt, config, cli, runs, io) { const code = readExit(exitPath); if (code !== null) { const after = Math.round((ops.now() - started) / 1000); - appendRun(runs, 'claude --resume (window)', { + appendRun(runs, label, { status: code, stdout: '', stderr: 'exited within ' + after + 's; its output is in the window, which stays open when the exit is not 0', }); - if (code === 0) return { ok: true, how: 'claude --resume in a window' }; + if (code === 0) return { ok: true, how: fresh ? 'a new claude session in a window' : 'claude --resume in a window' }; return { ok: false, error: 'claude exited ' + code + ' in the window it opened (left open so the message can be read)' }; } } - appendRun(runs, 'claude --resume (window)', { + appendRun(runs, label, { status: 0, stdout: - 'still running after ' + Math.round(LAUNCH_GRACE_MS / 1000) + 's: an interactive session in its own window, Remote Control on. ' + - 'Its output is on screen and in the session transcript, not in this log.', + 'still running after ' + Math.round(LAUNCH_GRACE_MS / 1000) + 's: an interactive session in its own window, Remote Control on' + + (fresh ? ' as "' + freshName(record) + '", the hand-off ' + (launch.inline ? 'inline as its first prompt' : 'in ' + promptFile + ' (' + launch.why + ')') : '') + + '. Its output is on screen and in the session transcript, not in this log.', stderr: '', }); - return { ok: true, how: 'claude --resume in a window, Remote Control on' }; + return { ok: true, how: fresh ? 'a new claude session in a window, Remote Control on' : 'claude --resume in a window, Remote Control on' }; } function deliverClaude(record, prompt, config, cli, runs, io) { // Seen by default. relay show off keeps the headless run, and so does a @@ -556,11 +703,14 @@ function deliverClaude(record, prompt, config, cli, runs, io) { if (!visible.openFailed) return visible; } const args = claudeArgs(record, prompt, config, false); + const fresh = freshFor(config); + const label = fresh ? 'claude -p, a new session' : 'claude --resume'; // stdin, never argv. See claudeArgs for the 7.5 KB note that proved why. const options = Object.assign(spawnOptionsFor(record, config, cli), { input: String(prompt == null ? '' : prompt) }); - const run = appendRun(runs, 'claude --resume', spawnSync(cli, args, options)); - if (run.status === 0) return { ok: true, how: 'claude --resume' }; + const run = appendRun(runs, label, ((io && io.spawnSync) || spawnSync)(cli, args, options)); + if (run.status === 0) return { ok: true, how: label }; const detail = ((run.stderr || run.stdout || '') + '').trim().split('\n')[0]; + if (fresh) return { ok: false, error: detail || 'claude exited ' + run.status }; // A session id that no longer resolves used to fall back to `--continue`, // on the reasoning that the work still needs doing and only the thread is // gone. That fallback is removed, because of what `--continue` actually @@ -741,7 +891,8 @@ async function run(now, argv, overrides) { open = deps.liveSession(record.id); } if (open) { - relay.note('wake ' + record.id + ': the session is open in process ' + open.pid + ' but idle since the reset; opening it in a window', now); + relay.note('wake ' + record.id + ': the session is open in process ' + open.pid + ' but idle since the reset; ' + + (freshFor(config) ? 'starting a new session in a window' : 'opening it in a window'), now); } } @@ -805,7 +956,13 @@ async function run(now, argv, overrides) { } const continuation = relay.readContinuation(record.id); + // relay fresh: the new session has none of the conversation, so its prompt + // opens by saying so. A Codex wake resumes its thread either way. + const fresh = record.host !== host.CODEX && freshFor(config); const prompt = relay.compose({ + fresh, + deferred: record.deferred === true, + projectDir: record.launchCwd ? record.cwd : null, continuation, work: record.work && record.work.todos ? record.work : null, thinking: config.thinking !== 'off', @@ -821,9 +978,25 @@ async function run(now, argv, overrides) { } if (mode === 'notify') { + // A new session has none of the conversation, so what it needs is the + // whole hand-off, not the continuation alone: written where the window + // path writes it, and named in the note. + let handoffFile = null; + if (fresh) { + try { + fs.mkdirSync(relay.configDir(), { recursive: true }); + fs.writeFileSync(wakePromptFile(record.id), prompt + '\n'); + handoffFile = wakePromptFile(record.id); + } catch (err) { + handoffFile = null; + } + } deps.toast( 'Usage limits: the window has reset', - 'The plan for ' + (record.project || path.basename(record.cwd)) + ' is ready to pick up. Run: claude --resume ' + record.id.slice(0, 8) + 'The plan for ' + (record.project || path.basename(record.cwd)) + ' is ready to pick up. ' + + (fresh + ? 'Start claude in ' + record.cwd + ' and give it the hand-off in ' + (handoffFile || relay.planFile(record.id)) + : 'Run: claude --resume ' + record.id.slice(0, 8)) ); finish(state, record, 'notified', presence.present ? 'user present' : null, now); return { outcome: 'notified' }; @@ -836,7 +1009,12 @@ async function run(now, argv, overrides) { return { outcome: 'no-cli' }; } - deps.toast('Usage limits: resuming', 'Carrying on with ' + (record.project || path.basename(record.cwd)) + ' where the limit stopped it.'); + deps.toast( + fresh ? 'Usage limits: handing over' : 'Usage limits: resuming', + fresh + ? 'Starting a new session for ' + (record.project || path.basename(record.cwd)) + ' with the saved plan.' + : 'Carrying on with ' + (record.project || path.basename(record.cwd)) + ' where the limit stopped it.' + ); const runs = []; // The two start-up questions, answered again right before the launch in @@ -868,7 +1046,9 @@ async function run(now, argv, overrides) { : deps.deliverClaude(record, prompt, config, cli, runs); const logPath = config.runLog === false ? null : relay.writeRunLog(relay.runLogFile(record.id, now), runs.join('\n' + '\n')); if (delivered.ok) { - deps.toast('Usage limits: done', 'The resumed run finished. Open the session to read it.'); + deps.toast('Usage limits: done', fresh + ? 'A new session has the plan (' + delivered.how + '). Open it to read along.' + : 'The resumed run finished. Open the session to read it.'); finish(state, record, 'resumed', delivered.how + (logPath ? ' - log at ' + logPath : ''), now); return { outcome: 'resumed', how: delivered.how, log: logPath }; } @@ -965,7 +1145,9 @@ async function run(now, argv, overrides) { return { outcome: 'failed', error: delivered.error, attempts: attempt, kind: verdict.kind, log: logPath }; } -if (require.main === module) { +if (require.main === module && process.argv.includes('--exec-spec')) { + execSpec(argOf(process.argv.slice(2), '--exec-spec')); +} else if (require.main === module) { run(Date.now(), process.argv.slice(2)).then( (result) => { process.stdout.write(JSON.stringify(result) + '\n'); @@ -978,4 +1160,4 @@ if (require.main === module) { ); } -module.exports = { liveSession, transcriptActiveSince, pidAlive, LIVE_WAIT_MS, LIVE_POLL_MS, launcherScriptPosix, shQuote, visibleArgs, launcherScript, pointerPrompt, transcriptExists, wakePromptFile, LAUNCH_GRACE_MS, run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf, appendRun, spawnOptionsFor }; +module.exports = { freshFor, freshLaunch, freshArgs, freshName, freshPointerPrompt, FRESH_ARG_MAX, execSpec, specFile, bridgeCommand, deliverVisible, launcherFile, liveSession, transcriptActiveSince, pidAlive, LIVE_WAIT_MS, LIVE_POLL_MS, launcherScriptPosix, shQuote, visibleArgs, launcherScript, pointerPrompt, transcriptExists, transcriptFile, wakePromptFile, LAUNCH_GRACE_MS, run, toast, userIsPresent, claudeArgs, deliverClaude, deliverCodex, windowReopened, argOf, appendRun, spawnOptionsFor }; diff --git a/test/cloud-credit.test.js b/test/cloud-credit.test.js new file mode 100644 index 0000000..2346d8e --- /dev/null +++ b/test/cloud-credit.test.js @@ -0,0 +1,379 @@ +'use strict'; + +// The cloud-session credit, and the session that runs on it. +// +// Usage credits are one lever at the wall and the cloud-session credit is +// another, and on this account only the second one is there: extra usage is +// off at the org level (cachedExtraUsageDisabledReason "org_level_disabled"), +// while the CLI offers "$250 for cloud sessions, on top of your plan limits" +// at the same wall. Before this the report said "Work stops when it does" and +// nothing else, which was true of the local session and left out the one way +// past it. +// +// Two rules these tests defend. The amount is the server's, parsed out of its +// own copy, never a number written here. And a cloud container keeps no usage +// snapshot, so "run /usage once" is advice that cannot work inside one. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const tempdirs = require('../tools/test-tempdirs.js'); + +// Pinned before anything is required: the report reads the config directory, +// and this machine's own ~/.claude may carry the very flag under test. +const SCRATCH = tempdirs.make(path.join(os.tmpdir(), 'usage-limits-cloud-credit-')); +process.env.CLAUDE_CONFIG_DIR = SCRATCH; +process.env.USAGE_LIMITS_HOST = 'claude'; +process.env.USAGE_LIMITS_FETCH = 'off'; + +const lowpri = require('../skills/usage-limits/scripts/lowpri.js'); +const usage = require('../skills/usage-limits/scripts/usage.js'); +const brief = require('../skills/usage-limits/scripts/brief.js'); +const mode = require('../skills/usage-limits/scripts/mode.js'); + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const NOW = Date.parse('2026-09-28T22:00:00.000Z'); + +// The literal flag read off ~/.claude.json in a cloud container on 2026-09-28 +// (Claude Code 2.1.284). +const SWIFT_LYNX = { + version: 2, + endpoint: '/v1/code/promo/cloud_credit', + webPath: '/code/claim-credit', + desktopPath: '/claude-code-desktop/claim-credit', + claimLinkIncludesOrganization: true, + startup: { + text: "You've been granted a $250 bonus credit for cloud sessions, on top of your plan limits", + claimPageLine: 'Get started with your $250 cloud session credit', + buttonLabel: 'Get started', + }, + limitWall: { + noticeLine: 'While you wait, start a new cloud session with a $250 credit', + label: 'While you wait, start a new cloud session with a $250 credit', + claimPageLine: 'Get started with your $250 cloud session credit', + }, + ide: { text: 'While you wait, start a new cloud session with a $250 credit', buttonLabel: 'Get started' }, +}; + +function withFlag(value) { + return { cachedExtraUsageDisabledReason: 'org_level_disabled', cachedGrowthBookFeatures: { tengu_swift_lynx: value } }; +} + +// The same offer at another amount, so nothing can pass by knowing 250. +function offerOf(amount) { + return JSON.parse(JSON.stringify(SWIFT_LYNX).split('$250').join('$' + amount)); +} + +const CLOUD_ENV = { + CLAUDE_CODE_CONTAINER_ID: 'container_01Vg6--claude_code_remote--59b8', + CCR_AGENT_PROXY_ENABLED: '1', + CLAUDE_CODE_SESSION_ID: 'sess-cloud', +}; + +// ---------------------------------------------------------------- reader -- + +test('the cloud-session credit is read from tengu_swift_lynx, amount and link from the server\'s own copy', () => { + assert.deepStrictEqual(lowpri.cloudCredit(withFlag(SWIFT_LYNX)), { + amount: 250, + currency: 'USD', + claimUrl: 'https://claude.ai/code/claim-credit', + wallNotice: true, + version: 2, + }); + const other = lowpri.cloudCredit(withFlag(offerOf('1,000.50'))); + assert.strictEqual(other.amount, 1000.5, 'parsed, not assumed'); + assert.strictEqual(lowpri.creditText(other), '$1000.50'); + assert.strictEqual(lowpri.creditText(lowpri.cloudCredit(withFlag(SWIFT_LYNX))), '$250'); +}); + +test('an absent, malformed or empty offer is no offer at all', () => { + assert.strictEqual(lowpri.cloudCredit(null), null); + assert.strictEqual(lowpri.cloudCredit({}), null); + assert.strictEqual(lowpri.cloudCredit({ cachedGrowthBookFeatures: {} }), null); + for (const bad of [null, 'yes', 1, [], {}, { version: 3 }, Object.assign({}, SWIFT_LYNX, { enabled: false })]) { + assert.strictEqual(lowpri.cloudCredit(withFlag(bad)), null, JSON.stringify(bad)); + } + // Copy with no amount in it cannot be repeated as an offer of anything. + const wordless = { version: 2, webPath: '/code/claim-credit', startup: { text: 'A bonus credit for cloud sessions' } }; + assert.strictEqual(lowpri.cloudCredit(withFlag(wordless)), null); +}); + +test('a flag with only the startup copy still has an amount, but the CLI says nothing at the wall', () => { + const quiet = { version: 1, startup: { text: 'You have a $40 credit for cloud sessions' }, webPath: 'javascript:alert(1)' }; + const credit = lowpri.cloudCredit(withFlag(quiet)); + assert.strictEqual(credit.amount, 40); + assert.strictEqual(credit.wallNotice, false); + assert.strictEqual(credit.claimUrl, null, 'only a plain path is joined onto claude.ai'); +}); + +test('a cloud container is recognised from the environment, and nothing else is', () => { + assert.deepStrictEqual(lowpri.cloudSession(CLOUD_ENV), { cloud: true, sessionId: 'sess-cloud' }); + assert.deepStrictEqual( + lowpri.cloudSession({ CLAUDE_CODE_REMOTE: 'true', CCR_AGENT_PROXY_ENABLED: '1' }), + { cloud: true, sessionId: null } + ); + assert.strictEqual(lowpri.cloudSession({}), null); + assert.strictEqual(lowpri.cloudSession({ CLAUDE_CODE_REMOTE: 'true' }), null, 'one marker alone is not the container'); + assert.strictEqual(lowpri.cloudSession({ CLAUDE_CODE_CONTAINER_ID: 'some-other-container' }), null); +}); + +// ---------------------------------------------------------------- report -- + +const outOfRoom = { + key: 'five_hour', + label: '5-hour', + percentUsed: 92, + percentLeft: 8, + msToReset: 4 * HOUR, + resetsAt: NOW + 4 * HOUR, + remainingUSD: 2, + turnsLeft: 6, + headroomMs: 20 * MINUTE, + coarse: false, + stale: false, + verdict: 'runs-out', +}; + +function reportData(extra) { + return Object.assign( + { + plan: 'Claude Max', + planAdvice: null, + snapshotAgeMs: 60000, + settings: { model: 'opus', effortLevel: 'xhigh' }, + windows: [outOfRoom], + binding: outOfRoom, + resumeAt: outOfRoom.resetsAt, + scopeLabel: '5-hour', + models: [], + projects: [], + tokens: null, + credits: { enabled: false, limitReached: false, used: 0, limit: null, percent: 0, currency: 'USD', disabledReason: 'org_level_disabled' }, + cloudCredit: lowpri.cloudCredit(withFlag(SWIFT_LYNX)), + cloudSession: null, + recent: { turns: 3, usd: 0.5, usdPerTurn: 0.16, tokens: 0, effort: 'xhigh' }, + measuredTurns: 100, + }, + extra + ); +} + +test('at the wall with usage credits off, the report names the cloud credit as the way on now, and keeps the resume time', () => { + const text = usage.render(reportData({})); + assert.match(text, /Cloud credit {2}\$250 for cloud sessions, on top of the plan: claude --cloud, or claude\.ai\/code/); + assert.match(text, /Nothing carries on into paid credits/, 'usage credits are still off, and it still says so'); + assert.match(text, /To carry on now instead of waiting, start a cloud session on the \$250\n {2}cloud-session credit: relay cloud/); + assert.match(text, /resume after \d{1,2}:\d{2}/); + // The order is the argument: what stops, the way on, then the plan B. + assert.ok(text.indexOf('Nothing carries on') < text.indexOf('To carry on now')); + assert.ok(text.indexOf('To carry on now') < text.indexOf('resume after')); +}); + +test('the amount in the report is the one on the flag, and with no flag nothing about it is said', () => { + const text = usage.render(reportData({ cloudCredit: lowpri.cloudCredit(withFlag(offerOf(75))) })); + assert.match(text, /Cloud credit {2}\$75 for cloud sessions/); + assert.match(text, /cloud session on the \$75/); + assert.doesNotMatch(text, /\$250/); + const without = usage.render(reportData({ cloudCredit: null })); + assert.doesNotMatch(without, /cloud/i); + assert.match(without, /Nothing carries on into paid credits/); +}); + +test('with usage credits on, the wall is a cost boundary and the cloud line is not repeated there', () => { + const text = usage.render( + reportData({ credits: { enabled: true, limitReached: false, used: 4.2, limit: 50, percent: 8, currency: 'USD' } }) + ); + assert.match(text, /Cloud credit {2}\$250/, 'the offer still exists, so the header row stays'); + assert.doesNotMatch(text, /To carry on now/); + // Spend limit reached puts the wall back. + const capped = usage.render( + reportData({ credits: { enabled: true, limitReached: true, used: 50, limit: 50, percent: 100, currency: 'USD' } }) + ); + assert.match(capped, /To carry on now instead of waiting/); +}); + +test('with room left there is no wall to get past, so only the header row mentions it', () => { + const roomy = Object.assign({}, outOfRoom, { verdict: 'resets-first' }); + const text = usage.render(reportData({ windows: [roomy], binding: roomy })); + assert.match(text, /Cloud credit/); + assert.doesNotMatch(text, /To carry on now/); +}); + +test('inside a cloud container the report never says to run /usage, and says what this session has cost', () => { + const text = usage.render( + reportData({ + windows: [], + binding: null, + resumeAt: null, + credits: null, + cloudSession: { cloud: true, sessionId: 'sess-cloud', spent: { turns: 29, cost: 4.2033, tokens: 5778203 } }, + }) + ); + assert.doesNotMatch(text, /Run \/usage once|No usage snapshot/); + assert.match(text, /Cloud session {2}a claude\.ai\/code session, billed to the cloud-session credit\n {17}rather than the plan's 5-hour window/); + assert.match(text, /So far {9}about \$4\.20 this session at API list prices, 29 turns;/); + assert.match(text, /the credit is \$250 across all cloud sessions/); + assert.match(text, /not readable here/, 'the balance is not in any file, and it says so'); + assert.doesNotMatch(text, /Cloud credit {2}/, 'no advice to start the kind of session this already is'); +}); + +test('a cloud session with no readable offer is not said to be on a credit', () => { + const text = usage.render( + reportData({ + windows: [], + binding: null, + credits: null, + cloudCredit: null, + cloudSession: { cloud: true, sessionId: 'sess-cloud', spent: null }, + }) + ); + assert.doesNotMatch(text, /Run \/usage once/); + assert.doesNotMatch(text, /credit/); + assert.match(text, /Cloud session {2}a claude\.ai\/code session\. Its container keeps no usage/); + assert.match(text, /nothing this session has spent is on record yet/); +}); + +// The report end to end: the flag read from a fixture config, the cloud +// detected from an injected environment, and the session priced from its own +// transcript and its subagent's, at list prices. +function transcriptLine(id, at, session, over) { + return JSON.stringify( + Object.assign( + { + type: 'assistant', + timestamp: new Date(at).toISOString(), + requestId: 'req_' + id, + sessionId: session, + message: { + id: 'msg_' + id, + model: 'claude-opus-5', + usage: { input_tokens: 1000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 100 }, + }, + }, + over || {} + ) + ); +} + +test('report() finds the offer in the config and prices this cloud session from its transcripts', async () => { + // The snapshot is there so this fixture, and not the machine's own + // ~/.claude.json, is the account file on every machine. + fs.writeFileSync( + path.join(SCRATCH, '.claude.json'), + JSON.stringify({ + cachedUsageUtilization: { fetchedAtMs: NOW - MINUTE, utilization: { five_hour: { utilization: 10, resets_at: new Date(NOW + 3 * HOUR).toISOString() } } }, + cachedExtraUsageDisabledReason: 'org_level_disabled', + cachedGrowthBookFeatures: { tengu_swift_lynx: offerOf(175) }, + }) + ); + const project = path.join(SCRATCH, 'projects', '-home-user'); + fs.mkdirSync(path.join(project, 'sess-cloud', 'subagents'), { recursive: true }); + fs.writeFileSync( + path.join(project, 'sess-cloud.jsonl'), + [transcriptLine('a', NOW - 20 * MINUTE, 'sess-cloud'), transcriptLine('b', NOW - 10 * MINUTE, 'sess-cloud')].join('\n') + '\n' + ); + fs.writeFileSync( + path.join(project, 'sess-cloud', 'subagents', 'agent-x.jsonl'), + transcriptLine('c', NOW - 5 * MINUTE, 'sess-cloud', { isSidechain: true }) + '\n' + ); + // Another session on the same disk is not this one's spend. + fs.writeFileSync(path.join(project, 'sess-other.jsonl'), transcriptLine('d', NOW - 5 * MINUTE, 'sess-other') + '\n'); + + const cloud = await usage.report(NOW, { env: CLOUD_ENV }); + assert.strictEqual(cloud.cloudCredit.amount, 175); + assert.strictEqual(cloud.cloudSession.cloud, true); + assert.strictEqual(cloud.cloudSession.sessionId, 'sess-cloud'); + // Three Opus calls at (1000 * 5 + 100 * 25) / 1e6 each; two are turns. + assert.strictEqual(cloud.cloudSession.spent.turns, 2); + assert.ok(Math.abs(cloud.cloudSession.spent.cost - 3 * 0.0075) < 1e-9, String(cloud.cloudSession.spent.cost)); + + // The same machine, not in a container: no cloud block, and the offer stays. + const local = await usage.report(NOW, { env: {} }); + assert.strictEqual(local.cloudSession, null); + assert.strictEqual(local.cloudCredit.amount, 175); +}); + +// ----------------------------------------------------------------- brief -- + +const TIGHT = { key: 'five_hour', label: '5-hour', percentUsed: 96, stale: false, applies: true, resetsAt: NOW + HOUR, turnsLeft: 3 }; + +function decided(name) { + return { name, label: name, source: 'test', policy: mode.MODES[name], bounds: { floor: null, ceiling: null, pin: false }, directive: mode.directive(name) }; +} + +test('near the wall the brief names the cloud credit once, in both styles, and not when there is room', () => { + for (const name of ['standard', 'max']) { + const text = brief.briefText({ mode: decided(name), binding: TIGHT, turnsLeft: 3, pressure: 'tight', cloudCredit: '$250' }); + assert.match(text, /A cloud session is the way past this wall now rather than after the reset/, name); + assert.match(text, /\$250 cloud-session credit on top of the plan/, name); + assert.match(text, /relay cloud prints the claude --cloud command/, name); + assert.match(text, /push first/, name); + assert.match(text, /starting it is their call/, name); + assert.strictEqual(text.split('cloud-session credit').length, 2, 'said once: ' + name); + } + const roomy = brief.briefText({ + mode: decided('standard'), + binding: Object.assign({}, TIGHT, { percentUsed: 20, turnsLeft: 300 }), + turnsLeft: 300, + pressure: 'roomy', + cloudCredit: '$250', + }); + assert.doesNotMatch(roomy, /cloud/i); +}); + +test('the brief offers it once a session, and a second session hears it too', () => { + const credit = lowpri.cloudCredit(withFlag(SWIFT_LYNX)); + assert.strictEqual(brief.cloudCreditFor('s1', credit, NOW), '$250'); + assert.strictEqual(brief.cloudCreditFor('s1', credit, NOW + MINUTE), null, 'not again in the same session'); + assert.strictEqual(brief.cloudCreditFor('s2', credit, NOW + MINUTE), '$250'); + // A different offer is news. + assert.strictEqual(brief.cloudCreditFor('s1', lowpri.cloudCredit(withFlag(offerOf(300))), NOW + 2 * MINUTE), '$300'); + assert.strictEqual(brief.cloudCreditFor('s3', null, NOW), null); +}); + +test('the brief\'s reading: the offer and whether this is already a cloud session', () => { + const account = withFlag(SWIFT_LYNX); + const local = lowpri.forBrief({ account, now: NOW, env: {} }); + assert.strictEqual(local.cloudCredit.amount, 250); + assert.strictEqual(local.inCloud, false); + assert.strictEqual(local.credits.reason, 'org_level_disabled'); + assert.strictEqual(lowpri.forBrief({ account, now: NOW, env: CLOUD_ENV }).inCloud, true); +}); + +test('the hook offers it only where it is the way past the wall: credits not on, and not already in the cloud', () => { + const file = path.join(SCRATCH, '.claude.json'); + const keys = ['CLAUDE_CODE_CONTAINER_ID', 'CLAUDE_CODE_REMOTE', 'CCR_AGENT_PROXY_ENABLED']; + const saved = {}; + for (const key of keys) saved[key] = process.env[key]; + const accountWith = (extraUsage) => + fs.writeFileSync( + file, + JSON.stringify({ + cachedUsageUtilization: { fetchedAtMs: NOW, utilization: { five_hour: { utilization: 96 }, extra_usage: extraUsage } }, + cachedExtraUsageDisabledReason: extraUsage.is_enabled ? undefined : 'org_level_disabled', + cachedGrowthBookFeatures: { tengu_swift_lynx: SWIFT_LYNX }, + }) + ); + try { + for (const key of keys) delete process.env[key]; + accountWith({ is_enabled: false }); + assert.strictEqual(brief.wallFeatures(NOW, TIGHT, [TIGHT], 'claude').cloudCredit.amount, 250); + assert.strictEqual(brief.wallFeatures(NOW, TIGHT, [TIGHT], 'codex').cloudCredit, undefined, 'a Claude Code offer, not Codex\'s'); + + accountWith({ is_enabled: true }); + assert.strictEqual(brief.wallFeatures(NOW, TIGHT, [TIGHT], 'claude').cloudCredit, null, 'usage credits carry it already'); + + accountWith({ is_enabled: false }); + Object.assign(process.env, { CLAUDE_CODE_CONTAINER_ID: CLOUD_ENV.CLAUDE_CODE_CONTAINER_ID }); + assert.strictEqual(brief.wallFeatures(NOW, TIGHT, [TIGHT], 'claude').cloudCredit, null, 'already a cloud session'); + } finally { + for (const key of keys) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +}); diff --git a/test/relay-cloud.test.js b/test/relay-cloud.test.js new file mode 100644 index 0000000..56a476a --- /dev/null +++ b/test/relay-cloud.test.js @@ -0,0 +1,347 @@ +'use strict'; + +// relay cloud: the same hand-off the wake would deliver, into a cloud session +// now instead of this one after the reset. +// +// A cloud session is not this machine. It clones the repository from GitHub, +// so an uncommitted change or an unpushed commit is not there; and it cannot +// read a file here, so the hand-off rides inline. Both are said, never +// assumed. And it spends a real credit, so - like the scheduler - no test may +// ever start one: every launch below is a dry run or goes to a fake spawner. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const tempdirs = require('../tools/test-tempdirs.js'); + +const RELAY = path.join(__dirname, '..', 'skills', 'usage-limits', 'scripts', 'relay.js'); + +// Each test its own config directory and none of this machine's session ids. +function isolated(fn) { + const dir = tempdirs.make(path.join(os.tmpdir(), 'usage-limits-relay-cloud-')); + const keys = ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'USAGE_LIMITS_CLOUD']; + const before = {}; + for (const key of keys) before[key] = process.env[key]; + process.env.CLAUDE_CONFIG_DIR = dir; + delete process.env.CLAUDE_CODE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + delete process.env.USAGE_LIMITS_CLOUD; + try { + return fn(dir); + } finally { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + } +} + +function relay() { + return require(RELAY); +} + +// A clean, pushed repository as git would describe it. +function cleanGit(args) { + const key = args.join(' '); + if (key === 'rev-parse --is-inside-work-tree') return { status: 0, stdout: 'true' }; + if (key === 'status --porcelain') return { status: 0, stdout: '' }; + if (key === 'rev-parse --abbrev-ref HEAD') return { status: 0, stdout: 'feature' }; + if (key === 'rev-parse --abbrev-ref --symbolic-full-name @{u}') return { status: 0, stdout: 'origin/feature' }; + if (key === 'rev-list --count @{u}..HEAD') return { status: 0, stdout: '0' }; + if (key === 'remote') return { status: 0, stdout: 'origin' }; + return { status: 1, stdout: '' }; +} + +function gitWith(overrides) { + return (args, cwd) => { + const key = args.join(' '); + return Object.prototype.hasOwnProperty.call(overrides, key) ? overrides[key] : cleanGit(args, cwd); + }; +} + +const ACCOUNT = { + cachedGrowthBookFeatures: { + tengu_swift_lynx: { version: 2, webPath: '/code/claim-credit', limitWall: { noticeLine: 'While you wait, start a new cloud session with a $90 credit' } }, + }, +}; + +test('the hand-off is the wake\'s prompt, opened for a cloud session, with the continuation inline', () => + isolated(() => { + const r = relay(); + r.saveContinuation('sess-1', 'Done: the reader. Next: tests for the report, then the docs. Mid-change: usage.js render().'); + const handoff = r.cloudHandoff({ sessionId: 'sess-1', git: cleanGit, platform: 'linux', cwd: '/work' }); + assert.strictEqual(handoff.ok, true); + assert.strictEqual(handoff.source, 'the saved continuation'); + assert.match(handoff.prompt, /handing its work to a cloud session rather than waiting for the reset/); + assert.match(handoff.prompt, /You have none of that conversation/); + assert.match(handoff.prompt, /fresh clone from GitHub, so only what was pushed is here/); + assert.match(handoff.prompt, /Next: tests for the report, then the docs/); + // Everything else the wake says is still said. + assert.match(handoff.prompt, /Verify anything that was mid-change/); + assert.ok(handoff.prompt.includes(r.BUGCHECK_LINE)); + // And the wake's own opening is not, because the window has not reset. + assert.doesNotMatch(handoff.prompt, /The usage window has reset/); + assert.deepStrictEqual(handoff.warnings, []); + // The wake's own prompt is unchanged by any of this. + assert.match(r.compose({ continuation: 'x' }), /^The usage window has reset and this is the plugin picking the work back up/); + })); + +test('text on the command line stands in for the stored continuation, and an armed record\'s todos ride along', () => + isolated(() => { + const r = relay(); + const state = r.read(); + r.putRecord(state, { + id: 'sess-2', + cwd: '/proj', + wakeAt: Date.now() + 3600000, + work: { hasWork: true, pending: 2, source: 'todos', todos: [ + { content: 'Write the relay test', status: 'in_progress' }, + { content: 'Bump the version', status: 'pending' }, + { content: 'Read the code', status: 'completed' }, + ], plan: null }, + }); + r.write(state); + const handoff = r.cloudHandoff({ text: 'Push the branch, then open the PR.', env: {}, git: cleanGit, platform: 'linux' }); + assert.strictEqual(handoff.id, 'sess-2', 'the one armed relay, when nothing names a session'); + assert.strictEqual(handoff.cwd, '/proj', 'run from where that session was working'); + assert.strictEqual(handoff.source, 'the text given'); + assert.match(handoff.prompt, /Push the branch, then open the PR\./); + assert.match(handoff.prompt, /- Write the relay test \(was mid-change\)\n- Bump the version/); + assert.doesNotMatch(handoff.prompt, /Read the code/); + })); + +test('with nothing to hand over it refuses, because a cloud session has none of this conversation', () => + isolated(() => { + const r = relay(); + const text = r.cloud(['--session', 'sess-empty'], { git: cleanGit, platform: 'linux' }); + assert.match(text, /^Nothing to hand over for session sess-emp/); + assert.match(text, /relay cloud ""/); + assert.doesNotMatch(text, /claude --cloud '/); + })); + +test('what the clone will not have is named: uncommitted changes, unpushed commits, a branch with no upstream', () => { + const r = relay(); + const state = (overrides) => r.gitState('/x', gitWith(overrides)); + const warn = (overrides) => r.cloudWarnings(state(overrides), 'short', 'linux').join('\n'); + assert.strictEqual(warn({}), ''); + assert.match(warn({ 'status --porcelain': { status: 0, stdout: ' M a.js\n?? b.js' } }), /2 uncommitted changes in the working tree\. The cloud session clones from GitHub and will not see them/); + assert.match(warn({ 'rev-list --count @{u}..HEAD': { status: 0, stdout: '1' } }), /1 commit on feature not pushed to origin\/feature: push first/); + assert.match(warn({ 'rev-parse --abbrev-ref --symbolic-full-name @{u}': { status: 128, stdout: '' } }), /Branch feature has no upstream.*git push -u origin feature/); + assert.match(warn({ remote: { status: 0, stdout: '' }, 'rev-parse --abbrev-ref --symbolic-full-name @{u}': { status: 128, stdout: '' } }), /no remote/); + assert.match(warn({ 'rev-parse --is-inside-work-tree': { status: 128, stdout: '' } }), /not a git repository/); + // Too long for one argument is said before anyone pastes it. + assert.match(r.cloudWarnings(state({}), 'x'.repeat(r.CLOUD_ARG_MAX.win32 + 1), 'win32').join('\n'), /more than one command-line argument holds/); +}); + +test('gitState reads a real repository: one dirty file, one unpushed commit', { skip: spawnSync('git', ['--version']).status !== 0 && 'git is not installed' }, () => { + const root = tempdirs.make(path.join(os.tmpdir(), 'usage-limits-relay-cloud-git-')); + const remote = path.join(root, 'remote.git'); + const work = path.join(root, 'work'); + const git = (cwd, ...args) => { + const run = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' }); + assert.strictEqual(run.status, 0, args.join(' ') + ': ' + run.stderr); + return run.stdout.trim(); + }; + fs.mkdirSync(work); + git(root, 'init', '-q', '--bare', remote); + git(work, 'init', '-q'); + git(work, 'remote', 'add', 'origin', remote); + fs.writeFileSync(path.join(work, 'a.txt'), 'one\n'); + git(work, 'add', 'a.txt'); + git(work, 'commit', '-q', '-m', 'one'); + const branch = git(work, 'rev-parse', '--abbrev-ref', 'HEAD'); + git(work, 'push', '-q', '-u', 'origin', branch); + const r = relay(); + assert.deepStrictEqual(r.gitState(work), { repo: true, dirty: 0, branch, upstream: 'origin/' + branch, unpushed: 0, remote: true }); + fs.writeFileSync(path.join(work, 'a.txt'), 'two\n'); + git(work, 'commit', '-q', '-am', 'two'); + fs.writeFileSync(path.join(work, 'b.txt'), 'dirty\n'); + const after = r.gitState(work); + assert.strictEqual(after.dirty, 1); + assert.strictEqual(after.unpushed, 1); + assert.strictEqual(r.gitState(root).repo, false, 'the folder above is not a working tree'); +}); + +test('the printed command survives the shell: sh gets the hand-off back byte for byte', { skip: process.platform === 'win32' && 'sh quoting, checked where sh is' }, () => { + const r = relay(); + const prompt = "It's done.\nNext: \"quotes\", $HOME, `ticks` and a \\ backslash.\n\n- a todo"; + const command = r.cloudCommand(prompt, 'linux'); + assert.ok(command.startsWith("claude --cloud '")); + // Swap the program for printf and let sh do the unquoting a person's shell would. + const run = spawnSync('sh', ['-c', command.replace(/^claude --cloud /, "printf '%s' ")], { encoding: 'utf8' }); + assert.strictEqual(run.status, 0, run.stderr); + assert.strictEqual(run.stdout, prompt); +}); + +test('on Windows the command is PowerShell, single-quoted with quotes doubled', () => { + const r = relay(); + assert.strictEqual(r.cloudCommand("it's\nnext", 'win32'), "claude --cloud 'it''s\nnext'"); + const warnings = r.cloudWarnings(r.gitState('/x', cleanGit), 'say "hi"', 'win32').join('\n'); + assert.match(warnings, /PowerShell before 7\.3/); + // PowerShell ends a single-quoted string at a curly single quote as well, so + // each is doubled like a straight one. + const curly = 'it\u2019s \u2018done\u2019, \u201Aand\u201B'; + assert.strictEqual(r.cloudCommand(curly, 'win32'), "claude --cloud 'it\u2019\u2019s \u2018\u2018done\u2019\u2019, \u201A\u201Aand\u201B\u201B'"); +}); + +test('the hand-off names the branch the work was pushed to, since the clone may start on another', () => + isolated(() => { + const r = relay(); + const named = r.cloudHandoff({ sessionId: 'sess-b', text: 'x', git: cleanGit, platform: 'linux', cwd: '/w' }); + assert.match(named.prompt, /The work is on branch feature: check it out first if the clone is on another\./); + const detached = r.cloudHandoff({ sessionId: 'sess-b', text: 'x', git: gitWith({ 'rev-parse --abbrev-ref HEAD': { status: 0, stdout: 'HEAD' } }), platform: 'linux', cwd: '/w' }); + assert.doesNotMatch(detached.prompt, /The work is on branch/); + assert.doesNotMatch(r.compose({ continuation: 'x', branch: 'feature' }), /The work is on branch/, 'the wake resumes in place and names no branch'); + })); + +test('the printed answer: session, source, the credit, the warnings, where to run it, and no launch without --go', () => + isolated(() => { + const r = relay(); + let spawned = 0; + const text = r.cloud(['--session', 'sess-3', 'Carry on with the docs.'], { + git: gitWith({ 'status --porcelain': { status: 0, stdout: ' M README.md' } }), + platform: 'linux', + cwd: '/repo', + account: ACCOUNT, + spawn: () => { + spawned += 1; + return { pid: 1, unref() {}, on() {} }; + }, + }); + assert.strictEqual(spawned, 0); + assert.match(text, /^Cloud hand-off for session sess-3, from the text given: \d+ characters, inline, because a cloud session cannot read a file on this machine\./); + assert.match(text, /It runs on the \$90 cloud-session credit/); + assert.match(text, /Warning: 1 uncommitted change in the working tree/); + assert.match(text, /Run this from \/repo:\n\nclaude --cloud 'ultrathink\n\nA local session reached its usage limit/); + assert.match(text, /relay cloud --go starts it from here\.$/); + })); + +test('--go under node --test is a dry run: nothing is spawned, and it says why', () => + isolated(() => { + const r = relay(); + assert.ok(process.env.NODE_TEST_CONTEXT, 'node --test sets NODE_TEST_CONTEXT'); + assert.deepStrictEqual(r.cloudLaunchMode(), { dry: true, why: 'running under node --test' }); + const text = r.cloud(['--go', '--session', 'sess-4', 'x'], { + git: cleanGit, + platform: 'linux', + cli: '/bin/claude-fake', + spawn: () => { + throw new Error('a test launched a cloud session'); + }, + }); + assert.match(text, /Dry run: nothing was launched \(running under node --test\)\.$/); + assert.match(fs.readFileSync(r.logFile(), 'utf8'), /cloud: dry run for session sess-4, nothing launched/); + })); + +test('--go, when it is real, starts claude --cloud detached in the project, as nobody\'s child, and logs it', () => + isolated(() => { + const r = relay(); + process.env.CLAUDE_CODE_SESSION_ID = 'sess-5'; + const calls = []; + let unrefs = 0; + const text = r.cloud(['--go', 'Finish the tests.'], { + mode: { dry: false, why: null }, + git: cleanGit, + platform: 'linux', + cwd: '/repo', + cli: '/opt/bin/claude', + env: { PATH: '/usr/bin', CLAUDE_CODE_SESSION_ID: 'sess-5', CLAUDE_CODE_CHILD_SESSION: '1' }, + spawn: (cli, args, options) => { + calls.push({ cli, args, options }); + return { pid: 4242, unref() { unrefs += 1; }, on() {} }; + }, + }); + assert.strictEqual(calls.length, 1); + const call = calls[0]; + assert.strictEqual(call.cli, '/opt/bin/claude'); + assert.strictEqual(call.args[0], '--cloud'); + assert.match(call.args[1], /Finish the tests\./); + assert.strictEqual(call.args.length, 2, 'the hand-off is one argument, not split'); + assert.strictEqual(call.options.cwd, '/repo'); + assert.strictEqual(call.options.detached, true); + assert.strictEqual(call.options.env.CLAUDE_CODE_SESSION_ID, undefined); + assert.strictEqual(call.options.env.CLAUDE_CODE_CHILD_SESSION, undefined); + assert.strictEqual(call.options.env.PATH, '/usr/bin'); + assert.strictEqual(unrefs, 1); + assert.match(text, /Started claude --cloud \(pid 4242\)\. What it prints goes to .*cloud-.*\.log\. The session appears on claude\.ai\/code\.$/); + assert.match(fs.readFileSync(r.logFile(), 'utf8'), /cloud: launched claude --cloud for session sess-5 from \/repo \(pid 4242\)/); + assert.match(r.latestRunLog().text, /--- claude --cloud from \/repo for session sess-5/); + })); + +test('--go will not push a multi-line hand-off through a Windows batch shim, or launch with no repository', () => + isolated(() => { + const r = relay(); + const never = () => { + throw new Error('should not have launched'); + }; + const shim = r.cloud(['--go', '--session', 's6', 'x'], { mode: { dry: false }, git: cleanGit, platform: 'win32', cli: 'C:\\npm\\claude.cmd', spawn: never }); + assert.match(shim, /Not launched: claude here is C:\\npm\\claude\.cmd, a batch shim/); + // PowerShell hands a .cmd to cmd.exe as well, so it is no way round the shim. + assert.doesNotMatch(shim, /into PowerShell instead/); + assert.match(shim, /native claude\.exe/); + const bare = r.cloud(['--go', '--session', 's7', 'x'], { + mode: { dry: false }, + git: () => ({ status: 128, stdout: '' }), + platform: 'linux', + cli: '/bin/claude', + spawn: never, + }); + assert.match(bare, /Not launched: there is no repository here/); + })); + +// The switch itself, outside the test runner, the way scheduler-guard checks +// the scheduler's: it computes the mode and launches nothing. +test('outside the test runner the switch reads USAGE_LIMITS_CLOUD, and a redirected home is a dry run', () => { + const env = Object.assign({}, process.env); + delete env.NODE_TEST_CONTEXT; + delete env.USAGE_LIMITS_CLOUD; + const modeIn = (extra) => { + const run = spawnSync(process.execPath, ['-e', 'process.stdout.write(JSON.stringify(require(' + JSON.stringify(RELAY) + ').cloudLaunchMode()))'], { + encoding: 'utf8', + env: Object.assign({}, env, extra), + timeout: 30000, + windowsHide: true, + }); + assert.strictEqual(run.status, 0, run.stderr); + return JSON.parse(run.stdout); + }; + assert.deepStrictEqual(modeIn({ USAGE_LIMITS_CLOUD: 'dry-run' }), { dry: true, why: 'USAGE_LIMITS_CLOUD=dry-run' }); + assert.deepStrictEqual(modeIn({ USAGE_LIMITS_CLOUD: 'real', NODE_TEST_CONTEXT: 'child-v8' }), { dry: false, why: null }); + const home = tempdirs.make(path.join(os.tmpdir(), 'usage-limits-relay-cloud-home-')); + const sandboxed = modeIn({ HOME: home, USERPROFILE: home }); + assert.strictEqual(sandboxed.dry, true); + assert.match(sandboxed.why, /home folder is redirected/); +}); + +test('the plugin\'s own flags stay out of the hand-off text', () => + isolated(() => { + const r = relay(); + const text = r.cloud(['--host', 'claude', '--session', 'sess-8', 'Ship', 'the', 'docs.'], { git: cleanGit, platform: 'linux', cwd: '/repo', account: {} }); + assert.match(text, /This is what the session left for itself:\n\nShip the docs\.\n/); + assert.doesNotMatch(text, /--host|sess-8\n|claude Ship/); + })); + +// The printed command on Windows meets whatever claude is on PATH, and a batch +// shim runs through cmd.exe even from PowerShell: cmd.exe ends the command at +// the first line break, so the cloud session would get one line of the +// hand-off. Said before anyone pastes it, not only when --go refuses. +test('a Windows command meant for a batch shim warns that cmd.exe cuts the hand-off at its first line break', () => + isolated(() => { + const r = relay(); + const shim = r.cloud(['--session', 'sess-9', 'First line.\nSecond line.'], { git: cleanGit, platform: 'win32', cwd: 'C:\\repo', account: {}, cli: 'C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd', nativeCli: null }); + assert.match(shim, /Warning: claude here is C:\\Users\\me\\AppData\\Roaming\\npm\\claude\.cmd, a batch shim\. PowerShell runs it through cmd\.exe, and cmd\.exe cuts the hand-off at its first line break/); + assert.match(shim, /native claude\.exe \(claude install puts one in ~\/\.local\/bin\)/); + // A native claude.exe elsewhere on the machine is named, and --go uses it. + const both = r.cloud(['--session', 'sess-9', 'x'], { git: cleanGit, platform: 'win32', cwd: 'C:\\repo', account: {}, cli: 'C:\\npm\\claude.cmd', nativeCli: 'C:\\Users\\me\\.local\\bin\\claude.exe' }); + assert.match(both, /run it with the native claude\.exe instead \(& 'C:\\Users\\me\\\.local\\bin\\claude\.exe' in place of claude\), or use --go, which does\./); + assert.match(r.cloud(['--session', 'sess-9', 'x'], { git: cleanGit, platform: 'win32', cwd: 'C:\\repo', account: {}, cli: 'C:\\tools\\CLAUDE.BAT', nativeCli: null }), /Warning: claude here is C:\\tools\\CLAUDE\.BAT, a batch shim/); + const native = r.cloud(['--session', 'sess-9', 'x'], { git: cleanGit, platform: 'win32', cwd: 'C:\\repo', account: {}, cli: 'C:\\Users\\me\\.local\\bin\\claude.exe' }); + assert.doesNotMatch(native, /batch shim/); + // Only Windows has the problem; elsewhere the name is not asked about. + assert.doesNotMatch(r.cloud(['--session', 'sess-9', 'x'], { git: cleanGit, platform: 'linux', cwd: '/repo', account: {}, cli: '/x/claude.cmd' }), /batch shim/); + assert.deepStrictEqual(r.cloudWarnings(r.gitState('/x', cleanGit), 'short', 'win32', 'C:\\bin\\claude.exe'), []); + })); diff --git a/test/relay-fresh.test.js b/test/relay-fresh.test.js new file mode 100644 index 0000000..6b770ef --- /dev/null +++ b/test/relay-fresh.test.js @@ -0,0 +1,366 @@ +'use strict'; + +// relay fresh: hand the work to a NEW conversation instead of resuming the old. +// +// Found 2026-09-29: defer.md said the wake "starts a fresh session in the +// original directory and hands it the saved plan", and wake.js ran +// `claude --resume ` - the same conversation. A long session is the one +// that runs out, and resuming carries all of it into the next window. With +// fresh on the wake starts `claude` in the project with the hand-off as its +// first prompt, inline, Remote Control on under a name that says so, and the +// relay's permission mode. Off, which is the default, nothing changes. +// +// No test here starts claude or registers a scheduled task: windows go to a +// fake opener, headless runs to a fake spawnSync, the arming under node --test +// is a dry run, and the two things that really run are sh with a fake CLI and +// node itself. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const relay = require('../skills/usage-limits/scripts/relay.js'); +const wake = require('../skills/usage-limits/scripts/wake.js'); +const defer = require('../skills/usage-limits/scripts/defer.js'); +const tempdirs = require('../tools/test-tempdirs.js'); + +const ID = '5a1d0c2e-1234-4abc-9def-0123456789ab'; +const WAKE = path.join(__dirname, '..', 'skills', 'usage-limits', 'scripts', 'wake.js'); +const PROMPT = "ultrathink\n\nThe work: it's \"quoted\", $HOME, `ticks`, 100% and a \\ backslash.\n\n- a todo"; + +function isolated(fn) { + const dir = tempdirs.make(path.join(os.tmpdir(), 'usage-limits-relay-fresh-')); + const keys = ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'CODEX_SESSION_ID', 'USAGE_LIMITS_HOST', 'USAGE_LIMITS_RELAY_MODE']; + const before = {}; + for (const key of keys) before[key] = process.env[key]; + process.env.CLAUDE_CONFIG_DIR = dir; + for (const key of keys.slice(1)) delete process.env[key]; + const done = () => { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + }; + let out; + try { + out = fn(dir); + } catch (err) { + done(); + throw err; + } + if (out && typeof out.then === 'function') return out.finally(done); + done(); + return out; +} + +function clockIo(extra) { + let clock = 0; + return Object.assign({ sleep: (ms) => { clock += ms; }, now: () => clock }, extra || null); +} + +// --- the switch --------------------------------------------------------------- + +test('fresh is off by default, and relay fresh on|off says exactly what the wake will do', () => + isolated(() => { + assert.strictEqual(relay.DEFAULTS.fresh, false); + assert.strictEqual(relay.settings(relay.read()).fresh, false); + assert.match(relay.main(['fresh', 'maybe']), /^Fresh is OFF now\. Say relay fresh on .* or relay fresh off/); + assert.strictEqual(relay.settings(relay.read()).fresh, false, 'an unreadable value changes nothing'); + + const on = relay.main(['fresh', 'on']); + assert.strictEqual(relay.settings(relay.read()).fresh, true); + assert.match(on, /^Fresh ON: the wake starts a new claude session in the project, with the whole hand-off as its first prompt, Remote Control on/); + assert.match(on, /no permission mode set \(relay permission acceptEdits\)/); + assert.match(on, /if it is still open and working again, the wake stands down/); + assert.match(on, /applies once relay mode resume is set/, 'mode notify starts nothing either way, and it says so'); + + relay.main(['mode', 'resume']); + relay.main(['permission', 'acceptEdits']); + const again = relay.main(['fresh', 'on']); + assert.match(again, /--permission-mode acceptEdits/); + assert.doesNotMatch(again, /applies once relay mode resume/); + assert.match(relay.status(Date.now()), /delivery resume into a new session \(fresh on\)\./); + + assert.match(relay.main(['fresh', 'off']), /^Fresh OFF: the wake resumes the same conversation \(claude --resume\)\./); + assert.match(relay.status(Date.now()), /delivery resume of the same conversation \(fresh off\)\./); + })); + +// --- the prompt --------------------------------------------------------------- + +test('the new session is told it has none of the conversation; the resume\'s opening is unchanged', () => { + const fresh = relay.compose({ fresh: true, continuation: 'Next: the docs.' }); + assert.match(fresh, /^The usage window has reset, and this is the plugin handing an earlier session's work to this new one, not a new request\./); + assert.match(fresh, /You have none of that conversation: what follows is what it left\./); + assert.match(fresh, /You are in its project folder, with the files as it left them, uncommitted changes included\./); + assert.match(fresh, /This is what the session left for itself:\n\nNext: the docs\./); + const deferred = relay.compose({ fresh: true, deferred: true, continuation: 'x' }); + assert.match(deferred, /^The time this work was deferred to has come, and this is the plugin handing it to a new session/); + const fromHome = relay.compose({ fresh: true, projectDir: '/home/me', continuation: 'x' }); + assert.match(fromHome, /The project is \/home\/me, added to this session, which was started from a trusted folder on purpose: use absolute paths\./); + assert.match(relay.compose({ continuation: 'x' }), /^The usage window has reset and this is the plugin picking the work back up, not a new request\. Carry on from where the last turn stopped/); +}); + +// --- the window --------------------------------------------------------------- + +test('fresh on: a new session in a window, the hand-off inline as its first prompt, no transcript needed', () => + isolated((dir) => { + const record = { id: ID, cwd: dir, project: 'proj' }; + const runs = []; + const opened = []; + const result = wake.deliverClaude(record, PROMPT, { show: true, fresh: true, permissionMode: 'acceptEdits', model: 'opus' }, '/usr/local/bin/claude', runs, + clockIo({ platform: 'linux', open: (launcher) => opened.push(launcher) })); + assert.strictEqual(result.ok, true, 'there is no transcript for ' + ID + ', and a new session needs none'); + assert.strictEqual(result.how, 'a new claude session in a window, Remote Control on'); + assert.deepStrictEqual(opened, [path.join(dir, 'relay-wake-' + ID + '.sh')]); + const script = fs.readFileSync(opened[0], 'utf8'); + assert.ok(!script.includes('--resume'), 'not the same conversation'); + assert.ok(script.includes( + "'/usr/local/bin/claude' '--permission-mode' 'acceptEdits' '--model' 'opus' '--remote-control' 'usage-limits relay proj (new session)' " + + wake.shQuote(PROMPT) + ), script); + assert.ok(script.includes('cd ' + wake.shQuote(dir)), 'in the project directory'); + assert.match(runs[0], /--- claude, a new session \(window\) \(exit 0\) ---/); + assert.match(runs[0], /Remote Control on as "usage-limits relay proj \(new session\)", the hand-off inline as its first prompt/); + })); + +test('the POSIX launcher hands claude the multi-line hand-off byte for byte', { skip: process.platform === 'win32' && 'sh, checked where sh is' }, () => + isolated((dir) => { + // A stand-in for claude that writes each argument it was given, NUL-separated. + const fake = path.join(dir, 'fake-claude.sh'); + const out = path.join(dir, 'args.bin'); + fs.writeFileSync(fake, '#!/bin/sh\nfor a in "$@"; do printf \'%s\\0\' "$a"; done > ' + wake.shQuote(out) + '\n'); + fs.chmodSync(fake, 0o755); + const record = { id: ID, cwd: dir, project: 'proj' }; + const result = wake.deliverClaude(record, PROMPT, { show: true, fresh: true, permissionMode: 'auto' }, fake, [], clockIo({ + platform: 'linux', + open: (launcher) => { + const ran = spawnSync('sh', [launcher], { encoding: 'utf8', env: Object.assign({}, process.env, { CLAUDE_CODE_SESSION_ID: 'parent' }) }); + assert.strictEqual(ran.status, 0, ran.stderr); + }, + })); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.how, 'a new claude session in a window', 'the fake exited 0 inside the grace period'); + const args = fs.readFileSync(out, 'utf8').split('\0').slice(0, -1); + assert.deepStrictEqual(args, ['--permission-mode', 'auto', '--remote-control', 'usage-limits relay proj (new session)', PROMPT]); + })); + +test('on Windows the inline hand-off goes through node to the native claude.exe, never through cmd', () => + isolated((dir) => { + const record = { id: ID, cwd: 'C:\\work\\proj', project: 'proj' }; + const opened = []; + const result = wake.deliverClaude(record, PROMPT, { show: true, fresh: true, permissionMode: 'acceptEdits' }, 'C:\\npm\\claude.cmd', [], clockIo({ + platform: 'win32', + nativeCli: () => 'C:\\Users\\me\\.local\\bin\\claude.exe', + open: (launcher) => opened.push(launcher), + })); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(opened, [path.join(dir, 'relay-wake-' + ID + '.cmd')]); + const script = fs.readFileSync(opened[0], 'utf8'); + assert.ok(script.includes('call ' + wake.bridgeCommand(wake.specFile(ID)) + '\r\n'), script); + assert.ok(script.includes('"--exec-spec"')); + assert.ok(!script.includes('claude.cmd'), 'the shim would cut the hand-off at its first line break'); + assert.ok(!script.includes('it\'s'), 'the hand-off is not in the batch file at all'); + const spec = JSON.parse(fs.readFileSync(wake.specFile(ID), 'utf8')); + assert.strictEqual(spec.cli, 'C:\\Users\\me\\.local\\bin\\claude.exe'); + assert.deepStrictEqual(spec.args, ['--permission-mode', 'acceptEdits', '--remote-control', 'usage-limits relay proj (new session)', PROMPT]); + })); + +test('with only a batch shim, or a hand-off too long for one argument, the first prompt points at the file instead', () => + isolated((dir) => { + const record = { id: ID, cwd: 'C:\\work\\proj', project: 'proj' }; + const runs = []; + const opened = []; + const result = wake.deliverClaude(record, PROMPT, { show: true, fresh: true }, 'C:\\npm\\claude.cmd', runs, clockIo({ + platform: 'win32', + nativeCli: () => 'C:\\npm\\claude.cmd', + open: (launcher) => opened.push(launcher), + })); + assert.strictEqual(result.ok, true); + const script = fs.readFileSync(opened[0], 'utf8'); + const promptFile = wake.wakePromptFile(ID); + assert.ok(script.includes('call "C:\\npm\\claude.cmd" "--remote-control" "usage-limits relay proj (new session)" "This is a new session taking over work'), script); + assert.ok(script.includes(promptFile), 'the pointer names the file'); + assert.ok(!script.includes('--resume')); + assert.strictEqual(fs.readFileSync(promptFile, 'utf8').trim(), PROMPT.trim(), 'the whole hand-off is on disk for it'); + assert.match(runs[0], /the hand-off in .*relay-wake-.*\.md \(C:\\npm\\claude\.cmd is a batch shim and cmd\.exe ends a command at the first line break\)/); + assert.match(fs.readFileSync(relay.logFile(), 'utf8'), /the hand-off is not inline \(C:\\npm\\claude\.cmd is a batch shim/); + + const long = 'x'.repeat(wake.FRESH_ARG_MAX.win32 + 1); + const plan = wake.freshLaunch(record, {}, 'C:\\bin\\claude.exe', long, 'C:\\cfg\\p.md', 'win32'); + assert.strictEqual(plan.inline, false); + assert.match(plan.why, /30001 characters, more than one argument holds here \(30000\)/); + assert.ok(plan.args[plan.args.length - 1].includes('C:\\cfg\\p.md')); + assert.strictEqual(wake.freshLaunch(record, {}, '/bin/claude', long, '/p.md', 'linux').inline, true, 'Linux holds 128 KiB in one argument'); + // Quoting for CreateProcess can add one character per quote or backslash. + const quoted = '"'.repeat(16000); + assert.strictEqual(wake.freshLaunch(record, {}, 'C:\\bin\\claude.exe', quoted, 'C:\\cfg\\p.md', 'win32').inline, false); + })); + +test('the node bridge passes a multi-line hand-off to the program as one argument, and its exit code back', () => + isolated((dir) => { + const spec = path.join(dir, 'spec.json'); + // node itself stands in for claude.exe: it prints the argument it got and exits 7. + fs.writeFileSync(spec, JSON.stringify({ cli: process.execPath, args: ['-e', 'process.stdout.write(process.argv[1]); process.exit(7)', PROMPT] })); + const run = spawnSync(process.execPath, [WAKE, '--exec-spec', spec], { encoding: 'utf8', timeout: 30000, windowsHide: true }); + assert.strictEqual(run.stdout, PROMPT); + assert.strictEqual(run.status, 7); + const missing = spawnSync(process.execPath, [WAKE, '--exec-spec', path.join(dir, 'none.json')], { encoding: 'utf8', timeout: 30000, windowsHide: true }); + assert.strictEqual(missing.status, 1); + assert.match(missing.stderr, /could not read/); + })); + +// --- headless ----------------------------------------------------------------- + +test('relay show off with fresh on is a new headless run, the hand-off on stdin; fresh off is unchanged', () => + isolated((dir) => { + const config = { show: false, fresh: true, permissionMode: 'acceptEdits' }; + assert.deepStrictEqual(wake.claudeArgs({ id: ID, cwd: dir }, 'x', config, false), ['-p', '--permission-mode', 'acceptEdits', '--permission-prompts', 'none']); + const calls = []; + const runs = []; + const result = wake.deliverClaude({ id: ID, cwd: dir }, PROMPT, config, '/bin/claude', runs, { + spawnSync: (cli, args, options) => { + calls.push({ cli, args, options }); + return { status: 0, stdout: 'done', stderr: '' }; + }, + }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.how, 'claude -p, a new session'); + assert.strictEqual(calls[0].options.input, PROMPT); + assert.strictEqual(calls[0].options.cwd, dir); + assert.ok(!calls[0].args.includes('--resume')); + assert.match(runs[0], /^--- claude -p, a new session \(exit 0\) ---/); + assert.deepStrictEqual(wake.claudeArgs({ id: ID, cwd: dir }, 'x', { show: false }, false).slice(0, 3), ['--resume', ID, '-p']); + })); + +// --- the wake ----------------------------------------------------------------- + +const ONLINE = async () => ({ online: true, reason: 'ok', detail: null, results: [] }); + +function seed(dir, extra) { + const state = relay.read(); + state.config = Object.assign({}, state.config, { mode: 'resume', enabled: true, fresh: true, voice: false }); + state.armed = Object.assign({ + id: ID, task: null, host: 'claude', cwd: dir, project: 'proj', armedAt: 1, wakeAt: Date.now(), + windowKey: 'five_hour', mode: 'resume', attempt: 0, continuation: false, work: { pending: 1, todos: [] }, + }, extra || null); + relay.write(state); +} + +function deps(over) { + const seen = { delivered: [], toasts: [] }; + let clock = 1000000; + const base = { + reachable: ONLINE, + windowReopened: async () => ({ known: true, percent: 2 }), + deliverClaude: (record, prompt, config) => { + seen.delivered.push({ record, prompt, config }); + return { ok: true, how: 'test window' }; + }, + deliverCodex: () => ({ ok: false, error: 'not here' }), + toast: (title, body) => seen.toasts.push(title + ': ' + body), + userIsPresent: () => ({ known: true, present: false }), + capabilities: () => ({ claude: 'claude', codex: null, computerUse: null }), + arm: () => { + throw new Error('no wake may be booked from this test'); + }, + liveSession: () => null, + transcriptActiveSince: () => false, + now: () => clock, + sleep: (ms) => { + clock += ms; + }, + }; + return { deps: Object.assign(base, over || null), seen }; +} + +test('ONE TERMINAL holds with fresh on: an old session working again means no new one, a quiet one gets exactly one', () => + isolated(async (dir) => { + seed(dir); + const { deps: d, seen } = deps({ liveSession: () => ({ pid: 4242 }), transcriptActiveSince: () => true }); + const result = await wake.run(Date.now(), [], d); + assert.strictEqual(result.outcome, 'live'); + assert.deepStrictEqual(seen.delivered, [], 'no new session beside the one that is working'); + assert.match(seen.toasts.join('\n'), /no second window/); + + // Open but quiet for the whole wait: the one new session, and the log says so. + seed(dir); + const quiet = deps({ liveSession: () => ({ pid: 4242 }), transcriptActiveSince: () => false }); + const opened = await wake.run(Date.now(), [], quiet.deps); + assert.strictEqual(opened.outcome, 'resumed'); + assert.strictEqual(quiet.seen.delivered.length, 1); + assert.match(fs.readFileSync(relay.logFile(), 'utf8'), /open in process 4242 but idle since the reset; starting a new session in a window/); + })); + +test('fresh on: the wake delivers the new session\'s prompt and says it is handing over', () => + isolated(async (dir) => { + seed(dir); + relay.saveContinuation(ID, 'Next: finish the relay docs.'); + const { deps: d, seen } = deps(); + const result = await wake.run(Date.now(), [], d); + assert.strictEqual(result.outcome, 'resumed'); + assert.strictEqual(seen.delivered.length, 1); + assert.strictEqual(seen.delivered[0].config.fresh, true); + assert.match(seen.delivered[0].prompt, /^ultrathink\n\nThe usage window has reset, and this is the plugin handing an earlier session's work to this new one/); + assert.match(seen.delivered[0].prompt, /Next: finish the relay docs\./); + assert.match(seen.toasts.join('\n'), /Usage limits: handing over: Starting a new session for proj with the saved plan\./); + })); + +test('fresh on with mode notify starts nothing, and the note says how to start the new session by hand', () => + isolated(async (dir) => { + seed(dir); + relay.configure({ mode: 'notify' }); + const { deps: d, seen } = deps(); + const result = await wake.run(Date.now(), [], d); + assert.strictEqual(result.outcome, 'notified'); + assert.deepStrictEqual(seen.delivered, []); + assert.match(seen.toasts.join('\n'), /Start claude in .* and give it the hand-off in .*relay-wake-5a1d0c2e[^ ]*\.md$/); + const handoff = fs.readFileSync(wake.wakePromptFile(ID), 'utf8'); + assert.match(handoff, /handing an earlier session's work to this new one/, 'the whole hand-off, opened for a new session'); + assert.doesNotMatch(seen.toasts.join('\n'), /claude --resume/); + })); + +// --- defer -------------------------------------------------------------------- + +test('defer says what fires in each mode, and under fresh a deferral with no session id still starts', () => + isolated(async (dir) => { + assert.match(defer.whatFires({ mode: 'notify' }, 'claude', true), /only raises a notification with the plan \(relay mode notify\)/); + assert.match(defer.whatFires({ mode: 'resume', fresh: false }, 'claude', true), /resumes this same conversation \(relay fresh off\)/); + assert.match(defer.whatFires({ mode: 'resume', fresh: false }, 'claude', false), /no session id is known here, so it will fail: relay fresh on starts a new session instead/); + assert.match(defer.whatFires({ mode: 'resume', fresh: true }, 'claude', false), /starts a new claude session in this folder with the saved work as its first prompt \(relay fresh on\)/); + + relay.configure({ mode: 'resume', fresh: true, voice: false }); + const line = defer.main(['in 90m', '--host', 'claude', '--cwd', dir, '--work', 'Write the fresh tests\nBump the version'], Date.now()); + assert.match(line, /Nothing has been started; 2 lines saved; then it starts a new claude session in this folder/); + const record = relay.read().armed; + assert.match(record.id, /^defer-/, 'no session id: nothing to resume, which fresh does not need'); + assert.strictEqual(record.deferred, true); + + // The wake for it, through the real window path to a fake opener. + const opened = []; + const { deps: d } = deps({ + deliverClaude: (rec, prompt, config, cli, runs) => + wake.deliverClaude(rec, prompt, config, '/usr/local/bin/claude', runs, clockIo({ platform: 'linux', open: (launcher) => opened.push(launcher) })), + }); + const result = await wake.run(Date.now(), ['--id', record.id], d); + assert.strictEqual(result.outcome, 'resumed', JSON.stringify(result)); + const script = fs.readFileSync(opened[0], 'utf8'); + assert.ok(!script.includes('--resume')); + assert.ok(script.includes("'--remote-control' 'usage-limits relay " + path.basename(dir) + " (new session)'")); + assert.match(script, /The time this work was deferred to has come, and this is the plugin handing it to a new session/); + assert.match(script, /Write the fresh tests\nBump the version/); + })); + +test('defer marks its own record, not whichever relay was armed first', () => + isolated((dir) => { + const state = relay.read(); + state.armed = { id: 'someone-else', task: null, host: 'claude', cwd: dir, wakeAt: Date.now() + 3 * 3600000, continuation: false }; + relay.write(state); + const line = defer.main(['in 2h', '--host', 'claude', '--session-id', 'sess-defer-own', '--cwd', dir, '--work', 'x'], Date.now()); + assert.match(line, /^Doing this at /); + const held = relay.read(); + assert.strictEqual(relay.armedFor(held, 'sess-defer-own').deferred, true); + assert.strictEqual(relay.armedFor(held, 'someone-else').deferred, undefined, 'the other relay is left as it was'); + })); diff --git a/vscode/package.json b/vscode/package.json index 5a9171b..b5dcc6c 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "claude-usage-limits", "displayName": "Claude Usage Limits", "description": "Your Claude Code session, weekly and per-model limits as live bars in a view beside the Claude Code chat, drawn in Claude's own colours from Claude's own numbers.", - "version": "1.41.1", + "version": "1.43.0", "publisher": "ridelink", "license": "MIT", "icon": "media/icon.png",