Skip to content

Commit c7600a5

Browse files
committed
Add TermDeck safety and lifecycle controls
1 parent 2c8d490 commit c7600a5

16 files changed

Lines changed: 370 additions & 28 deletions

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ Inspection:
142142
```bash
143143
termdeck state <session> [--lines N] [--autostart]
144144
termdeck summary <session> [--lines N] [--events N] [--autostart]
145+
termdeck last-command <session>
146+
termdeck sensitive <session> --on|--off
145147
termdeck screen <session>
146148
termdeck scrollback <session> [--lines N]
147149
termdeck transcript <session>
@@ -157,7 +159,7 @@ termdeck clear-scrollback <session>
157159
Background task helpers:
158160

159161
```bash
160-
termdeck task start <name> <command> --cwd <path> [--owner USER] [--labels a,b] [--ttl-ms N] [--ready-url URL] [--ready-port N] [--expect PATTERN]
162+
termdeck task start <name> <command> --cwd <path> [--owner USER] [--labels a,b] [--ttl-ms N] [--restart-policy never|on-exit|on-failure] [--max-restarts N] [--backoff-ms N] [--ready-url URL] [--ready-port N] [--expect PATTERN]
161163
termdeck task status <name>
162164
termdeck task recover <name>
163165
termdeck task logs <name> [--lines N]
@@ -188,9 +190,11 @@ env = { TERMDECK_HOME = "/path/to/project/.termdeck" }
188190

189191
The MCP `step` tool is the agent-friendly default entrypoint. It can autostart `termdeckd`, create a missing session when `cwd` is supplied, and returns stable JSON fields such as `status`, `reason`, `prompt`, `exitCode`, `timedOut`, `outputTruncated`, `lastSeq`, `transcriptPath`, and `cwd`. `project_step` goes one level higher by deriving a stable session id from `cwd` and an optional label.
190192

191-
`summary` returns a compact inspection object with a screen tail, output tail, recent events, and likely error lines. Use it when an agent needs state without replaying a large transcript.
193+
`summary` returns a compact inspection object with a screen tail, output tail, recent events, and likely error lines. `last_command` returns structured command id, command text, seq bounds, duration, exit code, timeout flag, and output tail. Use these when an agent needs state without replaying a large transcript.
192194

193-
Task helpers report stale metadata, expired TTLs, exited backing processes, restart counts, readiness diagnostics, and orphan `task-*` sessions. The web UI surfaces the same dashboard data with filters for active and attention-needed work.
195+
Sensitive mode redacts returned text, log/events views, summaries, and web output while hiding web snapshots. Raw transcripts remain local artifacts and should still be treated as sensitive.
196+
197+
Task helpers report stale metadata, expired TTLs, exited backing processes, restart counts, readiness diagnostics, and orphan `task-*` sessions. Optional restart policies can restart exited tasks on any exit or only non-zero exit. The web UI surfaces the same dashboard data with filters for active and attention-needed work plus safe task stop/recover/prune controls.
194198

195199
Synchronization:
196200

