aicodeman 1.12.2 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -13
- package/dist/config/agent-wait.d.ts +73 -0
- package/dist/config/agent-wait.d.ts.map +1 -0
- package/dist/config/agent-wait.js +99 -0
- package/dist/config/agent-wait.js.map +1 -0
- package/dist/hooks-config.d.ts +4 -2
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +14 -4
- package/dist/hooks-config.js.map +1 -1
- package/dist/session-cli-builder.d.ts.map +1 -1
- package/dist/session-cli-builder.js +6 -2
- package/dist/session-cli-builder.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +5 -1
- package/dist/tmux-manager.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.2e9ffd31.js → app.b24f795e.js} +5 -7
- package/dist/web/public/app.b24f795e.js.br +0 -0
- package/dist/web/public/app.b24f795e.js.gz +0 -0
- package/dist/web/public/constants.e7175b95.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/i18n.da34cfaf.js +1 -0
- package/dist/web/public/i18n.da34cfaf.js.br +0 -0
- package/dist/web/public/i18n.da34cfaf.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +12 -5
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.9936227d.js.gz +0 -0
- package/dist/web/public/mobile-handlers.32fdd57f.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.32cacde5.css.gz +0 -0
- package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.1d4203e0.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.1356a4f4.js.gz +0 -0
- package/dist/web/public/settings-ui.ea63d5db.js +67 -0
- package/dist/web/public/settings-ui.ea63d5db.js.br +0 -0
- package/dist/web/public/settings-ui.ea63d5db.js.gz +0 -0
- package/dist/web/public/styles.b10e5edd.css +1 -0
- package/dist/web/public/styles.b10e5edd.css.br +0 -0
- package/dist/web/public/styles.b10e5edd.css.gz +0 -0
- package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.78c71f3a.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +1 -2
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +22 -0
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +4 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +499 -17
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +53 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +59 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -0
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +28 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +407 -0
- package/dist/web/session-wait-registry.d.ts.map +1 -0
- package/dist/web/session-wait-registry.js +895 -0
- package/dist/web/session-wait-registry.js.map +1 -0
- package/package.json +2 -1
- package/skills/codeman/SKILL.md +274 -0
- package/skills/codeman/reference/endpoints.md +214 -0
- package/skills/codeman/reference/recipes.md +249 -0
- package/dist/web/public/app.2e9ffd31.js.br +0 -0
- package/dist/web/public/app.2e9ffd31.js.gz +0 -0
- package/dist/web/public/i18n.0fbea500.js +0 -1
- package/dist/web/public/i18n.0fbea500.js.br +0 -0
- package/dist/web/public/i18n.0fbea500.js.gz +0 -0
- package/dist/web/public/settings-ui.01a1ae7d.js +0 -67
- package/dist/web/public/settings-ui.01a1ae7d.js.br +0 -0
- package/dist/web/public/settings-ui.01a1ae7d.js.gz +0 -0
- package/dist/web/public/styles.50cd52bd.css +0 -1
- package/dist/web/public/styles.50cd52bd.css.br +0 -0
- package/dist/web/public/styles.50cd52bd.css.gz +0 -0
|
@@ -8,12 +8,34 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
|
|
|
8
8
|
import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
|
9
9
|
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
|
10
10
|
import { getDataDir } from '../../config/instance.js';
|
|
11
|
+
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
|
|
11
12
|
export function registerHookEventRoutes(app, ctx) {
|
|
12
13
|
app.post('/api/hook-event', async (req) => {
|
|
13
14
|
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
|
|
14
15
|
if (!ctx.sessions.has(sessionId)) {
|
|
15
16
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
|
16
17
|
}
|
|
18
|
+
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
|
|
19
|
+
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
|
|
20
|
+
// and can flap mid-turn), so these two are what an orchestrating agent should
|
|
21
|
+
// wait on.
|
|
22
|
+
//
|
|
23
|
+
// Gated on the session's MODE, matching `resolveWaitSignals` on the read side.
|
|
24
|
+
// Without it the guard is one-sided: a caller cannot ASK for `stop` on a shell or
|
|
25
|
+
// codex session, but this endpoint would happily deliver one for it. Hook events
|
|
26
|
+
// carry no identity beyond a per-instance secret shared by every case, so this is
|
|
27
|
+
// also the cheap half of the forgery surface — a `stop` claimed for a session that
|
|
28
|
+
// could never legitimately emit one is now dropped instead of steering another
|
|
29
|
+
// agent's control flow.
|
|
30
|
+
const waitSession = ctx.sessions.get(sessionId);
|
|
31
|
+
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
|
|
32
|
+
if (event === 'stop') {
|
|
33
|
+
sessionWaits.notifySignal(sessionId, 'stop');
|
|
34
|
+
}
|
|
35
|
+
else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
|
|
36
|
+
sessionWaits.notifySignal(sessionId, 'blocked');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
17
39
|
// Signal the respawn controller based on hook event type
|
|
18
40
|
const controller = ctx.respawnControllers.get(sessionId);
|
|
19
41
|
if (controller) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-event-routes.js","sourceRoot":"","sources":["../../../src/web/routes/hook-event-routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,EAAE,gCAAgC,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"hook-event-routes.js","sourceRoot":"","sources":["../../../src/web/routes/hook-event-routes.ts"],"names":[],"mappings":"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"}
|
|
@@ -32,5 +32,9 @@ export declare function stripInkRedrawBloat(buffer: string): string;
|
|
|
32
32
|
export declare function imageMagicMatchesExt(data: Buffer, ext: string): boolean;
|
|
33
33
|
export declare function consumePasteToken(key: string, now?: number): boolean;
|
|
34
34
|
export declare function _resetPasteRateBuckets(): void;
|
|
35
|
+
/** Test seam: pane-liveness state is module-level, so a suite must be able to reset it. */
|
|
36
|
+
export declare function _resetPaneLivenessState(): void;
|
|
37
|
+
/** Test seam: how many panes are currently being watched for a dead worker. */
|
|
38
|
+
export declare function _paneDeathWatcherCount(): number;
|
|
35
39
|
export declare function registerSessionRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort): void;
|
|
36
40
|
//# sourceMappingURL=session-routes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/session-routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,eAAe,
|
|
1
|
+
{"version":3,"file":"session-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/session-routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,eAAe,EAAqB,MAAM,SAAS,CAAC;AA0F7D,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AA+DjG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CA0C1D;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAmCvE;AAWD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,OAAO,CAiBhF;AAGD,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C;AA+ND,2FAA2F;AAC3F,wBAAgB,uBAAuB,IAAI,IAAI,CAI9C;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AA8BD,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,GAC/D,IAAI,CAgtGN"}
|
|
@@ -12,8 +12,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
12
12
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
13
13
|
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
|
14
14
|
import { SseEvent } from '../sse-events.js';
|
|
15
|
-
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, } from '../schemas.js';
|
|
15
|
+
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, } from '../schemas.js';
|
|
16
16
|
import { mergeSessionOrder } from '../../session-order.js';
|
|
17
|
+
import { sessionWaits, resolveWaitSignals, signalForStatus, WaitCapacityError, } from '../session-wait-registry.js';
|
|
18
|
+
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
|
17
19
|
import { autoConfigureRalph, canAccessOwned, CASES_DIR, findSessionOrFail, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, parseBody, persistAndBroadcastSession, resolveCasesDir, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
|
|
18
20
|
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
|
19
21
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
|
@@ -223,6 +225,226 @@ async function clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig,
|
|
|
223
225
|
: antigravityConfig;
|
|
224
226
|
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
|
|
225
227
|
}
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════
|
|
229
|
+
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════
|
|
231
|
+
/**
|
|
232
|
+
* Validate a wait query WITHOUT throwing away the Zod issue.
|
|
233
|
+
*
|
|
234
|
+
* `parseBody`'s message argument REPLACES the issue text, so `?timeout=30s` came
|
|
235
|
+
* back as a bare "Invalid wait parameters": the caller could not tell which of
|
|
236
|
+
* `until`, `timeout` or `fresh` it got wrong, and its only move was to retry with
|
|
237
|
+
* a different guess. These endpoints are driven by an LLM with no documentation in
|
|
238
|
+
* context — the error message IS the documentation, which is why the signal parser
|
|
239
|
+
* one line later goes to the trouble of naming the bad token and listing the valid
|
|
240
|
+
* ones. This keeps the endpoint label AND names the offending field.
|
|
241
|
+
*/
|
|
242
|
+
function parseWaitQuery(schema, query, label) {
|
|
243
|
+
const result = schema.safeParse(query);
|
|
244
|
+
if (result.success)
|
|
245
|
+
return result.data;
|
|
246
|
+
const issue = result.error.issues[0];
|
|
247
|
+
const field = issue && issue.path.length > 0 ? issue.path.join('.') : '';
|
|
248
|
+
const detail = issue?.message ?? 'validation failed';
|
|
249
|
+
const message = field ? `Invalid ${label} parameter '${field}': ${detail}` : `Invalid ${label} parameters: ${detail}`;
|
|
250
|
+
throw Object.assign(new Error(message), {
|
|
251
|
+
statusCode: 400,
|
|
252
|
+
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, message),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Map a waiter-cap rejection to the code that tells the caller the truth.
|
|
257
|
+
*
|
|
258
|
+
* The two caps mean different things and warrant different recovery: `session` is
|
|
259
|
+
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
|
|
260
|
+
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
|
|
261
|
+
* documented as "Session is busy") sent an agent off to a different session to hit
|
|
262
|
+
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
|
|
263
|
+
* later", and clients and proxies already treat 429 that way.
|
|
264
|
+
*/
|
|
265
|
+
function waitCapacityResponse(err) {
|
|
266
|
+
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
|
|
267
|
+
// The registry's message already names the scope and the limit; passing it through
|
|
268
|
+
// verbatim keeps the wording in one place.
|
|
269
|
+
return createErrorResponse(code, err.message);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The signal a session is ALREADY emitting, corrected for liveness.
|
|
273
|
+
*
|
|
274
|
+
* `signalForStatus` alone is not enough here, because `Session` parks a DEAD PTY at
|
|
275
|
+
* `_status = 'idle'` (both `onExit` handlers do) and the object survives in the
|
|
276
|
+
* session map until an explicit DELETE. Trusting the status therefore answers the
|
|
277
|
+
* default wait with `{signal:"idle", immediate:true}` for a worker that has
|
|
278
|
+
* crashed — HTTP 200, no error anywhere, and the agent types its next prompt into a
|
|
279
|
+
* corpse — while `until=exit` blocks for the full timeout on an event that already
|
|
280
|
+
* happened and can never happen again.
|
|
281
|
+
*
|
|
282
|
+
* `pid === null` means no process is behind this session: it exited, it was
|
|
283
|
+
* detached, or it was created and never started. All three are `exit` from a
|
|
284
|
+
* caller's point of view — nothing is running — and in all three the agent's
|
|
285
|
+
* correct next move is to (re)start the worker rather than to type at it. The
|
|
286
|
+
* response still carries the raw `status` alongside, so nothing is hidden.
|
|
287
|
+
*
|
|
288
|
+
* ⚠️ `pid` alone is NOT enough, and on the normal configuration it is never the
|
|
289
|
+
* thing that fires — see `workerIsDead()`. `dead` carries the mux layer's answer.
|
|
290
|
+
*
|
|
291
|
+
* Fixing it HERE rather than in `signalForStatus` is deliberate: liveness is not
|
|
292
|
+
* derivable from `SessionStatus`, and the registry holds no `Session` reference.
|
|
293
|
+
*/
|
|
294
|
+
function currentSignalFor(session, dead) {
|
|
295
|
+
if (dead || session.pid === null || session.pid === undefined)
|
|
296
|
+
return 'exit';
|
|
297
|
+
return signalForStatus(session.status);
|
|
298
|
+
}
|
|
299
|
+
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
|
300
|
+
//
|
|
301
|
+
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
|
302
|
+
// `remain-on-exit on` for every session it creates, so when the command inside the
|
|
303
|
+
// pane exits, tmux keeps the pane (`pane_dead=1`), the tmux session survives, the
|
|
304
|
+
// attach client keeps running and `pid` never goes null — no `exit` event is emitted
|
|
305
|
+
// and nothing in `Session` changes. Measured on a shell worker killed with `exit 42`:
|
|
306
|
+
// tmux reports `pane_dead=1 status=42` while Codeman reports `pid=309406 status=idle`
|
|
307
|
+
// and the DEFAULT wait answers `{signal:"idle", immediate:true}` in 0 ms for a corpse.
|
|
308
|
+
// So the liveness check has to ask the mux layer. `pid === null` still matters: it is
|
|
309
|
+
// the right (and only) answer for a direct-PTY session, which has no pane to ask about.
|
|
310
|
+
//
|
|
311
|
+
// Cost control, because `isPaneDead()` is a synchronous `execSync` and `/wait` is
|
|
312
|
+
// polled in a loop by design:
|
|
313
|
+
// 1. Only mux-backed sessions are probed at all.
|
|
314
|
+
// 2. Only requests that actually wait probe — a plain `POST .../input` (the browser's
|
|
315
|
+
// hot path, thousands per session) never touches tmux.
|
|
316
|
+
// 3. Results are cached per pane for PANE_DEATH_TTL_MS, so a poll loop cannot turn
|
|
317
|
+
// into one exec per request.
|
|
318
|
+
// 4. The while-blocked watcher is ONE timer per session no matter how many waiters
|
|
319
|
+
// are parked on it, and it exists only while at least one of them is.
|
|
320
|
+
/** How long a pane-liveness probe is reused. Long enough to absorb a poll loop. */
|
|
321
|
+
const PANE_DEATH_TTL_MS = 750;
|
|
322
|
+
/** How often a session with a parked waiter is re-checked for a dead worker. */
|
|
323
|
+
const PANE_DEATH_POLL_MS = 3_000;
|
|
324
|
+
/** Bounded, because a 24h server churns through panes. */
|
|
325
|
+
const paneDeathCache = new LRUMap({ maxSize: 256 });
|
|
326
|
+
/** One watcher per pane, refcounted by the waits currently parked on it. */
|
|
327
|
+
const paneDeathWatchers = new Map();
|
|
328
|
+
/**
|
|
329
|
+
* Whether the worker inside this session's tmux pane has exited.
|
|
330
|
+
*
|
|
331
|
+
* False for anything not tmux-backed (nothing to ask), and false when the probe is
|
|
332
|
+
* unavailable or throws — an unknown answer must never invent a death.
|
|
333
|
+
*/
|
|
334
|
+
function workerIsDead(mux, session, now = Date.now()) {
|
|
335
|
+
const muxName = session.usesMux === false ? null : session.muxName;
|
|
336
|
+
if (!muxName)
|
|
337
|
+
return false;
|
|
338
|
+
// Defensive: `TerminalMultiplexer` declares it, but route-test doubles may not.
|
|
339
|
+
if (typeof mux?.isPaneDead !== 'function')
|
|
340
|
+
return false;
|
|
341
|
+
const cached = paneDeathCache.get(muxName);
|
|
342
|
+
if (cached && now - cached.at < PANE_DEATH_TTL_MS)
|
|
343
|
+
return cached.dead;
|
|
344
|
+
let dead = false;
|
|
345
|
+
try {
|
|
346
|
+
dead = mux.isPaneDead(muxName) === true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
dead = false;
|
|
350
|
+
}
|
|
351
|
+
paneDeathCache.set(muxName, { dead, at: now });
|
|
352
|
+
return dead;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Release every waiter on a session whose worker has died, in the documented order.
|
|
356
|
+
*
|
|
357
|
+
* The same pair the PTY-exit listener and the delete path use, for the same reason:
|
|
358
|
+
* `until=exit` callers get their signal, everyone else gets `ended: true` instead of
|
|
359
|
+
* burning the rest of their timeout on feeds that will never produce anything.
|
|
360
|
+
*/
|
|
361
|
+
function releaseWaitersForDeadWorker(sessionId) {
|
|
362
|
+
sessionWaits.notifySignal(sessionId, 'exit');
|
|
363
|
+
sessionWaits.cancelAll(sessionId);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* While a wait is parked on a mux-backed session, poll for the worker dying.
|
|
367
|
+
*
|
|
368
|
+
* Without this, a worker that dies DURING a wait is invisible: no `exit` event fires
|
|
369
|
+
* (the attach client is still alive), no output arrives, and the caller blocks for its
|
|
370
|
+
* full timeout — the common orchestration case, "send a prompt and wait", where the
|
|
371
|
+
* worker crashes mid-turn.
|
|
372
|
+
*
|
|
373
|
+
* @returns a release function; call it in a `finally`, or the timer outlives the wait.
|
|
374
|
+
*/
|
|
375
|
+
function watchForDeadWorker(mux, session, sessionId) {
|
|
376
|
+
const muxName = session.usesMux === false ? null : session.muxName;
|
|
377
|
+
if (!muxName || typeof mux?.isPaneDead !== 'function')
|
|
378
|
+
return () => { };
|
|
379
|
+
const existing = paneDeathWatchers.get(muxName);
|
|
380
|
+
if (existing) {
|
|
381
|
+
existing.refs++;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const timer = setInterval(() => {
|
|
385
|
+
if (!workerIsDead(mux, session))
|
|
386
|
+
return;
|
|
387
|
+
releaseWaitersForDeadWorker(sessionId);
|
|
388
|
+
}, PANE_DEATH_POLL_MS);
|
|
389
|
+
// Auxiliary to the waiter's own timer, which is deliberately NOT unref'd; this one
|
|
390
|
+
// must never be the reason the process stays up.
|
|
391
|
+
timer.unref();
|
|
392
|
+
paneDeathWatchers.set(muxName, { timer, refs: 1 });
|
|
393
|
+
}
|
|
394
|
+
let released = false;
|
|
395
|
+
return () => {
|
|
396
|
+
if (released)
|
|
397
|
+
return;
|
|
398
|
+
released = true;
|
|
399
|
+
const entry = paneDeathWatchers.get(muxName);
|
|
400
|
+
if (!entry)
|
|
401
|
+
return;
|
|
402
|
+
entry.refs--;
|
|
403
|
+
if (entry.refs <= 0) {
|
|
404
|
+
clearInterval(entry.timer);
|
|
405
|
+
paneDeathWatchers.delete(muxName);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** Test seam: pane-liveness state is module-level, so a suite must be able to reset it. */
|
|
410
|
+
export function _resetPaneLivenessState() {
|
|
411
|
+
for (const entry of paneDeathWatchers.values())
|
|
412
|
+
clearInterval(entry.timer);
|
|
413
|
+
paneDeathWatchers.clear();
|
|
414
|
+
paneDeathCache.clear();
|
|
415
|
+
}
|
|
416
|
+
/** Test seam: how many panes are currently being watched for a dead worker. */
|
|
417
|
+
export function _paneDeathWatcherCount() {
|
|
418
|
+
return paneDeathWatchers.size;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* An `AbortController` that fires when the CLIENT goes away, and only then.
|
|
422
|
+
*
|
|
423
|
+
* Freeing an abandoned waiter matters because the documented pattern is a loop of
|
|
424
|
+
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
|
|
425
|
+
* every iteration until the cap is hit and an innocent session reports busy. Same for
|
|
426
|
+
* any proxy that cuts the connection.
|
|
427
|
+
*
|
|
428
|
+
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
|
|
429
|
+
* as soon as the request body has finished streaming, which on a POST is BEFORE the
|
|
430
|
+
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
|
|
431
|
+
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
|
|
432
|
+
* instantly and silently kills the feature (it survives on GET only because a GET has
|
|
433
|
+
* no body to finish). `reply.raw` emits `'close'` both when the response completes
|
|
434
|
+
* and when the socket dies, and `writableFinished` is what tells those apart: true
|
|
435
|
+
* only if the response actually went out. The guard is load-bearing, not defensive.
|
|
436
|
+
*
|
|
437
|
+
* `app.inject()` never emits `'close'` at all, so this is only observable over real
|
|
438
|
+
* HTTP — which is why the regression test for it binds a port.
|
|
439
|
+
*/
|
|
440
|
+
function abortOnClientHangUp(reply) {
|
|
441
|
+
const controller = new AbortController();
|
|
442
|
+
reply.raw.on('close', () => {
|
|
443
|
+
if (!reply.raw.writableFinished)
|
|
444
|
+
controller.abort();
|
|
445
|
+
});
|
|
446
|
+
return controller;
|
|
447
|
+
}
|
|
226
448
|
export function registerSessionRoutes(app, ctx) {
|
|
227
449
|
// ═══════════════════════════════════════════════════════════════
|
|
228
450
|
// Auth
|
|
@@ -681,35 +903,111 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
681
903
|
// Terminal I/O (input, resize, buffer)
|
|
682
904
|
// ═══════════════════════════════════════════════════════════════
|
|
683
905
|
// ========== Send Input ==========
|
|
684
|
-
app.post('/api/sessions/:id/input', async (req) => {
|
|
906
|
+
app.post('/api/sessions/:id/input', async (req, reply) => {
|
|
685
907
|
const { id } = req.params;
|
|
686
|
-
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
|
908
|
+
const { input, useMux, seq, clientId, wait, waitTimeout } = parseBody(SessionInputWithLimitSchema, req.body);
|
|
687
909
|
const session = findSessionOrFail(ctx, id, req);
|
|
688
910
|
const inputStr = String(input);
|
|
689
911
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
|
690
912
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
|
691
913
|
}
|
|
914
|
+
// Send-and-wait (agent orchestration). This has to be ONE endpoint rather than a
|
|
915
|
+
// POST followed by GET .../wait: between the write and the session flipping to
|
|
916
|
+
// `working` there is a window in which a separate wait sees the session still
|
|
917
|
+
// idle and returns instantly, reporting the PREVIOUS turn as this turn's answer.
|
|
918
|
+
// Registering the waiter before the write closes that window.
|
|
919
|
+
const wantsWait = wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
|
|
920
|
+
let until = [];
|
|
921
|
+
if (wantsWait) {
|
|
922
|
+
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
|
|
923
|
+
if (resolved.error)
|
|
924
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
|
|
925
|
+
until = resolved.until;
|
|
926
|
+
}
|
|
692
927
|
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
|
|
693
928
|
// client's ACK, so a tagged duplicate redelivery must still return 200 but
|
|
694
929
|
// skip the write. Untagged requests (curl/legacy) always apply.
|
|
695
930
|
const tagged = typeof clientId === 'string' && typeof seq === 'number';
|
|
696
|
-
|
|
931
|
+
const duplicate = tagged && !session.shouldApplyInput(clientId, seq);
|
|
932
|
+
if (duplicate && !wantsWait) {
|
|
697
933
|
return {};
|
|
698
934
|
}
|
|
935
|
+
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
|
936
|
+
// (thousands of calls per session) must stay exec-free.
|
|
937
|
+
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
|
938
|
+
const timeoutMs = clampWaitMs(waitTimeout ?? undefined);
|
|
939
|
+
// Same slot leak as the GET routes: a client that gives up mid-wait would
|
|
940
|
+
// otherwise hold a waiter for the full timeout. Response-side, always — see
|
|
941
|
+
// abortOnClientHangUp: on THIS route a request-side listener fires the moment the
|
|
942
|
+
// JSON body finishes streaming and aborts every send-and-wait before it starts.
|
|
943
|
+
const abort = abortOnClientHangUp(reply);
|
|
944
|
+
let waitPromise = null;
|
|
945
|
+
if (wantsWait) {
|
|
946
|
+
try {
|
|
947
|
+
waitPromise = sessionWaits.waitForSignal(id, {
|
|
948
|
+
until,
|
|
949
|
+
timeoutMs,
|
|
950
|
+
owner: ownerFor(req),
|
|
951
|
+
abortSignal: abort.signal,
|
|
952
|
+
// A FRESH delivery must not be satisfied by the state the session is already
|
|
953
|
+
// in: it is idle right now, which is precisely why we are typing at it.
|
|
954
|
+
// A DUPLICATE has no new turn coming, so it answers from the current state
|
|
955
|
+
// instead of blocking for a transition that already happened.
|
|
956
|
+
requireTransition: !duplicate,
|
|
957
|
+
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
if (err instanceof WaitCapacityError) {
|
|
962
|
+
// Nothing has been written yet, but `shouldApplyInput` already consumed the
|
|
963
|
+
// seq. Give it back or the caller's retry is rejected as a duplicate and the
|
|
964
|
+
// input is lost by the very mechanism meant to make delivery reliable.
|
|
965
|
+
if (tagged && !duplicate)
|
|
966
|
+
session.forgetInputSeq(clientId, seq);
|
|
967
|
+
return waitCapacityResponse(err);
|
|
968
|
+
}
|
|
969
|
+
throw err;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => { };
|
|
699
973
|
// Write input to PTY. Direct write is synchronous; writeViaMux
|
|
700
974
|
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
|
701
|
-
|
|
975
|
+
//
|
|
976
|
+
// Because the response has already been sent by then, a failure there is the
|
|
977
|
+
// one case the caller can never learn about — so the dedup bookkeeping is
|
|
978
|
+
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
|
|
979
|
+
// very mechanism reliable delivery exists for, is rejected as a duplicate.
|
|
980
|
+
const undoOnFailure = () => {
|
|
981
|
+
if (tagged)
|
|
982
|
+
session.forgetInputSeq(clientId, seq);
|
|
983
|
+
};
|
|
984
|
+
// Whether the bytes actually reached a write path. Only meaningful on the wait
|
|
985
|
+
// path (the fire-and-forget branches return before the response is built), and
|
|
986
|
+
// reported there instead of the old `!duplicate`: a PTY that has exited fails
|
|
987
|
+
// BOTH writes, and telling the caller "delivered, but it timed out" points it at
|
|
988
|
+
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
|
989
|
+
let delivered = false;
|
|
990
|
+
if (duplicate) {
|
|
991
|
+
// Redelivery of an already-applied input: skip the write, but still honor the
|
|
992
|
+
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
|
993
|
+
}
|
|
994
|
+
else if (useMux && waitPromise) {
|
|
995
|
+
// The response is already staying open for the wait, so the tmux write can be
|
|
996
|
+
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
|
997
|
+
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
|
998
|
+
if (ok) {
|
|
999
|
+
delivered = true;
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
|
1003
|
+
delivered = session.write(inputStr);
|
|
1004
|
+
if (!delivered)
|
|
1005
|
+
undoOnFailure();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
else if (useMux) {
|
|
702
1009
|
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
|
703
|
-
// Fallback to a direct write on failure.
|
|
704
|
-
//
|
|
705
|
-
// Because the response has already been sent by then, a failure here is the
|
|
706
|
-
// one case the caller can never learn about — so the dedup bookkeeping is
|
|
707
|
-
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
|
|
708
|
-
// very mechanism reliable delivery exists for, is rejected as a duplicate.
|
|
709
|
-
const undoOnFailure = () => {
|
|
710
|
-
if (tagged)
|
|
711
|
-
session.forgetInputSeq(clientId, seq);
|
|
712
|
-
};
|
|
1010
|
+
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
|
713
1011
|
session
|
|
714
1012
|
.writeViaMux(inputStr)
|
|
715
1013
|
.then((ok) => {
|
|
@@ -728,11 +1026,195 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
728
1026
|
// Same rollback. NOT an error response, deliberately: a session can
|
|
729
1027
|
// legitimately have no PTY yet (created but not started), and callers have
|
|
730
1028
|
// always been able to write to one without a 4xx.
|
|
731
|
-
|
|
1029
|
+
delivered = session.write(inputStr);
|
|
1030
|
+
if (!delivered && tagged) {
|
|
732
1031
|
session.forgetInputSeq(clientId, seq);
|
|
733
1032
|
}
|
|
734
1033
|
}
|
|
735
|
-
|
|
1034
|
+
if (!waitPromise)
|
|
1035
|
+
return {};
|
|
1036
|
+
try {
|
|
1037
|
+
// `send-keys` SUCCEEDS against a dead pane — tmux is happy to write into a corpse
|
|
1038
|
+
// — so a truthful `delivered` cannot come from the write's return value alone.
|
|
1039
|
+
// This is the case the field exists for: "delivered, but it timed out" tells an
|
|
1040
|
+
// agent to wait longer when the truth is "restart the worker".
|
|
1041
|
+
if (delivered && workerDead) {
|
|
1042
|
+
delivered = false;
|
|
1043
|
+
// The bytes went nowhere, so the seq must not be recorded as applied or the
|
|
1044
|
+
// caller's retry against a restarted worker is refused as a duplicate.
|
|
1045
|
+
if (!duplicate)
|
|
1046
|
+
undoOnFailure();
|
|
1047
|
+
}
|
|
1048
|
+
// Nothing was written and nothing will be: no turn is coming, so blocking for the
|
|
1049
|
+
// full timeout would only delay the caller's real recovery by up to ten minutes.
|
|
1050
|
+
// Releasing the waiter also hands its slot back immediately.
|
|
1051
|
+
const selfReleased = !delivered && !duplicate;
|
|
1052
|
+
if (selfReleased)
|
|
1053
|
+
abort.abort();
|
|
1054
|
+
const result = await waitPromise;
|
|
1055
|
+
return {
|
|
1056
|
+
success: true,
|
|
1057
|
+
data: {
|
|
1058
|
+
delivered,
|
|
1059
|
+
duplicate,
|
|
1060
|
+
status: session.status,
|
|
1061
|
+
limitPaused: session.isLimitPaused,
|
|
1062
|
+
// Identical `wait` object to the two GET endpoints, so one client helper
|
|
1063
|
+
// reads all three, `timeoutMs` (post-clamp) included.
|
|
1064
|
+
//
|
|
1065
|
+
// `aborted` is the CLIENT-facing "you hung up, nobody is reading this", and
|
|
1066
|
+
// by that definition it is unobservable — which is exactly what the API
|
|
1067
|
+
// reference promises. The abort above is the server releasing its own waiter
|
|
1068
|
+
// on a delivery that failed, and the client IS reading this response, so
|
|
1069
|
+
// reporting `aborted: true` there would break that promise and hand an agent
|
|
1070
|
+
// a second, contradictory reason for the same outcome. `delivered: false`
|
|
1071
|
+
// already says what happened; `ended` says the wait was released early.
|
|
1072
|
+
wait: { ...result, aborted: selfReleased ? false : result.aborted, until: [...until] },
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
stopDeathWatch();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
// ========== Wait For A Signal (agent orchestration) ==========
|
|
1081
|
+
//
|
|
1082
|
+
// A bounded long-poll: block until the session hits one of `until`, then answer.
|
|
1083
|
+
// This exists because SSE is the only "tell me when" channel Codeman has, and an
|
|
1084
|
+
// agent driving the API from a shell tool cannot hold a stream and parse events
|
|
1085
|
+
// inline. See docs/agent-control-plan.md.
|
|
1086
|
+
//
|
|
1087
|
+
// A TIMEOUT IS A 200, not an error: callers are expected to loop over short waits
|
|
1088
|
+
// (proxies such as `tailscale serve` cut idle connections), and turning every poll
|
|
1089
|
+
// boundary into a 4xx would make that loop indistinguishable from a real failure.
|
|
1090
|
+
app.get('/api/sessions/:id/wait', async (req, reply) => {
|
|
1091
|
+
const { id } = req.params;
|
|
1092
|
+
const query = parseWaitQuery(SessionWaitQuerySchema, req.query, 'wait');
|
|
1093
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1094
|
+
// An agent polls this URL in a loop with identical parameters. Any intermediary
|
|
1095
|
+
// applying heuristic freshness to the 200 would serve the stored `timedOut:true`
|
|
1096
|
+
// body to the next iteration instantly, turning the loop into a busy spin that
|
|
1097
|
+
// never observes the signal.
|
|
1098
|
+
reply.header('Cache-Control', 'no-store');
|
|
1099
|
+
// Shared with the `wait` field on POST .../input: unknown token is a 400,
|
|
1100
|
+
// hook-only signals are rejected explicitly but dropped from the default.
|
|
1101
|
+
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
|
|
1102
|
+
if (error)
|
|
1103
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
|
|
1104
|
+
// The value actually applied after clamping, echoed below: a caller that asked
|
|
1105
|
+
// for 30 minutes and silently got 10 could not otherwise tell a poll boundary
|
|
1106
|
+
// from a wedged worker, and would kill a session that was working fine.
|
|
1107
|
+
const timeoutMs = clampWaitMs(query.timeout);
|
|
1108
|
+
// Free the waiter when the caller hangs up; the response can no longer be sent by
|
|
1109
|
+
// then, so freeing the slot is the entire purpose.
|
|
1110
|
+
const abort = abortOnClientHangUp(reply);
|
|
1111
|
+
// A worker that dies while this request is parked emits nothing at all (the tmux
|
|
1112
|
+
// attach client survives it), so a wait would otherwise run to its full timeout.
|
|
1113
|
+
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
|
1114
|
+
try {
|
|
1115
|
+
const result = await sessionWaits.waitForSignal(id, {
|
|
1116
|
+
until,
|
|
1117
|
+
timeoutMs,
|
|
1118
|
+
owner: ownerFor(req),
|
|
1119
|
+
abortSignal: abort.signal,
|
|
1120
|
+
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
|
1121
|
+
// Read BEFORE awaiting: this is the state the caller is asking about.
|
|
1122
|
+
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
|
|
1123
|
+
});
|
|
1124
|
+
return {
|
|
1125
|
+
success: true,
|
|
1126
|
+
data: {
|
|
1127
|
+
sessionId: id,
|
|
1128
|
+
// Post-wait status, so a caller that timed out still learns where things stand.
|
|
1129
|
+
status: session.status,
|
|
1130
|
+
// A session paused on a usage limit emits nothing until its reset, so a
|
|
1131
|
+
// timeout here is expected rather than a stall worth retrying hard.
|
|
1132
|
+
limitPaused: session.isLimitPaused,
|
|
1133
|
+
// One shape across all three endpoints, so a single `is_done(resp)` helper
|
|
1134
|
+
// works against any of them. `result.timeoutMs` is the value actually
|
|
1135
|
+
// applied after clamping, which is what makes the clamp observable.
|
|
1136
|
+
wait: { ...result, until: [...until] },
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
if (err instanceof WaitCapacityError)
|
|
1142
|
+
return waitCapacityResponse(err);
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
stopDeathWatch();
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
// ========== Wait For Output (agent orchestration) ==========
|
|
1150
|
+
//
|
|
1151
|
+
// The companion to /wait: block until a literal string appears in this session's
|
|
1152
|
+
// output. Same 200-on-timeout contract. Fed by the `terminal` listener in
|
|
1153
|
+
// session-listener-wiring.ts, so what this scans is byte-for-byte what the pane
|
|
1154
|
+
// printed, ANSI stripped.
|
|
1155
|
+
//
|
|
1156
|
+
// ⚠️ A tmux repaint replays text already on screen, so `from=now` can match
|
|
1157
|
+
// something printed before the request. Callers need a marker unique per call.
|
|
1158
|
+
app.get('/api/sessions/:id/wait-output', async (req, reply) => {
|
|
1159
|
+
const { id } = req.params;
|
|
1160
|
+
// Reject `regex` loudly instead of ignoring it. Matching is deliberately literal
|
|
1161
|
+
// (no ReDoS surface on a caller-supplied pattern over a live stream), and an agent
|
|
1162
|
+
// that assumed otherwise would silently wait on the wrong thing.
|
|
1163
|
+
if (req.query && typeof req.query === 'object' && 'regex' in req.query) {
|
|
1164
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'regex is not supported; use match=<literal substring> (optionally with nocase=1)');
|
|
1165
|
+
}
|
|
1166
|
+
const query = parseWaitQuery(SessionWaitOutputQuerySchema, req.query, 'wait-output');
|
|
1167
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1168
|
+
// Same reason as /wait: this URL is polled in a loop with identical parameters.
|
|
1169
|
+
reply.header('Cache-Control', 'no-store');
|
|
1170
|
+
const timeoutMs = clampWaitMs(query.timeout);
|
|
1171
|
+
const abort = abortOnClientHangUp(reply);
|
|
1172
|
+
const owner = ownerFor(req);
|
|
1173
|
+
// Output waiters are the ones a dead worker strands hardest: the feed simply stops.
|
|
1174
|
+
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
|
1175
|
+
try {
|
|
1176
|
+
// Check the cap BEFORE touching the buffer. `session.terminalBuffer` is
|
|
1177
|
+
// `BufferAccumulator.value`, which joins the WHOLE accumulator (up to 32MB)
|
|
1178
|
+
// before the slice below takes its tail — so a request that is going to be
|
|
1179
|
+
// rejected anyway must not pay for a full materialization first, or the cap
|
|
1180
|
+
// provides no backpressure at all against a `from=buffer` loop.
|
|
1181
|
+
sessionWaits.assertCapacity(id, owner);
|
|
1182
|
+
// `from=buffer` scans what already scrolled past before blocking. Bounded to a
|
|
1183
|
+
// tail: the buffer runs to 32MB and this is a per-request ANSI strip.
|
|
1184
|
+
let initialText;
|
|
1185
|
+
if (query.from === 'buffer') {
|
|
1186
|
+
const buffer = session.terminalBuffer;
|
|
1187
|
+
initialText =
|
|
1188
|
+
buffer.length > MAX_BUFFER_SCAN_BYTES ? buffer.slice(buffer.length - MAX_BUFFER_SCAN_BYTES) : buffer;
|
|
1189
|
+
}
|
|
1190
|
+
const result = await sessionWaits.waitForOutput(id, {
|
|
1191
|
+
match: query.match,
|
|
1192
|
+
nocase: query.nocase === '1' || query.nocase === 'true',
|
|
1193
|
+
timeoutMs,
|
|
1194
|
+
owner,
|
|
1195
|
+
abortSignal: abort.signal,
|
|
1196
|
+
initialText,
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
success: true,
|
|
1200
|
+
data: {
|
|
1201
|
+
sessionId: id,
|
|
1202
|
+
status: session.status,
|
|
1203
|
+
limitPaused: session.isLimitPaused,
|
|
1204
|
+
// Same envelope as /wait; this one carries `matched`/`snippet`/`match`
|
|
1205
|
+
// where the signal wait carries `signal`/`until`.
|
|
1206
|
+
wait: { ...result, match: query.match },
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
catch (err) {
|
|
1211
|
+
if (err instanceof WaitCapacityError)
|
|
1212
|
+
return waitCapacityResponse(err);
|
|
1213
|
+
throw err;
|
|
1214
|
+
}
|
|
1215
|
+
finally {
|
|
1216
|
+
stopDeathWatch();
|
|
1217
|
+
}
|
|
736
1218
|
});
|
|
737
1219
|
// ========== Send Named Key (tmux send-keys -H) ==========
|
|
738
1220
|
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|