@ours.network/fleet 1.0.5 → 1.1.0-nightly.1

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 (75) hide show
  1. package/README.md +28 -41
  2. package/dist/application/capabilities.d.ts +0 -1
  3. package/dist/application/capabilities.js +3 -13
  4. package/dist/application/fleet-query-service.d.ts +0 -3
  5. package/dist/application/fleet-query-service.js +1 -24
  6. package/dist/application/role-creation-service.d.ts +5 -5
  7. package/dist/application/role-creation-service.js +5 -7
  8. package/dist/application/role-repository.d.ts +1 -2
  9. package/dist/application/role-repository.js +7 -7
  10. package/dist/application/session-control.d.ts +0 -12
  11. package/dist/application/session-control.js +0 -42
  12. package/dist/application/session-mutations.d.ts +5 -5
  13. package/dist/application/types.d.ts +0 -7
  14. package/dist/briefing.js +1 -4
  15. package/dist/build-info.json +5 -5
  16. package/dist/cli.js +9 -23
  17. package/dist/config.d.ts +1 -4
  18. package/dist/config.js +13 -17
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +20 -26
  21. package/dist/doctor.js +0 -7
  22. package/dist/fleet-proxy.d.ts +1 -1
  23. package/dist/harness/acp-session-transport.d.ts +8 -0
  24. package/dist/harness/acp-session-transport.js +3 -0
  25. package/dist/harness/agent-session.d.ts +33 -0
  26. package/dist/harness/agent-session.js +1 -0
  27. package/dist/harness/claude-code-session.d.ts +18 -0
  28. package/dist/harness/claude-code-session.js +28 -0
  29. package/dist/harness/claude-code.d.ts +2 -1
  30. package/dist/harness/claude-code.js +29 -81
  31. package/dist/harness/codex-session.d.ts +18 -0
  32. package/dist/harness/codex-session.js +28 -0
  33. package/dist/harness/codex.d.ts +2 -1
  34. package/dist/harness/codex.js +35 -45
  35. package/dist/harness/types.d.ts +6 -38
  36. package/dist/index.d.ts +3 -5
  37. package/dist/index.js +1 -3
  38. package/dist/isolation/resources.d.ts +3 -7
  39. package/dist/isolation/resources.js +3 -7
  40. package/dist/model-env.d.ts +2 -3
  41. package/dist/model-env.js +2 -3
  42. package/dist/monitor.d.ts +1 -24
  43. package/dist/monitor.js +5 -140
  44. package/dist/owner-channel/channel.d.ts +2 -2
  45. package/dist/runner.d.ts +5 -15
  46. package/dist/runner.js +26 -102
  47. package/dist/session/acp.d.ts +2 -2
  48. package/dist/session/acp.js +2 -3
  49. package/dist/session/activity.d.ts +2 -3
  50. package/dist/session/activity.js +2 -3
  51. package/dist/session/arbiter.d.ts +4 -4
  52. package/dist/session/control.d.ts +3 -3
  53. package/dist/session/types.d.ts +11 -5
  54. package/dist/spawn.js +8 -9
  55. package/dist/supervisor/none.d.ts +1 -1
  56. package/dist/supervisor/none.js +2 -2
  57. package/dist/tmux.d.ts +1 -14
  58. package/dist/tmux.js +1 -51
  59. package/dist/watchdog/config.js +6 -4
  60. package/dist/watchdog/run.js +8 -23
  61. package/dist/web/auth.d.ts +1 -1
  62. package/dist/web/runtime.js +4 -15
  63. package/dist/web/server.d.ts +1 -3
  64. package/dist/web/server.js +3 -14
  65. package/dist/web/topology-promote.js +2 -5
  66. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  67. package/dist/web-app/assets/{index-BCBK78hw.js → index-DhMDVRU1.js} +6 -6
  68. package/dist/web-app/index.html +2 -2
  69. package/package.json +3 -8
  70. package/dist/session/tmux.d.ts +0 -28
  71. package/dist/session/tmux.js +0 -80
  72. package/dist/web/terminal/bridge.d.ts +0 -27
  73. package/dist/web/terminal/bridge.js +0 -317
  74. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  75. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { type Socket } from 'node:net';
2
- import type { ControlFailureKind, SessionEvent, SessionHandle, SessionSnapshot } from './types.js';
2
+ import type { AgentSession, ControlFailureKind, SessionEvent, SessionSnapshot } from './types.js';
3
3
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
4
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
5
  import type { SpawnOpts } from '../spawn.js';
@@ -48,7 +48,7 @@ export interface RetainedEventPage {
48
48
  truncated: boolean;
49
49
  }
50
50
  /** The one retained-range projection shared by polling and live-follow admission. */
51
- export declare function retainedEventPage(session: SessionHandle, since: number): RetainedEventPage;
51
+ export declare function retainedEventPage(session: AgentSession, since: number): RetainedEventPage;
52
52
  /**
53
53
  * One line saying what a control failure does — and does not — prove about the
54
54
  * agent. Only `offline` is evidence that it is gone; every other kind used to
@@ -105,7 +105,7 @@ export declare class RoleControlServer {
105
105
  private loopManager?;
106
106
  private reloadConfig?;
107
107
  private fleetSpawner?;
108
- constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
108
+ constructor(stateDir: string, session: AgentSession, log: (line: string) => void);
109
109
  start(): Promise<void>;
110
110
  close(): Promise<void>;
111
111
  /** Attach only the already-started supervisor-owned channel client. */
@@ -96,7 +96,7 @@ export interface InterruptOutcome {
96
96
  reasonCode?: string;
97
97
  }
98
98
  /**
99
- * What a `SessionHandle.interrupt` implementation may resolve. Before 0.17.1 the
99
+ * What an `AgentSession.interrupt` implementation may resolve. Before 0.17.1 the
100
100
  * contract was `Promise<void>`, and resolving at all meant the cancellation had
101
101
  * taken effect cooperatively — so an implementation written against that
102
102
  * contract stays valid and keeps its exact meaning. Only in-tree consumers read
@@ -146,7 +146,7 @@ export interface ExitRecord {
146
146
  code?: number;
147
147
  /** Signal that killed it, when one did. */
148
148
  signal?: string;
149
- /** Raw wait status as the pane shell saw it (tmux only). */
149
+ /** Raw wait status retained when migrating a legacy shell-exit record. */
150
150
  status?: number;
151
151
  at?: string;
152
152
  /** One line an operator can read. */
@@ -194,8 +194,7 @@ export interface SessionSnapshot {
194
194
  };
195
195
  /**
196
196
  * Observed agent activity, independent of turn occupancy: the evidence a
197
- * human-facing surface needs before calling a role idle. Absent on backends
198
- * that cannot observe the agent at all (tmux), which is itself honest — no
197
+ * human-facing surface needs before calling a role idle. When absent, no
199
198
  * evidence is not evidence of inactivity.
200
199
  */
201
200
  activity?: SessionActivity;
@@ -259,7 +258,12 @@ export interface ConversationHandlePage {
259
258
  hasMore: boolean;
260
259
  snapshot: ConversationSnapshot;
261
260
  }
262
- export interface SessionHandle {
261
+ /**
262
+ * The live, harness-neutral contract between Fleet and one agent session.
263
+ * Harness adapters construct this handle; orchestration code must not depend
264
+ * on the ACP transport which currently implements it.
265
+ */
266
+ export interface AgentSession {
263
267
  readonly backend: SessionBackendId;
264
268
  readonly pid: number;
265
269
  isAlive(): boolean;
@@ -301,3 +305,5 @@ export interface SessionHandle {
301
305
  exitResult(): ExitRecord | null;
302
306
  close(): Promise<void>;
303
307
  }
308
+ /** @deprecated Public compatibility name; use AgentSession. */
309
+ export type SessionHandle = AgentSession;
package/dist/spawn.js CHANGED
@@ -123,8 +123,10 @@ export function readIsolationFile(path) {
123
123
  export function validateSpawnOpts(o) {
124
124
  if (o.mission !== undefined && o.missionFile)
125
125
  throw new Error('--mission and --mission-file are mutually exclusive');
126
- if (o.session && !['tmux', 'acp'].includes(o.session))
127
- throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
126
+ if (o.session === 'tmux')
127
+ throw new Error("invalid --session 'tmux'; tmux is no longer supported; use --session acp");
128
+ if (o.session && o.session !== 'acp')
129
+ throw new Error(`invalid --session '${o.session}'; allowed: acp`);
128
130
  if (o.approval && !['ask', 'auto', 'allow', 'deny'].includes(o.approval))
129
131
  throw new Error(`invalid --approval '${o.approval}'; allowed: ask, auto, allow (deprecated alias: deny)`);
130
132
  if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
@@ -203,7 +205,7 @@ export function spawnDryRun(o) {
203
205
  ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
204
206
  });
205
207
  const model = modelEnv.model;
206
- const session = raw.session ?? cfg.defaults.session ?? 'tmux';
208
+ const session = raw.session ?? cfg.defaults.session ?? 'acp';
207
209
  const resolvedRole = {
208
210
  ...raw,
209
211
  name: o.name,
@@ -255,7 +257,7 @@ function provenanceSettings(o, defaults) {
255
257
  const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
256
258
  return {
257
259
  harness: tagged('harness', provenanceOf(o.harness, defaults.harness, 'claude-code')),
258
- session: tagged('session', provenanceOf(o.session, defaults.session, 'tmux')),
260
+ session: tagged('session', provenanceOf(o.session, defaults.session, 'acp')),
259
261
  identity: o.identity
260
262
  ? { value: o.identity, source: 'cli' }
261
263
  : { value: o.name, source: 'built-in' }, // defaults to the role name
@@ -417,7 +419,7 @@ async function spawnTempInner(o, binPath, launch, tx, onStage) {
417
419
  ...(tempAuthProxy ? { authProxyBaseUrl: tempAuthProxy.base_url } : {}),
418
420
  });
419
421
  const model = modelEnv.model;
420
- const session = o.session ?? cfg.defaults.session ?? 'tmux';
422
+ const session = o.session ?? cfg.defaults.session ?? 'acp';
421
423
  const role = {
422
424
  ...fromOpts, // includes `isolation` when --isolation-file was given
423
425
  name: o.name,
@@ -467,10 +469,7 @@ async function spawnTempInner(o, binPath, launch, tx, onStage) {
467
469
  if (cfg.startStaggerMs > 0)
468
470
  writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
469
471
  prepareTempSupervisor(dir, o.name);
470
- // Run the supervisor independently NOT inside a tmux session named <name>.
471
- // `_run-temp` -> runOnce() creates AND kills the tmux session <name> for the
472
- // agent itself; a supervisor sharing that session name would SIGHUP its own
473
- // process before the agent ever launches. On a service-managed host the temp
472
+ // Run the supervisor independently. On a service-managed host the temp
474
473
  // runner gets its own transient unit/job, so stopping the coordinator's unit
475
474
  // cannot kill a live worker in the coordinator's cgroup.
476
475
  onStage?.('starting_temp');
@@ -1,7 +1,7 @@
1
1
  import { type Exec } from '../exec.js';
2
2
  import type { SupervisorBackend } from './types.js';
3
3
  /**
4
- * No supervision: sessions are plain tmux, nothing survives a reboot and
4
+ * No service supervision: tmux contains the direct runner process, nothing survives a reboot and
5
5
  * nothing restarts on crash. Used for temp agents and CI tests
6
6
  * (OURS_FLEET_SUPERVISOR=none).
7
7
  */
@@ -1,7 +1,7 @@
1
1
  import { Tmux, tmuxArgs } from '../tmux.js';
2
2
  import { realExec, shq } from '../exec.js';
3
3
  /**
4
- * No supervision: sessions are plain tmux, nothing survives a reboot and
4
+ * No service supervision: tmux contains the direct runner process, nothing survives a reboot and
5
5
  * nothing restarts on crash. Used for temp agents and CI tests
6
6
  * (OURS_FLEET_SUPERVISOR=none).
7
7
  */
@@ -9,7 +9,7 @@ export function makeNoneBackend(exec = realExec) {
9
9
  const tmux = new Tmux(exec);
10
10
  return {
11
11
  id: 'none',
12
- async init() { return ['no supervisor: sessions are plain tmux (no reboot survival)']; },
12
+ async init() { return ['no service supervisor: direct runner containment only (no reboot survival)']; },
13
13
  async install(name, binPath) {
14
14
  const existed = await tmux.kill(name); // true when a session was there
15
15
  await tmux.newSession(name, process.cwd(), `${shq(binPath)} _run ${shq(name)}`);
package/dist/tmux.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare const tmuxSocket: (session: string) => string;
20
20
  * the shared default server and re-creates #32.
21
21
  */
22
22
  export declare const tmuxArgs: (session: string, args: string[]) => string[];
23
- /** Thin tmux wrapper; all session handling in the core goes through this. */
23
+ /** Thin tmux wrapper retained only for supervisor=none process containment. */
24
24
  export declare class Tmux {
25
25
  private exec;
26
26
  constructor(exec?: Exec);
@@ -32,17 +32,4 @@ export declare class Tmux {
32
32
  * program inside it exited on its own".
33
33
  */
34
34
  kill(name: string): Promise<boolean>;
35
- capture(name: string, lines?: number): Promise<string>;
36
- /** Bounded ANSI/history seed for the browser terminal projection. */
37
- captureHistory(name: string, lines?: number): Promise<string>;
38
- panePid(name: string): Promise<number | null>;
39
- /**
40
- * List the live sessions among `names`, asking each server in turn.
41
- * There is no fleet-wide `tmux ls` any more: a session per server means the
42
- * caller says which sessions to ask about. A server that is not running
43
- * answers non-zero and contributes nothing.
44
- */
45
- list(names: readonly string[]): Promise<string>;
46
- sendText(name: string, text: string): Promise<void>;
47
- sendKey(name: string, key: string): Promise<void>;
48
35
  }
package/dist/tmux.js CHANGED
@@ -20,7 +20,7 @@ export const tmuxSocket = (session) => `${TMUX_SOCKET_PREFIX}${session}`;
20
20
  * the shared default server and re-creates #32.
21
21
  */
22
22
  export const tmuxArgs = (session, args) => ['-L', tmuxSocket(session), ...args];
23
- /** Thin tmux wrapper; all session handling in the core goes through this. */
23
+ /** Thin tmux wrapper retained only for supervisor=none process containment. */
24
24
  export class Tmux {
25
25
  exec;
26
26
  constructor(exec = realExec) {
@@ -42,54 +42,4 @@ export class Tmux {
42
42
  async kill(name) {
43
43
  return (await this.exec('tmux', tmuxArgs(name, ['kill-session', '-t', name]))).code === 0;
44
44
  }
45
- async capture(name, lines = 40) {
46
- const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p']));
47
- if (r.code !== 0)
48
- throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
49
- const all = r.stdout.replace(/\n+$/, '').split('\n');
50
- return all.slice(-lines).join('\n');
51
- }
52
- /** Bounded ANSI/history seed for the browser terminal projection. */
53
- async captureHistory(name, lines = 5_000) {
54
- const bounded = Math.min(Math.max(Math.trunc(lines), 1), 20_000);
55
- const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p', '-e', '-J', '-S', `-${bounded}`]));
56
- if (r.code !== 0)
57
- throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
58
- return Buffer.from(r.stdout).subarray(0, 4 * 1024 * 1024).toString();
59
- }
60
- async panePid(name) {
61
- const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
62
- if (r.code !== 0)
63
- return null;
64
- const pid = parseInt(r.stdout.trim().split('\n')[0], 10);
65
- return Number.isFinite(pid) ? pid : null;
66
- }
67
- /**
68
- * List the live sessions among `names`, asking each server in turn.
69
- * There is no fleet-wide `tmux ls` any more: a session per server means the
70
- * caller says which sessions to ask about. A server that is not running
71
- * answers non-zero and contributes nothing.
72
- */
73
- async list(names) {
74
- const lines = [];
75
- for (const name of names) {
76
- const r = await this.exec('tmux', tmuxArgs(name, ['ls']));
77
- if (r.code === 0 && r.stdout.trim())
78
- lines.push(r.stdout.trimEnd());
79
- }
80
- return lines.join('\n');
81
- }
82
- async sendText(name, text) {
83
- let r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, '-l', text]));
84
- if (r.code !== 0)
85
- throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
86
- r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, 'Enter']));
87
- if (r.code !== 0)
88
- throw new Error(`tmux send-keys Enter '${name}' failed: ${r.stderr.trim()}`);
89
- }
90
- async sendKey(name, key) {
91
- const r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, key]));
92
- if (r.code !== 0)
93
- throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
94
- }
95
45
  }
@@ -31,7 +31,7 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
31
31
  const roleIdentities = new Set(roles.map(r => r.identity));
32
32
  // Tracks identity -> owning watchdog name across entries:
33
33
  // two watchdogs declaring the same identity would otherwise share a temp
34
- // dir, tmux session, and run lock with no error until they collide at
34
+ // dir, agent session, and run lock with no error until they collide at
35
35
  // runtime.
36
36
  const watchdogIdentities = new Map();
37
37
  const out = [];
@@ -61,9 +61,11 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
61
61
  throw new ConfigError(`${where}: identity '${identity}' collides with watchdog '${collidingWatchdog}'`);
62
62
  watchdogIdentities.set(identity, name);
63
63
  const harness = w.harness ?? defaults.harness ?? 'claude-code';
64
- const sessionRaw = w.session ?? defaults.session ?? 'tmux';
65
- if (sessionRaw !== 'tmux' && sessionRaw !== 'acp')
66
- throw new ConfigError(`${where}: session must be 'tmux' or 'acp'`);
64
+ const sessionRaw = w.session ?? defaults.session ?? 'acp';
65
+ if (sessionRaw === 'tmux')
66
+ throw new ConfigError(`${where}: session 'tmux' is no longer supported; use session: acp`);
67
+ if (sessionRaw !== 'acp')
68
+ throw new ConfigError(`${where}: session must be 'acp'`);
67
69
  if (w.prompt_file !== undefined) {
68
70
  if (typeof w.prompt_file !== 'string' || !isAbsolute(w.prompt_file))
69
71
  throw new ConfigError(`${where}: prompt_file must be an absolute path`);
@@ -1,7 +1,6 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
- import { spawn as spawnChild, execFile } from 'node:child_process';
2
+ import { spawn as spawnChild } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
- import { promisify } from 'node:util';
5
4
  import { stringify } from 'yaml';
6
5
  import { errorReport, normalizeWatchdogReport, validateWatchdogReport } from './report.js';
7
6
  import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport } from './store.js';
@@ -15,8 +14,6 @@ import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, ROLE_NAME_RE, }
15
14
  import { getAdapter } from '../harness/registry.js';
16
15
  import { redactLogLine } from '../application/log-service.js';
17
16
  import { controlRequest, controlSocketPath } from '../session/control.js';
18
- import { Tmux } from '../tmux.js';
19
- const execFileAsync = promisify(execFile);
20
17
  /** How often the deadline loop polls for a completed report or a dead child. */
21
18
  const POLL_MS = 1000;
22
19
  /** A `report.json` this old, with no further writes, is treated as finished. */
@@ -56,7 +53,6 @@ async function killIfNeeded(reason, child, roleName, sleep) {
56
53
  if (reason === 'stable')
57
54
  await sleep(HARVEST_GRACE_MS);
58
55
  child.kill();
59
- await killTmuxSession(roleName);
60
56
  }
61
57
  }
62
58
  function defaultLaunchChild(binPath, roleName, runDir) {
@@ -77,13 +73,6 @@ function defaultLaunchChild(binPath, roleName, runDir) {
77
73
  }),
78
74
  };
79
75
  }