README_zh.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ termdeck signal <session> <signal> [--timeout-ms N] [--quiescence-ms N]
135135
```bash
136136
termdeck state <session> [--lines N] [--autostart]
137137
termdeck summary <session> [--lines N] [--events N] [--autostart]
138+
termdeck last-command <session>
139+
termdeck sensitive <session> --on|--off
138140
termdeck screen <session>
139141
termdeck scrollback <session> [--lines N]
140142
termdeck transcript <session>
@@ -151,7 +153,9 @@ termdeck clear-scrollback <session>
151153

152154
`run` 会在 shell 内加入 begin/exit marker,以便从终端回显中稳定切出命令输出并返回 `exitCode`。命令仍在持久 shell 中执行,所以 `cd`、环境变量和 shell 函数等状态会保留。
153155

154-
后台任务支持 `--owner``--labels``--ttl-ms``task dashboard``task prune``task recover`。状态会区分 stale metadata、TTL 过期、进程已退出、restart count 和 orphan `task-*` session。Web UI 会展示 task dashboard,并提供 active/attention 过滤。
156+
`last-command` 返回结构化 command id、命令、seq 范围、duration、exit code、timeout 和 output tail。`sensitive` 模式会对返回文本、log/events/summary 和 Web 输出做 redaction,并隐藏 Web snapshot;原始 transcript 仍是本地磁盘 artifact,需要继续按敏感数据处理。
157+
158+
后台任务支持 `--owner``--labels``--ttl-ms``--restart-policy``--max-restarts``--backoff-ms``task dashboard``task prune``task recover`。状态会区分 stale metadata、TTL 过期、进程已退出、restart count 和 orphan `task-*` session。Web UI 会展示 task dashboard,并提供 active/attention 过滤以及 stop/recover/prune 安全控制,但仍不向 PTY 发送输入。
155159

156160
同步等待:
157161

docs/usage.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ termdeck run main 'echo ok' --json
103103

104104
`run` wraps the command with shell markers so the response can separate command output from terminal echo and report `exitCode` when the command completes. The persistent shell still executes the command itself, so stateful operations such as `cd`, exported variables, and shell functions remain in the session.
105105

106+
Read the last structured command record:
107+
108+
```bash
109+
termdeck last-command main --json
110+
```
111+
112+
Enable sensitive mode when returned views may contain secrets:
113+
114+
```bash
115+
termdeck sensitive main --on
116+
```
117+
118+
Sensitive mode redacts returned command output, screen/log/events/summary views, and web output. It also hides the web snapshot for that session. The raw local transcript remains an artifact on disk, so keep `TERMDECK_HOME` permissions tight and avoid entering secrets unless necessary.
119+
106120
Use `--raw` when a command path needs the original PTY bytes, including ANSI color/control sequences:
107121

108122
```bash
@@ -299,7 +313,7 @@ termdeck transcript main
299313
Task helpers are named TermDeck sessions with small readiness metadata. They do not bypass the daemon or create a separate terminal runner.
300314

301315
```bash
302-
termdeck task start web 'pnpm dev --host 127.0.0.1' --cwd "$PWD" --labels dev,web --ttl-ms 7200000 --ready-port 5173 --autostart
316+
termdeck task start web 'pnpm dev --host 127.0.0.1' --cwd "$PWD" --labels dev,web --ttl-ms 7200000 --restart-policy on-failure --max-restarts 2 --backoff-ms 3000 --ready-port 5173 --autostart
303317
termdeck task status web
304318
termdeck task recover web
305319
termdeck task logs web --lines 100
@@ -308,7 +322,9 @@ termdeck task prune --stale --expired --dry-run
308322
termdeck task stop web
309323
```
310324

311-
Readiness can be detected with `--ready-url`, `--ready-port`, or `--expect`. When more than one readiness check is supplied, all checks must pass and `task status` reports per-check diagnostics plus a short log tail on failure. Task metadata can include `--owner`, `--labels`, and `--ttl-ms`. Status distinguishes stale metadata, expired TTLs, exited backing processes, and restart counts. If task metadata exists but the backing session is gone, status reports a stale task; `task recover` recreates the session from metadata and reruns the task command. `task dashboard` also reports orphan `task-*` sessions that have no task metadata.
325+
Readiness can be detected with `--ready-url`, `--ready-port`, or `--expect`. When more than one readiness check is supplied, all checks must pass and `task status` reports per-check diagnostics plus a short log tail on failure. Task metadata can include `--owner`, `--labels`, `--ttl-ms`, and restart policy fields. Status distinguishes stale metadata, expired TTLs, exited backing processes, and restart counts. If task metadata exists but the backing session is gone, status reports a stale task; `task recover` recreates the session from metadata and reruns the task command. `task dashboard` also reports orphan `task-*` sessions that have no task metadata.
326+
327+
Restart policies are `never`, `on-exit`, and `on-failure`. Automatic restarts honor `--max-restarts` and `--backoff-ms`.
312328

