Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,15 @@ concurrency:

jobs:
unit-tests:
name: Unit tests (server, node ${{ matrix.node-version }})
name: Unit tests (server)
runs-on: ubuntu-latest
strategy:
matrix:
# README's stated minimum, and the version the rest of CI runs on.
node-version: ['20', '22']
timeout-minutes: 30
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
node-version: '22'
cache: npm

- name: Install dependencies
Expand All @@ -32,7 +29,7 @@ jobs:
- name: Syntax-check all server sources
run: |
find server \( -name '*.js' -o -name '*.cjs' \) -not -path '*/node_modules/*' -print0 \
| xargs -0 -n1 node --check
| xargs -0 -P4 -n1 node --check

- name: Validate sandbox.config.example.json
run: node -e "JSON.parse(require('fs').readFileSync('server/sandbox.config.example.json', 'utf-8'))"
Expand All @@ -43,6 +40,7 @@ jobs:
client-build:
name: Client build
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5

Expand All @@ -60,6 +58,7 @@ jobs:
e2e:
name: E2E tests (Playwright)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5

Expand All @@ -71,6 +70,12 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ playwright-report/
.saved-notifications.json
server/sandbox.config.json
/tmp/
.claude/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ VS Code のようにフォルダを選択し、ブラウザ内のターミナル

## 必要な環境

- Node.js >= 20 / npm >= 9
- Node.js >= 22 / npm >= 9
- C++ コンパイラ(node-pty のビルドに必要。Arch: `base-devel`、Ubuntu: `build-essential`)
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) — このプロジェクトの主対象。既定で起動するエージェント。
- [opencode](https://opencode.ai/) — 任意。入っていれば起動時にアプリとして選べます (下記「起動」参照)。入れずに opencode を選んだ場合、ターミナルに `execvp(3) failed` 等のエラーが表示され起動に失敗します。
Expand Down
27 changes: 25 additions & 2 deletions server/ws/sessionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox
startedClaudeSessionId: claudeSessionId || null,
scheduleId: null, // key into the module-level `schedules` map, if any
pendingInjection: null, // { text, at } — scheduled prompt awaiting a freshly-resumed session
pendingInjectionTimer: null, // RESUME_INJECT_FALLBACK_MS safety net; cleared on teardown
// Lightweight virtual screen (see screenModel.js): fed every output
// chunk, exposing the current visible screen and a change counter so
// read_output can tell "spinner still drawing" from "static screen".
Expand Down Expand Up @@ -375,6 +376,10 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox
if (session.pendingInjection) {
const inj = session.pendingInjection;
session.pendingInjection = null;
if (session.pendingInjectionTimer) {
clearTimeout(session.pendingInjectionTimer);
session.pendingInjectionTimer = null;
}
const delivered = injectIntoLiveSession(session, inj.text);
notifyFired(session, { at: inj.at, text: inj.text }, delivered);
}
Expand Down Expand Up @@ -478,7 +483,7 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox
}));
}

if (!session.socket) {
if (!session.socket && sessions.has(session.id)) {
startTimeout(session, SESSION_EXITED_TIMEOUT_MS);
}
});
Expand Down Expand Up @@ -794,10 +799,13 @@ async function fireSchedule(scheduleId) {
session.pendingInjection = { text: entry.text, at: entry.at };
// Safety net: deliver even if the session never emits an idle gap (e.g. a
// plain shell). The idle path normally fires first for Claude sessions.
setTimeout(() => {
// Tracked on the session so a destroyed one doesn't keep a dead timer
// armed (it no-ops, but holds the event loop in tests and lingers in prod).
session.pendingInjectionTimer = setTimeout(() => {
if (session.exited || !session.pendingInjection) return;
const inj = session.pendingInjection;
session.pendingInjection = null;
session.pendingInjectionTimer = null;
const delivered = injectIntoLiveSession(session, inj.text);
notifyFired(session, inj, delivered);
}, RESUME_INJECT_FALLBACK_MS);
Expand Down Expand Up @@ -977,6 +985,11 @@ export function destroySession(id, { keepSchedule = true } = {}) {
session.idleTimer = null;
}

if (session.pendingInjectionTimer) {
clearTimeout(session.pendingInjectionTimer);
session.pendingInjectionTimer = null;
}

// By default the scheduled prompt outlives the session (disconnect / idle
// timeout / shutdown) and auto-resumes at fire time. Only an explicit
// user-initiated teardown cancels it.
Expand All @@ -994,6 +1007,16 @@ export function destroySession(id, { keepSchedule = true } = {}) {
}
}

// Force-close the pty master read stream. kill() alone only signals the
// child; if a grandchild still holds the slave fd (or the child lingers),
// the master never sees EOF and the read stream keeps the event loop
// alive indefinitely (hanging test runners and lingering handles in prod).
try {
session.ptyProcess.destroy();
} catch {
// already torn down
}

// Remove the sandbox's unique rootlesskit state dir. The --unshare-pid tree is
// torn down by the kill above (kernel reaps dockerd with the namespace); this
// just clears the leftover socket dir under /run. Best effort — the dir is
Expand Down
4 changes: 4 additions & 0 deletions server/ws/sessionManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ before(async () => {

after(() => {
try { rmSync(runtimeDir, { recursive: true, force: true }); } catch { /* ignore */ }
// Release the exited-session retention timers (30s cleanup) and any
// pending schedule/fallback timers the tests armed, so the runner process
// exits promptly instead of waiting out the production retention period.
sessionManager.destroyAllSessions();
});

test('savedSessionPublic preserves group membership (restart filter keeps working)', () => {
Expand Down
Loading