80
- /** Best-effort: the tmux session outlives the supervisor child (`runOnce` created it). */
81
- async function killTmuxSession(roleName) {
82
- try {
83
- await execFileAsync('tmux', ['kill-session', '-t', roleName]);
84
- }
85
- catch { /* best effort */ }
86
- }
87
76
  /** Last 4096 chars of run.log, redacted — attached to error reports as diagnostic tail. */
88
77
  function readTail(runDir) {
89
78
  try {
@@ -121,19 +110,15 @@ async function discoverLiveTemporaryRoles() {
121
110
  catch {
122
111
  return [];
123
112
  }
124
- const tmux = new Tmux();
125
113
  const live = await Promise.all(names.map(async (name) => {
126
114
  const dir = agentDir(name, true);
127
- const [acpAlive, tmuxAlive] = await Promise.all([
128
- existsSync(controlSocketPath(dir))
129
- ? controlRequest(dir, { command: 'status' }, 2_000)
130
- .then(response => response.ok
131
- && response.result?.alive === true)
132
- .catch(() => false)
133
- : Promise.resolve(false),
134
- tmux.has(name).catch(() => false),
135
- ]);
136
- return acpAlive || tmuxAlive ? name : undefined;
115
+ const alive = existsSync(controlSocketPath(dir))
116
+ ? await controlRequest(dir, { command: 'status' }, 2_000)
117
+ .then(response => response.ok
118
+ && response.result?.alive === true)
119
+ .catch(() => false)
120
+ : false;
121
+ return alive ? name : undefined;
137
122
  }));
138
123
  return live.filter((name) => name !== undefined);
139
124
  }
@@ -16,7 +16,7 @@ export interface AuthResult {
16
16
  interface Ticket {
17
17
  value: string;
18
18
  sessionId: string;
19
- purpose: 'events' | 'terminal' | 'conversation';
19
+ purpose: 'events' | 'conversation';
20
20
  roleId?: string;
21
21
  expiresAt: number;
22
22
  }
@@ -4,14 +4,13 @@ import { resolve } from 'node:path';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { RoleRepository } from '../application/role-repository.js';
6
6
  import { FleetQueryService } from '../application/fleet-query-service.js';
7
- import { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from '../application/session-control.js';
7
+ import { AcpRoleSessionAdapter } from '../application/session-control.js';
8
8
  import { StructuredLogService } from '../application/log-service.js';
9
9
  import { RoleCommandService } from '../application/role-command-service.js';
10
10
  import { RoleCreationService } from '../application/role-creation-service.js';
11
11
  import { RoleRemovalService } from '../application/role-removal-service.js';
12
12
  import { FleetError } from '../application/errors.js';
13
13
  import { controlRequest, controlSocketPath } from '../session/control.js';
14
- import { Tmux } from '../tmux.js';
15
14
  import { pickBackend } from '../supervisor/index.js';
16
15
  import { realExec } from '../exec.js';
17
16
  import { home, stateRoot } from '../paths.js';
@@ -23,7 +22,6 @@ import { mergeTopology } from './topology-model.js';
23
22
  import { TopologyDraftStore } from './topology-draft-store.js';
24
23
  import { TopologyPromoteService } from './topology-promote.js';
25
24
  import { doctor } from '../doctor.js';
26
- import { TerminalBridgeManager } from './terminal/bridge.js';
27
25
  import { acquireWebServerLock } from './lock.js';
28
26
  import { TrustedDeviceStore } from './device-store.js';
29
27
  import { WebAuth } from './auth.js';
@@ -62,7 +60,6 @@ export async function startWebConsole(options) {
62
60
  }
63
61
  const access = options.access ?? new WebAccessStore(webDir).read();
64
62
  const auth = new WebAuth(publicOrigin?.origin ?? `http://127.0.0.1:${requestedPort}`, publicOrigin?.host ?? `127.0.0.1:${requestedPort}`, Date.now, new TrustedDeviceStore(webDir), access);
65
- const tmux = new Tmux();
66
63
  const backend = pickBackend();
67
64
  const repository = new RoleRepository({
68
65
  configPath: options.configPath,
@@ -78,13 +75,11 @@ export async function startWebConsole(options) {
78
75
  }
79
76
  catch { /* stale socket is evidence, not reachability */ }
80
77
  }
81
- return { acp, tmux: await tmux.has(name).catch(() => false) };
78
+ return { acp };
82
79
  },
83
80
  });
84
81
  const events = new FleetEventBus();
85
82
  const audit = new AuditSink();
86
- const terminals = new TerminalBridgeManager({ repository, audit, tmux });
87
- const terminalAvailable = await terminals.available();
88
83
  const watchdogConfigProvider = cachedConfigProvider(options.configPath);
89
84
  const log = options.log ?? (() => { });
90
85
  let loggedWatchdogFindingsError = false;
@@ -109,8 +104,8 @@ export async function startWebConsole(options) {
109
104
  }
110
105
  }, CONFIG_CACHE_TTL_MS);