313329
## MCP
314330

@@ -356,7 +372,7 @@ The browser uses:
356372
- JSON REST for serialized xterm snapshots
357373
- binary protobuf WebSocket events for live output after the snapshot sequence
358374

359-
The browser loads a serialized xterm snapshot first, then subscribes with `afterSeq=lastSeq`. Reconnects use `afterSeq` to replay events missed during a disconnect while the daemon retains them. The sidebar supports active and attention filters for sessions and tasks; the top dashboard summarizes session count, task count, ready tasks, and attention-needed items.
375+
The browser loads a serialized xterm snapshot first, then subscribes with `afterSeq=lastSeq`. Reconnects use `afterSeq` to replay events missed during a disconnect while the daemon retains them. The sidebar supports active and attention filters for sessions and tasks; the top dashboard summarizes session count, task count, ready tasks, and attention-needed items. Web task controls can stop, recover, and prune stale/expired tasks, but the browser remains observe-only for PTY input.
360376

361377
## Automation pattern
362378

src/cli.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { readFileSync } from 'node:fs';
22
import { Command } from 'commander';
33
import { ensureSession, request, requestWithDaemon, stateSnapshot } from './client.js';
4+
import { lastCommand } from './commands.js';
45
import { projectSessionName } from './project.js';
6+
import { setSensitiveSession } from './sensitive.js';
57
import { sessionSummary } from './summary.js';
68
import { listSessions, listTasks, pruneSessions, taskDashboard, taskLogs, taskRecover, taskPrune, taskStart, taskStatus, taskStop } from './tasks.js';
79
import type { Response } from './protocol.js';
@@ -137,6 +139,21 @@ program.command('summary')
137139
.option('--autostart', 'start termdeckd when it is not running')
138140
.action(async (session, opts) => printResponse(await sessionSummary({ session, lines: opts.lines, events: opts.events, autostart: opts.autostart }), opts.json ? 'json' : 'default'));
139141

142+
program.command('last-command')
143+
.argument('<session>')
144+
.option('--json')
145+
.action(async (session, opts) => printObject({ command: lastCommand(session) }, opts.json));
146+
147+
program.command('sensitive')
148+
.argument('<session>')
149+
.option('--on', 'enable sensitive mode')
150+
.option('--off', 'disable sensitive mode')
151+
.option('--json')
152+
.action(async (session, opts) => {
153+
if (Boolean(opts.on) === Boolean(opts.off)) throw new Error('pass exactly one of --on or --off');
154+
printObject(setSensitiveSession(session, Boolean(opts.on)), opts.json);
155+
});
156+
140157
program.command('step')
141158
.argument('<session>')
142159
.argument('[command]')
@@ -437,6 +454,9 @@ task.command('start')
437454
.option('--owner <owner>')
438455
.option('--labels <labels>', 'comma-separated labels')
439456
.option('--ttl-ms <ms>', 'task metadata TTL', (v) => Number(v))
457+
.option('--restart-policy <policy>', 'never, on-exit, or on-failure')
458+
.option('--max-restarts <count>', 'maximum automatic restarts', (v) => Number(v))
459+
.option('--backoff-ms <ms>', 'minimum delay between automatic restarts', (v) => Number(v))
440460
.option('--ready-url <url>')
441461
.option('--ready-port <port>', 'localhost port readiness probe', (v) => Number(v))
442462
.option('--expect <pattern>')
@@ -457,6 +477,9 @@ task.command('start')
457477
owner: opts.owner,
458478
labels: opts.labels ? String(opts.labels).split(',').map((s) => s.trim()).filter(Boolean) : undefined,
459479
ttlMs: opts.ttlMs,
480+
restartPolicy: opts.restartPolicy,
481+
maxRestarts: opts.maxRestarts,
482+
backoffMs: opts.backoffMs,
460483
readyUrl: opts.readyUrl,
461484
readyPort: opts.readyPort,
462485
expect: opts.expect,

src/commands.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { redactValue } from './redact.js';
4+
import { isSensitiveSession } from './sensitive.js';
5+
import { sessionDir } from './paths.js';
6+
7+
export type LastCommand = {
8+
id: string;
9+
kind: string;
10+
data: string;
11+
tsMs: number;
12+
startSeq?: number;
13+
endSeq?: number;
14+
durationMs?: number;
15+
exitCode?: number;
16+
timedOut?: boolean;
17+
outputTail?: string;
18+
};
19+
20+
export function lastCommand(session: string): LastCommand | undefined {
21+
const file = join(sessionDir(session), 'commands.log');
22+
if (!existsSync(file)) return undefined;
23+
const rows = readFileSync(file, 'utf8').split('\n').filter(Boolean);
24+
const byId = new Map<string, Partial<LastCommand>>();
25+
for (const row of rows) {
26+
try {
27+
const parsed = JSON.parse(row) as Partial<LastCommand> & { result?: Partial<LastCommand> };
28+
const id = parsed.id ?? `${parsed.tsMs ?? byId.size}`;
29+
const current = byId.get(id) ?? {};
30+
byId.set(id, { ...current, ...parsed, ...(parsed.result ?? {}) });
31+
} catch {}
32+
}
33+
const last = [...byId.values()].filter((row): row is LastCommand => typeof row.data === 'string' && typeof row.tsMs === 'number').at(-1);
34+
if (!last) return undefined;
35+
return isSensitiveSession(session) ? redactValue(last) as LastCommand : last;
36+
}

src/daemon.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import type { Duplex } from 'node:stream';
88
import stripAnsi from 'strip-ansi';
99
import { encodeEvent, FrameReader, writeFrame, type Event, type Request, type Response } from './protocol.js';
1010
import { socketAccessMode } from './platform.js';
11+
import { redactJsonl, redactText } from './redact.js';
1112
import { rootDir, sessionDir, sessionsDir, socketPath } from './paths.js';
1213
import { replayTranscript } from './replay.js';
1314
import { TermSession } from './session.js';
14-
import { taskDashboard } from './tasks.js';
15+
import { isSensitiveSession } from './sensitive.js';
16+
import { taskDashboard, taskPrune, taskRecover, taskStop } from './tasks.js';
1517
import { webAppJs, webHtml } from './web.js';
1618

1719
const require = createRequire(import.meta.url);
@@ -123,10 +125,10 @@ function inspectSession(id: string): Record<string, unknown> {
123125
return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>;
124126
}
125127

126-
function tailFile(file: string, lines: number): string {
128+
function tailFile(file: string, lines: number, redact = false): string {
127129
const text = readFileSync(file, 'utf8');
128-
if (!lines || lines <= 0) return text;
129-
return text.split('\n').slice(-lines).join('\n');
130+
const out = !lines || lines <= 0 ? text : text.split('\n').slice(-lines).join('\n');
131+
return redact ? redactText(out) : out;
130132
}
131133

132134
function eventLines(id: string, afterSeq: number, limit: number): string {
@@ -138,7 +140,8 @@ function eventLines(id: string, afterSeq: number, limit: number): string {
138140
return false;
139141
}
140142
});
141-
return rows.slice(0, limit || rows.length).join('\n');
143+
const out = rows.slice(0, limit || rows.length).join('\n');
144+
return isSensitiveSession(id) ? redactJsonl(out) : out;
142145
}
143146

