@camstack/addon-terminal 0.1.1 → 0.1.3

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/dist/addon.js CHANGED
@@ -2,6 +2,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region \0rolldown/runtime.js
3
3
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
4
4
  //#endregion
5
+ let node_fs = require("node:fs");
6
+ let node_path = require("node:path");
5
7
  //#region ../types/dist/event-category-BLcNejAE.mjs
6
8
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
7
9
  EventCategory["SystemBoot"] = "system.boot";
@@ -7505,16 +7507,23 @@ var StorageLocationDeclarationSchema = object({
7505
7507
  * Which node root the seeded `<id>:default` instance is placed under on a
7506
7508
  * FRESH install:
7507
7509
  * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
7508
- * the appData volume. Right for small/durable data (backups, logs, models).
7510
+ * the appData volume. Right for small/durable data (logs, models).
7509
7511
  * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
7510
7512
  * env is set, else falls back to the data root. Right for bulky, hot media
7511
7513
  * (recordings, event media) that should stay off the appData disk.
7514
+ * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
7515
+ * `/backups` in the image) so archives live on their own mount rather than
7516
+ * filling the appData disk. Falls back to the data root when unset.
7512
7517
  *
7513
7518
  * Only affects the seeded default's `basePath`; operators can repoint any
7514
7519
  * location afterwards, and a `defaultsTo` slot inherits its parent's root
7515
7520
  * regardless of this field. Absent (the common case) is treated as `'data'`.
7516
7521
  */
7517
- defaultRoot: _enum(["data", "media"]).optional()
7522
+ defaultRoot: _enum([
7523
+ "data",
7524
+ "media",
7525
+ "backup"
7526
+ ]).optional()
7518
7527
  });
7519
7528
  var DecoderStatsSchema = object({
7520
7529
  inputFps: number(),
@@ -14526,11 +14535,53 @@ var LocationStatSchema = object({
14526
14535
  fileCount: number(),
14527
14536
  present: boolean()
14528
14537
  });
14538
+ /**
14539
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
14540
+ * SET of destination locations. Supersedes the per-location cron on
14541
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
14542
+ * `backups` locations it should write to, and the orchestrator fans a
14543
+ * single archive out to all of them when the cron fires.
14544
+ *
14545
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
14546
+ * location targeted by this schedule keeps this many archives from
14547
+ * this schedule's runs.
14548
+ *
14549
+ * `dataSources` optionally narrows which top-level state locations
14550
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
14551
+ * default full set.
14552
+ */
14553
+ var BackupScheduleSchema = object({
14554
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
14555
+ id: string(),
14556
+ /** Operator-facing display name. */
14557
+ label: string(),
14558
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
14559
+ cron: string(),
14560
+ /** Master on/off toggle for the whole schedule. */
14561
+ enabled: boolean(),
14562
+ /** `backups`-location ids this schedule writes to (fan-out set). */
14563
+ locationIds: array(string()).readonly(),
14564
+ /** Archives kept per targeted location for this schedule. */
14565
+ retentionCount: number().int().min(1).max(1e3),
14566
+ /** Optional subset of source locations to include; omitted = all. */
14567
+ dataSources: array(string()).readonly().optional(),
14568
+ /** ms-epoch of last successful run. */
14569
+ lastRunAt: number().optional(),
14570
+ /** ms-epoch of next computed firing (read-only, filled on list). */
14571
+ nextRunAt: number().optional()
14572
+ });
14529
14573
  method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
14530
14574
  /** Subset of registered `backup-destination` addon ids to write to. */
14531
14575
  destinations: array(string()).optional(),
14532
14576
  locations: array(string()).optional(),
14533
- label: string().optional()
14577
+ label: string().optional(),
14578
+ /**
14579
+ * Per-run retention override applied to every targeted
14580
+ * destination. Used by schedule-driven runs (per-entry
14581
+ * retention). Omitted = each destination's own policy
14582
+ * retention (manual runs).
14583
+ */
14584
+ retentionCount: number().int().min(1).max(1e3).optional()
14534
14585
  }).optional(), array(BackupEntrySchema).readonly(), {
14535
14586
  kind: "mutation",
14536
14587
  auth: "admin"
@@ -14579,7 +14630,21 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
14579
14630
  ok: boolean(),
14580
14631
  error: string().optional(),
14581
14632
  nextRuns: array(number()).readonly()
14582
- }));
14633
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
14634
+ id: string().optional(),
14635
+ label: string(),
14636
+ cron: string(),
14637
+ enabled: boolean(),
14638
+ locationIds: array(string()).readonly(),
14639
+ retentionCount: number().int().min(1).max(1e3),
14640
+ dataSources: array(string()).readonly().optional()
14641
+ }), BackupScheduleSchema, {
14642
+ kind: "mutation",
14643
+ auth: "admin"
14644
+ }), method(object({ id: string() }), _void(), {
14645
+ kind: "mutation",
14646
+ auth: "admin"
14647
+ });
14583
14648
  /**
14584
14649
  * `broker` — unified pub/sub broker registry, system-scoped collection.
14585
14650
  *
@@ -21701,6 +21766,12 @@ Object.freeze({
21701
21766
  addonId: null,
21702
21767
  access: "delete"
21703
21768
  },
21769
+ "backup.deleteSchedule": {
21770
+ capName: "backup",
21771
+ capScope: "system",
21772
+ addonId: null,
21773
+ access: "delete"
21774
+ },
21704
21775
  "backup.getEntries": {
21705
21776
  capName: "backup",
21706
21777
  capScope: "system",
@@ -21731,6 +21802,12 @@ Object.freeze({
21731
21802
  addonId: null,
21732
21803
  access: "view"
21733
21804
  },
21805
+ "backup.listSchedules": {
21806
+ capName: "backup",
21807
+ capScope: "system",
21808
+ addonId: null,
21809
+ access: "view"
21810
+ },
21734
21811
  "backup.previewSchedule": {
21735
21812
  capName: "backup",
21736
21813
  capScope: "system",
@@ -21755,6 +21832,12 @@ Object.freeze({
21755
21832
  addonId: null,
21756
21833
  access: "create"
21757
21834
  },
21835
+ "backup.upsertSchedule": {
21836
+ capName: "backup",
21837
+ capScope: "system",
21838
+ addonId: null,
21839
+ access: "create"
21840
+ },
21758
21841
  "battery.wakeForStream": {
21759
21842
  capName: "battery",
21760
21843
  capScope: "device",
@@ -26081,6 +26164,40 @@ Object.freeze({
26081
26164
  });
26082
26165
  //#endregion
26083
26166
  //#region src/pty.ts
26167
+ /**
26168
+ * Minimal pty abstraction. The manager depends on this interface, never on
26169
+ * `node-pty` directly, so its tests spawn no real process and the native
26170
+ * module is imported lazily (only when a real session opens on the host).
26171
+ *
26172
+ * `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
26173
+ * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
26174
+ * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
26175
+ */
26176
+ /**
26177
+ * Resolve `file` to an executable path the way a shell would: a path-qualified
26178
+ * name is checked directly, a bare name is searched on `PATH`. Returns the
26179
+ * resolved absolute path, or `null` when nothing executable matches — the
26180
+ * caller turns that into a clear "binary not found" error BEFORE spawning,
26181
+ * instead of a pty that dies at 127 and vanishes with no visible cause.
26182
+ */
26183
+ function resolveExecutable(file) {
26184
+ if (file.includes("/")) return isExecutableFile(file) ? file : null;
26185
+ const dirs = (process.env.PATH ?? "").split(node_path.delimiter).filter((d) => d.length > 0);
26186
+ for (const dir of dirs) {
26187
+ const candidate = (0, node_path.join)(dir, file);
26188
+ if (isExecutableFile(candidate)) return candidate;
26189
+ }
26190
+ return null;
26191
+ }
26192
+ function isExecutableFile(p) {
26193
+ try {
26194
+ if (!(0, node_fs.statSync)(p).isFile()) return false;
26195
+ (0, node_fs.accessSync)(p, node_fs.constants.X_OK);
26196
+ return true;
26197
+ } catch {
26198
+ return false;
26199
+ }
26200
+ }
26084
26201
  var cachedModule = null;
26085
26202
  async function loadNodePty() {
26086
26203
  if (cachedModule) return cachedModule;
@@ -26142,7 +26259,7 @@ function parsePath(url) {
26142
26259
  };
26143
26260
  }
26144
26261
  function handleOut(manager, sessionId, req, res) {
26145
- if (!manager.hasSession(sessionId)) {
26262
+ if (!manager.hasEntry(sessionId)) {
26146
26263
  res.writeHead(404, { "content-type": "text/plain" });
26147
26264
  res.end("no such session");
26148
26265
  return;
@@ -26264,6 +26381,13 @@ function findProfile(profiles, profileId) {
26264
26381
  var MIN_GRID = 1;
26265
26382
  var MAX_COLS = 1e3;
26266
26383
  var MAX_ROWS = 500;
26384
+ /**
26385
+ * How long an exited session is kept addressable so a GET /out that arrives
26386
+ * AFTER the pty died still delivers the `exit` event (with its code) to the UI,
26387
+ * instead of a bare 404 that renders as a blank terminal. A process that fails
26388
+ * to start (missing binary → exit 127) is the common case this makes visible.
26389
+ */
26390
+ var EXITED_RETENTION_MS = 1e4;
26267
26391
  function clampDimension(value, max) {
26268
26392
  if (!Number.isFinite(value)) return MIN_GRID;
26269
26393
  const int = Math.floor(value);
@@ -26277,6 +26401,7 @@ var TerminalSessionManager = class {
26277
26401
  profiles;
26278
26402
  mintId;
26279
26403
  now;
26404
+ resolveBinary;
26280
26405
  constructor(opts) {
26281
26406
  this.opts = opts;
26282
26407
  this.profiles = buildProfiles({
@@ -26286,6 +26411,7 @@ var TerminalSessionManager = class {
26286
26411
  });
26287
26412
  this.mintId = opts.mintId ?? (() => crypto.randomUUID());
26288
26413
  this.now = opts.now ?? (() => Date.now());
26414
+ this.resolveBinary = opts.resolveBinary ?? ((file) => file);
26289
26415
  }
26290
26416
  /** Re-derive the allowlist after a settings change. */
26291
26417
  reconfigureProfiles(config) {
@@ -26308,8 +26434,10 @@ var TerminalSessionManager = class {
26308
26434
  const cols = clampDimension(input.cols, MAX_COLS);
26309
26435
  const rows = clampDimension(input.rows, MAX_ROWS);
26310
26436
  const sessionId = this.mintId();
26437
+ const resolvedFile = this.resolveBinary(profile.file);
26438
+ if (resolvedFile === null) throw new Error(`Terminal profile '${profile.profileId}' cannot start: executable '${profile.file}' was not found on PATH. Install it in the server image or set its path in Terminal settings.`);
26311
26439
  const pty = this.opts.spawn({
26312
- file: profile.file,
26440
+ file: resolvedFile,
26313
26441
  args: profile.args,
26314
26442
  cols,
26315
26443
  rows,
@@ -26354,7 +26482,11 @@ var TerminalSessionManager = class {
26354
26482
  } catch {}
26355
26483
  session.sinks.clear();
26356
26484
  session.screen.dispose();
26357
- this.sessions.delete(sessionId);
26485
+ const retire = setTimeout(() => {
26486
+ this.sessions.delete(sessionId);
26487
+ }, EXITED_RETENTION_MS);
26488
+ retire.unref?.();
26489
+ session.retireTimer = retire;
26358
26490
  });
26359
26491
  this.opts.logger.info("terminal: session opened", { meta: {
26360
26492
  sessionId,
@@ -26395,6 +26527,15 @@ var TerminalSessionManager = class {
26395
26527
  return s !== void 0 && !s.exited;
26396
26528
  }
26397
26529
  /**
26530
+ * True when the session is still addressable — including an EXITED one inside
26531
+ * its retention window. `handleOut` gates on this (not `hasSession`) so the
26532
+ * exit event reaches the UI; live-only paths (`writeInput`) gate on
26533
+ * `hasSession`.
26534
+ */
26535
+ hasEntry(sessionId) {
26536
+ return this.sessions.has(sessionId);
26537
+ }
26538
+ /**
26398
26539
  * Attach an output sink. Immediately pushes a full-screen repaint (the current
26399
26540
  * authoritative buffer) so a fresh xterm.js is in sync, then streams live
26400
26541
  * output. Returns a detach function.
@@ -26428,6 +26569,7 @@ var TerminalSessionManager = class {
26428
26569
  /** Kill every live session — called on addon shutdown. */
26429
26570
  disposeAll() {
26430
26571
  for (const session of this.sessions.values()) {
26572
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
26431
26573
  try {
26432
26574
  session.pty.kill();
26433
26575
  } catch {}
@@ -31290,6 +31432,7 @@ var TerminalAddon = class extends BaseAddon {
31290
31432
  const manager = new TerminalSessionManager({
31291
31433
  spawn: createNodePtySpawner(),
31292
31434
  screenFactory: createXtermScreen,
31435
+ resolveBinary: resolveExecutable,
31293
31436
  logger: this.ctx.logger,
31294
31437
  btmPath: this.config.btmPath,
31295
31438
  allowShell: this.config.allowShell,
package/dist/addon.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
1
3
  //#region \0rolldown/runtime.js
2
4
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
3
5
  //#endregion
@@ -7504,16 +7506,23 @@ var StorageLocationDeclarationSchema = object({
7504
7506
  * Which node root the seeded `<id>:default` instance is placed under on a
7505
7507
  * FRESH install:
7506
7508
  * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
7507
- * the appData volume. Right for small/durable data (backups, logs, models).
7509
+ * the appData volume. Right for small/durable data (logs, models).
7508
7510
  * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
7509
7511
  * env is set, else falls back to the data root. Right for bulky, hot media
7510
7512
  * (recordings, event media) that should stay off the appData disk.
7513
+ * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
7514
+ * `/backups` in the image) so archives live on their own mount rather than
7515
+ * filling the appData disk. Falls back to the data root when unset.
7511
7516
  *
7512
7517
  * Only affects the seeded default's `basePath`; operators can repoint any
7513
7518
  * location afterwards, and a `defaultsTo` slot inherits its parent's root
7514
7519
  * regardless of this field. Absent (the common case) is treated as `'data'`.
7515
7520
  */
7516
- defaultRoot: _enum(["data", "media"]).optional()
7521
+ defaultRoot: _enum([
7522
+ "data",
7523
+ "media",
7524
+ "backup"
7525
+ ]).optional()
7517
7526
  });
7518
7527
  var DecoderStatsSchema = object({
7519
7528
  inputFps: number(),
@@ -14525,11 +14534,53 @@ var LocationStatSchema = object({
14525
14534
  fileCount: number(),
14526
14535
  present: boolean()
14527
14536
  });
14537
+ /**
14538
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
14539
+ * SET of destination locations. Supersedes the per-location cron on
14540
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
14541
+ * `backups` locations it should write to, and the orchestrator fans a
14542
+ * single archive out to all of them when the cron fires.
14543
+ *
14544
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
14545
+ * location targeted by this schedule keeps this many archives from
14546
+ * this schedule's runs.
14547
+ *
14548
+ * `dataSources` optionally narrows which top-level state locations
14549
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
14550
+ * default full set.
14551
+ */
14552
+ var BackupScheduleSchema = object({
14553
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
14554
+ id: string(),
14555
+ /** Operator-facing display name. */
14556
+ label: string(),
14557
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
14558
+ cron: string(),
14559
+ /** Master on/off toggle for the whole schedule. */
14560
+ enabled: boolean(),
14561
+ /** `backups`-location ids this schedule writes to (fan-out set). */
14562
+ locationIds: array(string()).readonly(),
14563
+ /** Archives kept per targeted location for this schedule. */
14564
+ retentionCount: number().int().min(1).max(1e3),
14565
+ /** Optional subset of source locations to include; omitted = all. */
14566
+ dataSources: array(string()).readonly().optional(),
14567
+ /** ms-epoch of last successful run. */
14568
+ lastRunAt: number().optional(),
14569
+ /** ms-epoch of next computed firing (read-only, filled on list). */
14570
+ nextRunAt: number().optional()
14571
+ });
14528
14572
  method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
14529
14573
  /** Subset of registered `backup-destination` addon ids to write to. */
14530
14574
  destinations: array(string()).optional(),
14531
14575
  locations: array(string()).optional(),
14532
- label: string().optional()
14576
+ label: string().optional(),
14577
+ /**
14578
+ * Per-run retention override applied to every targeted
14579
+ * destination. Used by schedule-driven runs (per-entry
14580
+ * retention). Omitted = each destination's own policy
14581
+ * retention (manual runs).
14582
+ */
14583
+ retentionCount: number().int().min(1).max(1e3).optional()
14533
14584
  }).optional(), array(BackupEntrySchema).readonly(), {
14534
14585
  kind: "mutation",
14535
14586
  auth: "admin"
@@ -14578,7 +14629,21 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
14578
14629
  ok: boolean(),
14579
14630
  error: string().optional(),
14580
14631
  nextRuns: array(number()).readonly()
14581
- }));
14632
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
14633
+ id: string().optional(),
14634
+ label: string(),
14635
+ cron: string(),
14636
+ enabled: boolean(),
14637
+ locationIds: array(string()).readonly(),
14638
+ retentionCount: number().int().min(1).max(1e3),
14639
+ dataSources: array(string()).readonly().optional()
14640
+ }), BackupScheduleSchema, {
14641
+ kind: "mutation",
14642
+ auth: "admin"
14643
+ }), method(object({ id: string() }), _void(), {
14644
+ kind: "mutation",
14645
+ auth: "admin"
14646
+ });
14582
14647
  /**
14583
14648
  * `broker` — unified pub/sub broker registry, system-scoped collection.
14584
14649
  *
@@ -21700,6 +21765,12 @@ Object.freeze({
21700
21765
  addonId: null,
21701
21766
  access: "delete"
21702
21767
  },
21768
+ "backup.deleteSchedule": {
21769
+ capName: "backup",
21770
+ capScope: "system",
21771
+ addonId: null,
21772
+ access: "delete"
21773
+ },
21703
21774
  "backup.getEntries": {
21704
21775
  capName: "backup",
21705
21776
  capScope: "system",
@@ -21730,6 +21801,12 @@ Object.freeze({
21730
21801
  addonId: null,
21731
21802
  access: "view"
21732
21803
  },
21804
+ "backup.listSchedules": {
21805
+ capName: "backup",
21806
+ capScope: "system",
21807
+ addonId: null,
21808
+ access: "view"
21809
+ },
21733
21810
  "backup.previewSchedule": {
21734
21811
  capName: "backup",
21735
21812
  capScope: "system",
@@ -21754,6 +21831,12 @@ Object.freeze({
21754
21831
  addonId: null,
21755
21832
  access: "create"
21756
21833
  },
21834
+ "backup.upsertSchedule": {
21835
+ capName: "backup",
21836
+ capScope: "system",
21837
+ addonId: null,
21838
+ access: "create"
21839
+ },
21757
21840
  "battery.wakeForStream": {
21758
21841
  capName: "battery",
21759
21842
  capScope: "device",
@@ -26080,6 +26163,40 @@ Object.freeze({
26080
26163
  });
26081
26164
  //#endregion
26082
26165
  //#region src/pty.ts
26166
+ /**
26167
+ * Minimal pty abstraction. The manager depends on this interface, never on
26168
+ * `node-pty` directly, so its tests spawn no real process and the native
26169
+ * module is imported lazily (only when a real session opens on the host).
26170
+ *
26171
+ * `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
26172
+ * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
26173
+ * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
26174
+ */
26175
+ /**
26176
+ * Resolve `file` to an executable path the way a shell would: a path-qualified
26177
+ * name is checked directly, a bare name is searched on `PATH`. Returns the
26178
+ * resolved absolute path, or `null` when nothing executable matches — the
26179
+ * caller turns that into a clear "binary not found" error BEFORE spawning,
26180
+ * instead of a pty that dies at 127 and vanishes with no visible cause.
26181
+ */
26182
+ function resolveExecutable(file) {
26183
+ if (file.includes("/")) return isExecutableFile(file) ? file : null;
26184
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter((d) => d.length > 0);
26185
+ for (const dir of dirs) {
26186
+ const candidate = join(dir, file);
26187
+ if (isExecutableFile(candidate)) return candidate;
26188
+ }
26189
+ return null;
26190
+ }
26191
+ function isExecutableFile(p) {
26192
+ try {
26193
+ if (!statSync(p).isFile()) return false;
26194
+ accessSync(p, constants.X_OK);
26195
+ return true;
26196
+ } catch {
26197
+ return false;
26198
+ }
26199
+ }
26083
26200
  var cachedModule = null;
26084
26201
  async function loadNodePty() {
26085
26202
  if (cachedModule) return cachedModule;
@@ -26141,7 +26258,7 @@ function parsePath(url) {
26141
26258
  };
26142
26259
  }
26143
26260
  function handleOut(manager, sessionId, req, res) {
26144
- if (!manager.hasSession(sessionId)) {
26261
+ if (!manager.hasEntry(sessionId)) {
26145
26262
  res.writeHead(404, { "content-type": "text/plain" });
26146
26263
  res.end("no such session");
26147
26264
  return;
@@ -26263,6 +26380,13 @@ function findProfile(profiles, profileId) {
26263
26380
  var MIN_GRID = 1;
26264
26381
  var MAX_COLS = 1e3;
26265
26382
  var MAX_ROWS = 500;
26383
+ /**
26384
+ * How long an exited session is kept addressable so a GET /out that arrives
26385
+ * AFTER the pty died still delivers the `exit` event (with its code) to the UI,
26386
+ * instead of a bare 404 that renders as a blank terminal. A process that fails
26387
+ * to start (missing binary → exit 127) is the common case this makes visible.
26388
+ */
26389
+ var EXITED_RETENTION_MS = 1e4;
26266
26390
  function clampDimension(value, max) {
26267
26391
  if (!Number.isFinite(value)) return MIN_GRID;
26268
26392
  const int = Math.floor(value);
@@ -26276,6 +26400,7 @@ var TerminalSessionManager = class {
26276
26400
  profiles;
26277
26401
  mintId;
26278
26402
  now;
26403
+ resolveBinary;
26279
26404
  constructor(opts) {
26280
26405
  this.opts = opts;
26281
26406
  this.profiles = buildProfiles({
@@ -26285,6 +26410,7 @@ var TerminalSessionManager = class {
26285
26410
  });
26286
26411
  this.mintId = opts.mintId ?? (() => crypto.randomUUID());
26287
26412
  this.now = opts.now ?? (() => Date.now());
26413
+ this.resolveBinary = opts.resolveBinary ?? ((file) => file);
26288
26414
  }
26289
26415
  /** Re-derive the allowlist after a settings change. */
26290
26416
  reconfigureProfiles(config) {
@@ -26307,8 +26433,10 @@ var TerminalSessionManager = class {
26307
26433
  const cols = clampDimension(input.cols, MAX_COLS);
26308
26434
  const rows = clampDimension(input.rows, MAX_ROWS);
26309
26435
  const sessionId = this.mintId();
26436
+ const resolvedFile = this.resolveBinary(profile.file);
26437
+ if (resolvedFile === null) throw new Error(`Terminal profile '${profile.profileId}' cannot start: executable '${profile.file}' was not found on PATH. Install it in the server image or set its path in Terminal settings.`);
26310
26438
  const pty = this.opts.spawn({
26311
- file: profile.file,
26439
+ file: resolvedFile,
26312
26440
  args: profile.args,
26313
26441
  cols,
26314
26442
  rows,
@@ -26353,7 +26481,11 @@ var TerminalSessionManager = class {
26353
26481
  } catch {}
26354
26482
  session.sinks.clear();
26355
26483
  session.screen.dispose();
26356
- this.sessions.delete(sessionId);
26484
+ const retire = setTimeout(() => {
26485
+ this.sessions.delete(sessionId);
26486
+ }, EXITED_RETENTION_MS);
26487
+ retire.unref?.();
26488
+ session.retireTimer = retire;
26357
26489
  });
26358
26490
  this.opts.logger.info("terminal: session opened", { meta: {
26359
26491
  sessionId,
@@ -26394,6 +26526,15 @@ var TerminalSessionManager = class {
26394
26526
  return s !== void 0 && !s.exited;
26395
26527
  }
26396
26528
  /**
26529
+ * True when the session is still addressable — including an EXITED one inside
26530
+ * its retention window. `handleOut` gates on this (not `hasSession`) so the
26531
+ * exit event reaches the UI; live-only paths (`writeInput`) gate on
26532
+ * `hasSession`.
26533
+ */
26534
+ hasEntry(sessionId) {
26535
+ return this.sessions.has(sessionId);
26536
+ }
26537
+ /**
26397
26538
  * Attach an output sink. Immediately pushes a full-screen repaint (the current
26398
26539
  * authoritative buffer) so a fresh xterm.js is in sync, then streams live
26399
26540
  * output. Returns a detach function.
@@ -26427,6 +26568,7 @@ var TerminalSessionManager = class {
26427
26568
  /** Kill every live session — called on addon shutdown. */
26428
26569
  disposeAll() {
26429
26570
  for (const session of this.sessions.values()) {
26571
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
26430
26572
  try {
26431
26573
  session.pty.kill();
26432
26574
  } catch {}
@@ -31289,6 +31431,7 @@ var TerminalAddon = class extends BaseAddon {
31289
31431
  const manager = new TerminalSessionManager({
31290
31432
  spawn: createNodePtySpawner(),
31291
31433
  screenFactory: createXtermScreen,
31434
+ resolveBinary: resolveExecutable,
31292
31435
  logger: this.ctx.logger,
31293
31436
  btmPath: this.config.btmPath,
31294
31437
  allowShell: this.config.allowShell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",