111
106
  const query = new FleetQueryService({
112
- repository, supervisor: backend, tmux,
113
- capabilityContext: { terminalPtyAvailable: terminalAvailable },
107
+ repository, supervisor: backend,
108
+ capabilityContext: {},
114
109
  watchdogFindings,
115
110
  });
116
111
  const ops = { backend, binPath: options.binPath, log };
@@ -162,9 +157,6 @@ export async function startWebConsole(options) {
162
157
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
163
158
  taskRooms: new TaskRoomApplicationService(options.configPath),
164
159
  topology: readTopology, topologyDrafts, topologyPromote,
165
- terminalUpgrade: terminalAvailable
166
- ? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
167
- : undefined,
168
160
  async session(roleId) {
169
161
  const role = await repository.get(roleId);
170
162
  if (!role)
@@ -175,8 +167,6 @@ export async function startWebConsole(options) {
175
167
  throw new FleetError('control_unavailable', 'role state directory is unavailable');
176
168
  return new AcpRoleSessionAdapter(dir);
177
169
  }
178
- if (role.configuredBackend === 'tmux' || role.detectedBackend === 'tmux')
179
- return new TmuxRoleSessionAdapter(roleId, tmux);
180
170
  throw new FleetError('capability_unavailable', 'role session backend is unavailable');
181
171
  },
182
172
  }, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth });
@@ -231,7 +221,6 @@ export async function startWebConsole(options) {
231
221
  async close() {
232
222
  try {
233
223
  await control.close();
234
- await terminals.close();
235
224
  await server.close();
236
225
  }
237
226
  finally {
@@ -1,5 +1,4 @@
1
- import { type FastifyInstance, type FastifyRequest } from 'fastify';
2
- import type { WebSocket } from 'ws';
1
+ import { type FastifyInstance } from 'fastify';
3
2
  import type { FleetQueryService } from '../application/fleet-query-service.js';
4
3
  import type { RoleRepository } from '../application/role-repository.js';
5
4
  import type { RoleSessionControl } from '../application/session-control.js';
@@ -32,7 +31,6 @@ export interface WebServices {
32
31
  topologyPromote?: TopologyPromoteService;
33
32
  removal?: RoleRemovalService;
34
33
  taskRooms?: TaskRoomApplicationService;
35
- terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
36
34
  }
37
35
  export interface WebServer {
38
36
  app: FastifyInstance;
@@ -101,7 +101,7 @@ export async function buildWebServer(services, boundary, options = {}) {
101
101
  return {
102
102
  version: VERSION, api: { major: 1, minor: 0 },
103
103
  websocketProtocols: [
104
- 'ours-fleet-events.v1', 'ours-fleet-terminal.v1', 'ours-fleet-conversation.v1',
104
+ 'ours-fleet-events.v1', 'ours-fleet-conversation.v1',
105
105
  ],
106
106
  auditDegraded: audit.degraded,
107
107
  };
@@ -335,7 +335,7 @@ export async function buildWebServer(services, boundary, options = {}) {
335
335
  const control = await services.session(request.params.id);
336
336
  // The idempotent, durably admitted path — used whenever the caller sends a
337
337
  // command id and the role has a conversation ledger. The legacy path stays
338
- // for old clients and tmux roles.
338
+ // for old clients.
339
339
  if (control.submitPromptV2) {
340
340
  const admittedCommandId = commandId ?? randomBytes(16).toString('hex');
341
341
  const receipt = await control.submitPromptV2({
@@ -440,7 +440,7 @@ export async function buildWebServer(services, boundary, options = {}) {
440
440
  });
441
441
  app.post('/api/v1/ws-tickets', async (request) => {
442
442
  const body = request.body;
443
- if (!body?.purpose || !['events', 'terminal', 'conversation'].includes(body.purpose))
443
+ if (!body?.purpose || !['events', 'conversation'].includes(body.purpose))
444
444
  throw new FleetError('invalid_request', 'ticket purpose is required');
445
445
  if (body.purpose === 'conversation' && !body.roleId)
446
446
  throw new FleetError('invalid_request', 'a conversation ticket must be bound to a role');
@@ -534,17 +534,6 @@ export async function buildWebServer(services, boundary, options = {}) {
534
534
  socket.on('close', () => { clearInterval(heartbeat); follow.close(); });
535
535
  });
536
536
  });
537
- app.get('/api/v1/roles/:id/terminal', { websocket: true }, (socket, request) => {
538
- requireSubprotocol(request, 'ours-fleet-terminal.v1');
539
- authorizeSocket(socket, request, async (hello) => {
540
- const ticket = String(hello.ticket ?? '');
541
- const session = auth.consumeTicket(request, ticket, 'terminal', request.params.id);
542
- auth.bindSocket(session.id, socket);
543
- if (!services.terminalUpgrade)
544
- throw new FleetError('capability_unavailable', 'terminal PTY support is unavailable');
545
- await services.terminalUpgrade(socket, request, request.params.id, ticket, hello);
546
- });
547
- });
548
537
  const staticRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'web-app');
549
538
  if (existsSync(staticRoot)) {
550
539
  await app.register(fastifyStatic, { root: staticRoot, prefix: '/' });
@@ -126,10 +126,7 @@ function loopEntry(node, merged, model) {
126
126
  roles, ...pick(node, LOOP_FIELDS),
127
127
  };
128
128
  entry.interval ??= DEFAULT_LOOP_INTERVAL;
129
- // An enabled loop hard-requires `session: acp` on every target. Adding one to a
130
- // tmux agent would make the whole fleet unloadable, so it arrives switched off
131
- // and badged instead — the owner turns it on after switching the agent to ACP.
132
- if (node.enabled === false || !roles.every(role => sessionOf(model, role) === 'acp'))
129
+ if (node.enabled === false)
133
130
  entry.enabled = false;
134
131
  return entry;
135
132
  }
@@ -142,7 +139,7 @@ function linked(merged, id, kind) {
142
139
  function sessionOf(model, role) {
143
140
  const roles = model.roles;
144
141
  const defaults = model.defaults;
145
- return String(roles?.[role]?.session ?? defaults?.session ?? 'tmux');
142
+ return String(roles?.[role]?.session ?? defaults?.session ?? 'acp');
146
143
  }
147
144
  function section(model, key) {
148
145
  const existing = model[key];
@@ -0,0 +1 @@
1
+ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#e9eef0;background:#080b0c;font-synthesis:none;--panel: #111617;--line: #222a2c;--muted: #849093;--mint: #7ce7b0;--amber: #f0c66c;--red: #ff827c}*{box-sizing:border-box}body{margin:0;min-width:0;min-height:100vh;background:radial-gradient(circle at 70% -20%,#183027 0,transparent 35%),#080b0c}button,input,select,textarea{font:inherit}button{color:inherit}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--mint);outline-offset:2px}.auth-screen{display:grid;place-content:center;text-align:center;min-height:100vh;color:var(--muted)}.auth-screen h1{color:#fff;margin-bottom:0}.brand-wordmark{color:var(--mint);font-size:21px;font-weight:820;letter-spacing:-.02em}.auth-screen .brand-wordmark{justify-self:center;font-size:32px}.shell{display:grid;grid-template-columns:224px 1fr;min-height:100vh}aside{position:fixed;inset:0 auto 0 0;width:224px;display:flex;flex-direction:column;padding:24px 14px;border-right:1px solid #1d2425;background:#080b0ceb;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.brand{display:flex;gap:12px;align-items:center;padding:0 8px 30px}.brand div{display:flex;flex-direction:column}.brand small{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:1.4px}nav{display:grid;gap:5px}nav button{display:grid;grid-template-columns:24px 1fr auto;gap:8px;align-items:center;width:100%;border:0;border-radius:8px;padding:10px 12px;text-align:left;color:#98a3a5;background:transparent;text-transform:capitalize;cursor:pointer}nav button:hover,nav button.active{color:#fff;background:#151b1c}nav button.active{box-shadow:inset 2px 0 var(--mint)}nav b{background:#422f22;color:var(--amber);border-radius:10px;font-size:11px;padding:2px 7px}.sidebar-foot{margin-top:auto;display:grid;grid-template-columns:12px 1fr;align-items:center;padding:12px;color:var(--muted);font-size:12px;text-transform:capitalize}.sidebar-foot i{width:7px;height:7px;border-radius:50%;background:var(--amber)}.sidebar-foot i.live{background:var(--mint);box-shadow:0 0 9px var(--mint)}.sidebar-foot small{grid-column:2;opacity:.6;margin-top:3px}.main{grid-column:2;min-width:0}header{display:flex;justify-content:space-between;align-items:flex-end;height:124px;padding:25px 36px 23px;border-bottom:1px solid var(--line);background:#080b0c8c}h1,h2,h3,p{margin-top:0}header h1{margin:4px 0 0;font-size:29px;letter-spacing:-.8px}.eyebrow{color:var(--mint);text-transform:uppercase;font-size:10px;letter-spacing:1.8px;font-weight:700}.header-actions,.actions{display:flex;gap:9px}button.primary,button.secondary,.actions button{border-radius:7px;padding:9px 13px;cursor:pointer}button.primary{border:1px solid #83e9b5;color:#07130d;background:var(--mint);font-weight:700}button.primary:disabled{opacity:.4;cursor:default}button.secondary,.actions button{border:1px solid #30393b;background:#141a1b}button.secondary:hover,.actions button:hover{border-color:#526064}.content,.workspace{padding:28px 36px 60px}.toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;color:var(--muted);font-size:12px}.toolbar input{width:340px;padding:10px 13px;color:#fff;background:#101516;border:1px solid var(--line);border-radius:8px}.toolbar>div{display:flex;align-items:center;gap:10px}.toolbar button{padding:7px 10px}.role-row.inactive{opacity:.72}.role-row.active{background:#141a1b;border-left:2px solid var(--mint)}.watchdog-detail-layout{display:grid;gap:18px}.watchdog-report-head{display:flex;align-items:center;gap:12px}.watchdog-report-head h2{flex:1}.watchdog-roles{display:grid;gap:10px;margin-top:12px}.watchdog-role{border:1px solid var(--line);border-radius:8px;padding:12px 14px;background:#0d1213}.watchdog-role-head{display:flex;align-items:center;gap:10px}.watchdog-role-head strong{text-transform:capitalize}.watchdog-role-detail,.watchdog-role-note{margin:8px 0 0;font-size:12px;color:var(--muted)}.watchdog-role-note{color:var(--amber)}.watchdog-role details{margin-top:8px;font-size:12px}.watchdog-role summary{cursor:pointer;color:var(--muted)}.watchdog-role details ul{margin:8px 0 0;padding-left:18px;color:#c9d1d3}.watchdog-role details li{margin-bottom:4px}.status-guide{display:flex;flex-wrap:wrap;gap:7px;align-items:center;margin-bottom:15px;color:var(--muted);font-size:11px}.status-guide>span,.status-chip{display:inline-flex;align-items:center;gap:5px;border:1px solid #30393b;border-radius:12px;padding:3px 7px;white-space:nowrap}.status-guide b{color:#fff;font-size:11px}.status-guide small{flex-basis:100%;color:var(--muted)}.status-guide .ready,.status-chip.ready{border-color:#315e4a}.status-guide .busy,.status-chip.busy{border-color:#315478}.status-guide .attention,.status-chip.attention,.status-chip.unknown{border-color:#66552f}.status-guide .offline,.status-chip.offline{border-color:#693b38}.role-table{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0e1213cc}.role-row{display:grid;grid-template-columns:minmax(260px,1.55fr) repeat(4,minmax(125px,1fr));width:100%;align-items:center;gap:15px;padding:14px 18px;border:0;border-bottom:1px solid var(--line);background:transparent;text-align:left;cursor:pointer}button.role-row:hover{background:#141a1b}.role-row.heading{padding-block:10px;color:#6f7b7e;font-size:10px;letter-spacing:1px;text-transform:uppercase;cursor:default}.role-row:last-child{border-bottom:0}.role-row>span{min-width:0;display:flex;flex-direction:column;gap:3px}.role-row strong{font-size:13px;text-transform:capitalize;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-row small{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-name{position:relative;display:grid!important;grid-template-columns:auto 1fr auto;align-items:center}.role-name .mission-summary{display:-webkit-box;max-height:2.5em;line-height:1.25;overflow:hidden;overflow-wrap:anywhere;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2}.status-chip{color:#c9d1d3;font-size:10px;padding:3px 6px}.status-chip i{width:6px;height:6px;border-radius:50%;background:#667174}.status-chip.ready i{background:var(--mint)}.status-chip.busy i{background:#71b7ff}.status-chip.attention i,.status-chip.unknown i{background:var(--amber)}.status-chip.offline i{background:var(--red)}.state.ready,.hero-state.ready i{background:var(--mint);box-shadow:0 0 7px #69dba0}.state.busy,.hero-state.busy i{background:#71b7ff}.state.attention,.state.unknown,.hero-state.attention i,.hero-state.unknown i{background:var(--amber)}.state.offline,.hero-state.offline i{background:var(--red)}.role-name em{color:#7f8a8d;font-size:10px;font-style:normal;border:1px solid #2e3739;border-radius:10px;padding:3px 6px}.role-pills{display:flex;flex-direction:column;align-items:flex-end;gap:4px}.empty{padding:60px;text-align:center;color:var(--muted)}.banner{margin:16px 36px 0;border:1px solid;border-radius:8px;padding:10px 13px;font-size:13px}.banner.error,.error{border-color:#5c302f;background:#271616;color:#ffaaa5}.banner.warning,.warning{border-color:#5a4726;background:#241e12;color:#f1ce83}.banner.info{border-color:#28463b;background:#10231c;color:#9be4bf}.workspace>.banner{margin-inline:0}.back{border:0;background:none;color:var(--muted);padding:0 0 18px;cursor:pointer}.workspace-summary{display:flex;flex-wrap:wrap;align-items:center;gap:7px;color:var(--muted);font-size:12px}.workspace-summary>span{border:1px solid var(--line);border-radius:12px;padding:4px 8px}.workspace-summary>small{margin-left:auto}.hero-state{display:flex;gap:7px;align-items:center;color:#fff;text-transform:capitalize}.hero-state i{width:7px;height:7px;border-radius:50%;background:#788}.tabs{display:flex;gap:22px;margin:26px 0 22px;border-bottom:1px solid var(--line)}.tabs button{border:0;border-bottom:2px solid transparent;padding:10px 2px;background:none;color:var(--muted);text-transform:capitalize;cursor:pointer}.tabs button.active{color:#fff;border-bottom-color:var(--mint)}.tabs button:disabled{opacity:.35;cursor:not-allowed}.dashboard-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel{border:1px solid var(--line);border-radius:10px;background:#101516db;padding:18px}.panel h2{font-size:15px;letter-spacing:-.2px}.panel.wide{grid-column:1 / -1}.evidence>div:first-child{display:flex;justify-content:space-between}.evidence>div:first-child strong{color:var(--mint);text-transform:capitalize;font-size:12px}dl{margin:0}dl div{display:grid;grid-template-columns:120px 1fr;gap:15px;padding:7px 0;border-top:1px solid #1d2426;font-size:12px}dt{color:var(--muted)}dd{margin:0;word-break:break-word}.danger{color:#ff9e99!important;border-color:#613432!important}.activity-layout{display:grid;grid-template-columns:1fr 290px;gap:14px;align-items:start}.activity{display:grid;gap:9px}.activity-filter{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px}.activity-filter button{padding:6px 9px}.event{border-left:2px solid #334044;padding:10px 13px;background:#0d1213}.event.agent_text{border-color:var(--mint)}.event.error,.event.permission{border-color:var(--amber)}.event p{margin:5px 0 0;white-space:pre-wrap}.event small,.muted{color:var(--muted)}.composer{position:sticky;top:16px;display:grid;gap:9px}textarea{min-height:84px;resize:vertical}.composer textarea,fieldset input,fieldset select,fieldset textarea{width:100%;color:#fff;border:1px solid #303a3c;border-radius:6px;background:#0d1112;padding:9px 10px}.composer small{color:var(--muted);line-height:1.45}pre{max-width:100%;overflow:auto;white-space:pre-wrap;word-break:break-word;font:12px/1.55 SFMono-Regular,Consolas,monospace;color:#cbd5d7}.log-panel>pre{margin:0;padding:5px 8px;border-bottom:1px solid #1b2224}.audit-list div{display:grid;grid-template-columns:180px 1fr 120px;padding:10px 0;border-top:1px solid var(--line);font-size:12px}.audit-list time{color:var(--muted)}.modal-backdrop{position:fixed;z-index:10;inset:0;display:grid;place-items:center;padding:30px;background:#000000b8;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.modal{width:min(900px,95vw);max-height:92vh;overflow:auto;border:1px solid #354043;border-radius:13px;background:#0d1112;box-shadow:0 30px 100px #000}.modal-head{position:sticky;z-index:2;top:0;display:flex;justify-content:space-between;align-items:flex-start;padding:22px 26px 18px;border-bottom:1px solid var(--line);background:#0d1112f5}.modal-head h2{margin:4px 0 0}.icon{border:0;background:none;color:var(--muted);font-size:25px;cursor:pointer}.wizard,.progress{padding:22px 26px}fieldset{border:0;border-bottom:1px solid var(--line);padding:0 0 20px;margin:0 0 20px}legend{margin-bottom:13px;color:var(--mint);text-transform:uppercase;font-size:10px;letter-spacing:1.5px;font-weight:700}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px}.form-grid.three{grid-template-columns:repeat(3,1fr)}.wake-sources{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px 14px}.wake-sources>small{grid-column:1 / -1;color:var(--muted)}.wake-sources label{display:flex;flex-direction:row;align-items:center;gap:7px;color:#c7d0d1;font-size:11px;text-transform:none;letter-spacing:0}.form-grid label{display:grid;gap:6px;color:#b7c0c2;font-size:12px}.form-grid label small{color:var(--muted)}.form-grid .wide{grid-column:1 / -1}.risk{display:flex;align-items:center;gap:8px;margin-top:14px;color:var(--amber);font-size:12px}.review{border:1px solid #2f493f;border-radius:8px;background:#101b17;padding:15px}.review dl{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.modal-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}.stage{display:flex;gap:12px;min-height:54px;position:relative}.stage:after{position:absolute;left:5px;top:14px;bottom:0;width:1px;background:#34413d;content:""}.stage:last-child:after{display:none}.stage i{z-index:1;width:11px;height:11px;margin-top:2px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 4px #173026}.stage span{display:flex;flex-direction:column;text-transform:capitalize}.stage small{color:var(--muted);margin-top:3px}@media(max-width:1050px){.role-row{grid-template-columns:1.5fr repeat(3,1fr)}.role-row>span:nth-child(4){display:none}}.topology{overflow-x:auto;margin-bottom:16px;border:1px solid var(--line);border-radius:12px;background:radial-gradient(circle at 50% 50%,#13201b 0,#0b1011 58%)}.topology-canvas{position:relative;width:1000px;min-width:100%}.topology svg{position:absolute;inset:0;width:100%;height:100%}.edge line{stroke:#405052;stroke-width:1.5;vector-effect:non-scaling-stroke}.edge.spawned line{stroke:var(--mint);stroke-dasharray:5 4}.edge.watches line{stroke:#d7ad58;stroke-dasharray:2 3}.edge.targets line{stroke:#729ed6;stroke-dasharray:9 3 2 3}.topology-legend{display:grid;gap:8px;margin:0 0 12px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:#0c1112}.topology-legend ul{display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));gap:8px;margin:0;padding:0;list-style:none}.topology-legend li{display:flex;gap:9px;align-items:start;padding:7px;border-radius:7px}.topology-legend li:focus{outline:2px solid var(--mint);outline-offset:1px}.topology-legend li>span:last-child{display:grid;gap:2px}.topology-legend small{color:var(--muted);line-height:1.3}.legend-line{width:34px;margin-top:7px;border-top:2px solid #405052;flex:0 0 auto}.legend-line.spawned{border-color:var(--mint);border-top-style:dashed}.legend-line.watches{border-color:#d7ad58;border-top-style:dotted}.legend-line.targets{height:5px;border-color:#729ed6;border-bottom:1px dotted #729ed6}.topology-node{position:absolute;width:150px;min-height:62px;display:grid;gap:3px;z-index:1;padding:0;text-align:left;border:1px solid #344044;border-radius:10px;background:#111718;box-shadow:0 8px 24px #00000047;transform:translateY(-50%)}.topology-open{display:grid;gap:3px;min-width:0;padding:10px 28px 10px 12px;color:inherit;text-align:left;border:0;background:transparent;cursor:pointer}.topology-remove{position:absolute;top:5px;right:5px;width:22px;height:22px;padding:0;color:var(--muted);border:0;background:transparent}.topology-remove:hover,.topology-remove:focus{color:#ff8e85}.topology-node:hover{border-color:var(--mint);transform:translateY(-50%) translateY(-2px)}.topology-open:disabled{cursor:default;opacity:1}.topology-node small{color:var(--muted);text-transform:uppercase;font-size:9px;letter-spacing:1px}.topology-node strong{overflow:hidden;text-overflow:ellipsis}.topology-node span{color:var(--muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:10px}.topology-node.watchdog{border-color:#6a5830}.topology-node.loop{border-color:#385778}.topology-node.temporary{border-style:dashed}.topology-node.attention,.topology-node.unknown{box-shadow:inset 3px 0 var(--amber)}.topology-node.offline{opacity:.65}.lineage-note{color:var(--muted);font-size:11px}.topology-editor{display:grid;gap:12px}.topology-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.topology-toolbar .spacer{flex:1 1 auto}.draft-count{color:var(--amber);font-size:11px}.topology-empty{display:grid;gap:10px;justify-items:start;padding:26px;border:1px dashed var(--line);border-radius:12px;background:#0c1112}.topology-empty h3{margin:0}.topology-empty p{margin:0;color:var(--muted)}.topology-tutorial{padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:#0c1112;font-size:12px}.topology-tutorial ol{display:grid;gap:6px;margin:0 0 8px;padding-left:18px;color:var(--muted)}.topology-tutorial li.current{color:#d5dddf}.banner{margin:0;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:#0c1112;color:var(--muted);font-size:12px}.banner.warning{border-color:#6a5830;color:var(--amber)}.node-badge{display:inline-block;padding:1px 6px;border-radius:999px;font-size:9px;letter-spacing:.3px}.node-badge.draft-incomplete{background:#2a2413;color:var(--amber)}.node-badge.draft-ready{background:#12261f;color:var(--mint)}.node-badge.configured{background:#16202a;color:#9fc0e0}.node-badge.running{background:#12261f;color:var(--mint)}.node-detail{display:block}.topology-node.draft-incomplete,.topology-node.draft-ready{border-style:dashed}.topology-node.selected{border-color:var(--mint);box-shadow:0 0 0 2px #58c7a059}.topology-node[data-node-id]{touch-action:none}.edge.implicit line{stroke-dasharray:1 5;opacity:.75}.edge.dangling line{stroke:var(--red);stroke-dasharray:3 3}.edge.drafted line{opacity:.7}.topology-list{width:100%;border-collapse:collapse;color:#d5dddf;text-align:left;font-size:12px}.topology-list th,.topology-list td{padding:8px;border-top:1px solid var(--line)}.topology-list tr.selected{background:#101718}.node-inspector{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#0c1112}.node-inspector header{display:flex;align-items:center;justify-content:space-between}.node-inspector h3,.node-inspector h4{margin:0}.node-inspector label{display:grid;gap:4px;font-size:12px}.node-inspector small{color:var(--muted);font-size:10px}.missing-list{display:grid;gap:4px;margin:0;padding-left:18px;color:var(--amber);font-size:12px}.connection-list{display:grid;gap:4px;margin:0;padding-left:18px;color:var(--muted);font-size:11px}.inspector-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.review-panel{display:grid;gap:10px;padding:14px;border:1px solid var(--mint);border-radius:12px;background:#0c1112}.review-panel h3{margin:0}.review-panel .row{display:flex;gap:8px}.topology-fallback{margin:0 0 18px;color:var(--muted);font-size:12px}.topology-fallback summary{cursor:pointer}.topology-fallback table{width:100%;border-collapse:collapse;margin-top:10px;color:#d5dddf;text-align:left}.topology-fallback th,.topology-fallback td{padding:8px;border-top:1px solid var(--line)}.text-button{border:0;padding:0;color:var(--mint);background:transparent;cursor:pointer;text-align:left}.setup-entry{max-width:1180px;margin-inline:auto}.setup-hero{display:grid;grid-template-columns:1fr 300px;gap:30px;align-items:center;padding:12px 0 26px}.setup-hero h2{margin:6px 0 8px;font-size:28px}.setup-hero p{max-width:680px;color:var(--muted)}.setup-safety{display:grid;gap:5px;padding:15px;border:1px solid #315e4a;border-radius:10px;background:#102019}.setup-safety span{color:var(--muted);font-size:12px;line-height:1.45}.setup-steps{display:grid;grid-template-columns:repeat(4,1fr);gap:0;margin:0 0 18px;padding:0;list-style:none}.setup-steps li{display:flex;gap:8px;align-items:center;color:#647174;border-bottom:2px solid #263033;padding:10px;font-size:12px}.setup-steps li.active{color:#fff;border-color:var(--mint)}.setup-steps li.done{color:var(--mint);border-color:#315e4a}.setup-panel{min-height:360px}.setup-panel h3{margin-bottom:6px}.mode-switch{display:flex;gap:8px;align-items:center;margin:0 0 14px;padding:8px;border:1px solid var(--line);border-radius:10px}.mode-switch button.active{color:#07110d;background:var(--mint);border-color:var(--mint)}.mode-switch span{margin-left:6px;color:var(--muted);font-size:12px}.decision-note{display:flex;gap:10px;margin:12px 0;padding:11px;border-left:3px solid var(--mint);background:#102019;color:var(--muted)}.decision-note strong{color:#dce6e2;white-space:nowrap}.setup-actions{display:grid;grid-template-columns:auto 1fr auto;gap:10px;margin-top:22px;padding-top:18px;border-top:1px solid var(--line)}.section-title{display:flex;justify-content:space-between;align-items:start;gap:20px}.entity-list{display:grid;gap:12px}.entity-card{position:relative;padding:15px;border:1px solid var(--line);border-radius:9px;background:#0c1112}.entity-card>.danger{position:absolute;right:14px;bottom:12px}.empty.compact{padding:34px}.preflight{display:grid;gap:4px;margin:12px 0 18px;padding:13px;border:1px solid #315e4a;border-radius:8px;background:#102019}.preflight.failed{border-color:#66552f;background:#211b10}.preflight span{color:var(--muted);font-size:12px}.setup-panel details{margin-top:14px}.setup-panel summary{cursor:pointer;color:#cbd4d6}.config-diff{max-height:360px;padding:14px;border-radius:8px;background:#080b0c}@media(max-width:760px){.topology-legend ul{grid-template-columns:1fr}.mode-switch{align-items:stretch;flex-wrap:wrap}.mode-switch span{flex-basis:100%;margin-left:0}}.check-list{display:grid;gap:7px;padding-left:20px;font-size:12px}.check-list .ok::marker{color:var(--mint)}.check-list .failed::marker{color:var(--red)}@media(max-width:760px){.shell{display:block;padding-bottom:68px}aside{z-index:9;inset:auto 0 0;width:auto;height:64px;padding:7px;border:1px solid var(--line);background:#080b0cf7}aside .brand,.sidebar-foot{display:none}aside nav{grid-template-columns:repeat(5,1fr);gap:2px}aside nav button{display:flex;min-width:0;flex-direction:column;gap:2px;justify-content:center;padding:6px 2px;text-align:center;font-size:9px}aside nav button span{font-size:15px}aside nav b{display:none}.main{width:100%}header{height:auto;min-height:104px;padding:18px;align-items:start;gap:12px}header h1{font-size:23px}.header-actions{flex-wrap:wrap;justify-content:flex-end}.header-actions button{padding:7px 9px;font-size:11px}.content,.workspace{padding:18px 14px 36px}.banner{margin-inline:14px}.toolbar{align-items:stretch;gap:10px;flex-direction:column}.toolbar input{width:100%}.role-table{overflow-x:auto}.role-row{min-width:720px}.setup-hero{grid-template-columns:1fr}.setup-steps li{flex-direction:column;font-size:10px}.form-grid,.form-grid.three{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:stretch;flex-direction:column}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important;animation:none!important}}.conversation-layout{display:grid;grid-template-rows:1fr auto;gap:14px;min-height:60vh}.conversation{display:flex;flex-direction:column;min-height:0;position:relative}.conversation-header{display:flex;flex-wrap:wrap;gap:12px;align-items:baseline;padding-bottom:10px;border-bottom:1px solid var(--line);font-size:12px}.stream-state{text-transform:uppercase;font-size:10px;letter-spacing:1.4px;font-weight:700;color:var(--amber)}.stream-state.live{color:var(--mint)}.stream-state.offline{color:var(--red)}.conversation-header .warning{color:var(--amber)}.transcript{flex:1;overflow-y:auto;display:grid;gap:14px;padding:16px 4px;max-height:62vh}.turn{display:grid;gap:8px}.bubble{border:1px solid var(--line);border-radius:10px;padding:10px 14px;background:#0d1213}.bubble small{color:var(--muted);display:block;margin-bottom:6px}.bubble.user{border-left:2px solid var(--mint);margin-left:8%;background:#10181a}.bubble.assistant{margin-right:8%}.prompt-text,.assistant-text,.thought-text{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:14px;line-height:1.5}.assistant-text+.assistant-text{margin-top:10px;padding-top:10px;border-top:1px dashed var(--line)}.thought-text{color:var(--muted);font-size:13px;margin-top:8px}.thoughts{margin-bottom:8px}.streaming-hint{animation:pulse 1.4s ease-in-out infinite}@media(prefers-reduced-motion:reduce){.streaming-hint{animation:none}}@keyframes pulse{50%{opacity:.45}}.tool-card{border:1px solid var(--line);border-radius:8px;padding:7px 11px;margin:6px 0;font-size:13px;background:#101516}.tool-card summary{display:flex;gap:10px;align-items:baseline;cursor:pointer}.tool-detail{display:grid;gap:9px;padding-top:10px}.tool-detail code{display:block;overflow-wrap:anywhere;color:var(--muted);margin-top:4px}.tool-content{border-top:1px dashed var(--line);padding-top:8px;min-width:0}.tool-content pre{max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:5px 0 0;color:#d9e4e2}.diff-old{color:#d99b96!important}.diff-new{color:#9ed8c4!important}.plan-card{border:1px solid var(--line);border-radius:8px;padding:9px 12px;margin:6px 0;font-size:13px}.plan-card ol{list-style:none;margin:7px 0 0;padding:0;display:grid;gap:5px}.plan-entry{display:grid;grid-template-columns:18px 1fr auto;gap:7px;color:var(--muted)}.plan-entry.in_progress{color:var(--amber)}.plan-entry.completed{color:var(--mint)}.usage-badge{margin-top:10px;color:var(--muted)}.tool-status{text-transform:uppercase;font-size:10px;letter-spacing:1px;font-weight:700;color:var(--muted)}.tool-status.in_progress{color:var(--amber)}.tool-status.completed{color:var(--mint)}.tool-status.failed{color:var(--red)}.permission-card{border:1px solid var(--amber);border-radius:8px;padding:10px 12px;margin:8px 0;font-size:13px;display:grid;gap:8px}.permission-card.resolved{border-color:var(--line);color:var(--muted)}.permission-actions{display:flex;gap:8px;flex-wrap:wrap}.jump-latest{position:absolute;right:20px;bottom:16px}.conversation-layout .composer{display:grid;gap:8px}.conversation-layout .composer textarea{min-height:68px;max-height:200px;resize:vertical;color:#fff;background:#101516;border:1px solid var(--line);border-radius:8px;padding:10px 13px}.composer-actions{display:flex;gap:9px;justify-content:space-between}.composer-actions .danger{margin-left:auto}button.danger{border:1px solid #5b322f;color:var(--red);background:#1a1213;border-radius:7px;padding:9px 13px;cursor:pointer}button.danger:disabled{opacity:.4;cursor:default}