144147
async function handle(req: Request, socket?: Socket): Promise<Response> {
@@ -229,7 +232,7 @@ async function handle(req: Request, socket?: Socket): Promise<Response> {
229232
case 'inspect':
230233
return { id: req.id, ok: true, metadata: manager.list()?.some((s) => s.id === req.session) ? manager.get(req.session).metadata() : inspectSession(req.session) };
231234
case 'log':
232-
return { id: req.id, ok: true, logText: tailFile(join(sessionDir(req.session), 'transcript.log'), req.lines ?? 200) };
235+
return { id: req.id, ok: true, logText: tailFile(join(sessionDir(req.session), 'transcript.log'), req.lines ?? 200, isSensitiveSession(req.session)) };
233236
case 'events':
234237
return { id: req.id, ok: true, eventsText: eventLines(req.session, req.afterSeq ?? 0, req.limit ?? 200) };
235238
case 'replay': {
@@ -328,6 +331,20 @@ function handleWebRequest(req: IncomingMessage, res: { writeHead(code: number, h
328331
void taskDashboard({ timeoutMs: 1 }).then((dashboard) => send(res, 200, 'application/json', JSON.stringify(dashboard))).catch((err: unknown) => send(res, 500, 'text/plain; charset=utf-8', err instanceof Error ? err.message : String(err)));
329332
return;
330333
}
334+
if (req.method === 'POST') {
335+
const taskActionMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/(stop|recover)$/);
336+
if (taskActionMatch) {
337+
const name = decodeURIComponent(taskActionMatch[1]);
338+
const action = taskActionMatch[2];
339+
const run = action === 'stop' ? taskStop(name, true) : taskRecover(name, { autostart: true });
340+
void run.then((body) => send(res, 200, 'application/json', JSON.stringify(body))).catch((err: unknown) => send(res, 500, 'text/plain; charset=utf-8', err instanceof Error ? err.message : String(err)));
341+
return;
342+
}
343+
if (url.pathname === '/api/tasks/prune') {
344+
void taskPrune({ stale: true, expired: true, autostart: true }).then((body) => send(res, 200, 'application/json', JSON.stringify(body))).catch((err: unknown) => send(res, 500, 'text/plain; charset=utf-8', err instanceof Error ? err.message : String(err)));
345+
return;
346+
}
347+
}
331348
const screenMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/screen$/);
332349
if (screenMatch) {
333350
const s = manager.get(decodeURIComponent(screenMatch[1]));
@@ -336,7 +353,7 @@ function handleWebRequest(req: IncomingMessage, res: { writeHead(code: number, h
336353
const snapshotMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/snapshot$/);
337354
if (snapshotMatch) {
338355
const s = manager.get(decodeURIComponent(snapshotMatch[1]));
339-
return send(res, 200, 'application/json', JSON.stringify({ status: s.status().status, lastSeq: s.info().lastSeq, rows: s.rows, cols: s.cols, snapshot: s.snapshot() }));
356+
return send(res, 200, 'application/json', JSON.stringify({ status: s.status().status, lastSeq: s.info().lastSeq, rows: s.rows, cols: s.cols, sensitive: s.isSensitive(), snapshot: s.snapshot() }));
340357
}
341358
send(res, 404, 'text/plain; charset=utf-8', 'not found');
342359
} catch (err) {
@@ -377,9 +394,14 @@ function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex): void {
377394
}
378395
});
379396
const onEvent = (event: Event) => {
380-
if (event.session === session) socket.write(wsBinary(encodeEvent(event)));
397+
if (event.session !== session) return;
398+
const out = s.isSensitive() && (event.kind === 'output' || event.kind === 'input') ? { ...event, data: redactText(event.data) } as Event : event;
399+
socket.write(wsBinary(encodeEvent(out)));
381400
};
382-
for (const event of s.eventsAfter(afterSeq)) socket.write(wsBinary(encodeEvent(event)));
401+
for (const event of s.eventsAfter(afterSeq)) {
402+
const out = s.isSensitive() && (event.kind === 'output' || event.kind === 'input') ? { ...event, data: redactText(event.data) } as Event : event;
403+
socket.write(wsBinary(encodeEvent(out)));
404+
}
383405
s.on('event', onEvent);
384406
socket.on('close', () => s.off('event', onEvent));
385407
}

0 commit comments

Comments
 (0)