mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 21:19:41 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a33b91107 | ||
|
|
28b531fa5b | ||
|
|
68310619a7 | ||
|
|
055f18fb66 | ||
|
|
cf2a7f54bf |
@@ -1,5 +1,28 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.9.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Auto-resume on usage limit ("token pause" control) plus a set of mobile-view fixes for regressions introduced in 0.9.8.
|
||||
|
||||
**Auto-resume on usage limit** — new opt-in checkbox at the top of the session Respawn tab (off by default). When Claude stops because a usage limit was reached, Codeman parses the reset time from the limit message, waits until the limit lifts (plus a 2-minute safety buffer), then dismisses the rate-limit dialog (Esc) and sends "continue" so the session picks its work back up automatically. All Claude Code message formats from 1.0.x through 2.1.x are recognized ("5-hour limit reached ∙ resets 8pm", "Limit reached · resets 1pm (America/Chicago) · /upgrade…", "You've hit your weekly limit · resets Mon 12:00am", weekly date forms, and the raw API `usage limit reached|<epoch>` form). Still-limited responses re-arm the scheduler (5-minute retry loop); a pending schedule persists across Codeman restarts and re-arms on boot; respawn cycles are blocked while a limit pause is active so the cycle's `/clear` cannot wipe the paused conversation. New endpoint `POST /api/sessions/:id/auto-resume`; new SSE events `session:limitPauseScheduled`, `session:limitResume`, `session:limitResumeCancelled`; toast/notification on pause and resume, plus a live "resumes at HH:MM" status line in the modal. The Respawn tab layout was also tidied: compact single-row Update/Kickstart prompt fields and a merged options row.
|
||||
|
||||
**Mobile fixes (0.9.8 regressions)**:
|
||||
- **Activity-based resize arbitration** — a desktop sizing claim now only blocks a phone's resize while that desktop has actually typed within the last 90 seconds. Previously any connected desktop tab (even one abandoned hours ago) silently discarded the phone's resize with no fallback, leaving the phone rendering a desktop-width stream in a narrow terminal: mid-word wraps, tmux dot-fill rows, overdrawn garbled text, and misplaced keyboard echo. Now an idle desktop yields the pane to the phone, and the next desktop keystroke automatically restores the desktop layout ("whoever is actively using the session wins"). Phones also re-send their dimensions every 30 seconds (visible tab only, skipped while the virtual keyboard is open) so attaching under a momentarily-active desktop self-corrects.
|
||||
- **Keyboard accessory bar and toolbar restored on iOS** — the lift offset is measured against the layout viewport (`window.innerHeight`) again instead of the keyboard-shrunken app element; on iOS the offset computed to 0, leaving both bars hidden behind the OS keyboard with a dead black gap above it.
|
||||
- **Removed the mobile header utility ("three dots") toggle** — the header-utilities tray stays collapsed on small viewports.
|
||||
|
||||
## 0.9.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Documentation refresh — README catches up with the Codex run mode, plus a CLAUDE.md correction.
|
||||
|
||||
**README (en + zh-CN)**: Codex is now listed as a third supported AI coding CLI everywhere the docs previously said "Claude Code or OpenCode": the install requirement in Quick Start (now "any combination works", linking to the official Codex CLI docs), the Windows/WSL setup note, the renamed **Multi-CLI** feature bullet (env-prefix gating now reads `CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`), the Zod schema-validation security bullet, and the architecture mermaid diagram. The header tagline was also finalized to "Claude Code • OpenCode • Codex — One Dashboard • Any Device" in both languages.
|
||||
|
||||
**CLAUDE.md**: fixed a stale "Local packages" line that claimed the xterm-zerolag-input local-echo overlay had a copy embedded in `app.js` — it is single-source in `packages/xterm-zerolag-input/`, bundled to the gitignored vendor file, and only consumed by `app.js`, matching the existing single-source gotcha.
|
||||
|
||||
## 0.9.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.9.11 (must match `package.json`)
|
||||
**Version**: 0.9.13 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -117,7 +117,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Domain | Key files | Notes |
|
||||
|--------|-----------|-------|
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts` | |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
@@ -134,7 +134,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`. `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
|
||||
**Config**: `src/config/` — 10 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
@@ -153,6 +153,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
|
||||
@@ -209,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~135 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~136 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex — One Dashboard • Zero-Lag Mobile Input • Any Device</em>
|
||||
<em>Claude Code • OpenCode • Codex — One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -34,7 +34,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -103,7 +103,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -293,7 +293,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Dual-CLI** — run **Claude Code** or **OpenCode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
@@ -447,7 +447,7 @@ These run for **every** request — before auth, even on the default no-password
|
||||
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
@@ -579,7 +579,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
|
||||
+6
-6
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex —— 统一仪表盘 • 零延迟移动输入 • 任意设备</em>
|
||||
<em>Claude Code • OpenCode • Codex —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai)(两个都装也可以)。安装完成后:
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
@@ -105,7 +105,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code) 或 [OpenCode](https://opencode.ai))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -295,7 +295,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **双 CLI** —— 每个会话可选 **Claude Code** 或 **OpenCode**;环境变量前缀自动隔离(`CLAUDE_CODE_*` 与 `OPENCODE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||
@@ -449,7 +449,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设
|
||||
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
@@ -581,7 +581,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["外部"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||
end
|
||||
end
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.11",
|
||||
"version": "0.9.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.11",
|
||||
"version": "0.9.13",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.9.11",
|
||||
"version": "0.9.13",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -2779,6 +2779,19 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Usage-limit pause: Claude can't work and the cycle's /clear would wipe
|
||||
// the paused conversation — the auto-resume scheduler owns recovery here.
|
||||
if (this.session.isLimitPaused) {
|
||||
this.log('Skipping respawn cycle - usage-limit pause active (auto-resume armed)');
|
||||
this.logAction('health', 'Respawn skipped: usage-limit pause (auto-resume armed)');
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'usage_limit',
|
||||
details: 'Usage limit reached — waiting for scheduled auto-resume',
|
||||
});
|
||||
this.setState('watching');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the respawn cycle
|
||||
this.cycleCount++;
|
||||
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
||||
|
||||
+184
-1
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* @fileoverview Auto-compact and auto-clear automation for Session.
|
||||
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
|
||||
*
|
||||
* Monitors token counts and triggers /compact or /clear commands when
|
||||
* configurable thresholds are reached. Waits for Claude to be idle
|
||||
* before sending commands, with retry logic and mutual exclusion
|
||||
* (compact and clear never run simultaneously).
|
||||
*
|
||||
* Also implements auto-resume on usage limit ("token pause" control):
|
||||
* when enabled and Claude stops on a usage-limit message ("5-hour limit
|
||||
* reached ∙ resets 8pm" and friends — see usage-limit-patterns.ts), a timer
|
||||
* is armed for the parsed reset time plus a safety buffer, then Escape
|
||||
* (dismisses the rate-limit options dialog if open) and a "continue" prompt
|
||||
* are sent so work resumes automatically. If the session is still limited,
|
||||
* the fresh limit message re-arms the scheduler — that retry loop is the
|
||||
* safety net for clock skew and parse imprecision.
|
||||
*
|
||||
* @module session-auto-ops
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -78,6 +88,28 @@ async function executeWhenIdle(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume (usage-limit pause) constants
|
||||
// ============================================================================
|
||||
|
||||
/** Safety buffer after the stated reset time before resuming (2 minutes) */
|
||||
const RESUME_BUFFER_MS = 2 * 60_000;
|
||||
|
||||
/** Minimum delay before an overdue resume fires (lets output settle) */
|
||||
const RESUME_MIN_DELAY_MS = 5_000;
|
||||
|
||||
/** Retry interval when the reset time is stale/past (5 minutes) */
|
||||
const RESUME_RETRY_MS = 5 * 60_000;
|
||||
|
||||
/** Re-detections scheduling within this window of the current schedule are ignored */
|
||||
const RESUME_DEDUP_TOLERANCE_MS = 90_000;
|
||||
|
||||
/** Delay between Escape (dialog dismiss) and the resume prompt */
|
||||
const RESUME_ESC_DELAY_MS = 600;
|
||||
|
||||
/** Prompt sent to resume work after the limit resets */
|
||||
const RESUME_PROMPT = 'continue';
|
||||
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
const MIN_AUTO_THRESHOLD = 1000;
|
||||
|
||||
@@ -131,6 +163,16 @@ export class SessionAutoOps extends EventEmitter {
|
||||
private _isClearing: boolean = false;
|
||||
private _autoClearTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Auto-resume (usage-limit pause) state
|
||||
private _autoResumeEnabled: boolean = false;
|
||||
private _autoResumeTimer: NodeJS.Timeout | null = null;
|
||||
/** Esc→continue gap timer; detections must NOT cancel a resume in flight */
|
||||
private _resumeFollowupTimer: NodeJS.Timeout | null = null;
|
||||
/** When the scheduled resume fires (epoch ms), null when not armed */
|
||||
private _autoResumeAt: number | null = null;
|
||||
private _limitPaused: boolean = false;
|
||||
private _resumeAttempts: number = 0;
|
||||
|
||||
private readonly callbacks: AutoOpsCallbacks;
|
||||
|
||||
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
|
||||
@@ -207,6 +249,145 @@ export class SessionAutoOps extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume (usage-limit pause) — getters/setters
|
||||
// ============================================================================
|
||||
|
||||
get autoResumeEnabled(): boolean {
|
||||
return this._autoResumeEnabled;
|
||||
}
|
||||
|
||||
/** When the scheduled resume fires (epoch ms), or null when not armed. */
|
||||
get autoResumeAt(): number | null {
|
||||
return this._autoResumeAt;
|
||||
}
|
||||
|
||||
/** True while the session is believed to be paused on a usage limit. */
|
||||
get isLimitPaused(): boolean {
|
||||
return this._limitPaused;
|
||||
}
|
||||
|
||||
setAutoResume(enabled: boolean): void {
|
||||
this._autoResumeEnabled = enabled;
|
||||
if (!enabled) {
|
||||
this._cancelAutoResume('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore auto-resume state after a Codeman restart. A persisted pending
|
||||
* schedule is re-armed; an overdue one fires shortly after boot (the limit
|
||||
* footer won't reprint on its own, so without this the pause would stall).
|
||||
*/
|
||||
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
|
||||
this._autoResumeEnabled = enabled;
|
||||
if (!enabled || !resumeAt) return;
|
||||
const now = Date.now();
|
||||
this._scheduleResume(Math.max(resumeAt, now + RESUME_MIN_DELAY_MS), resumeAt, 'restored');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-resume — detection and scheduling
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Scan cleaned terminal output for a usage-limit pause message and (re)arm
|
||||
* the resume schedule. Called from the session's throttled parser path.
|
||||
*/
|
||||
processCleanData(cleanData: string): void {
|
||||
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
|
||||
// A resume is in flight (Esc sent, continue pending): output from our own
|
||||
// Escape can redraw the stale limit footer — don't let it re-arm and
|
||||
// cancel the continue. Fresh evidence arrives after the prompt is sent.
|
||||
if (this._resumeFollowupTimer) return;
|
||||
|
||||
const detection = detectUsageLimitPause(cleanData);
|
||||
if (!detection) return;
|
||||
|
||||
const now = Date.now();
|
||||
const overdue = detection.resetAt <= now;
|
||||
const fireAt = overdue
|
||||
? now + RESUME_RETRY_MS // stale reset time → gentle retry loop
|
||||
: Math.max(detection.resetAt + RESUME_BUFFER_MS, now + RESUME_MIN_DELAY_MS);
|
||||
|
||||
if (this._autoResumeTimer && this._autoResumeAt !== null) {
|
||||
// Already armed: the footer redraws constantly, so ignore re-detections
|
||||
// that land on (or later than) the current schedule. Only an EARLIER
|
||||
// parsed time replaces it — an overdue retry never preempts a real one.
|
||||
if (overdue || fireAt >= this._autoResumeAt - RESUME_DEDUP_TOLERANCE_MS) return;
|
||||
}
|
||||
|
||||
this._scheduleResume(fireAt, detection.resetAt, detection.matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude started working — the limit is lifted (or the user resumed
|
||||
* manually), so any pending auto-resume is obsolete.
|
||||
*/
|
||||
notifyWorking(): void {
|
||||
this._resumeAttempts = 0;
|
||||
if (!this._limitPaused && !this._autoResumeTimer && !this._resumeFollowupTimer) return;
|
||||
this._cancelAutoResume('working');
|
||||
}
|
||||
|
||||
private _scheduleResume(fireAt: number, resetAt: number, matched: string): void {
|
||||
if (this._autoResumeTimer) {
|
||||
clearTimeout(this._autoResumeTimer);
|
||||
this._autoResumeTimer = null;
|
||||
}
|
||||
this._limitPaused = true;
|
||||
this._autoResumeAt = fireAt;
|
||||
const delay = Math.max(fireAt - Date.now(), 0);
|
||||
console.log(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Usage-limit pause detected ("${matched.slice(0, 60)}"), auto-resume in ${Math.round(delay / 60000)}min`
|
||||
);
|
||||
this._autoResumeTimer = setTimeout(() => void this._fireResume(), delay);
|
||||
this.emit('limitPauseScheduled', { resetAt, resumeAt: fireAt, matched });
|
||||
}
|
||||
|
||||
private async _fireResume(): Promise<void> {
|
||||
this._autoResumeTimer = null;
|
||||
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
|
||||
|
||||
if (this.callbacks.isWorking()) {
|
||||
// Session resumed on its own (or via the user) — nothing to do.
|
||||
this._cancelAutoResume('working');
|
||||
return;
|
||||
}
|
||||
|
||||
this._resumeAttempts++;
|
||||
const attempt = this._resumeAttempts;
|
||||
this._limitPaused = false; // optimistic: a fresh limit message re-arms us
|
||||
this._autoResumeAt = null;
|
||||
|
||||
// Escape first: dismisses the rate-limit options dialog if Claude opened
|
||||
// one (harmless at an idle prompt), then the resume prompt after a beat.
|
||||
await this.callbacks.writeCommand('\x1b');
|
||||
this._resumeFollowupTimer = setTimeout(() => {
|
||||
this._resumeFollowupTimer = null;
|
||||
if (this.callbacks.isStopped()) return;
|
||||
void this.callbacks.writeCommand(`${RESUME_PROMPT}\r`);
|
||||
this.emit('limitResume', { attempt });
|
||||
}, RESUME_ESC_DELAY_MS);
|
||||
}
|
||||
|
||||
private _cancelAutoResume(reason: 'disabled' | 'working' | 'stopped'): void {
|
||||
const wasArmed = this._autoResumeTimer !== null || this._resumeFollowupTimer !== null || this._limitPaused;
|
||||
if (this._autoResumeTimer) {
|
||||
clearTimeout(this._autoResumeTimer);
|
||||
this._autoResumeTimer = null;
|
||||
}
|
||||
if (this._resumeFollowupTimer) {
|
||||
clearTimeout(this._resumeFollowupTimer);
|
||||
this._resumeFollowupTimer = null;
|
||||
}
|
||||
this._limitPaused = false;
|
||||
this._autoResumeAt = null;
|
||||
if (wasArmed && reason !== 'stopped') {
|
||||
this.emit('limitResumeCancelled', { reason });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Threshold checks
|
||||
// ============================================================================
|
||||
@@ -321,5 +502,7 @@ export class SessionAutoOps extends EventEmitter {
|
||||
this._autoClearTimer = null;
|
||||
}
|
||||
this._isClearing = false;
|
||||
|
||||
this._cancelAutoResume('stopped');
|
||||
}
|
||||
}
|
||||
|
||||
+95
-5
@@ -78,6 +78,7 @@ import {
|
||||
buildShellEnv,
|
||||
} from './session-cli-builder.js';
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
@@ -520,6 +521,9 @@ export class Session extends EventEmitter {
|
||||
this._totalOutputTokens = 0;
|
||||
this.emit('autoClear', data);
|
||||
});
|
||||
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
|
||||
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
|
||||
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
@@ -825,6 +829,39 @@ export class Session extends EventEmitter {
|
||||
this._autoOps.setAutoCompact(enabled, threshold, prompt);
|
||||
}
|
||||
|
||||
get autoResumeEnabled(): boolean {
|
||||
return this._autoOps.autoResumeEnabled;
|
||||
}
|
||||
|
||||
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
|
||||
get autoResumeAt(): number | null {
|
||||
return this._autoOps.autoResumeAt;
|
||||
}
|
||||
|
||||
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
|
||||
get isLimitPaused(): boolean {
|
||||
return this._autoOps.isLimitPaused;
|
||||
}
|
||||
|
||||
setAutoResume(enabled: boolean): void {
|
||||
this._autoOps.setAutoResume(enabled);
|
||||
// Users typically enable this WHILE a session already sits paused — the
|
||||
// limit footer won't reprint on its own, so scan the recent buffer once.
|
||||
// Only a future reset time counts: stale scrollback must not arm a resume.
|
||||
if (enabled && !isExternalCliMode(this.mode)) {
|
||||
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
||||
const detection = detectUsageLimitPause(tail);
|
||||
if (detection && detection.resetAt > Date.now()) {
|
||||
this._autoOps.processCleanData(tail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
|
||||
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
|
||||
this._autoOps.restoreAutoResume(enabled, resumeAt);
|
||||
}
|
||||
|
||||
get imageWatcherEnabled(): boolean {
|
||||
return this._imageWatcherEnabled;
|
||||
}
|
||||
@@ -869,6 +906,8 @@ export class Session extends EventEmitter {
|
||||
autoCompactEnabled: this._autoOps.autoCompactEnabled,
|
||||
autoCompactThreshold: this._autoOps.autoCompactThreshold,
|
||||
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
@@ -1250,6 +1289,7 @@ export class Session extends EventEmitter {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
this.emit('working');
|
||||
this._autoOps.notifyWorking();
|
||||
}
|
||||
this._awaitingIdleConfirmation = false;
|
||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||
@@ -1356,6 +1396,11 @@ export class Session extends EventEmitter {
|
||||
this._bashToolParser.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Usage-limit pause detection (auto-resume on usage limit)
|
||||
if (this._autoOps.autoResumeEnabled) {
|
||||
this._autoOps.processCleanData(getCleanData());
|
||||
}
|
||||
|
||||
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||
if (rawData.includes('token')) {
|
||||
this.parseTokensFromStatusLine(getCleanData());
|
||||
@@ -1384,6 +1429,7 @@ export class Session extends EventEmitter {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
this.emit('working');
|
||||
this._autoOps.notifyWorking();
|
||||
this._awaitingIdleConfirmation = false;
|
||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||
}
|
||||
@@ -2096,9 +2142,29 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _desktopSizeClaims = new Set<symbol>();
|
||||
|
||||
/**
|
||||
* A desktop sizing claim only blocks small-viewport resizes while the
|
||||
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
|
||||
* window). An abandoned-but-connected desktop tab (left open at home, screen
|
||||
* locked) must not hold a phone's view hostage: without this, the phone
|
||||
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
|
||||
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
|
||||
*/
|
||||
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
|
||||
|
||||
/** Last evidence of a live desktop user (claim registered / typed input). */
|
||||
private _lastDesktopActivityAt = 0;
|
||||
|
||||
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
|
||||
private _lastDesktopDims: { cols: number; rows: number } | null = null;
|
||||
|
||||
/** True while a small viewport reflowed the pane past an idle desktop claim. */
|
||||
private _mobileSizeOverride = false;
|
||||
|
||||
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
|
||||
claimDesktopSizing(token: symbol): void {
|
||||
this._desktopSizeClaims.add(token);
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
}
|
||||
|
||||
/** Release a desktop sizing claim when its connection goes away. */
|
||||
@@ -2106,23 +2172,47 @@ export class Session extends EventEmitter {
|
||||
this._desktopSizeClaims.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record desktop user activity (typed input over a claim-holding socket).
|
||||
* If a phone reflowed the pane while the desktop was idle, the desktop
|
||||
* layout is restored — "whoever is actively using the session wins".
|
||||
*/
|
||||
noteDesktopActivity(): void {
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
if (this._mobileSizeOverride && this._lastDesktopDims) {
|
||||
this._mobileSizeOverride = false;
|
||||
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the PTY terminal dimensions.
|
||||
* Skips the resize if dimensions haven't changed to avoid triggering
|
||||
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
|
||||
*
|
||||
* Arbitration: while a desktop connection holds a sizing claim, resizes from
|
||||
* small viewports (mobile/tablet) are ignored entirely — shrink AND grow
|
||||
* would both reflow the desktop view. Without a desktop connected, small
|
||||
* viewports control the PTY size freely.
|
||||
* Arbitration: while a desktop connection holds a sizing claim AND has been
|
||||
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
|
||||
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
|
||||
* desktop view. Once the desktop goes idle, a phone may take the pane (the
|
||||
* desktop re-asserts its size on its next typed input via
|
||||
* noteDesktopActivity). Without a desktop connected, small viewports
|
||||
* control the PTY size freely.
|
||||
*
|
||||
* @param cols - Number of columns (width in characters)
|
||||
* @param rows - Number of rows (height in lines)
|
||||
*/
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
|
||||
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
||||
if (options.viewportType === 'desktop') {
|
||||
this._lastDesktopDims = { cols, rows };
|
||||
this._lastDesktopActivityAt = Date.now();
|
||||
this._mobileSizeOverride = false;
|
||||
}
|
||||
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
||||
return;
|
||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
}
|
||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||
this._ptyCols = cols;
|
||||
|
||||
@@ -133,6 +133,10 @@ export interface SessionState {
|
||||
autoCompactThreshold?: number;
|
||||
/** Auto-compact prompt */
|
||||
autoCompactPrompt?: string;
|
||||
/** Auto-resume on usage limit enabled */
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/** Image watcher enabled for this session */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @fileoverview Pure detection of Claude Code usage-limit pause messages.
|
||||
*
|
||||
* When a Claude subscription limit (5-hour rolling window, weekly, Opus weekly,
|
||||
* or extra-usage balance) is hit, the Claude Code TUI stops working and prints a
|
||||
* status line with the reset time. These helpers detect that state in cleaned
|
||||
* (ANSI-stripped) terminal output and parse the reset time, so the session
|
||||
* auto-resume feature (SessionAutoOps) can schedule a "continue" nudge.
|
||||
*
|
||||
* Message shapes covered (observed across Claude Code 1.0.x–2.1.x, 2025–2026):
|
||||
* - `5-hour limit reached ∙ resets 8pm` (v1.0.109+ footer)
|
||||
* - `Session limit reached ∙ resets 8pm`
|
||||
* - `Weekly limit reached ∙ resets 6pm`
|
||||
* - `Opus weekly limit reached ∙ resets Oct 6, 1pm`
|
||||
* - `Limit reached · resets 1pm (America/Chicago) · /upgrade to Max…` (v2.0.55+)
|
||||
* - `You've hit your limit · resets 1:40pm (America/New_York)` (v2.1.x)
|
||||
* - `You've hit your weekly limit · resets Mon 12:00am`
|
||||
* - `You've hit your limit · resets May 5 at 9pm (America/New_York)`
|
||||
* - `You're out of extra usage · resets 1pm (America/Los_Angeles)`
|
||||
* - `Claude usage limit reached. Your limit will reset at 2pm (America/New_York)` (v1.0.x inline)
|
||||
* - `Claude AI usage limit reached|1755309600` (raw API, epoch seconds)
|
||||
*
|
||||
* Deliberately conservative: a limit phrase WITHOUT a parseable reset time is
|
||||
* ignored (returns null) so ordinary conversation text mentioning "limit
|
||||
* reached" can't arm the scheduler. The downstream retry loop (re-detection
|
||||
* after each resume attempt) compensates for any parsing imprecision.
|
||||
*
|
||||
* All functions are pure (caller passes `now`) for testability.
|
||||
*
|
||||
* @module usage-limit-patterns
|
||||
*/
|
||||
|
||||
/** Result of scanning terminal output for a usage-limit pause. */
|
||||
export interface UsageLimitDetection {
|
||||
/**
|
||||
* Epoch ms when the limit resets. May be in the past when the matched
|
||||
* message is stale (caller should treat past values as "retry soon").
|
||||
*/
|
||||
resetAt: number;
|
||||
/** Matched message snippet (for logging and UI). */
|
||||
matched: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit phrases that indicate Claude stopped on a usage limit.
|
||||
* `\blimit reached` covers all "<X> limit reached" footer variants.
|
||||
*/
|
||||
const LIMIT_PHRASE_PATTERN =
|
||||
/(?:\blimit\s+reached\b|you'?ve\s+hit\s+your\s+(?:\w+\s+)?limit\b|you'?re\s+out\s+of\s+extra\s+usage\b)/gi;
|
||||
|
||||
/**
|
||||
* Reset-time spec following a limit phrase. Captures:
|
||||
* 1 month (weekly resets >1 day out: "Oct 6, 1pm" / "May 5 at 9pm")
|
||||
* 2 day-of-month
|
||||
* 3 day-of-week ("Mon 12:00am")
|
||||
* 4 hour (12h) 5 minutes 6 am/pm 7 IANA timezone in parens (optional)
|
||||
* `resets?` + optional `at` also covers the v1.0.x "will reset at 2pm" form.
|
||||
*/
|
||||
const RESET_TIME_PATTERN =
|
||||
/\bresets?\s+(?:at\s+)?(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:\s*,\s*|\s+at\s+)|(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^()\n]{1,64})\))?/i;
|
||||
|
||||
/** Raw API form: `Claude AI usage limit reached|1755309600` (epoch seconds). */
|
||||
const EPOCH_LIMIT_PATTERN = /\busage\s+limit\s+reached\|(\d{9,11})\b/gi;
|
||||
|
||||
/** How far after a limit phrase the reset-time spec may appear (chars). */
|
||||
const RESET_TIME_WINDOW = 160;
|
||||
|
||||
/** Parsed reset spec must not be further out than this (weekly max ≈ 7 days). */
|
||||
const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Current UTC offset of an IANA timezone in ms, or null if unresolvable
|
||||
* (e.g. the `(Etc/Unknown)` failure variant Claude Code can print).
|
||||
* DST transitions inside the wait window can skew the result by an hour;
|
||||
* the auto-resume retry loop absorbs that.
|
||||
*/
|
||||
function zoneOffsetMs(timeZone: string, at: number): number | null {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
|
||||
const name = dtf.formatToParts(at).find((p) => p.type === 'timeZoneName')?.value;
|
||||
if (!name) return null;
|
||||
const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name);
|
||||
if (!m) return null;
|
||||
if (!m[1]) return 0; // plain "GMT"
|
||||
const sign = m[1] === '-' ? -1 : 1;
|
||||
return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0)) * 60_000;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResetSpec {
|
||||
month?: number; // 0-11
|
||||
dayOfMonth?: number; // 1-31
|
||||
dayOfWeek?: number; // 0-6 (Sun-Sat)
|
||||
hour: number; // 0-23
|
||||
minute: number; // 0-59
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the epoch ms for a parsed reset spec. Times are wall-clock in the
|
||||
* given IANA timezone when present (and resolvable), otherwise server-local —
|
||||
* Claude CLI runs on the same host as Codeman, so local time is the right
|
||||
* default. Returns null when the spec is implausible (> ~8 days out).
|
||||
*/
|
||||
function resolveResetSpec(spec: ResetSpec, now: number): number | null {
|
||||
const offset = spec.timeZone ? zoneOffsetMs(spec.timeZone, now) : null;
|
||||
|
||||
// Wall-clock view of "now": shifted-UTC when a zone offset is known,
|
||||
// server-local otherwise. Read/build components with the matching API.
|
||||
const useZone = offset !== null;
|
||||
const wallNow = useZone ? new Date(now + offset) : new Date(now);
|
||||
const get = {
|
||||
year: () => (useZone ? wallNow.getUTCFullYear() : wallNow.getFullYear()),
|
||||
month: () => (useZone ? wallNow.getUTCMonth() : wallNow.getMonth()),
|
||||
date: () => (useZone ? wallNow.getUTCDate() : wallNow.getDate()),
|
||||
day: () => (useZone ? wallNow.getUTCDay() : wallNow.getDay()),
|
||||
};
|
||||
const build = (y: number, mo: number, d: number): number => {
|
||||
const wall = useZone
|
||||
? Date.UTC(y, mo, d, spec.hour, spec.minute)
|
||||
: new Date(y, mo, d, spec.hour, spec.minute).getTime();
|
||||
return useZone ? wall - offset : wall;
|
||||
};
|
||||
|
||||
let ts: number;
|
||||
if (spec.month !== undefined && spec.dayOfMonth !== undefined) {
|
||||
// Explicit date ("Oct 6, 1pm"). More than 2 days in the past → assume year
|
||||
// rollover (message seen near New Year); slightly past → stale, keep as-is.
|
||||
ts = build(get.year(), spec.month, spec.dayOfMonth);
|
||||
if (ts < now - 2 * DAY_MS) {
|
||||
ts = build(get.year() + 1, spec.month, spec.dayOfMonth);
|
||||
}
|
||||
} else if (spec.dayOfWeek !== undefined) {
|
||||
// Day-of-week ("Mon 12:00am") → next occurrence.
|
||||
const delta = (spec.dayOfWeek - get.day() + 7) % 7;
|
||||
ts = build(get.year(), get.month(), get.date() + delta);
|
||||
if (ts <= now) ts += 7 * DAY_MS;
|
||||
} else {
|
||||
// Time-only ("resets 8pm") → next occurrence within 24h.
|
||||
ts = build(get.year(), get.month(), get.date());
|
||||
if (ts <= now) ts += DAY_MS;
|
||||
}
|
||||
|
||||
if (ts > now + MAX_RESET_HORIZON_MS) return null;
|
||||
return ts;
|
||||
}
|
||||
|
||||
/** Parse the reset-time spec found within `window`, or null. */
|
||||
function parseResetTime(window: string, now: number): number | null {
|
||||
const m = RESET_TIME_PATTERN.exec(window);
|
||||
if (!m) return null;
|
||||
|
||||
const hour12 = parseInt(m[4], 10);
|
||||
const minute = m[5] ? parseInt(m[5], 10) : 0;
|
||||
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
|
||||
const pm = m[6].toLowerCase() === 'pm';
|
||||
const hour = (hour12 % 12) + (pm ? 12 : 0);
|
||||
|
||||
const spec: ResetSpec = { hour, minute };
|
||||
if (m[1] && m[2]) {
|
||||
spec.month = MONTHS.indexOf(m[1].toLowerCase());
|
||||
spec.dayOfMonth = parseInt(m[2], 10);
|
||||
if (spec.dayOfMonth < 1 || spec.dayOfMonth > 31) return null;
|
||||
} else if (m[3]) {
|
||||
spec.dayOfWeek = WEEKDAYS.indexOf(m[3].toLowerCase());
|
||||
}
|
||||
if (m[7]) spec.timeZone = m[7].trim();
|
||||
|
||||
return resolveResetSpec(spec, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan cleaned (ANSI-stripped) terminal output for a usage-limit pause message
|
||||
* with a parseable reset time. Returns the LAST parseable occurrence in the
|
||||
* chunk (most recent on screen), or null when none is found.
|
||||
*/
|
||||
export function detectUsageLimitPause(cleanData: string, now: number = Date.now()): UsageLimitDetection | null {
|
||||
if (!cleanData || !/limit|extra usage/i.test(cleanData)) return null;
|
||||
|
||||
let result: UsageLimitDetection | null = null;
|
||||
|
||||
// Raw API epoch form
|
||||
EPOCH_LIMIT_PATTERN.lastIndex = 0;
|
||||
let em: RegExpExecArray | null;
|
||||
while ((em = EPOCH_LIMIT_PATTERN.exec(cleanData)) !== null) {
|
||||
const resetAt = parseInt(em[1], 10) * 1000;
|
||||
if (resetAt > now + MAX_RESET_HORIZON_MS) continue;
|
||||
result = { resetAt, matched: em[0] };
|
||||
}
|
||||
|
||||
// TUI phrase + "resets <time>" forms
|
||||
LIMIT_PHRASE_PATTERN.lastIndex = 0;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = LIMIT_PHRASE_PATTERN.exec(cleanData)) !== null) {
|
||||
const window = cleanData.slice(pm.index, pm.index + RESET_TIME_WINDOW);
|
||||
const resetAt = parseResetTime(window, now);
|
||||
if (resetAt !== null) {
|
||||
result = { resetAt, matched: window.slice(0, 80).trim() };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+68
-2
@@ -153,6 +153,9 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
|
||||
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
|
||||
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
|
||||
// Scheduled runs
|
||||
@@ -576,7 +579,6 @@ class CodemanApp {
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -1768,6 +1770,33 @@ class CodemanApp {
|
||||
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
_onSessionLimitPauseScheduled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = data.resumeAt;
|
||||
const at = new Date(data.resumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast(`Usage limit reached — auto-resume at ${at}`, 'warning');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'warning', 'limit-pause', 'Usage Limit Reached', `Auto-resume scheduled for ${at}`);
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResume(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast('Usage limit reset — work resumed automatically', 'success');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'info', 'limit-resume', 'Auto-Resumed', 'Usage limit reset — continuing work');
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResumeCancelled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) {
|
||||
@@ -1843,6 +1872,7 @@ class CodemanApp {
|
||||
// selectSession's earlier resizes ran before this WS existed, so they
|
||||
// went over HTTP, which never claims (see ws-routes sizingToken).
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
this._startMobileResizeRetry(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1868,6 +1898,7 @@ class CodemanApp {
|
||||
this._ws = null;
|
||||
this._wsSessionId = null;
|
||||
this._wsReady = false;
|
||||
this._stopMobileResizeRetry();
|
||||
|
||||
// Reconnect on unexpected close (server restart, network blip, ping timeout).
|
||||
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
|
||||
@@ -1893,6 +1924,7 @@ class CodemanApp {
|
||||
_disconnectWs() {
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
this._stopMobileResizeRetry();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
this._ws.close();
|
||||
@@ -1902,6 +1934,41 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
|
||||
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
|
||||
* and the single resize sent on attach is deduped client-side — without a
|
||||
* retry, a phone that attached under an active desktop would render a
|
||||
* desktop-width stream forever. Re-send the current dims periodically (a
|
||||
* server-side no-op once the pane already matches) so the pane reflows to
|
||||
* this device shortly after the desktop goes idle. Visible-tab only: a
|
||||
* phone in a pocket must not steal the pane from an active desktop.
|
||||
*/
|
||||
_startMobileResizeRetry(sessionId) {
|
||||
this._stopMobileResizeRetry();
|
||||
const type =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: 'desktop';
|
||||
if (type === 'desktop') return;
|
||||
this._mobileResizeRetryTimer = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (!this._wsReady || this._wsSessionId !== sessionId) return;
|
||||
// Same guard as throttledResize: while the virtual keyboard is up, a
|
||||
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
|
||||
// and shifts the accessory toolbar mid-typing. Retry after it closes.
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
}, MOBILE_RESIZE_RETRY_MS);
|
||||
}
|
||||
|
||||
_stopMobileResizeRetry() {
|
||||
if (this._mobileResizeRetryTimer) {
|
||||
clearInterval(this._mobileResizeRetryTimer);
|
||||
this._mobileResizeRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to server without blocking the keystroke flush cycle.
|
||||
* Uses a sequential promise chain to preserve character ordering
|
||||
@@ -2093,7 +2160,6 @@ class CodemanApp {
|
||||
KeyboardHandler.cleanup();
|
||||
MobileDetection.init();
|
||||
KeyboardHandler.init();
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
// Clear tab alerts
|
||||
this.tabAlerts.clear();
|
||||
// Clear shown completions (used for duplicate notification prevention)
|
||||
|
||||
@@ -51,6 +51,7 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
|
||||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
@@ -243,6 +244,9 @@ const SSE_EVENTS = {
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_AUTO_COMPACT: 'session:autoCompact',
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
|
||||
+17
-17
@@ -69,10 +69,6 @@
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
|
||||
</div>
|
||||
|
||||
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
@@ -619,6 +615,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-resume-box">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
|
||||
<span>Auto-resume when usage limit resets</span>
|
||||
</label>
|
||||
<span class="auto-resume-status" id="autoResumeStatus"></span>
|
||||
<span class="form-hint">If Claude pauses on a usage limit ("limit reached · resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Duration</label>
|
||||
<div class="duration-presets">
|
||||
@@ -658,7 +663,7 @@
|
||||
<div class="form-section-header">Respawn Cycle</div>
|
||||
<div class="form-row">
|
||||
<label>1. Update Prompt</label>
|
||||
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
|
||||
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
|
||||
</div>
|
||||
|
||||
<div class="respawn-options-row" style="margin: 8px 0;">
|
||||
@@ -670,22 +675,17 @@
|
||||
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>3. Send /init</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Behavior</div>
|
||||
<div class="respawn-options-row">
|
||||
<label class="checkbox-inline">
|
||||
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
|
||||
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>Auto-accept prompts</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle · Auto-accept presses Enter for plan approvals and default options</span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End respawn-tab -->
|
||||
|
||||
|
||||
@@ -304,8 +304,16 @@ const KeyboardHandler = {
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
const appEl = document.querySelector('.app');
|
||||
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
|
||||
@@ -94,30 +94,6 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -4px 0.2rem -4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle.active,
|
||||
.mobile-header-utility-toggle:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
@@ -474,25 +450,6 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -6px 0.15rem -6px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Smaller header buttons on mobile */
|
||||
.btn-icon-header {
|
||||
width: 26px;
|
||||
|
||||
@@ -690,6 +690,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
|
||||
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
|
||||
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
|
||||
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -790,6 +794,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch { /* silent */ }
|
||||
},
|
||||
|
||||
async autoSaveAutoResume() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
|
||||
try {
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
session.autoResumeEnabled = enabled;
|
||||
if (!enabled) session.autoResumeAt = undefined;
|
||||
}
|
||||
this.updateAutoResumeStatus(this.editingSessionId);
|
||||
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Show "resumes at HH:MM" in the session options modal while a usage-limit
|
||||
// pause is armed for the session being edited
|
||||
updateAutoResumeStatus(sessionId) {
|
||||
const el = document.getElementById('autoResumeStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
|
||||
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
el.textContent = `Usage limit pause active — resumes at ${at}`;
|
||||
el.classList.add('active');
|
||||
} else {
|
||||
el.textContent = '';
|
||||
el.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
|
||||
@@ -1711,70 +1711,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
toggleMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray) return;
|
||||
|
||||
const expanded = tray.classList.toggle('mobile-collapsed') === false;
|
||||
if (toggle) {
|
||||
toggle.classList.toggle('active', expanded);
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
handleMobileHeaderUtilityToggle(event) {
|
||||
if (event) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const now = Date.now();
|
||||
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
|
||||
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
|
||||
}
|
||||
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
|
||||
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
|
||||
}
|
||||
if (event.type === 'touchend') {
|
||||
this._lastMobileHeaderUtilityTouchAt = now;
|
||||
}
|
||||
if (event.type === 'pointerup') {
|
||||
this._lastMobileHeaderUtilityPointerAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
this.toggleMobileHeaderUtilities();
|
||||
},
|
||||
|
||||
bindMobileHeaderUtilityToggle() {
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
|
||||
|
||||
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
}
|
||||
|
||||
this._mobileHeaderUtilityToggleEl = toggle;
|
||||
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
|
||||
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
|
||||
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
},
|
||||
|
||||
closeMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray || tray.classList.contains('mobile-collapsed')) return;
|
||||
|
||||
tray.classList.add('mobile-collapsed');
|
||||
if (toggle) {
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
},
|
||||
|
||||
applyTabWrapSettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
|
||||
@@ -190,12 +190,6 @@ body {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Mobile-only header utility toggle — hidden by default; the mobile/tablet
|
||||
media queries in mobile.css (max-width: 768px) re-enable it as flex. */
|
||||
.mobile-header-utility-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Session Tabs */
|
||||
.session-tabs {
|
||||
display: flex;
|
||||
@@ -4676,6 +4670,39 @@ body.solo-mode .btn-lifecycle-log {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Auto-resume on usage limit (token pause control) */
|
||||
.auto-resume-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auto-resume-box .checkbox-inline {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auto-resume-box .form-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auto-resume-status {
|
||||
display: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auto-resume-status.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Respawn actions in modal */
|
||||
.respawn-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ResizeSchema,
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
@@ -1003,6 +1004,27 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Auto-Resume (usage-limit pause) ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
autoResume: {
|
||||
enabled: session.autoResumeEnabled,
|
||||
resumeAt: session.autoResumeAt ?? undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Image Watcher ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
|
||||
@@ -114,6 +114,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
// can ignore small-viewport resizes only while a desktop is actually
|
||||
// connected (see Session._desktopSizeClaims).
|
||||
const sizingToken = Symbol('ws-desktop-sizing');
|
||||
let holdsDesktopClaim = false;
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
@@ -122,6 +123,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
@@ -135,10 +139,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
|
||||
if (viewportType === 'desktop') {
|
||||
session.claimDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = true;
|
||||
} else if (viewportType) {
|
||||
// The connection's viewport can change (e.g. browser window
|
||||
// narrowed past the tablet breakpoint) — drop a stale claim.
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
}
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
|
||||
@@ -450,6 +450,11 @@ export const AutoCompactSchema = z.object({
|
||||
prompt: z.string().max(10000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/auto-resume */
|
||||
export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/image-watcher */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -1993,6 +1993,12 @@ export class WebServer extends EventEmitter {
|
||||
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
|
||||
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
|
||||
}
|
||||
// Auto-resume on usage limit (re-arms a pending schedule; an
|
||||
// overdue one fires shortly after boot — the limit footer won't
|
||||
// reprint on its own, so the pause would otherwise stall)
|
||||
if (savedState.autoResumeEnabled) {
|
||||
session.restoreAutoResume(true, savedState.autoResumeAt);
|
||||
}
|
||||
// Token tracking
|
||||
if (
|
||||
savedState.inputTokens !== undefined ||
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface SessionListenerRefs {
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
|
||||
limitResume: (data: { attempt: number }) => void;
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
@@ -243,6 +246,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitPauseScheduled` — usage-limit pause detected, auto-resume armed.
|
||||
* Persisted so a pending schedule survives a Codeman restart. */
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitPauseScheduled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResume` — auto-resume prompt sent after limit reset */
|
||||
limitResume: (data: { attempt: number }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResume, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResumeCancelled` — pending auto-resume no longer needed */
|
||||
limitResumeCancelled: (data: { reason: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResumeCancelled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
@@ -350,6 +375,9 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('taskFailed', refs.taskFailed);
|
||||
session.on('autoClear', refs.autoClear);
|
||||
session.on('autoCompact', refs.autoCompact);
|
||||
session.on('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.on('limitResume', refs.limitResume);
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
@@ -379,6 +407,9 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('taskFailed', refs.taskFailed);
|
||||
session.off('autoClear', refs.autoClear);
|
||||
session.off('autoCompact', refs.autoCompact);
|
||||
session.off('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.off('limitResume', refs.limitResume);
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
|
||||
@@ -73,6 +73,12 @@ export const SessionWorking = 'session:working' as const;
|
||||
export const SessionAutoClear = 'session:autoClear' as const;
|
||||
/** Auto-compact triggered for the session. */
|
||||
export const SessionAutoCompact = 'session:autoCompact' as const;
|
||||
/** Usage-limit pause detected; auto-resume scheduled. */
|
||||
export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const;
|
||||
/** Auto-resume prompt sent after a usage-limit reset. */
|
||||
export const SessionLimitResume = 'session:limitResume' as const;
|
||||
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
|
||||
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
@@ -361,6 +367,9 @@ export const SseEvent = {
|
||||
SessionWorking,
|
||||
SessionAutoClear,
|
||||
SessionAutoCompact,
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
|
||||
@@ -132,54 +132,23 @@ describe('Tab Navigation', () => {
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('top-left mobile menu button opens the header utility tray', async () => {
|
||||
it('header has no utility toggle and the tray stays collapsed on mobile', async () => {
|
||||
// The three-dot header utility toggle was removed (owner decision,
|
||||
// 2026-06-10): nothing interactive may occupy the top-left corner, and
|
||||
// the headerRight tray stays collapsed (hidden) on small viewports.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
toggle?.classList.remove('active');
|
||||
toggle?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
const topLeftElements = await page.evaluate(() => {
|
||||
return document.elementsFromPoint(16, 16).map((el) => ({
|
||||
tag: el.tagName,
|
||||
id: el.id,
|
||||
className: String(el.className),
|
||||
closestButtonId: el.closest('button')?.id ?? '',
|
||||
}));
|
||||
});
|
||||
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
|
||||
expect(toggleCount).toBe(0);
|
||||
|
||||
expect(topLeftElements[0]?.closestButtonId).toBe('mobileHeaderUtilityToggle');
|
||||
|
||||
const toggleBox = await page.locator('#mobileHeaderUtilityToggle').boundingBox();
|
||||
expect(toggleBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.touchscreen.tap(
|
||||
(toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) / 2,
|
||||
(toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2
|
||||
);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const trayClass = await page.locator('#headerRight').getAttribute('class');
|
||||
const expanded = await page.locator('#mobileHeaderUtilityToggle').getAttribute('aria-expanded');
|
||||
const trayBox = await page.locator('#headerRight').boundingBox();
|
||||
const topTrayElement = await page.evaluate(() => {
|
||||
const trayVisible = await page.evaluate(() => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const rect = tray?.getBoundingClientRect();
|
||||
if (!rect) return '';
|
||||
return (
|
||||
document
|
||||
.elementsFromPoint(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2))
|
||||
.find((el) => el.id === 'headerRight' || el.closest?.('#headerRight'))
|
||||
?.closest?.('#headerRight')?.id ?? ''
|
||||
);
|
||||
return tray ? getComputedStyle(tray).display !== 'none' : false;
|
||||
});
|
||||
expect(trayClass).not.toMatch(/mobile-collapsed/);
|
||||
expect(expanded).toBe('true');
|
||||
expect(trayBox?.x ?? 9999).toBeLessThanOrEqual((toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) + 8);
|
||||
expect(topTrayElement).toBe('headerRight');
|
||||
expect(trayVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
|
||||
@@ -222,6 +222,15 @@ export class MockSession extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-resume on usage limit (token pause control) */
|
||||
autoResumeEnabled: boolean = false;
|
||||
autoResumeAt: number | null = null;
|
||||
isLimitPaused: boolean = false;
|
||||
setAutoResume = vi.fn((enabled: boolean) => {
|
||||
this.autoResumeEnabled = enabled;
|
||||
if (!enabled) this.autoResumeAt = null;
|
||||
});
|
||||
|
||||
/** Check if session is busy */
|
||||
isBusy = vi.fn(() => false);
|
||||
|
||||
@@ -239,6 +248,7 @@ export class MockSession extends EventEmitter {
|
||||
/** Stubs for the desktop sizing claims used by resize arbitration */
|
||||
claimDesktopSizing = vi.fn();
|
||||
releaseDesktopSizing = vi.fn();
|
||||
noteDesktopActivity = vi.fn();
|
||||
|
||||
/** Stub for runPrompt */
|
||||
runPrompt = vi.fn(async () => {});
|
||||
|
||||
@@ -144,6 +144,42 @@ describe('RespawnController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage-limit pause guard', () => {
|
||||
it('blocks the cycle while the session is paused on a usage limit', async () => {
|
||||
let cycleStarted = false;
|
||||
let blockedReason: string | null = null;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
controller.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockedReason = data.reason;
|
||||
});
|
||||
|
||||
session.isLimitPaused = true;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(false);
|
||||
expect(blockedReason).toBe('usage_limit');
|
||||
expect(controller.state).toBe('watching');
|
||||
});
|
||||
|
||||
it('cycles normally when the pause is not active', async () => {
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
session.isLimitPaused = false;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Respawn Cycle', () => {
|
||||
it('should start cycle when completion message detected and confirmed', async () => {
|
||||
let cycleStarted = false;
|
||||
|
||||
@@ -263,6 +263,63 @@ describe('session-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/auto-resume ==========
|
||||
|
||||
describe('POST /api/sessions/:id/auto-resume', () => {
|
||||
it('enables auto-resume on usage limit', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.autoResume.enabled).toBe(true);
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
|
||||
expect(session.setAutoResume).toHaveBeenCalledWith(true);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
|
||||
});
|
||||
|
||||
it('disables auto-resume', async () => {
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
|
||||
session.autoResumeEnabled = true;
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.autoResume.enabled).toBe(false);
|
||||
expect(session.setAutoResume).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('rejects invalid body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/auto-resume',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/input ==========
|
||||
|
||||
describe('POST /api/sessions/:id/input', () => {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Tests for SessionAutoOps auto-resume on usage limit (token pause control).
|
||||
*
|
||||
* Uses fake timers; the SessionAutoOps callbacks are plain mocks, so no
|
||||
* real session, tmux, or ports are involved. Port: N/A
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { SessionAutoOps } from '../src/session-auto-ops.js';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
const BUFFER_MS = 2 * 60_000; // RESUME_BUFFER_MS in session-auto-ops.ts
|
||||
const ESC_DELAY_MS = 600; // RESUME_ESC_DELAY_MS
|
||||
|
||||
function limitLine(resetInMs: number): string {
|
||||
// Raw API epoch form gives exact control over the parsed reset time
|
||||
const epoch = Math.floor((Date.now() + resetInMs) / 1000);
|
||||
return `Claude AI usage limit reached|${epoch}`;
|
||||
}
|
||||
|
||||
describe('SessionAutoOps auto-resume', () => {
|
||||
let ops: SessionAutoOps;
|
||||
let writeCommand: ReturnType<typeof vi.fn>;
|
||||
let working: boolean;
|
||||
let stopped: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Whole-second clock: limitLine() floors to epoch seconds, and a fractional
|
||||
// start time would shift the fire point by up to 999ms (flaky assertions)
|
||||
vi.setSystemTime(Math.floor(Date.now() / 1000) * 1000);
|
||||
writeCommand = vi.fn(async () => true);
|
||||
working = false;
|
||||
stopped = false;
|
||||
ops = new SessionAutoOps({
|
||||
writeCommand,
|
||||
isWorking: () => working,
|
||||
isStopped: () => stopped,
|
||||
getTotalTokens: () => 0,
|
||||
getSessionId: () => 'test-session',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ops.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
ops.processCleanData(limitLine(60 * 60_000));
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(ops.autoResumeAt).toBeNull();
|
||||
});
|
||||
|
||||
it('arms a schedule at reset time + buffer when enabled', () => {
|
||||
ops.setAutoResume(true);
|
||||
const scheduled = vi.fn();
|
||||
ops.on('limitPauseScheduled', scheduled);
|
||||
|
||||
ops.processCleanData(limitLine(60 * 60_000)); // resets in 1h
|
||||
|
||||
expect(ops.isLimitPaused).toBe(true);
|
||||
expect(ops.autoResumeAt).not.toBeNull();
|
||||
const expected = Date.now() + 60 * 60_000 + BUFFER_MS;
|
||||
expect(Math.abs(ops.autoResumeAt! - expected)).toBeLessThan(2000);
|
||||
expect(scheduled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dedups repeated footer redraws of the same limit message', () => {
|
||||
ops.setAutoResume(true);
|
||||
const scheduled = vi.fn();
|
||||
ops.on('limitPauseScheduled', scheduled);
|
||||
|
||||
const line = limitLine(30 * 60_000);
|
||||
ops.processCleanData(line);
|
||||
ops.processCleanData(line);
|
||||
ops.processCleanData(line);
|
||||
|
||||
expect(scheduled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reschedules when an EARLIER reset time appears', () => {
|
||||
ops.setAutoResume(true);
|
||||
const scheduled = vi.fn();
|
||||
ops.on('limitPauseScheduled', scheduled);
|
||||
|
||||
ops.processCleanData(limitLine(60 * 60_000));
|
||||
const firstAt = ops.autoResumeAt!;
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
|
||||
expect(scheduled).toHaveBeenCalledTimes(2);
|
||||
expect(ops.autoResumeAt!).toBeLessThan(firstAt);
|
||||
});
|
||||
|
||||
it('keeps the schedule when a LATER/stale time appears', () => {
|
||||
ops.setAutoResume(true);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
const firstAt = ops.autoResumeAt!;
|
||||
ops.processCleanData(limitLine(60 * 60_000)); // later → ignored
|
||||
ops.processCleanData(limitLine(-5 * 60_000)); // overdue → never preempts
|
||||
expect(ops.autoResumeAt).toBe(firstAt);
|
||||
});
|
||||
|
||||
it('fires Escape then the continue prompt at the scheduled time', async () => {
|
||||
ops.setAutoResume(true);
|
||||
const resumed = vi.fn();
|
||||
ops.on('limitResume', resumed);
|
||||
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
|
||||
|
||||
expect(writeCommand).toHaveBeenCalledWith('\x1b');
|
||||
expect(resumed).not.toHaveBeenCalled(); // continue still pending
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
|
||||
expect(writeCommand).toHaveBeenCalledWith('continue\r');
|
||||
expect(resumed).toHaveBeenCalledTimes(1);
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(ops.autoResumeAt).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stale-footer re-detections while the resume is in flight', async () => {
|
||||
ops.setAutoResume(true);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
|
||||
|
||||
// Esc sent; before the continue fires, the stale footer redraws
|
||||
ops.processCleanData(limitLine(-1000));
|
||||
expect(ops.isLimitPaused).toBe(false); // not re-armed mid-resume
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
|
||||
expect(writeCommand).toHaveBeenCalledWith('continue\r');
|
||||
});
|
||||
|
||||
it('re-arms a retry when still limited after a resume attempt', async () => {
|
||||
ops.setAutoResume(true);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
|
||||
expect(writeCommand).toHaveBeenCalledWith('continue\r');
|
||||
|
||||
// The submit echoes a fresh limit line with an already-past reset → retry path
|
||||
const scheduled = vi.fn();
|
||||
ops.on('limitPauseScheduled', scheduled);
|
||||
ops.processCleanData(limitLine(-1000));
|
||||
expect(scheduled).toHaveBeenCalledTimes(1);
|
||||
expect(ops.isLimitPaused).toBe(true);
|
||||
// Retry fires within RESUME_RETRY_MS (5 min)
|
||||
expect(ops.autoResumeAt! - Date.now()).toBeLessThanOrEqual(5 * 60_000 + 1000);
|
||||
});
|
||||
|
||||
it('skips the resume when Claude is already working at fire time', async () => {
|
||||
ops.setAutoResume(true);
|
||||
const cancelled = vi.fn();
|
||||
ops.on('limitResumeCancelled', cancelled);
|
||||
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
working = true;
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
|
||||
|
||||
expect(writeCommand).not.toHaveBeenCalled();
|
||||
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the pending schedule when Claude starts working', () => {
|
||||
ops.setAutoResume(true);
|
||||
const cancelled = vi.fn();
|
||||
ops.on('limitResumeCancelled', cancelled);
|
||||
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
expect(ops.isLimitPaused).toBe(true);
|
||||
|
||||
ops.notifyWorking();
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(ops.autoResumeAt).toBeNull();
|
||||
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
|
||||
});
|
||||
|
||||
it('notifyWorking is a no-op when nothing is armed', () => {
|
||||
ops.setAutoResume(true);
|
||||
const cancelled = vi.fn();
|
||||
ops.on('limitResumeCancelled', cancelled);
|
||||
ops.notifyWorking();
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disabling cancels the pending schedule', () => {
|
||||
ops.setAutoResume(true);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
|
||||
ops.setAutoResume(false);
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(ops.autoResumeAt).toBeNull();
|
||||
|
||||
// and detection stays off
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('destroy clears timers without emitting', () => {
|
||||
ops.setAutoResume(true);
|
||||
const cancelled = vi.fn();
|
||||
ops.on('limitResumeCancelled', cancelled);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
|
||||
ops.destroy();
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(60 * 60_000);
|
||||
expect(writeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire after the session stops', async () => {
|
||||
ops.setAutoResume(true);
|
||||
ops.processCleanData(limitLine(10 * 60_000));
|
||||
stopped = true;
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
|
||||
expect(writeCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Session wiring (terminal output → detection → events)', () => {
|
||||
it('detects a limit message flowing through the expensive-parser path', () => {
|
||||
const session = new Session({ workingDir: '/tmp' }); // mode 'claude'
|
||||
const scheduled = vi.fn();
|
||||
session.on('limitPauseScheduled', scheduled);
|
||||
session.setAutoResume(true);
|
||||
|
||||
// Same choke-point the claude-mode PTY handler uses (throttled batch)
|
||||
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
|
||||
'5-hour limit reached ∙ resets 8pm'
|
||||
);
|
||||
|
||||
expect(session.isLimitPaused).toBe(true);
|
||||
expect(session.autoResumeAt).not.toBeNull();
|
||||
expect(scheduled).toHaveBeenCalledTimes(1);
|
||||
expect(session.toState().autoResumeEnabled).toBe(true);
|
||||
expect(session.toState().autoResumeAt).toBe(session.autoResumeAt!);
|
||||
session.setAutoResume(false); // clears the armed timer
|
||||
});
|
||||
|
||||
it('catches an already-displayed limit message when enabling mid-pause', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
// limit footer already on screen before the user finds the checkbox
|
||||
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
|
||||
'\x1b[33m5-hour limit reached ∙ resets 8pm\x1b[0m'
|
||||
);
|
||||
session.setAutoResume(true);
|
||||
expect(session.isLimitPaused).toBe(true);
|
||||
session.setAutoResume(false);
|
||||
});
|
||||
|
||||
it('ignores stale scrollback (past reset time) when enabling', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
const pastEpoch = Math.floor(Date.now() / 1000) - 3600;
|
||||
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
|
||||
`Claude AI usage limit reached|${pastEpoch}`
|
||||
);
|
||||
session.setAutoResume(true);
|
||||
expect(session.isLimitPaused).toBe(false);
|
||||
session.setAutoResume(false);
|
||||
});
|
||||
|
||||
it('stays inert when the checkbox is disabled (default)', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
|
||||
'5-hour limit reached ∙ resets 8pm'
|
||||
);
|
||||
expect(session.isLimitPaused).toBe(false);
|
||||
expect(session.toState().autoResumeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreAutoResume (recovery after Codeman restart)', () => {
|
||||
it('re-arms a future schedule', () => {
|
||||
ops.restoreAutoResume(true, Date.now() + 30 * 60_000);
|
||||
expect(ops.autoResumeEnabled).toBe(true);
|
||||
expect(ops.isLimitPaused).toBe(true);
|
||||
expect(ops.autoResumeAt! - Date.now()).toBeGreaterThan(29 * 60_000);
|
||||
});
|
||||
|
||||
it('fires an overdue schedule shortly after boot', async () => {
|
||||
ops.restoreAutoResume(true, Date.now() - 60_000);
|
||||
expect(ops.isLimitPaused).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5_000 + ESC_DELAY_MS + 200);
|
||||
expect(writeCommand).toHaveBeenCalledWith('\x1b');
|
||||
expect(writeCommand).toHaveBeenCalledWith('continue\r');
|
||||
});
|
||||
|
||||
it('enables without arming when no schedule was persisted', () => {
|
||||
ops.restoreAutoResume(true);
|
||||
expect(ops.autoResumeEnabled).toBe(true);
|
||||
expect(ops.isLimitPaused).toBe(false);
|
||||
expect(ops.autoResumeAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
/** Must exceed Session.DESKTOP_CLAIM_IDLE_MS (90s) */
|
||||
const PAST_IDLE_MS = 91_000;
|
||||
|
||||
type ResizeableSessionInternals = {
|
||||
ptyProcess: { resize: (cols: number, rows: number) => void };
|
||||
_ptyCols: number;
|
||||
@@ -109,4 +112,64 @@ describe('Session resize arbitration', () => {
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(100, 30);
|
||||
});
|
||||
|
||||
describe('idle-desktop override (whoever is active wins)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('lets a mobile client take the pane once the desktop claim goes idle', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored
|
||||
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).toHaveBeenCalledWith(48, 28); // idle desktop → applied
|
||||
});
|
||||
|
||||
it('keeps blocking mobile while the desktop stays active via typed input', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS - 10_000);
|
||||
session.noteDesktopActivity(); // user typed on desktop
|
||||
vi.advanceTimersByTime(20_000); // idle since claim, but not since input
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-asserts the desktop layout on desktop input after a mobile override', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over
|
||||
expect(resize).toHaveBeenLastCalledWith(48, 28);
|
||||
|
||||
session.noteDesktopActivity(); // desktop user types again
|
||||
expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored
|
||||
});
|
||||
|
||||
it('does not re-assert when no mobile override happened', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
resize.mockClear();
|
||||
session.noteDesktopActivity();
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Tests for usage-limit pause detection (auto-resume on usage limit).
|
||||
*
|
||||
* Message corpus mirrors real Claude Code output observed across
|
||||
* 1.0.x–2.1.x (GitHub issues + official error docs). Time expectations are
|
||||
* computed with the same local-Date APIs the implementation uses, so the
|
||||
* tests are timezone-independent.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
|
||||
|
||||
/** Fixed "now" for deterministic tests: a real timestamp, any value works. */
|
||||
const NOW = new Date(2026, 5, 10, 14, 0, 0).getTime(); // local Jun 10 2026, 2:00pm
|
||||
|
||||
/** Expected epoch ms for the next local occurrence of hour:minute after NOW. */
|
||||
function nextLocal(hour: number, minute = 0, from = NOW): number {
|
||||
const d = new Date(from);
|
||||
let ts = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute).getTime();
|
||||
if (ts <= from) ts += 24 * 60 * 60 * 1000;
|
||||
return ts;
|
||||
}
|
||||
|
||||
describe('detectUsageLimitPause', () => {
|
||||
describe('era 2 footer forms (v1.0.109+, ∙ separator, no timezone)', () => {
|
||||
it.each([
|
||||
['5-hour limit reached ∙ resets 8pm', 20, 0],
|
||||
['Session limit reached ∙ resets 8pm', 20, 0],
|
||||
['Weekly limit reached ∙ resets 6pm', 18, 0],
|
||||
['5-hour limit reached ∙ resets 10:30pm', 22, 30],
|
||||
['5-hour limit reached ∙ resets 3am', 3, 0],
|
||||
['5-hour limit reached ∙ resets 12am', 0, 0],
|
||||
['5-hour limit reached ∙ resets 12pm', 12, 0],
|
||||
])('parses "%s"', (msg, hour, minute) => {
|
||||
const det = detectUsageLimitPause(msg, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(nextLocal(hour, minute));
|
||||
});
|
||||
|
||||
it('rolls over to tomorrow when the time already passed today', () => {
|
||||
// NOW is 2:00pm local; "resets 9am" must be tomorrow 9am
|
||||
const det = detectUsageLimitPause('5-hour limit reached ∙ resets 9am', NOW);
|
||||
expect(det!.resetAt).toBe(nextLocal(9, 0));
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 3 forms (v2.0.55+, · separator, IANA timezone, action hints)', () => {
|
||||
it('parses with timezone and /upgrade suffix', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'5-hour limit reached · resets 3pm (Europe/Stockholm) · /upgrade to Max 20x or turn on /extra-usage',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 3pm in Stockholm (UTC+2 in June) = 13:00 UTC
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(13);
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
|
||||
it('parses generic "Limit reached" with minutes and timezone', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'Limit reached · resets 11:30am (Asia/Calcutta) · /upgrade to Max or turn on /extra-usage',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 11:30am IST (UTC+5:30) = 06:00 UTC
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(6);
|
||||
expect(d.getUTCMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to local time for unresolvable timezone (Etc/Unknown)', () => {
|
||||
const det = detectUsageLimitPause('Limit reached · resets 5pm (Etc/Unknown)', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(nextLocal(17, 0));
|
||||
});
|
||||
|
||||
it('parses weekly date form "Dec 2, 7pm"', () => {
|
||||
const det = detectUsageLimitPause('Weekly limit reached · resets Dec 2, 7pm (Europe/Moscow) ·', NOW);
|
||||
// Dec 2 is >8 days from Jun 10 → implausible horizon → rejected
|
||||
expect(det).toBeNull();
|
||||
});
|
||||
|
||||
it('parses near-future date form within the horizon', () => {
|
||||
const det = detectUsageLimitPause('Weekly limit reached ∙ resets Jun 15, 1pm', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const expected = new Date(2026, 5, 15, 13, 0).getTime();
|
||||
expect(det!.resetAt).toBe(expected);
|
||||
});
|
||||
|
||||
it('parses malformed separator-less render', () => {
|
||||
const det = detectUsageLimitPause('Limit reached resets 1:30pm (Asia/Calcutta) ·', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 4 forms (v2.1.x, "You\'ve hit your limit")', () => {
|
||||
it.each([
|
||||
["You've hit your limit · resets 1:40pm (UTC)", 13, 40],
|
||||
["You've hit your session limit · resets 3:45pm", 15, 45],
|
||||
["You've hit your Opus limit · resets 3:45pm", 15, 45],
|
||||
])('parses "%s"', (msg) => {
|
||||
const det = detectUsageLimitPause(msg, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
|
||||
it('parses day-of-week form "resets Mon 12:00am"', () => {
|
||||
const det = detectUsageLimitPause("You've hit your weekly limit · resets Mon 12:00am", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
expect(d.getHours()).toBe(0);
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
expect(det!.resetAt).toBeLessThanOrEqual(NOW + 8 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses "May 5 at 9pm" date-with-at form (next-year rollover)', () => {
|
||||
// NOW is Jun 10 2026 — "May 5" is >2 days past → next year → beyond horizon → null
|
||||
const det = detectUsageLimitPause("You've hit your limit · resets May 5 at 9pm (UTC)", NOW);
|
||||
expect(det).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "Jun 12 at 9pm" date-with-at form within horizon', () => {
|
||||
const det = detectUsageLimitPause("You've hit your limit · resets Jun 12 at 9pm", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(new Date(2026, 5, 12, 21, 0).getTime());
|
||||
});
|
||||
|
||||
it('parses extra-usage form "You\'re out of extra usage"', () => {
|
||||
const det = detectUsageLimitPause("You're out of extra usage · resets 1pm (UTC)", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 1 inline form (v1.0.x)', () => {
|
||||
it('parses "Your limit will reset at 2pm (America/New_York)"', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'Claude usage limit reached. Your limit will reset at 2pm (America/New_York)',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 2pm EDT (UTC-4 in June) = 18:00 UTC
|
||||
expect(new Date(det!.resetAt).getUTCHours()).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw API epoch form', () => {
|
||||
it('parses "Claude AI usage limit reached|<epoch>"', () => {
|
||||
const epoch = Math.floor(NOW / 1000) + 3600; // resets in 1h
|
||||
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(epoch * 1000);
|
||||
});
|
||||
|
||||
it('returns past epoch as-is (stale message → caller retries soon)', () => {
|
||||
const epoch = Math.floor(NOW / 1000) - 600;
|
||||
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBeLessThan(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcript and multi-line contexts', () => {
|
||||
it('detects the ⎿-prefixed transcript echo', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
"⎿ You've hit your limit · resets 6pm (Asia/Bangkok)\n /upgrade to increase your usage limit.",
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns the LAST parseable occurrence in a chunk', () => {
|
||||
const chunk = [
|
||||
'5-hour limit reached ∙ resets 3pm',
|
||||
'some scrollback text',
|
||||
'5-hour limit reached ∙ resets 5pm',
|
||||
].join('\n');
|
||||
const det = detectUsageLimitPause(chunk, NOW);
|
||||
expect(det!.resetAt).toBe(nextLocal(17, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('false-positive guards', () => {
|
||||
it.each([
|
||||
'the rate limit reached in tests was 30/min',
|
||||
'limit reached',
|
||||
'Usage limit reached', // bare banner: no reset time → not actionable
|
||||
'we reset at dawn',
|
||||
'resets 3pm', // time without a limit phrase
|
||||
'The speed limit reached 120 km/h before it resets',
|
||||
'5-hour limit reached ∙ resets 25pm', // invalid hour
|
||||
'limit reached ∙ resets 99:99pm',
|
||||
])('ignores "%s"', (msg) => {
|
||||
expect(detectUsageLimitPause(msg, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores empty and irrelevant data', () => {
|
||||
expect(detectUsageLimitPause('', NOW)).toBeNull();
|
||||
expect(detectUsageLimitPause('compiling project...', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('requires the reset time within the window after the phrase', () => {
|
||||
const farApart = 'limit reached' + ' x'.repeat(200) + ' resets 3pm';
|
||||
expect(detectUsageLimitPause(farApart, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user