local-operator-ui 0.34.30 → 0.34.31

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.
Files changed (2) hide show
  1. package/out/main/index.js +213 -114
  2. package/package.json +1 -1
package/out/main/index.js CHANGED
@@ -15725,10 +15725,10 @@ class ConsoleRegistry {
15725
15725
  count() {
15726
15726
  return this.entries.size;
15727
15727
  }
15728
- /** How many were created by that session's agent. */
15728
+ /** How many live surfaces were created by that session's agent. */
15729
15729
  agentCount(sessionId2) {
15730
15730
  return this.list(sessionId2).filter(
15731
- (entry) => entry.record.origin === "agent"
15731
+ (entry) => entry.record.origin === "agent" && entry.record.live
15732
15732
  ).length;
15733
15733
  }
15734
15734
  /**
@@ -15747,7 +15747,7 @@ class ConsoleRegistry {
15747
15747
  { limit: MAX_SURFACES_PER_APP, scope: "app" }
15748
15748
  );
15749
15749
  }
15750
- if (input.origin === "agent" && this.agentCount(input.sessionId) >= MAX_AGENT_SURFACES_PER_SESSION) {
15750
+ if (input.live !== false && input.origin === "agent" && this.agentCount(input.sessionId) >= MAX_AGENT_SURFACES_PER_SESSION) {
15751
15751
  throw new ConsoleError(
15752
15752
  "tab_limit",
15753
15753
  `this session's agent is already running ${MAX_AGENT_SURFACES_PER_SESSION} console surfaces; close one first`,
@@ -15766,10 +15766,10 @@ class ConsoleRegistry {
15766
15766
  cwd: input.cwd,
15767
15767
  cols: input.cols,
15768
15768
  rows: input.rows,
15769
- running: true,
15769
+ running: input.live !== false,
15770
15770
  exitCode: null,
15771
15771
  lastActivity: this.now(),
15772
- live: true,
15772
+ live: input.live !== false,
15773
15773
  retain: input.retain,
15774
15774
  secure: false,
15775
15775
  lastActor: null,
@@ -15897,6 +15897,10 @@ const SURFACE_COLORTERM = "truecolor";
15897
15897
  const STRIPPED_ENV_PREFIXES = ["CMUX_", "LOP_"];
15898
15898
  class ConsoleHost {
15899
15899
  registry;
15900
+ rollbackDisposals = [];
15901
+ // Acquisition precedes runtime construction/registry adoption. Keep the exact
15902
+ // emulator until adoption succeeds; startup rollback must also own that gap.
15903
+ unboundRestoreEmulators = /* @__PURE__ */ new Set();
15900
15904
  options;
15901
15905
  now;
15902
15906
  env;
@@ -16058,6 +16062,7 @@ class ConsoleHost {
16058
16062
  // The handle the history recorded, so a caller holding one across a relaunch
16059
16063
  // finds the same surface rather than a lookalike with a new nonce.
16060
16064
  surface: meta.surface,
16065
+ live: false,
16061
16066
  sessionId: meta.session_id,
16062
16067
  origin: meta.origin,
16063
16068
  command: meta.command,
@@ -16081,7 +16086,7 @@ class ConsoleHost {
16081
16086
  rows: grid.rows,
16082
16087
  scrollback: SCROLLBACK_LINES
16083
16088
  });
16084
- emulator.write(bytes);
16089
+ this.unboundRestoreEmulators.add(emulator);
16085
16090
  const runtime = {
16086
16091
  // A reconstructed surface has no pty. `null` rather than a fake process:
16087
16092
  // every path that would signal one checks `running` first, and the record
@@ -16103,8 +16108,10 @@ class ConsoleHost {
16103
16108
  exitDelivered: true,
16104
16109
  exitWaiters: []
16105
16110
  };
16106
- runtime.log.append(bytes);
16107
16111
  this.registry.attach(entry.record.surface, runtime);
16112
+ this.unboundRestoreEmulators.delete(emulator);
16113
+ emulator.write(bytes);
16114
+ runtime.log.append(bytes);
16108
16115
  this.registry.update(entry.record.surface, {
16109
16116
  running: false,
16110
16117
  exitCode: meta.exit_code,
@@ -16818,17 +16825,63 @@ class ConsoleHost {
16818
16825
  this.options.onChanged?.();
16819
16826
  }
16820
16827
  /** Stop everything: every pty, every record, every subscriber. */
16821
- dispose() {
16822
- for (const entry of this.registry.clear()) {
16823
- const runtime = entry.runtime;
16824
- if (!runtime) continue;
16825
- if (runtime.pendingTimer) clearTimeout(runtime.pendingTimer);
16826
- if (runtime.persistTimer) clearTimeout(runtime.persistTimer);
16827
- runtime.subscribers.clear();
16828
- if (entry.record.retain) this.persist(entry, runtime);
16829
- if (entry.record.running) runtime.pty?.kill();
16830
- runtime.emulator.dispose();
16831
- }
16828
+ dispose(persistHistory = true) {
16829
+ if (!persistHistory || this.rollbackDisposals.length > 0 || this.unboundRestoreEmulators.size > 0) {
16830
+ const entries = [...this.rollbackDisposals, ...this.registry.clear()];
16831
+ this.rollbackDisposals = [];
16832
+ const failures = [];
16833
+ for (const entry of entries) {
16834
+ if (entry.runtime)
16835
+ this.unboundRestoreEmulators.delete(entry.runtime.emulator);
16836
+ }
16837
+ for (const entry of entries) {
16838
+ const runtime = entry.runtime;
16839
+ if (!runtime) continue;
16840
+ let incomplete = false;
16841
+ for (const release of [
16842
+ () => {
16843
+ if (runtime.pendingTimer) clearTimeout(runtime.pendingTimer);
16844
+ if (runtime.persistTimer) clearTimeout(runtime.persistTimer);
16845
+ runtime.subscribers.clear();
16846
+ },
16847
+ () => {
16848
+ if (entry.record.running) runtime.pty?.kill();
16849
+ },
16850
+ () => runtime.emulator.dispose()
16851
+ ]) {
16852
+ try {
16853
+ release();
16854
+ } catch (error) {
16855
+ incomplete = true;
16856
+ failures.push(error);
16857
+ }
16858
+ }
16859
+ if (incomplete) this.rollbackDisposals.push(entry);
16860
+ }
16861
+ for (const emulator of this.unboundRestoreEmulators) {
16862
+ try {
16863
+ emulator.dispose();
16864
+ this.unboundRestoreEmulators.delete(emulator);
16865
+ } catch (error) {
16866
+ failures.push(error);
16867
+ }
16868
+ }
16869
+ if (failures.length)
16870
+ throw new AggregateError(
16871
+ failures,
16872
+ "Partial console runtime cleanup incomplete."
16873
+ );
16874
+ } else
16875
+ for (const entry of this.registry.clear()) {
16876
+ const runtime = entry.runtime;
16877
+ if (!runtime) continue;
16878
+ if (runtime.pendingTimer) clearTimeout(runtime.pendingTimer);
16879
+ if (runtime.persistTimer) clearTimeout(runtime.persistTimer);
16880
+ runtime.subscribers.clear();
16881
+ if (persistHistory && entry.record.retain) this.persist(entry, runtime);
16882
+ if (entry.record.running) runtime.pty?.kill();
16883
+ runtime.emulator.dispose();
16884
+ }
16832
16885
  this.createdAt.clear();
16833
16886
  this.persistedBytes.clear();
16834
16887
  this.metaSignatures.clear();
@@ -17864,112 +17917,158 @@ async function startConsoleHost(options) {
17864
17917
  `[console] restored the exec bit on ${load.helper.path ?? "spawn-helper"}`
17865
17918
  );
17866
17919
  }
17867
- const configDir = options.configDir ?? localOperatorConfigDir();
17868
- const history = new ConsoleHistory(
17869
- path$1.join(configDir, "run", "ui-console", "history")
17870
- );
17871
- const registry = new ConsoleRegistry();
17872
17920
  let hostRef = null;
17873
- const completion = consoleCompletionHooks({
17874
- host: () => hostRef,
17875
- registry,
17876
- notifier: () => options.notifier ?? null,
17877
- log: log2,
17878
- now: options.now
17879
- });
17880
- const capture = options.consoleCaptureUrl && options.preloadPath ? new ConsoleCaptureView({
17881
- url: options.consoleCaptureUrl,
17882
- preload: options.preloadPath,
17883
- log: log2
17884
- }) : null;
17885
- const host = new ConsoleHost(registry, {
17886
- window: () => options.window,
17887
- log: log2,
17888
- history,
17889
- now: options.now,
17890
- // Resolved BEFORE the host exists, so no surface can be created against the
17891
- // launch environment and then start behaving like the user's terminal later:
17892
- // a surface's PATH is decided here, once, and every surface this host makes
17893
- // carries the same one. Bounded by the resolver's own timeout (it never
17894
- // rejects and answers null when the shell cannot speak), and no part of the
17895
- // app's own startup waits on it: the browser host is attached to the window
17896
- // without being awaited, so the cost of a slow rc here is this host coming
17897
- // online later, not the app failing to come up.
17898
- env: await withUserShellPath(process.env, options.userShellPath),
17899
- captureOffscreen: capture ? (request) => capture.capture(request) : void 0,
17900
- spawn: options.spawn ?? ((spawnOptions) => spawnPty(load.module, spawnOptions, (report) => {
17901
- log2(
17902
- `[console] restored the exec bit on ${report.path ?? "spawn-helper"}`
17903
- );
17904
- })),
17905
- /*
17906
- * ONE PUSHER, and it is A's. `broadcastConsoleState` was already the app's
17907
- * single "the projection moved" signal when this branch arrived; the
17908
- * completion seam below therefore does NOT push a second copy of the same
17909
- * frame. It only raises a banner, which is the half a push cannot express —
17910
- * and the host calls `onChanged` for a mark and an exit too, so the renderer
17911
- * still refetches on both (see `ConsoleHostOptions.onExit`).
17912
- */
17913
- onChanged: () => {
17914
- broadcastConsoleState(options.window);
17915
- },
17916
- onMark: completion.onMark,
17917
- onExit: completion.onExit,
17918
- onReveal: (request) => {
17919
- const window = options.window;
17920
- if (window.isDestroyed()) return;
17921
- window.webContents.send("console-reveal", {
17922
- surface: request.surface,
17923
- session_id: request.sessionId,
17924
- mode: request.mode
17925
- });
17921
+ let capture = null;
17922
+ let host = null;
17923
+ let rollbackOwners = [];
17924
+ const cleanupStartupOwners = () => {
17925
+ const pending = [];
17926
+ const failures = [];
17927
+ for (const release of rollbackOwners.splice(0).reverse()) {
17928
+ try {
17929
+ release();
17930
+ } catch (error) {
17931
+ pending.unshift(release);
17932
+ failures.push(error);
17933
+ }
17926
17934
  }
17927
- });
17928
- if (options.restoreHistory !== false) {
17929
- const retained = history.list().sort((a, b) => b.meta.last_seen_at - a.meta.last_seen_at).slice(0, MAX_SURFACES_PER_APP);
17930
- for (const surface of retained) {
17931
- host.restore(surface.meta, surface.bytes);
17935
+ rollbackOwners = pending;
17936
+ if (failures.length)
17937
+ throw new AggregateError(failures, "Partial console cleanup incomplete.");
17938
+ };
17939
+ try {
17940
+ const configDir = options.configDir ?? localOperatorConfigDir();
17941
+ const history = new ConsoleHistory(
17942
+ path$1.join(configDir, "run", "ui-console", "history")
17943
+ );
17944
+ const registry = new ConsoleRegistry();
17945
+ const completion = consoleCompletionHooks({
17946
+ host: () => hostRef,
17947
+ registry,
17948
+ notifier: () => options.notifier ?? null,
17949
+ log: log2,
17950
+ now: options.now
17951
+ });
17952
+ capture = options.consoleCaptureUrl && options.preloadPath ? new ConsoleCaptureView({
17953
+ url: options.consoleCaptureUrl,
17954
+ preload: options.preloadPath,
17955
+ log: log2
17956
+ }) : null;
17957
+ const captureView = capture;
17958
+ if (captureView) rollbackOwners.push(() => captureView.dispose());
17959
+ host = new ConsoleHost(registry, {
17960
+ window: () => options.window,
17961
+ log: log2,
17962
+ history,
17963
+ now: options.now,
17964
+ // Resolved BEFORE the host exists, so no surface can be created against the
17965
+ // launch environment and then start behaving like the user's terminal later:
17966
+ // a surface's PATH is decided here, once, and every surface this host makes
17967
+ // carries the same one. Bounded by the resolver's own timeout (it never
17968
+ // rejects and answers null when the shell cannot speak), and no part of the
17969
+ // app's own startup waits on it: the browser host is attached to the window
17970
+ // without being awaited, so the cost of a slow rc here is this host coming
17971
+ // online later, not the app failing to come up.
17972
+ env: await withUserShellPath(process.env, options.userShellPath),
17973
+ captureOffscreen: captureView ? (request) => captureView.capture(request) : void 0,
17974
+ spawn: options.spawn ?? ((spawnOptions) => spawnPty(load.module, spawnOptions, (report) => {
17975
+ log2(
17976
+ `[console] restored the exec bit on ${report.path ?? "spawn-helper"}`
17977
+ );
17978
+ })),
17979
+ /*
17980
+ * ONE PUSHER, and it is A's. `broadcastConsoleState` was already the app's
17981
+ * single "the projection moved" signal when this branch arrived; the
17982
+ * completion seam below therefore does NOT push a second copy of the same
17983
+ * frame. It only raises a banner, which is the half a push cannot express —
17984
+ * and the host calls `onChanged` for a mark and an exit too, so the renderer
17985
+ * still refetches on both (see `ConsoleHostOptions.onExit`).
17986
+ */
17987
+ onChanged: () => {
17988
+ broadcastConsoleState(options.window);
17989
+ },
17990
+ onMark: completion.onMark,
17991
+ onExit: completion.onExit,
17992
+ onReveal: (request) => {
17993
+ const window = options.window;
17994
+ if (window.isDestroyed()) return;
17995
+ window.webContents.send("console-reveal", {
17996
+ surface: request.surface,
17997
+ session_id: request.sessionId,
17998
+ mode: request.mode
17999
+ });
18000
+ }
18001
+ });
18002
+ const ownedHost = host;
18003
+ rollbackOwners.push(() => ownedHost.dispose(false));
18004
+ if (options.restoreHistory !== false) {
18005
+ const retained = history.list().sort((a, b) => b.meta.last_seen_at - a.meta.last_seen_at).slice(0, MAX_SURFACES_PER_APP);
18006
+ for (const surface of retained) {
18007
+ host.restore(surface.meta, surface.bytes);
18008
+ }
18009
+ if (retained.length > 0) {
18010
+ log2(
18011
+ `[console] restored ${retained.length} retained surface(s) from history; none of them is running`
18012
+ );
18013
+ }
17932
18014
  }
17933
- if (retained.length > 0) {
18015
+ const collected = history.collect();
18016
+ if (collected.removedSessions.length > 0) {
17934
18017
  log2(
17935
- `[console] restored ${retained.length} retained surface(s) from history; none of them is running`
18018
+ `[console] history GC removed ${collected.removedSurfaces} surface(s) from ${collected.removedSessions.length} session(s) older than the window`
17936
18019
  );
17937
18020
  }
17938
- }
17939
- const collected = history.collect();
17940
- if (collected.removedSessions.length > 0) {
18021
+ hostRef = host;
18022
+ const readyHost = host;
18023
+ registerConsoleIpc({
18024
+ window: () => options.window,
18025
+ expectedUrl: options.expectedUrl,
18026
+ host: () => readyHost,
18027
+ log: log2
18028
+ });
17941
18029
  log2(
17942
- `[console] history GC removed ${collected.removedSurfaces} surface(s) from ${collected.removedSessions.length} session(s) older than the window`
18030
+ `[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${helperStatus(load.helper)})`
17943
18031
  );
17944
- }
17945
- hostRef = host;
17946
- registerConsoleIpc({
17947
- window: () => options.window,
17948
- expectedUrl: options.expectedUrl,
17949
- host: () => host,
17950
- log: log2
17951
- });
17952
- log2(
17953
- `[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${helperStatus(load.helper)})`
17954
- );
17955
- return {
17956
- ok: true,
17957
- host,
17958
- registry,
17959
- counts: () => host.counts(),
17960
- dispatch: async (method, params) => {
17961
- if (!isConsoleDispatchMethod(method)) {
17962
- throw new Error(`not a console method: ${method}`);
18032
+ return {
18033
+ ok: true,
18034
+ host,
18035
+ registry,
18036
+ counts: () => readyHost.counts(),
18037
+ dispatch: async (method, params) => {
18038
+ if (!isConsoleDispatchMethod(method)) {
18039
+ throw new Error(`not a console method: ${method}`);
18040
+ }
18041
+ return dispatchConsole(readyHost, method, params);
18042
+ },
18043
+ stop: async () => {
18044
+ unregisterConsoleIpc();
18045
+ capture?.dispose();
18046
+ readyHost.dispose();
18047
+ log2("[console] host stopped");
17963
18048
  }
17964
- return dispatchConsole(host, method, params);
17965
- },
17966
- stop: async () => {
17967
- unregisterConsoleIpc();
17968
- capture?.dispose();
17969
- host.dispose();
17970
- log2("[console] host stopped");
18049
+ };
18050
+ } catch (error) {
18051
+ try {
18052
+ cleanupStartupOwners();
18053
+ } catch (cleanupError) {
18054
+ log2(
18055
+ `[console] partial cleanup retained for stop: ${String(cleanupError)}`
18056
+ );
17971
18057
  }
17972
- };
18058
+ hostRef = null;
18059
+ const detail = String(error);
18060
+ log2(`[console] startup failed: ${detail}`);
18061
+ registerRefusal("startup_failed", detail);
18062
+ return {
18063
+ ok: false,
18064
+ reason: "startup_failed",
18065
+ detail,
18066
+ stop: async () => {
18067
+ unregisterConsoleIpc();
18068
+ cleanupStartupOwners();
18069
+ }
18070
+ };
18071
+ }
17973
18072
  }
17974
18073
  const WEBAUTHN_GROUP_SUFFIX = ".webauthn";
17975
18074
  const WEBAUTHN_PROMPT_REASON = "use a passkey for $1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-operator-ui",
3
- "version": "0.34.30",
3
+ "version": "0.34.31",
4
4
  "productName": "Local Operator",
5
5
  "description": "User interface for AI agent assistants that run Python code safely on your device through an intuitive chat interface. Supports local and cloud LLM models with built-in security features.",
6
6
  "main": "./out/main/index.js",