Compare commits
5 Commits
4f6fa0e83b
...
db6be6dcf8
| Author | SHA1 | Date |
|---|---|---|
|
|
db6be6dcf8 | |
|
|
f89abd1125 | |
|
|
6f106d2411 | |
|
|
17c32e7e93 | |
|
|
bd990a07ab |
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* APNs scaffold — JWT generation + push primitive.
|
||||||
|
*
|
||||||
|
* Provides a minimal APNs HTTP/2 push implementation using the JWT
|
||||||
|
* provider-auth method (no persistent connection pool — deferred to Phase 2
|
||||||
|
* when iOS delivers real device tokens and call volume is known).
|
||||||
|
*
|
||||||
|
* Config comes from [apns] section in config.toml (T-1.7 wires this).
|
||||||
|
* Until then, an explicit ApnsConfig object is passed in.
|
||||||
|
*
|
||||||
|
* Owner: T-1.10
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSign } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
export interface ApnsConfig {
|
||||||
|
teamId: string; // 10-char Apple Team ID
|
||||||
|
keyId: string; // 10-char APNs key ID
|
||||||
|
keyPath: string; // path to .p8 private key file
|
||||||
|
bundleId: string; // app bundle ID, e.g. "com.example.pi-remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushPayload {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
badge?: number;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APNs host per environment */
|
||||||
|
const APNS_HOST = {
|
||||||
|
production: "api.push.apple.com",
|
||||||
|
sandbox: "api.development.push.apple.com",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JWT generation (ES256, valid 60 minutes per APNs spec)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface JwtCache {
|
||||||
|
token: string;
|
||||||
|
issuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _jwtCache = new Map<string, JwtCache>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate (or return cached) APNs provider JWT.
|
||||||
|
* APNs rejects tokens older than 60 min; we refresh at 55 min.
|
||||||
|
*/
|
||||||
|
export async function getProviderJwt(cfg: ApnsConfig): Promise<string> {
|
||||||
|
const cacheKey = `${cfg.teamId}:${cfg.keyId}`;
|
||||||
|
const cached = _jwtCache.get(cacheKey);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (cached && now - cached.issuedAt < 55 * 60) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPem = await fs.readFile(cfg.keyPath, "utf8");
|
||||||
|
|
||||||
|
const header = base64url(JSON.stringify({ alg: "ES256", kid: cfg.keyId }));
|
||||||
|
const claims = base64url(JSON.stringify({ iss: cfg.teamId, iat: now }));
|
||||||
|
const signingInput = `${header}.${claims}`;
|
||||||
|
|
||||||
|
const sign = createSign("SHA256");
|
||||||
|
sign.update(signingInput);
|
||||||
|
const sig = sign.sign({ key: keyPem, dsaEncoding: "ieee-p1363" });
|
||||||
|
const sigB64 = sig.toString("base64url");
|
||||||
|
|
||||||
|
const token = `${signingInput}.${sigB64}`;
|
||||||
|
_jwtCache.set(cacheKey, { token, issuedAt: now });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Push primitive
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
apnsId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single APNs push notification.
|
||||||
|
*
|
||||||
|
* Uses the Node fetch API (available since Node 18).
|
||||||
|
* No connection pooling — Phase 2 can upgrade to http2 if throughput demands.
|
||||||
|
*/
|
||||||
|
export async function sendPush(opts: {
|
||||||
|
cfg: ApnsConfig;
|
||||||
|
deviceToken: string;
|
||||||
|
environment: "production" | "sandbox";
|
||||||
|
payload: PushPayload;
|
||||||
|
collapseId?: string;
|
||||||
|
}): Promise<PushResult> {
|
||||||
|
const { cfg, deviceToken, environment, payload, collapseId } = opts;
|
||||||
|
|
||||||
|
const jwt = await getProviderJwt(cfg);
|
||||||
|
const host = APNS_HOST[environment];
|
||||||
|
const url = `https://${host}/3/device/${deviceToken}`;
|
||||||
|
|
||||||
|
const apsPayload = {
|
||||||
|
aps: {
|
||||||
|
...(payload.title || payload.body
|
||||||
|
? { alert: { title: payload.title, body: payload.body } }
|
||||||
|
: {}),
|
||||||
|
...(payload.badge !== undefined ? { badge: payload.badge } : {}),
|
||||||
|
},
|
||||||
|
...payload.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `bearer ${jwt}`,
|
||||||
|
"apns-topic": cfg.bundleId,
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
if (collapseId) headers["apns-collapse-id"] = collapseId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(apsPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const apnsId = res.headers.get("apns-id") ?? undefined;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return { ok: true, status: res.status, apnsId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
let errorReason = body;
|
||||||
|
try {
|
||||||
|
errorReason = (JSON.parse(body) as { reason?: string }).reason ?? body;
|
||||||
|
} catch {
|
||||||
|
// use raw body
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, apnsId, error: errorReason };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function base64url(input: string): string {
|
||||||
|
return Buffer.from(input).toString("base64url");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* QR-based pairing.
|
||||||
|
*
|
||||||
|
* Implements IC-3 — the `pi-remote://` URL scheme and `POST /pair` exchange.
|
||||||
|
*
|
||||||
|
* Pairing flow:
|
||||||
|
* 1. Server generates a one-time pairingToken (short-lived, 10 min).
|
||||||
|
* 2. `pi-remote pair` CLI prints the QR code containing the URL.
|
||||||
|
* 3. iOS app scans the QR, extracts host/port/pairingToken/fingerprint.
|
||||||
|
* 4. iOS app calls `POST /pair` with { pairingToken, deviceToken?, environment?, deviceName? }.
|
||||||
|
* 5. Server validates pairingToken, creates a bearer token, returns { bearerToken, sidecarId }.
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export interface PairingToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number; // unix ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store of active pairing tokens (cleared on server restart)
|
||||||
|
const _pairingTokens = new Map<string, PairingToken>();
|
||||||
|
|
||||||
|
const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
/** Generate a new pairing token. Invalidates any existing one. */
|
||||||
|
export function generatePairingToken(): PairingToken {
|
||||||
|
const token = randomBytes(16).toString("base64url");
|
||||||
|
const entry: PairingToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + PAIRING_TOKEN_TTL_MS,
|
||||||
|
};
|
||||||
|
_pairingTokens.set(token, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate and consume a pairing token. Returns true if valid. */
|
||||||
|
export function consumePairingToken(token: string): boolean {
|
||||||
|
const entry = _pairingTokens.get(token);
|
||||||
|
if (!entry) return false;
|
||||||
|
_pairingTokens.delete(token);
|
||||||
|
if (Date.now() > entry.expiresAt) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IC-3 pairing URL.
|
||||||
|
*
|
||||||
|
* pi-remote://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||||
|
*/
|
||||||
|
export function buildPairingUrl(opts: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
pairingToken: string;
|
||||||
|
fingerprint: string;
|
||||||
|
sidecarName?: string;
|
||||||
|
}): string {
|
||||||
|
const {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
pairingToken,
|
||||||
|
fingerprint,
|
||||||
|
sidecarName = "pi-remote",
|
||||||
|
} = opts;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pair: pairingToken,
|
||||||
|
fp: fingerprint,
|
||||||
|
name: sidecarName,
|
||||||
|
});
|
||||||
|
return `pi-remote://${host}:${port}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the pairing URL as a QR code to the terminal.
|
||||||
|
* Uses the `qrcode` package bundled as a dependency.
|
||||||
|
*/
|
||||||
|
export async function printPairingQr(url: string): Promise<void> {
|
||||||
|
// Dynamic import so this file loads even if qrcode isn't installed
|
||||||
|
const qrcode = await import("qrcode");
|
||||||
|
const qr = await qrcode.toString(url, { type: "terminal", small: true });
|
||||||
|
console.log(qr);
|
||||||
|
console.log(url);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Self-signed TLS certificate generation + fingerprint.
|
||||||
|
*
|
||||||
|
* Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint
|
||||||
|
* is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin.
|
||||||
|
*
|
||||||
|
* Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if
|
||||||
|
* openssl is not available.
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface TlsCert {
|
||||||
|
certPath: string;
|
||||||
|
keyPath: string;
|
||||||
|
/** SHA-256 hex fingerprint of the DER-encoded cert */
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TlsCertOptions {
|
||||||
|
stateDir?: string;
|
||||||
|
/** Common name for the cert (default: "pi-remote") */
|
||||||
|
cn?: string;
|
||||||
|
/** Days the cert is valid (default: 3650 — 10 years) */
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certDir(stateDir?: string): string {
|
||||||
|
return path.join(
|
||||||
|
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||||
|
"tls",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing cert or generate a new one.
|
||||||
|
* Returns paths + fingerprint.
|
||||||
|
*/
|
||||||
|
export async function loadOrCreateCert(
|
||||||
|
opts: TlsCertOptions = {},
|
||||||
|
): Promise<TlsCert> {
|
||||||
|
const { stateDir, cn = "pi-remote", days = 3650 } = opts;
|
||||||
|
const dir = certDir(stateDir);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const certPath = path.join(dir, "cert.pem");
|
||||||
|
const keyPath = path.join(dir, "key.pem");
|
||||||
|
|
||||||
|
// Reuse existing cert if present
|
||||||
|
try {
|
||||||
|
await fs.access(certPath);
|
||||||
|
await fs.access(keyPath);
|
||||||
|
const fingerprint = await computeFingerprint(certPath);
|
||||||
|
return { certPath, keyPath, fingerprint };
|
||||||
|
} catch {
|
||||||
|
// generate new
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate via openssl
|
||||||
|
await execFileAsync("openssl", [
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-keyout",
|
||||||
|
keyPath,
|
||||||
|
"-out",
|
||||||
|
certPath,
|
||||||
|
"-days",
|
||||||
|
String(days),
|
||||||
|
"-nodes",
|
||||||
|
"-subj",
|
||||||
|
`/CN=${cn}`,
|
||||||
|
]);
|
||||||
|
await fs.chmod(keyPath, 0o600);
|
||||||
|
|
||||||
|
const fingerprint = await computeFingerprint(certPath);
|
||||||
|
return { certPath, keyPath, fingerprint };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 fingerprint of a PEM cert.
|
||||||
|
* Returns hex string (no colons), e.g. "a1b2c3...".
|
||||||
|
*/
|
||||||
|
export async function computeFingerprint(certPath: string): Promise<string> {
|
||||||
|
// Use openssl to get DER bytes, then hash
|
||||||
|
const { stdout } = await execFileAsync("openssl", [
|
||||||
|
"x509",
|
||||||
|
"-in",
|
||||||
|
certPath,
|
||||||
|
"-outform",
|
||||||
|
"DER",
|
||||||
|
]);
|
||||||
|
return createHash("sha256").update(stdout).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if openssl is available on PATH.
|
||||||
|
*/
|
||||||
|
export async function isOpensslAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execFileAsync("openssl", ["version"]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* Bearer-token CRUD.
|
||||||
|
*
|
||||||
|
* Extends the minimal token support in auth.ts (legacy single-token) with
|
||||||
|
* named multi-token management. Each token entry has:
|
||||||
|
* - id: short random identifier
|
||||||
|
* - token: the bearer secret (base64url, 32 bytes)
|
||||||
|
* - name: human label (e.g. "Jay's iPhone")
|
||||||
|
* - createdAt: ISO timestamp
|
||||||
|
* - deviceToken?: APNs device token (set when device pairs in Phase 2)
|
||||||
|
* - environment?: "production" | "sandbox"
|
||||||
|
*
|
||||||
|
* Stored as JSON in $state_dir/auth/tokens.json (mode 0o600).
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface TokenEntry {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
deviceToken?: string; // APNs — optional pre-Phase-2, mandatory Phase-2+
|
||||||
|
environment?: "production" | "sandbox";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokensPath(stateDir?: string): string {
|
||||||
|
const base =
|
||||||
|
stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
|
||||||
|
return path.join(base, "auth", "tokens.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(tokensPath(stateDir), "utf8");
|
||||||
|
return JSON.parse(raw) as TokenEntry[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTokens(
|
||||||
|
entries: TokenEntry[],
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const fp = tokensPath(stateDir);
|
||||||
|
await fs.mkdir(path.dirname(fp), { recursive: true });
|
||||||
|
await fs.writeFile(fp, JSON.stringify(entries, null, 2), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new named bearer token. Returns the new entry (token visible once). */
|
||||||
|
export async function createToken(
|
||||||
|
name: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<TokenEntry> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry: TokenEntry = {
|
||||||
|
id: randomBytes(6).toString("base64url"),
|
||||||
|
token: randomBytes(32).toString("base64url"),
|
||||||
|
name,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all tokens (token field is included — protect at the API layer). */
|
||||||
|
export async function listTokens(stateDir?: string): Promise<TokenEntry[]> {
|
||||||
|
return loadTokens(stateDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a token by id. Returns true if found and removed. */
|
||||||
|
export async function revokeToken(
|
||||||
|
id: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const before = entries.length;
|
||||||
|
const filtered = entries.filter((e) => e.id !== id);
|
||||||
|
if (filtered.length === before) return false;
|
||||||
|
await saveTokens(filtered, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rename a token. Returns true if found. */
|
||||||
|
export async function renameToken(
|
||||||
|
id: string,
|
||||||
|
newName: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
entry.name = newName;
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update device token (called during Phase-2 pairing). */
|
||||||
|
export async function setDeviceToken(
|
||||||
|
id: string,
|
||||||
|
deviceToken: string,
|
||||||
|
environment: "production" | "sandbox",
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
entry.deviceToken = deviceToken;
|
||||||
|
entry.environment = environment;
|
||||||
|
await saveTokens(entries, stateDir);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a bearer token string against the store.
|
||||||
|
* Returns the matching entry or null.
|
||||||
|
*/
|
||||||
|
export async function validateBearer(
|
||||||
|
bearer: string,
|
||||||
|
stateDir?: string,
|
||||||
|
): Promise<TokenEntry | null> {
|
||||||
|
const entries = await loadTokens(stateDir);
|
||||||
|
const b = Buffer.from(bearer);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const a = Buffer.from(entry.token);
|
||||||
|
if (a.length === b.length && timingSafeEqual(a, b)) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Disk ring-buffer reader.
|
||||||
|
*
|
||||||
|
* Reads chunks from a session buffer file, optionally starting from a
|
||||||
|
* given seq number. Used by the stream route for reconnect replay (T-1.5).
|
||||||
|
*
|
||||||
|
* File format (mirrors writer.ts):
|
||||||
|
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||||
|
*
|
||||||
|
* Owner: T-1.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface BufferChunk {
|
||||||
|
seq: number;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderConfig {
|
||||||
|
stateDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDir(cfg?: ReaderConfig): string {
|
||||||
|
return (
|
||||||
|
cfg?.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferPath(session: string, cfg?: ReaderConfig): string {
|
||||||
|
return path.join(stateDir(cfg), "buffers", `${session}.buf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all chunks from a session buffer, optionally starting after `afterSeq`.
|
||||||
|
*
|
||||||
|
* Returns chunks in seq order. If the file doesn't exist, returns [].
|
||||||
|
* Stops at the first parse error (truncated file at end is tolerated).
|
||||||
|
*/
|
||||||
|
export function readChunks(
|
||||||
|
session: string,
|
||||||
|
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||||
|
): BufferChunk[] {
|
||||||
|
const { afterSeq = 0, cfg } = opts;
|
||||||
|
const fp = bufferPath(session, cfg);
|
||||||
|
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
buf = fs.readFileSync(fp);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: BufferChunk[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 12 <= buf.length) {
|
||||||
|
const seqBig = buf.readBigUInt64BE(offset);
|
||||||
|
const seq = Number(seqBig);
|
||||||
|
const length = buf.readUInt32BE(offset + 8);
|
||||||
|
offset += 12;
|
||||||
|
|
||||||
|
if (offset + length > buf.length) break; // truncated record at end
|
||||||
|
|
||||||
|
if (seq > afterSeq) {
|
||||||
|
chunks.push({ seq, data: buf.slice(offset, offset + length) });
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read chunks as an async generator (streaming, for large buffers).
|
||||||
|
* Yields one chunk at a time after `afterSeq`.
|
||||||
|
*/
|
||||||
|
export async function* streamChunks(
|
||||||
|
session: string,
|
||||||
|
opts: { afterSeq?: number; cfg?: ReaderConfig } = {},
|
||||||
|
): AsyncGenerator<BufferChunk> {
|
||||||
|
// Simple implementation: read all and yield. For large files T-1.5 can
|
||||||
|
// switch to a streaming file read if needed.
|
||||||
|
const { afterSeq = 0, cfg } = opts;
|
||||||
|
const chunks = readChunks(session, { afterSeq, cfg });
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* Disk ring-buffer writer.
|
||||||
|
*
|
||||||
|
* Appends chunks to a per-session file, enforcing:
|
||||||
|
* - Per-session cap: 100 MB (configurable)
|
||||||
|
* - Global cap: 1 GB across all sessions (configurable)
|
||||||
|
* - Free-space watchdog: refuse writes if free disk < 1 GB
|
||||||
|
* - Idle cleanup: sessions inactive for > 30 days are deleted
|
||||||
|
*
|
||||||
|
* File format (binary, append-only):
|
||||||
|
* Each record: [seq: 8 bytes BE uint64] [length: 4 bytes BE uint32] [data: N bytes]
|
||||||
|
*
|
||||||
|
* Risk R1 mitigation: all writes serialised through a per-session async queue.
|
||||||
|
* Global cap protected by a module-level mutex (simple flag since JS is single-threaded).
|
||||||
|
*
|
||||||
|
* Owner: T-1.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { SeqNum } from "../sequence.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config defaults (can be overridden; T-1.7 will wire these from config.toml)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BufferConfig {
|
||||||
|
stateDir: string;
|
||||||
|
perSessionMb: number; // default 100
|
||||||
|
globalGb: number; // default 1
|
||||||
|
freeMinGb: number; // default 1
|
||||||
|
idleDays: number; // default 30
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig(): BufferConfig {
|
||||||
|
return {
|
||||||
|
stateDir: path.join(os.homedir(), ".local", "share", "pi-remote"),
|
||||||
|
perSessionMb: 100,
|
||||||
|
globalGb: 1,
|
||||||
|
freeMinGb: 1,
|
||||||
|
idleDays: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _config: BufferConfig = defaultConfig();
|
||||||
|
|
||||||
|
export function configureBuffer(cfg: Partial<BufferConfig>): void {
|
||||||
|
_config = { ..._config, ...cfg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global cap mutex (JS single-threaded so a flag suffices)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _globalBusy = false;
|
||||||
|
let _globalBytes = 0; // tracked in-memory; recalculated on startup
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-session writer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class BufferWriter {
|
||||||
|
readonly session: string;
|
||||||
|
private filePath: string;
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
private sessionBytes = 0;
|
||||||
|
private lastWriteAt = Date.now();
|
||||||
|
|
||||||
|
constructor(session: string) {
|
||||||
|
this.session = session;
|
||||||
|
this.filePath = path.join(_config.stateDir, "buffers", `${session}.buf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||||
|
// Load existing size for cap tracking
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(this.filePath);
|
||||||
|
this.sessionBytes = stat.size;
|
||||||
|
_globalBytes += stat.size;
|
||||||
|
} catch {
|
||||||
|
this.sessionBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a chunk write. Writes are serialised per session.
|
||||||
|
*/
|
||||||
|
write(seq: SeqNum, data: Buffer): void {
|
||||||
|
this.queue = this.queue.then(() => this._write(seq, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _write(seq: SeqNum, data: Buffer): Promise<void> {
|
||||||
|
const perSessionCap = _config.perSessionMb * 1024 * 1024;
|
||||||
|
const globalCap = _config.globalGb * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Free-space watchdog
|
||||||
|
try {
|
||||||
|
const { available } = await checkFreeSpace(path.dirname(this.filePath));
|
||||||
|
const freeMin = _config.freeMinGb * 1024 * 1024 * 1024;
|
||||||
|
if (available < freeMin) return; // silently drop; could emit a warning
|
||||||
|
} catch {
|
||||||
|
// If we can't check, don't block writes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap enforcement
|
||||||
|
const recordSize = 8 + 4 + data.length;
|
||||||
|
if (
|
||||||
|
_globalBusy ||
|
||||||
|
this.sessionBytes + recordSize > perSessionCap ||
|
||||||
|
_globalBytes + recordSize > globalCap
|
||||||
|
) {
|
||||||
|
return; // drop oldest strategy: just don't write (ring via truncation not implemented yet)
|
||||||
|
}
|
||||||
|
|
||||||
|
_globalBusy = true;
|
||||||
|
try {
|
||||||
|
const header = Buffer.allocUnsafe(12);
|
||||||
|
header.writeBigUInt64BE(BigInt(seq), 0);
|
||||||
|
header.writeUInt32BE(data.length, 8);
|
||||||
|
|
||||||
|
await fs.appendFile(this.filePath, Buffer.concat([header, data]));
|
||||||
|
this.sessionBytes += recordSize;
|
||||||
|
_globalBytes += recordSize;
|
||||||
|
this.lastWriteAt = Date.now();
|
||||||
|
} finally {
|
||||||
|
_globalBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.queue; // drain
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the buffer file and reclaim global tracking bytes. */
|
||||||
|
async delete(): Promise<void> {
|
||||||
|
await this.queue;
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.filePath);
|
||||||
|
_globalBytes = Math.max(0, _globalBytes - this.sessionBytes);
|
||||||
|
this.sessionBytes = 0;
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get idleMs(): number {
|
||||||
|
return Date.now() - this.lastWriteAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Idle cleanup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete buffer files for sessions idle longer than idleDays.
|
||||||
|
* Safe to call periodically (e.g. on startup or daily timer).
|
||||||
|
*/
|
||||||
|
export async function cleanupIdleBuffers(
|
||||||
|
cfg: BufferConfig = _config,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const dir = path.join(cfg.stateDir, "buffers");
|
||||||
|
const maxIdleMs = cfg.idleDays * 24 * 60 * 60 * 1000;
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.name.endsWith(".buf")) continue;
|
||||||
|
const fp = path.join(dir, entry.name);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(fp);
|
||||||
|
if (Date.now() - stat.mtimeMs > maxIdleMs) {
|
||||||
|
await fs.unlink(fp);
|
||||||
|
deleted.push(entry.name.replace(/\.buf$/, ""));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Approximate free disk space on the filesystem containing `dir`. */
|
||||||
|
async function checkFreeSpace(dir: string): Promise<{ available: number }> {
|
||||||
|
// Node doesn't expose statvfs directly; use df -k as a fallback.
|
||||||
|
// If it fails, caller ignores the error.
|
||||||
|
const { execFile } = await import("node:child_process");
|
||||||
|
const { promisify } = await import("node:util");
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
const { stdout } = await exec("df", ["-k", dir]);
|
||||||
|
const lines = stdout.trim().split("\n");
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
const parts = last.split(/\s+/);
|
||||||
|
// df -k columns: Filesystem 1K-blocks Used Available Use% Mounted
|
||||||
|
const availKb = parseInt(parts[3], 10);
|
||||||
|
return { available: availKb * 1024 };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
/**
|
||||||
|
* pi-remote CLI entrypoints.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* pi-remote pair — generate QR code for device pairing
|
||||||
|
* pi-remote auth list — list bearer tokens
|
||||||
|
* pi-remote auth revoke <id> — revoke a token
|
||||||
|
* pi-remote auth name <id> <name> — rename a token
|
||||||
|
*
|
||||||
|
* Invoked by the extension via pi's flag registration or as a standalone
|
||||||
|
* script: `node cli/index.js <args>`
|
||||||
|
*
|
||||||
|
* Owner: T-1.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
buildPairingUrl,
|
||||||
|
generatePairingToken,
|
||||||
|
printPairingQr,
|
||||||
|
} from "../auth/pairing.js";
|
||||||
|
import { loadOrCreateCert } from "../auth/tls.js";
|
||||||
|
import {
|
||||||
|
createToken,
|
||||||
|
listTokens,
|
||||||
|
renameToken,
|
||||||
|
revokeToken,
|
||||||
|
} from "../auth/tokens.js";
|
||||||
|
import { readRemoteControlConfig } from "../config.js";
|
||||||
|
|
||||||
|
const DEFAULT_STATE_DIR = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".local",
|
||||||
|
"share",
|
||||||
|
"pi-remote",
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function runCli(
|
||||||
|
argv: string[] = process.argv.slice(2),
|
||||||
|
): Promise<void> {
|
||||||
|
const [cmd, sub, ...rest] = argv;
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "pair":
|
||||||
|
await cmdPair();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "auth":
|
||||||
|
await cmdAuth(sub, rest);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "help":
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
printHelp();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Unknown command: ${cmd ?? "(none)"}`);
|
||||||
|
printHelp();
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pair
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function cmdPair(): Promise<void> {
|
||||||
|
const config = await readRemoteControlConfig();
|
||||||
|
const stateDir = DEFAULT_STATE_DIR;
|
||||||
|
|
||||||
|
// Try to get TLS fingerprint; fall back to empty string if openssl unavailable
|
||||||
|
let fingerprint = "";
|
||||||
|
try {
|
||||||
|
const cert = await loadOrCreateCert({ stateDir });
|
||||||
|
fingerprint = cert.fingerprint;
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
"[pi-remote] Warning: openssl not available; fingerprint will be empty.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairingTokenEntry = generatePairingToken();
|
||||||
|
|
||||||
|
// Determine host for QR — use advertised URL or fall back to hostname
|
||||||
|
const bindAddress = config.bindAddress ?? "0.0.0.0:7777";
|
||||||
|
const portMatch = bindAddress.match(/:(\d+)$/);
|
||||||
|
const port = portMatch ? parseInt(portMatch[1], 10) : 7777;
|
||||||
|
const host =
|
||||||
|
(config.publicBaseUrl ?? config.advertisedBaseUrl)?.replace(
|
||||||
|
/^https?:\/\//,
|
||||||
|
"",
|
||||||
|
) ?? os.hostname();
|
||||||
|
|
||||||
|
const url = buildPairingUrl({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
pairingToken: pairingTokenEntry.token,
|
||||||
|
fingerprint,
|
||||||
|
sidecarName: "pi-remote",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nScan this QR code with the pi-remote iOS app:\n");
|
||||||
|
await printPairingQr(url);
|
||||||
|
console.log(
|
||||||
|
`\nPairing token expires in 10 minutes. Run "pi-remote pair" again to refresh.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function cmdAuth(sub: string | undefined, args: string[]): Promise<void> {
|
||||||
|
const stateDir = DEFAULT_STATE_DIR;
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "list": {
|
||||||
|
const tokens = await listTokens(stateDir);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"No tokens. Use `pi-remote auth create <name>` to create one.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("ID NAME CREATED");
|
||||||
|
for (const t of tokens) {
|
||||||
|
const created = new Date(t.createdAt).toLocaleDateString();
|
||||||
|
console.log(
|
||||||
|
`${t.id.padEnd(12)}${t.name.padEnd(22)}${created}${t.deviceToken ? " [device paired]" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create": {
|
||||||
|
const name = args[0] ?? "unnamed";
|
||||||
|
const entry = await createToken(name, stateDir);
|
||||||
|
console.log(`Created token "${entry.name}" (id: ${entry.id})`);
|
||||||
|
console.log(`Bearer token: ${entry.token}`);
|
||||||
|
console.log("Save this token — it won't be shown again.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "revoke": {
|
||||||
|
const id = args[0];
|
||||||
|
if (!id) {
|
||||||
|
console.error("Usage: pi-remote auth revoke <id>");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await revokeToken(id, stateDir);
|
||||||
|
if (ok) {
|
||||||
|
console.log(`Revoked token ${id}.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Token ${id} not found.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "name": {
|
||||||
|
const [id, ...nameParts] = args;
|
||||||
|
const newName = nameParts.join(" ");
|
||||||
|
if (!id || !newName) {
|
||||||
|
console.error("Usage: pi-remote auth name <id> <new-name>");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await renameToken(id, newName, stateDir);
|
||||||
|
if (ok) {
|
||||||
|
console.log(`Renamed token ${id} to "${newName}".`);
|
||||||
|
} else {
|
||||||
|
console.error(`Token ${id} not found.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Unknown auth subcommand: ${sub ?? "(none)"}`);
|
||||||
|
console.error("Available: list, create, revoke, name");
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// help
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
pi-remote — CLI for the pi-remote-control sidecar
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
pair Generate a QR code to pair the iOS app
|
||||||
|
auth list List all bearer tokens
|
||||||
|
auth create <name> Create a new named bearer token
|
||||||
|
auth revoke <id> Revoke a token by id
|
||||||
|
auth name <id> <name> Rename a token
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help, -h Show this help
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Standalone entrypoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Run when invoked as: node cli/index.js <args>
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
(process.argv[1].endsWith("cli/index.js") ||
|
||||||
|
process.argv[1].endsWith("cli/index.ts"))
|
||||||
|
) {
|
||||||
|
runCli().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* Auto-naming via `pi -p` (S-09a).
|
||||||
|
*
|
||||||
|
* After a configurable number of user messages, spawn a cheap `pi -p` call
|
||||||
|
* to generate a short session name from the conversation context.
|
||||||
|
* The result is stored as the tmux session's @description.
|
||||||
|
*
|
||||||
|
* Gated by [autoname] enabled in config.toml (T-1.7 wires the config;
|
||||||
|
* until then defaults are used).
|
||||||
|
*
|
||||||
|
* Owner: T-1.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { setDescription } from "../tmux/manager.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface AutonameConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
triggerAfter: number; // number of user messages before naming
|
||||||
|
model: string; // e.g. "claude-haiku-4-5"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AUTONAME_CONFIG: AutonameConfig = {
|
||||||
|
enabled: true,
|
||||||
|
triggerAfter: 3,
|
||||||
|
model: "claude-haiku-4-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to auto-name a session using `pi -p`.
|
||||||
|
* If pi is not on PATH or the call fails, silently no-ops.
|
||||||
|
*
|
||||||
|
* @param sessionId tmux session name to set @description on
|
||||||
|
* @param context recent conversation context (short excerpt)
|
||||||
|
* @param cfg autoname configuration
|
||||||
|
*/
|
||||||
|
export async function autoname(
|
||||||
|
sessionId: string,
|
||||||
|
context: string,
|
||||||
|
cfg: AutonameConfig = DEFAULT_AUTONAME_CONFIG,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!cfg.enabled) return;
|
||||||
|
|
||||||
|
const prompt = `Give a 2-4 word title for this conversation. Reply with only the title, no punctuation.\n\n${context.slice(0, 800)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"pi",
|
||||||
|
[
|
||||||
|
"-p",
|
||||||
|
"--model",
|
||||||
|
cfg.model,
|
||||||
|
"--no-session",
|
||||||
|
"--no-tools",
|
||||||
|
"--no-extensions",
|
||||||
|
"--no-skills",
|
||||||
|
"--offline",
|
||||||
|
prompt,
|
||||||
|
],
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const name = stdout.trim().slice(0, 60); // cap at 60 chars
|
||||||
|
if (name) {
|
||||||
|
await setDescription(sessionId, name);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Autoname failures are non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* pi.getCommands() wrapper.
|
||||||
|
*
|
||||||
|
* Fetches available slash commands from the pi ExtensionAPI and normalises
|
||||||
|
* them into the shape used by the /sessions/:id/commands REST endpoint (T-1.6).
|
||||||
|
*
|
||||||
|
* Owner: T-1.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
args?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of registered slash commands from pi.
|
||||||
|
* Returns an empty array if the API doesn't support getCommands.
|
||||||
|
*/
|
||||||
|
export async function getCommands(pi: ExtensionAPI): Promise<SlashCommand[]> {
|
||||||
|
try {
|
||||||
|
// getCommands may not exist in all pi versions
|
||||||
|
if (
|
||||||
|
typeof (pi as unknown as { getCommands?: unknown }).getCommands !==
|
||||||
|
"function"
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const raw = await (
|
||||||
|
pi as unknown as { getCommands: () => Promise<unknown[]> }
|
||||||
|
).getCommands();
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.filter(
|
||||||
|
(c): c is { name: string; description?: string; args?: string } =>
|
||||||
|
c !== null &&
|
||||||
|
typeof c === "object" &&
|
||||||
|
typeof (c as { name?: unknown }).name === "string",
|
||||||
|
)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
description: c.description ?? "",
|
||||||
|
args: c.args,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* pi ExtensionAPI event subscriptions.
|
||||||
|
*
|
||||||
|
* Bridges pi's lifecycle events into the sidecar's state model.
|
||||||
|
* Emits structured state updates that the WebSocket broadcaster (T-1.5)
|
||||||
|
* can forward as IC-1 `{ type: "state"; value: ... }` frames.
|
||||||
|
*
|
||||||
|
* Subscribes to:
|
||||||
|
* - agent_start / agent_end → "thinking" / "idle"
|
||||||
|
* - tool_start / tool_end → "tool" (with tool name)
|
||||||
|
* - awaiting_input → "awaiting-input"
|
||||||
|
*
|
||||||
|
* Owner: T-1.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
/** IC-1 state values */
|
||||||
|
export type AgentState = "thinking" | "tool" | "idle" | "awaiting-input";
|
||||||
|
|
||||||
|
export interface StateEvent {
|
||||||
|
value: AgentState;
|
||||||
|
tool?: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateCallback = (event: StateEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to pi agent lifecycle events.
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function subscribeAgentEvents(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
onState: StateCallback,
|
||||||
|
): () => void {
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
// agent_start → thinking
|
||||||
|
try {
|
||||||
|
const off = pi.on("agent_start", () => {
|
||||||
|
onState({ value: "thinking", ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (off) unsubs.push(off);
|
||||||
|
} catch {
|
||||||
|
// event may not exist in this pi version
|
||||||
|
}
|
||||||
|
|
||||||
|
// agent_end → idle
|
||||||
|
try {
|
||||||
|
const off = pi.on("agent_end", () => {
|
||||||
|
onState({ value: "idle", ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (off) unsubs.push(off);
|
||||||
|
} catch {
|
||||||
|
// event may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool_start → tool
|
||||||
|
try {
|
||||||
|
const off = pi.on("tool_start", (data: unknown) => {
|
||||||
|
const toolName =
|
||||||
|
data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
"name" in data &&
|
||||||
|
typeof (data as { name: unknown }).name === "string"
|
||||||
|
? (data as { name: string }).name
|
||||||
|
: undefined;
|
||||||
|
onState({ value: "tool", tool: toolName, ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (off) unsubs.push(off);
|
||||||
|
} catch {
|
||||||
|
// event may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool_end → thinking (agent is still running after tool)
|
||||||
|
try {
|
||||||
|
const off = pi.on("tool_end", () => {
|
||||||
|
onState({ value: "thinking", ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (off) unsubs.push(off);
|
||||||
|
} catch {
|
||||||
|
// event may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaiting_input → awaiting-input
|
||||||
|
try {
|
||||||
|
const off = pi.on("awaiting_input", () => {
|
||||||
|
onState({ value: "awaiting-input", ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (off) unsubs.push(off);
|
||||||
|
} catch {
|
||||||
|
// event may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const off of unsubs) off();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Monotonic sequence number generator — shared by stream + buffer.
|
||||||
|
*
|
||||||
|
* Each chunk of output gets a unique, monotonically increasing seq number.
|
||||||
|
* This lets clients resume a stream from a known position (IC-1 `lastSeq`).
|
||||||
|
*
|
||||||
|
* Owner: T-1.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SeqNum = number; // safe JS integer, starts at 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session sequence counter.
|
||||||
|
* Create one instance per session; share between the buffer writer and the
|
||||||
|
* WebSocket broadcaster.
|
||||||
|
*/
|
||||||
|
export class SequenceCounter {
|
||||||
|
private current: SeqNum = 0;
|
||||||
|
|
||||||
|
/** Increment and return the next seq number. */
|
||||||
|
next(): SeqNum {
|
||||||
|
this.current += 1;
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current value without incrementing. */
|
||||||
|
peek(): SeqNum {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset (e.g. after session restart). */
|
||||||
|
reset(): void {
|
||||||
|
this.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* tmux control-mode client — per-session.
|
||||||
|
*
|
||||||
|
* Spawns `tmux -C attach -t <session>`, parses `%output` notifications,
|
||||||
|
* decodes octal-escaped bytes, and broadcasts raw ANSI to subscribers.
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - One ControlClient instance per tmux session (per-session, not per-server).
|
||||||
|
* - Subscribers register a callback; each raw Buffer chunk is broadcast.
|
||||||
|
* - On %exit or process close, all subscribers are notified and removed.
|
||||||
|
*
|
||||||
|
* Reference: feat/spike-tmux-cc / spike-cc.ts (Phase 0.5 PoC).
|
||||||
|
*
|
||||||
|
* Risk mitigations:
|
||||||
|
* R4: streaming line-parser, per-line decode, no full-buffer copies.
|
||||||
|
*
|
||||||
|
* Owner: T-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
|
export type OutputCallback = (chunk: Buffer) => void;
|
||||||
|
export type CloseCallback = (reason: string) => void;
|
||||||
|
|
||||||
|
export interface ControlClientOptions {
|
||||||
|
session: string;
|
||||||
|
onClose?: CloseCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ControlClient {
|
||||||
|
readonly session: string;
|
||||||
|
private proc: ChildProcess | null = null;
|
||||||
|
private subscribers = new Map<symbol, OutputCallback>();
|
||||||
|
private closed = false;
|
||||||
|
private onClose?: CloseCallback;
|
||||||
|
|
||||||
|
constructor(opts: ControlClientOptions) {
|
||||||
|
this.session = opts.session;
|
||||||
|
this.onClose = opts.onClose;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.proc) return;
|
||||||
|
this.closed = false;
|
||||||
|
|
||||||
|
// -CC = control mode with passthrough (so tmux sends output events for all panes)
|
||||||
|
this.proc = spawn("tmux", ["-CC", "attach", "-t", this.session], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = createInterface({
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: stdout is always set when stdio includes 'pipe'
|
||||||
|
input: this.proc.stdout!,
|
||||||
|
crlfDelay: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on("line", (line: string) => {
|
||||||
|
this.parseLine(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.proc.stderr?.on("data", (_d: Buffer) => {
|
||||||
|
// Ignore tmux stderr (status messages). Can log at debug level if needed.
|
||||||
|
});
|
||||||
|
|
||||||
|
this.proc.on("close", (code: number | null) => {
|
||||||
|
this.closed = true;
|
||||||
|
this.subscribers.clear();
|
||||||
|
this.onClose?.(`tmux process exited (code=${code})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.proc && !this.closed) {
|
||||||
|
this.proc.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return !this.closed && this.proc !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Subscriptions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
subscribe(cb: OutputCallback): () => void {
|
||||||
|
const key = Symbol();
|
||||||
|
this.subscribers.set(key, cb);
|
||||||
|
return () => this.subscribers.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one line of tmux control-mode output.
|
||||||
|
*
|
||||||
|
* Control-mode lines that matter:
|
||||||
|
* %output %<pane-id> <octal-escaped-bytes>
|
||||||
|
* %exit [reason]
|
||||||
|
* Everything else is ignored.
|
||||||
|
*/
|
||||||
|
private parseLine(line: string): void {
|
||||||
|
if (!line.startsWith("%")) return;
|
||||||
|
|
||||||
|
const spaceIdx = line.indexOf(" ");
|
||||||
|
const type = spaceIdx === -1 ? line.slice(1) : line.slice(1, spaceIdx);
|
||||||
|
const rest = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1);
|
||||||
|
|
||||||
|
if (type === "output") {
|
||||||
|
this.handleOutput(rest);
|
||||||
|
} else if (type === "exit") {
|
||||||
|
this.closed = true;
|
||||||
|
this.subscribers.clear();
|
||||||
|
this.onClose?.(`%exit ${rest}`);
|
||||||
|
}
|
||||||
|
// layout-change, window-add, etc. are ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a %output notification.
|
||||||
|
* Format: %<pane-id> <octal-escaped-value>
|
||||||
|
*/
|
||||||
|
private handleOutput(data: string): void {
|
||||||
|
const spaceIdx = data.indexOf(" ");
|
||||||
|
if (spaceIdx === -1) return; // malformed, skip
|
||||||
|
|
||||||
|
const escapedValue = data.slice(spaceIdx + 1);
|
||||||
|
const decoded = decodeOctalEscapes(escapedValue);
|
||||||
|
if (decoded.length === 0) return;
|
||||||
|
|
||||||
|
for (const cb of this.subscribers.values()) {
|
||||||
|
cb(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Octal-escape decoder (from spike-cc.ts, adapted)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode tmux's octal-escaped output format.
|
||||||
|
* "hello\\012world" → Buffer containing "hello\nworld"
|
||||||
|
*/
|
||||||
|
export function decodeOctalEscapes(input: string): Buffer {
|
||||||
|
// Fast-path: nothing to decode
|
||||||
|
if (!input.includes("\\")) return Buffer.from(input, "binary");
|
||||||
|
|
||||||
|
const bytes: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < input.length) {
|
||||||
|
if (input[i] === "\\" && i + 3 < input.length) {
|
||||||
|
const oct = input.slice(i + 1, i + 4);
|
||||||
|
if (/^[0-7]{3}$/.test(oct)) {
|
||||||
|
bytes.push(parseInt(oct, 8));
|
||||||
|
i += 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes.push(input.charCodeAt(i));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* tmux send-keys input translation.
|
||||||
|
*
|
||||||
|
* Translates IC-1 key names (and literal text) into tmux send-keys arguments.
|
||||||
|
* Used by the input route (T-1.5) to deliver keystrokes to a pane.
|
||||||
|
*
|
||||||
|
* Owner: T-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** Named keys from IC-1 ClientToServer `{ type: "key"; name: string }`. */
|
||||||
|
const KEY_MAP: Record<string, string> = {
|
||||||
|
escape: "Escape",
|
||||||
|
tab: "Tab",
|
||||||
|
up: "Up",
|
||||||
|
down: "Down",
|
||||||
|
left: "Left",
|
||||||
|
right: "Right",
|
||||||
|
enter: "Enter",
|
||||||
|
"shift-enter": "S-Enter",
|
||||||
|
backspace: "BSpace",
|
||||||
|
"ctrl-c": "C-c",
|
||||||
|
"ctrl-d": "C-d",
|
||||||
|
"ctrl-z": "C-z",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single named key to a tmux pane.
|
||||||
|
* Pane defaults to the first pane of the session (session:0.0).
|
||||||
|
*/
|
||||||
|
export async function sendKey(
|
||||||
|
session: string,
|
||||||
|
name: string,
|
||||||
|
pane = "0.0",
|
||||||
|
): Promise<void> {
|
||||||
|
const tmuxKey = KEY_MAP[name.toLowerCase()];
|
||||||
|
if (!tmuxKey) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
`${session}:${pane}`,
|
||||||
|
tmuxKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send literal text to a tmux pane (IC-1 `{ type: "keys"; data: string }`).
|
||||||
|
* Uses send-keys -l which sends each character literally.
|
||||||
|
*/
|
||||||
|
export async function sendKeys(
|
||||||
|
session: string,
|
||||||
|
data: string,
|
||||||
|
pane = "0.0",
|
||||||
|
): Promise<void> {
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
`${session}:${pane}`,
|
||||||
|
"-l",
|
||||||
|
data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send bracketed-paste to a tmux pane (IC-1 `{ type: "paste"; data: string }`).
|
||||||
|
* Wraps the data in bracketed-paste sequences then sends literally.
|
||||||
|
*/
|
||||||
|
export async function sendPaste(
|
||||||
|
session: string,
|
||||||
|
data: string,
|
||||||
|
pane = "0.0",
|
||||||
|
): Promise<void> {
|
||||||
|
const wrapped = `\x1b[200~${data}\x1b[201~`;
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
`${session}:${pane}`,
|
||||||
|
"-l",
|
||||||
|
wrapped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* tmux session manager.
|
||||||
|
*
|
||||||
|
* Spawn, list, kill sessions and read metadata stored via tmux @description
|
||||||
|
* option. Checks tmux version at startup (requires >= 2.5).
|
||||||
|
*
|
||||||
|
* Owner: T-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface TmuxSession {
|
||||||
|
id: string; // tmux session name (used as our ID)
|
||||||
|
name: string; // human name (same as id for now; T-1.4 may rename via @description)
|
||||||
|
description?: string; // from tmux @description option
|
||||||
|
createdAt: string; // ISO string, from tmux session_created_string
|
||||||
|
lastActivityAt: string; // ISO string, from tmux session_last_attached
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let versionChecked = false;
|
||||||
|
|
||||||
|
export async function checkTmuxVersion(): Promise<void> {
|
||||||
|
if (versionChecked) return;
|
||||||
|
const { stdout } = await execFileAsync("tmux", ["-V"]);
|
||||||
|
const match = stdout.trim().match(/tmux (\d+)\.(\d+)/);
|
||||||
|
if (!match) throw new Error(`Cannot parse tmux version: ${stdout.trim()}`);
|
||||||
|
const major = parseInt(match[1], 10);
|
||||||
|
const minor = parseInt(match[2], 10);
|
||||||
|
if (major < 2 || (major === 2 && minor < 5)) {
|
||||||
|
throw new Error(
|
||||||
|
`tmux >= 2.5 required (found ${stdout.trim()}). Upgrade tmux to use pi-remote-control.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
versionChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a new detached tmux session.
|
||||||
|
* Returns the session name (used as stable ID).
|
||||||
|
*/
|
||||||
|
export async function spawnSession(opts: {
|
||||||
|
name: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
command?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
await checkTmuxVersion();
|
||||||
|
const { name, width = 120, height = 40, command = "" } = opts;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"new-session",
|
||||||
|
"-d",
|
||||||
|
"-s",
|
||||||
|
name,
|
||||||
|
"-x",
|
||||||
|
String(width),
|
||||||
|
"-y",
|
||||||
|
String(height),
|
||||||
|
];
|
||||||
|
if (command) args.push(command);
|
||||||
|
|
||||||
|
await execFileAsync("tmux", args);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tmux sessions with metadata.
|
||||||
|
*/
|
||||||
|
export async function listSessions(): Promise<TmuxSession[]> {
|
||||||
|
await checkTmuxVersion();
|
||||||
|
|
||||||
|
// Use a separator that's unlikely to appear in session names
|
||||||
|
const SEP = "\x1F"; // ASCII unit separator
|
||||||
|
const fmt = [
|
||||||
|
"#{session_name}",
|
||||||
|
"#{session_created_string}",
|
||||||
|
"#{session_last_attached_string}",
|
||||||
|
"#{window_width}",
|
||||||
|
"#{window_height}",
|
||||||
|
].join(SEP);
|
||||||
|
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
({ stdout } = await execFileAsync("tmux", ["list-sessions", "-F", fmt]));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// tmux exits 1 when no sessions exist
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"code" in err &&
|
||||||
|
(err as { code: number }).code === 1
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: TmuxSession[] = [];
|
||||||
|
for (const line of stdout.trim().split("\n")) {
|
||||||
|
if (!line) continue;
|
||||||
|
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
|
||||||
|
|
||||||
|
// Fetch @description option separately (may not be set)
|
||||||
|
let description: string | undefined;
|
||||||
|
try {
|
||||||
|
const r = await execFileAsync("tmux", [
|
||||||
|
"show-options",
|
||||||
|
"-t",
|
||||||
|
id,
|
||||||
|
"-qv",
|
||||||
|
"@description",
|
||||||
|
]);
|
||||||
|
const v = r.stdout.trim();
|
||||||
|
if (v) description = v;
|
||||||
|
} catch {
|
||||||
|
// option not set — that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description,
|
||||||
|
createdAt,
|
||||||
|
lastActivityAt,
|
||||||
|
width: parseInt(w, 10) || 120,
|
||||||
|
height: parseInt(h, 10) || 40,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single session by name. Returns null if not found.
|
||||||
|
*/
|
||||||
|
export async function getSession(name: string): Promise<TmuxSession | null> {
|
||||||
|
const all = await listSessions();
|
||||||
|
return all.find((s) => s.id === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a session. Throws if it doesn't exist.
|
||||||
|
*/
|
||||||
|
export async function killSession(name: string): Promise<void> {
|
||||||
|
await checkTmuxVersion();
|
||||||
|
await execFileAsync("tmux", ["kill-session", "-t", name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the @description option on a session.
|
||||||
|
*/
|
||||||
|
export async function setDescription(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
name,
|
||||||
|
"@description",
|
||||||
|
description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* tmux capture-pane snapshot.
|
||||||
|
*
|
||||||
|
* Returns a plain-text snapshot of a pane's visible content.
|
||||||
|
* Used by the snapshot route (T-1.5) and the /thumbnail endpoint (T-1.6).
|
||||||
|
*
|
||||||
|
* Owner: T-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface SnapshotOptions {
|
||||||
|
/** tmux session name */
|
||||||
|
session: string;
|
||||||
|
/** pane index within session (default "0.0") */
|
||||||
|
pane?: string;
|
||||||
|
/** capture width (default: actual pane width) */
|
||||||
|
width?: number;
|
||||||
|
/** capture height (default: actual pane height) */
|
||||||
|
height?: number;
|
||||||
|
/** include escape sequences for colour/style (default: false = plain text) */
|
||||||
|
escapes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a plain-text (or escape-annotated) snapshot of a tmux pane.
|
||||||
|
* Returns raw text as a string.
|
||||||
|
*/
|
||||||
|
export async function capturePane(opts: SnapshotOptions): Promise<string> {
|
||||||
|
const { session, pane = "0.0", escapes = false } = opts;
|
||||||
|
const target = `${session}:${pane}`;
|
||||||
|
|
||||||
|
const args = ["capture-pane", "-t", target, "-p"];
|
||||||
|
if (escapes) args.push("-e"); // include escape sequences
|
||||||
|
// Note: -S/-E (start/end line) omitted — captures current visible content
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync("tmux", args);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a thumbnail-sized snapshot (40×12) for the REST thumbnail endpoint.
|
||||||
|
* Returns plain text, trimmed.
|
||||||
|
*/
|
||||||
|
export async function captureThumbnail(
|
||||||
|
session: string,
|
||||||
|
pane = "0.0",
|
||||||
|
): Promise<string> {
|
||||||
|
// tmux can't resize the capture directly via capture-pane flags, so we
|
||||||
|
// capture full content and truncate to 40-char wide × 12 lines.
|
||||||
|
const raw = await capturePane({ session, pane, escapes: false });
|
||||||
|
const lines = raw.split("\n").slice(0, 12);
|
||||||
|
return lines.map((l) => l.slice(0, 40)).join("\n");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue