mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c496e1a4 | ||
|
|
eb831487a0 | ||
|
|
68594ac395 | ||
|
|
ec38fd11bf | ||
|
|
06f9ff6d9c | ||
|
|
257695ff8e | ||
|
|
2cfccc745f |
@@ -66,3 +66,6 @@ commands
|
||||
todo.md
|
||||
@fix_plan.md
|
||||
readme-preview.mjs
|
||||
|
||||
# Uploaded images land here under each session working dir (runtime artifact)
|
||||
.claude-images/
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Event-loop responsiveness fix, mobile image upload, response-viewer polish, and a mobile-UI trim.
|
||||
- **fix: avoid event-loop stalls from synchronous tmux/ps calls (#100):** The session manager ran `execSync` for tmux mouse-mode toggles, `list-panes`, and `ps`/`pgrep` resource-stat queries on the main thread. Under multi-session / many-pane load these blocking spawns froze Node's single event loop, stalling SSE broadcasts and PTY I/O (the ":3000 briefly unreachable, process never restarts" class of incident). Converted those calls to async `execAsync` and updated all callers to `await`. Added a lightweight `utils/event-loop-monitor.ts` that samples loop-delay and logs when a stall threshold is exceeded, started on web-server boot and stopped on shutdown — so future regressions leave a timestamped, quantified log line instead of vanishing silently.
|
||||
- **feat(web): mobile image upload to active session via paste dialog (#101):** The mobile keyboard-accessory paste dialog now attaches images, not just text — via a native picker (`accept=image/*` → camera / photo library / files) plus best-effort capture of images pasted into the textarea. Both paths reuse the existing `_uploadAndInsertImages()` → `POST /api/sessions/:id/paste-image` pipeline. Images are re-encoded client-side before upload (PNG→PNG to preserve transparency, everything else→JPEG, animated GIFs passed through untouched) so the bytes always match their declared extension — fixing the Android/MIUI case where a WebP/HEIF mislabeled as `image/jpeg` passed the extension allowlist but failed the server's magic-byte check. The server logs a precise diagnostic on any remaining magic-byte mismatch.
|
||||
- **feat(web): response-viewer transcript fallback + code-block rendering (#102):** A substantial response-viewer styling overhaul — proportional prose font (monospace kept for code), refined heading/code/blockquote/list styling, readable max content width, and a smoother slide-in animation; the `.rv-text` rules now also apply to `.response-viewer-body` so transcript-missing fallback content gets the same typography. Plus a `_renderMarkdown` null-safety fix (`text` → `src = text || ''`).
|
||||
- **feat(web): remove /compact button from the mobile keyboard accessory bar:** Dropped `/compact` from both the simple and extended accessory-bar layouts and the associated action handling. `/clear` retains its double-tap confirmation. Verified on a touch-emulated viewport that neither layout renders a compact action.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus**
|
||||
|
||||
Two related blockers in the respawn controller's auto-accept path:
|
||||
- Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered.
|
||||
- The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either.
|
||||
|
||||
Fix:
|
||||
- `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate.
|
||||
- `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter.
|
||||
- `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled.
|
||||
- AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu").
|
||||
- Removed the now-unused `elicitationDetected` field and its assignments.
|
||||
|
||||
Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter).
|
||||
|
||||
**docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor 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.7.0 (must match `package.json`)
|
||||
**Version**: 0.8.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+16
-9
@@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
|
||||
const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i;
|
||||
|
||||
/** The prompt sent to the AI plan checker */
|
||||
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not.
|
||||
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option.
|
||||
|
||||
A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics:
|
||||
A qualifying menu has all of these characteristics:
|
||||
- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own")
|
||||
- A selection indicator arrow (❯ or >) pointing to one of the options
|
||||
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?"
|
||||
- The prompt appears at the BOTTOM of the output (most recent content)
|
||||
- A selection indicator arrow (❯ or >) pointing to one of the options (the default)
|
||||
- The menu appears at the BOTTOM of the output (most recent content)
|
||||
- It is asking the user to choose, not just displaying numbered information
|
||||
|
||||
NOT a plan mode prompt:
|
||||
This includes BOTH:
|
||||
- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?")
|
||||
- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus)
|
||||
|
||||
NOT a qualifying menu:
|
||||
- Claude actively working (spinners, "Thinking", tool execution)
|
||||
- A completed response with no selection menu
|
||||
- An AskUserQuestion/elicitation dialog (different format, free-text input)
|
||||
- A completed response with no selection menu visible
|
||||
- A free-text input field with no numbered options
|
||||
- A numbered LIST in the assistant's prose with no selection arrow
|
||||
- Network lag or mid-output pause
|
||||
- Any state without a visible numbered selection menu
|
||||
- Any state without a visible selector arrow on a numbered option
|
||||
|
||||
The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default".
|
||||
|
||||
Terminal output (most recent at bottom):
|
||||
---
|
||||
|
||||
+33
-25
@@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
|
||||
sendInit: true, // send /init after /clear
|
||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||
autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs)
|
||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: AI_CHECK_MODEL,
|
||||
@@ -623,9 +623,6 @@ export class RespawnController extends EventEmitter {
|
||||
/** Whether any terminal output has been received since start/last-auto-accept */
|
||||
private hasReceivedOutput: boolean = false;
|
||||
|
||||
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
||||
private elicitationDetected: boolean = false;
|
||||
|
||||
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
|
||||
|
||||
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
|
||||
@@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter {
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
// Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X"
|
||||
// immediately before a plan-approval menu, and the auto-accept pre-filter is
|
||||
// responsible for distinguishing menu-present from menu-absent. Cancelling here
|
||||
// would silently block auto-accept for every plan approval and AskUserQuestion
|
||||
// dialog. If no menu is in the buffer, the pre-filter rejects and the
|
||||
// completion-confirm timer (started below) drives the normal idle flow.
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
@@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
@@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter {
|
||||
* @returns True if auto-accept should proceed to the AI confirmation stage
|
||||
*/
|
||||
private canAutoAccept(): boolean {
|
||||
// Only auto-accept in watching state (not during a respawn cycle)
|
||||
if (this._state !== 'watching') return false;
|
||||
|
||||
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
||||
if (this.completionMessageTime !== null) return false;
|
||||
// Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
|
||||
// when "Worked for X" was detected — which Claude Code now emits in the same PTY
|
||||
// burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to
|
||||
// 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
|
||||
|
||||
// Don't auto-accept if disabled
|
||||
if (!this.config.autoAcceptPrompts) return false;
|
||||
@@ -2234,15 +2235,15 @@ export class RespawnController extends EventEmitter {
|
||||
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
||||
if (!this.hasReceivedOutput) return false;
|
||||
|
||||
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
||||
if (this.elicitationDetected) {
|
||||
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
||||
return false;
|
||||
}
|
||||
// Note: completionMessageTime and elicitationDetected used to block here, but both
|
||||
// legitimately co-occur with selection menus (Claude Code emits "Worked for X"
|
||||
// before plan approvals, and AskUserQuestion fires the elicitation hook). The
|
||||
// pre-filter below is the authoritative gate for "is there a numbered menu?".
|
||||
|
||||
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
||||
// Stage 1: Pre-filter — check if buffer looks like a numbered selection menu
|
||||
// (covers both plan-mode approvals and AskUserQuestion dialogs)
|
||||
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
|
||||
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
||||
this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
if (result.verdict === 'PLAN_MODE') {
|
||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||
if (this._state !== 'watching') {
|
||||
// Don't send Enter if state moved into a respawn cycle while the check ran.
|
||||
// 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter()
|
||||
// self-transitions to 'watching' before sending.
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
|
||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||
return;
|
||||
}
|
||||
@@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
|
||||
* This prevents auto-accept from firing, since the user needs to make a selection.
|
||||
* The flag is cleared when working patterns are detected (new turn starts).
|
||||
* Used as a positive hint that a numbered selection menu is about to render —
|
||||
* we restart the auto-accept timer so the pre-filter gets a fresh shot at it
|
||||
* once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()`
|
||||
* plus (optionally) the AI plan check; this hook just primes the timer.
|
||||
* No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts`
|
||||
* is off, so this can never fire Enter when the user has disabled auto-accept.
|
||||
*/
|
||||
signalElicitation(): void {
|
||||
this.elicitationDetected = true;
|
||||
this.cancelAutoAcceptTimer();
|
||||
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
||||
this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
|
||||
if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-34
@@ -1153,19 +1153,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const psOutput = (
|
||||
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const childOutput = (
|
||||
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
@@ -1202,13 +1206,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 1: Get descendant PIDs
|
||||
const descendantMap = new Map<number, number[]>();
|
||||
|
||||
const pgrepOutput = execSync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const pgrepOutput = (
|
||||
await execAsync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
).stdout.trim();
|
||||
|
||||
for (const line of pgrepOutput.split('\n')) {
|
||||
const [pidStr, childrenStr] = line.split(':');
|
||||
@@ -1233,10 +1239,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 3: Single ps call
|
||||
const pidArray = Array.from(allPids);
|
||||
if (pidArray.length > 0) {
|
||||
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const psOutput = (
|
||||
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
|
||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
@@ -1324,11 +1332,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
clearInterval(this.mouseSyncInterval);
|
||||
}
|
||||
|
||||
this.mouseSyncInterval = setInterval(() => {
|
||||
this.mouseSyncInterval = setInterval(async () => {
|
||||
if (IS_TEST_MODE) return;
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
const panes = this.listPanes(session.muxName);
|
||||
const panes = await this.listPanes(session.muxName);
|
||||
const count = panes.length;
|
||||
if (count === 0) continue;
|
||||
|
||||
@@ -1337,12 +1345,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Pane count changed — toggle mouse mode
|
||||
if (count > 1) {
|
||||
if (this.enableMouseMode(session.muxName)) {
|
||||
if (await this.enableMouseMode(session.muxName)) {
|
||||
this.lastPaneCount.set(session.muxName, count);
|
||||
}
|
||||
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
||||
} else {
|
||||
if (this.disableMouseMode(session.muxName)) {
|
||||
if (await this.disableMouseMode(session.muxName)) {
|
||||
this.lastPaneCount.set(session.muxName, count);
|
||||
}
|
||||
}
|
||||
@@ -1473,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Allows clicking to select panes in agent team split-pane layouts.
|
||||
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
|
||||
*/
|
||||
enableMouseMode(muxName: string): boolean {
|
||||
async enableMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
||||
@@ -1481,7 +1489,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1497,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Disable mouse mode for an existing tmux session.
|
||||
* Restores native xterm.js text selection and browser clipboard copy.
|
||||
*/
|
||||
disableMouseMode(muxName: string): boolean {
|
||||
async disableMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
||||
@@ -1505,7 +1513,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1522,9 +1530,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Called by TeamWatcher when teammates spawn/despawn panes.
|
||||
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
||||
*/
|
||||
syncMouseMode(muxName: string): boolean {
|
||||
async syncMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
const panes = this.listPanes(muxName);
|
||||
const panes = await this.listPanes(muxName);
|
||||
if (panes.length > 1) {
|
||||
return this.enableMouseMode(muxName);
|
||||
} else {
|
||||
@@ -1536,7 +1544,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* List all panes in a tmux session.
|
||||
* Returns structured info for each pane.
|
||||
*/
|
||||
listPanes(muxName: string): PaneInfo[] {
|
||||
async listPanes(muxName: string): Promise<PaneInfo[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
||||
@@ -1544,10 +1552,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const output = (
|
||||
await execAsync(
|
||||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
).stdout.trim();
|
||||
|
||||
return output
|
||||
.split('\n')
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @fileoverview Event-loop lag monitor.
|
||||
*
|
||||
* Node is single-threaded: any synchronous work (e.g. a blocking `execSync`)
|
||||
* freezes the whole event loop, so the HTTP server stops answering on its port
|
||||
* while the process stays alive and other ports are unaffected. Such stalls
|
||||
* self-heal and never restart the process, so a periodic loopback healthcheck
|
||||
* misses them entirely — they leave no trace.
|
||||
*
|
||||
* This monitor samples how late a fixed-interval timer actually fires versus when
|
||||
* it was scheduled; the excess is time the loop was blocked. When that exceeds a
|
||||
* threshold it logs the measured stall, turning otherwise-invisible "port briefly
|
||||
* unreachable" incidents into a timestamped, quantified log line.
|
||||
*
|
||||
* @module utils/event-loop-monitor
|
||||
*/
|
||||
|
||||
export interface EventLoopMonitorHandle {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sampling event-loop lag.
|
||||
*
|
||||
* @param sampleMs How often to sample (and the baseline interval lag is measured against).
|
||||
* @param thresholdMs Only stalls at or above this many ms are logged (noise floor).
|
||||
* @param log Sink for stall reports; defaults to console.warn (lands in the web log).
|
||||
*/
|
||||
export function startEventLoopMonitor(
|
||||
sampleMs = 1000,
|
||||
thresholdMs = 1000,
|
||||
log: (msg: string) => void = (m) => console.warn(m)
|
||||
): EventLoopMonitorHandle {
|
||||
let last = performance.now();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
// Lag = elapsed beyond the scheduled interval = time the loop was blocked.
|
||||
const lag = Math.round(now - last - sampleMs);
|
||||
if (lag >= thresholdMs) {
|
||||
log(`[EventLoopLag] event loop blocked ~${lag}ms (at ${new Date().toISOString()})`);
|
||||
}
|
||||
last = now;
|
||||
}, sampleMs);
|
||||
|
||||
// Never keep the process alive solely for this monitor.
|
||||
timer.unref?.();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
export { BufferAccumulator } from './buffer-accumulator.js';
|
||||
export { CleanupManager } from './cleanup-manager.js';
|
||||
export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
||||
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
||||
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
||||
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||
export {
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
|
||||
@@ -1110,9 +1110,10 @@ class CodemanApp {
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
const src = text || '';
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
const prepared = this._preprocessAsciiArt(text);
|
||||
const prepared = this._preprocessAsciiArt(src);
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||
// on mobile without collapsing into block-level cells.
|
||||
@@ -1168,7 +1169,7 @@ class CodemanApp {
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escaped = src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const path = await this._uploadPasteImage(sessionId, file);
|
||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
||||
// but fails its magic-byte check ("bytes do not match declared type").
|
||||
// Decoding through the browser and re-encoding guarantees the bytes
|
||||
// match the extension we send.
|
||||
const normalized = await this._normalizeImageForUpload(file);
|
||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
@@ -145,4 +152,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
return data.path;
|
||||
},
|
||||
|
||||
// Decode an image File through the browser and re-encode it to a format the
|
||||
// server accepts, so the uploaded bytes always match their declared
|
||||
// extension. PNG is re-encoded as PNG (preserves transparency); everything
|
||||
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
|
||||
// through untouched since a canvas would flatten them to one frame. On any
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('decode failed'));
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
if (!width || !height) return file;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||
if (!blob) return file;
|
||||
|
||||
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
||||
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
|
||||
} catch (err) {
|
||||
console.warn('Image re-encode failed, uploading original:', err);
|
||||
return file;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
@@ -98,7 +99,6 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -128,7 +128,7 @@ const KeyboardAccessoryBar = {
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
(action === 'clear' && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
@@ -191,13 +191,11 @@ const KeyboardAccessoryBar = {
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
case 'clear': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand(cmd);
|
||||
this.sendCommand('/clear');
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
@@ -264,8 +262,17 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Read clipboard and send contents as input */
|
||||
/** Show a paste overlay with a textarea for iOS compatibility */
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
* on mobile, so it is a best-effort enhancement layered on the picker.
|
||||
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
@@ -274,23 +281,61 @@ const KeyboardAccessoryBar = {
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = overlay.querySelector('.paste-textarea');
|
||||
const send = () => {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
overlay.remove();
|
||||
close();
|
||||
if (text) app.sendInput(text);
|
||||
};
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.paste-send').addEventListener('click', send);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Image picker (camera / photo library) — the reliable mobile path.
|
||||
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => handleImages(fileInput.files));
|
||||
|
||||
// Best-effort: capture images pasted straight into the textarea.
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
handleImages(imageFiles);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', close);
|
||||
overlay.querySelector('.paste-send').addEventListener('click', sendText);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
textarea.focus();
|
||||
|
||||
@@ -1043,7 +1043,7 @@ html.mobile-init .file-browser-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send {
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -1051,6 +1051,14 @@ html.mobile-init .file-browser-panel {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Image attach button — left-aligned, accent outline */
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
|
||||
+122
-59
@@ -7971,14 +7971,15 @@ kbd {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85vh;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
max-height: 88vh;
|
||||
background: #14141f;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
border-radius: 14px 14px 0 0;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
|
||||
z-index: 5000;
|
||||
flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease-out;
|
||||
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.response-viewer.visible {
|
||||
@@ -7990,12 +7991,13 @@ kbd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
color: #e8e8ec;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.response-viewer-actions {
|
||||
@@ -8078,42 +8080,80 @@ kbd {
|
||||
background: rgba(109, 219, 127, 0.12);
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer */
|
||||
.rv-text {
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
/* Markdown rendered content inside response viewer.
|
||||
Prose uses a proportional font for readability; code keeps monospace. */
|
||||
.rv-text,
|
||||
.response-viewer-body > :not(.rv-message) {
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.rv-text p {
|
||||
margin: 0 0 0.6em;
|
||||
.rv-text p,
|
||||
.response-viewer-body > p {
|
||||
margin: 0 0 0.85em;
|
||||
}
|
||||
|
||||
.rv-text p:last-child {
|
||||
.rv-text p:last-child,
|
||||
.response-viewer-body > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 1em 0 0.4em;
|
||||
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
|
||||
.response-viewer-body > h1, .response-viewer-body > h2,
|
||||
.response-viewer-body > h3, .response-viewer-body > h4 {
|
||||
color: #f2f2f6;
|
||||
margin: 1.4em 0 0.5em;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rv-text h1 { font-size: 1.3em; }
|
||||
.rv-text h2 { font-size: 1.15em; }
|
||||
.rv-text h3 { font-size: 1.05em; }
|
||||
.rv-text h1:first-child, .rv-text h2:first-child,
|
||||
.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rv-text code {
|
||||
background: #2a2a3e;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
.rv-text h1, .response-viewer-body > h1 {
|
||||
font-size: 1.55em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid #2d2d40;
|
||||
}
|
||||
.rv-text h2, .response-viewer-body > h2 {
|
||||
font-size: 1.3em;
|
||||
color: #ffd27a;
|
||||
}
|
||||
.rv-text h3, .response-viewer-body > h3 {
|
||||
font-size: 1.13em;
|
||||
color: #bfc8ff;
|
||||
}
|
||||
.rv-text h4, .response-viewer-body > h4 {
|
||||
font-size: 1em;
|
||||
color: #c9c9d5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rv-text code,
|
||||
.response-viewer-body > :not(pre) code {
|
||||
background: #262638;
|
||||
color: #ffb4a2;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rv-text pre {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap,
|
||||
so the latest-response view (markdown rendered straight into the body) nests
|
||||
<pre> one level deeper than a direct child. The historical .rv-text path
|
||||
already matched via descendant; keep both in lockstep. */
|
||||
.rv-text pre,
|
||||
.response-viewer-body pre {
|
||||
background: #0f0f1a;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -8125,7 +8165,7 @@ kbd {
|
||||
Preserve indentation (pre-wrap) but allow breaks inside long tokens
|
||||
(URLs, paths, identifiers) so they don't overflow. */
|
||||
.rv-text pre code,
|
||||
.response-viewer-body > pre code {
|
||||
.response-viewer-body pre code {
|
||||
background: none;
|
||||
color: #e6e6f0;
|
||||
padding: 0;
|
||||
@@ -8294,38 +8334,43 @@ kbd {
|
||||
.rv-text ul, .rv-text ol,
|
||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.rv-text pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
.rv-text li,
|
||||
.response-viewer-body > ul > li, .response-viewer-body > ol > li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.rv-text ul, .rv-text ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
.rv-text li > p { margin: 0.2em 0; }
|
||||
|
||||
.rv-text blockquote,
|
||||
.response-viewer-body > blockquote {
|
||||
border-left: 3px solid #5c7cfa;
|
||||
background: rgba(92, 124, 250, 0.06);
|
||||
margin: 0.8em 0;
|
||||
padding: 0.5em 14px;
|
||||
color: #b8b8c8;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.rv-text li {
|
||||
margin-bottom: 0.2em;
|
||||
.rv-text strong,
|
||||
.response-viewer-body > p strong,
|
||||
.response-viewer-body > li strong {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rv-text blockquote {
|
||||
border-left: 3px solid #444;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.3em 0 0.3em 12px;
|
||||
color: #999;
|
||||
.rv-text em,
|
||||
.response-viewer-body em {
|
||||
color: #e0e0ec;
|
||||
}
|
||||
|
||||
.rv-text strong {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rv-text a {
|
||||
color: #5c7cfa;
|
||||
.rv-text a,
|
||||
.response-viewer-body a {
|
||||
color: #7aa2ff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(122, 162, 255, 0.35);
|
||||
}
|
||||
|
||||
.rv-text a:hover,
|
||||
@@ -8403,19 +8448,37 @@ kbd {
|
||||
.rv-text hr,
|
||||
.response-viewer-body > hr {
|
||||
border: none;
|
||||
border-top: 1px solid #333;
|
||||
margin: 1em 0;
|
||||
border-top: 1px solid #2d2d40;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
overscroll-behavior: contain;
|
||||
padding: 20px 22px 28px;
|
||||
/* Proportional font for prose — monospace only for code/pre */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
|
||||
'Noto Sans CJK SC', sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #d8d8e0;
|
||||
/* Comfortable reading width on wider viewports */
|
||||
--rv-content-max: 720px;
|
||||
}
|
||||
|
||||
/* Constrain content width for readability; code blocks can still scroll horizontally */
|
||||
.response-viewer-body > * {
|
||||
max-width: var(--rv-content-max);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.response-viewer-body > pre,
|
||||
.response-viewer-body > table,
|
||||
.response-viewer-body > .rv-message {
|
||||
max-width: var(--rv-content-max);
|
||||
}
|
||||
|
||||
.response-viewer-body:empty::after {
|
||||
|
||||
@@ -1731,8 +1731,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
|
||||
// idle/working handlers can still call it without conditional guards.
|
||||
// Vestigial no-op: this method has no callers today. It's kept (not deleted)
|
||||
// as a documented guard so the Ctrl+L behavior below isn't reintroduced.
|
||||
//
|
||||
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
|
||||
// idle/working to scrub mux-init junk from the screen. Two problems:
|
||||
|
||||
@@ -1674,6 +1674,14 @@ export function registerSessionRoutes(
|
||||
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
|
||||
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
|
||||
// the magic bytes do not. Log the real header so format mismatches can be
|
||||
// pinned down without a reproduce-and-guess loop. The client now
|
||||
// re-encodes images to JPEG/PNG before upload, so this should be rare.
|
||||
console.warn(
|
||||
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
|
||||
);
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||
}
|
||||
|
||||
+12
-1
@@ -94,7 +94,8 @@ import {
|
||||
type ImageDetectedEvent,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap, startEventLoopMonitor } from '../utils/index.js';
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
@@ -231,6 +232,7 @@ export class WebServer extends EventEmitter {
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
teamCreated: (config: unknown) => void;
|
||||
teamUpdated: (config: unknown) => void;
|
||||
@@ -1551,6 +1553,10 @@ export class WebServer extends EventEmitter {
|
||||
// older than 7 days from each live session's .claude-images/ hourly.
|
||||
if (!this.testMode) {
|
||||
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
|
||||
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
|
||||
// intermittent ":3000 briefly unreachable, process never restarts" class of
|
||||
// incident leaves a quantified log line instead of vanishing silently.
|
||||
this._eventLoopMonitor = startEventLoopMonitor();
|
||||
}
|
||||
|
||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||
@@ -1911,6 +1917,11 @@ export class WebServer extends EventEmitter {
|
||||
this._pasteImageGcStop = null;
|
||||
}
|
||||
|
||||
if (this._eventLoopMonitor) {
|
||||
this._eventLoopMonitor.stop();
|
||||
this._eventLoopMonitor = null;
|
||||
}
|
||||
|
||||
// Dispose all managed timers (intervals + resettable timeouts)
|
||||
this.cleanup.dispose();
|
||||
|
||||
|
||||
@@ -931,13 +931,17 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should NOT send Enter when completion message was detected', async () => {
|
||||
it('should NOT send Enter when completion message has no selection menu', async () => {
|
||||
// A bare "Worked for X" with no menu following must not trigger auto-accept —
|
||||
// the pre-filter is what gates this (since v0.7+, completion-message + menu IS
|
||||
// allowed; see "should send Enter even when a completion message preceded the menu").
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: true,
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 200, // Longer than autoAcceptDelay
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -947,7 +951,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
|
||||
autoAcceptController.start();
|
||||
|
||||
// Simulate completion message - normal idle flow should handle this
|
||||
// Completion message with no menu — pre-filter must reject
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for autoAcceptDelayMs
|
||||
@@ -957,6 +961,97 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should send Enter when completion message and menu arrive in separate PTY chunks', async () => {
|
||||
// Modern Claude Code emits "Worked for X" right before a plan-approval menu.
|
||||
// Two-chunk path: completion detected → confirming_idle, menu chunk hits the
|
||||
// substantial-output cancel and brings state back to watching.
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: true,
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 1000, // Longer than autoAcceptDelay so timer wins
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false, // Pre-filter only for this test
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
autoAcceptController.on('autoAcceptSent', () => {
|
||||
autoAcceptFired = true;
|
||||
});
|
||||
|
||||
autoAcceptController.start();
|
||||
|
||||
// "Worked for X" first (as Claude Code emits it before the menu)…
|
||||
session.simulateCompletionMessage();
|
||||
// …then the plan-approval menu renders.
|
||||
session.simulateTerminalOutput('\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(autoAcceptFired).toBe(true);
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should send Enter when completion message and menu arrive in a single PTY chunk', async () => {
|
||||
// Same-burst path: "Worked for X" and the menu arrive in one data chunk.
|
||||
// _detectCompletionMessage returns early so the substantial-output cancel
|
||||
// never fires — state stays 'confirming_idle'. canAutoAccept must accept it.
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: true,
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 1000, // Longer than autoAcceptDelay so auto-accept wins
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
autoAcceptController.on('autoAcceptSent', () => {
|
||||
autoAcceptFired = true;
|
||||
});
|
||||
|
||||
autoAcceptController.start();
|
||||
|
||||
// Single chunk: completion message immediately followed by the menu.
|
||||
session.simulateTerminalOutput('✻ Worked for 1m 30s\n\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(autoAcceptFired).toBe(true);
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should send Enter on AskUserQuestion menu after elicitation hook fires', async () => {
|
||||
// The elicitation_dialog hook now hints "menu coming" rather than blocking.
|
||||
// Once the menu renders, pre-filter matches and auto-accept fires.
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: true,
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 5000,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
autoAcceptController.on('autoAcceptSent', () => {
|
||||
autoAcceptFired = true;
|
||||
});
|
||||
|
||||
autoAcceptController.start();
|
||||
|
||||
// Question prose, then hook fires (Claude Code signals dialog opening)…
|
||||
session.simulateTerminalOutput('Which option do you prefer?');
|
||||
autoAcceptController.signalElicitation();
|
||||
// …then the numbered menu renders.
|
||||
session.simulateTerminalOutput('\n❯ 1. Yes\n 2. No\n');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(autoAcceptFired).toBe(true);
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should NOT send Enter when disabled', async () => {
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: false,
|
||||
@@ -1102,13 +1197,16 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
it('should NOT auto-accept when elicitation dialog is signaled', async () => {
|
||||
it('should NOT auto-accept on elicitation hook alone (without menu pattern)', async () => {
|
||||
// The elicitation hook hints "menu coming", but if the menu never actually
|
||||
// renders (no numbered options + selector), the pre-filter rejects.
|
||||
const autoAcceptController = new RespawnController(session as unknown as Session, {
|
||||
autoAcceptPrompts: true,
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
aiPlanCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1119,13 +1217,13 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptController.start();
|
||||
session.simulateTerminalOutput('Which option do you prefer?');
|
||||
|
||||
// Signal that an elicitation dialog (AskUserQuestion) was detected
|
||||
// Hook fires but no menu actually renders
|
||||
autoAcceptController.signalElicitation();
|
||||
|
||||
// Wait for autoAcceptDelayMs to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Auto-accept should NOT fire because elicitation was signaled
|
||||
// Pre-filter rejects (no numbered option / no selector arrow)
|
||||
expect(autoAcceptFired).toBe(false);
|
||||
autoAcceptController.stop();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user