@pellux/goodvibes-tui 1.17.0 → 1.19.0
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/CHANGELOG.md +208 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5025 -1636
- package/package.json +7 -7
- package/src/cli/endpoints.ts +50 -4
- package/src/cli/help.ts +29 -2
- package/src/cli/management-commands.ts +99 -23
- package/src/cli/management.ts +0 -2
- package/src/cli/service-posture.ts +5 -3
- package/src/cli/status.ts +27 -6
- package/src/cli/surface-command.ts +29 -15
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-diagnostics-gateway.ts +99 -0
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/memory-status.ts +118 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +21 -0
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +39 -0
- package/src/core/turn-event-wiring.ts +6 -0
- package/src/core/voice-provision-gateway.ts +202 -0
- package/src/core/voice-provision-status.ts +197 -0
- package/src/daemon/cli.ts +127 -60
- package/src/input/command-registry.ts +21 -0
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/experience-runtime.ts +25 -4
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +15 -1
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +44 -25
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -0
- package/src/input/config-modal-types.ts +11 -0
- package/src/input/config-modal.ts +65 -2
- package/src/input/handler-shortcuts.ts +23 -0
- package/src/input/keybindings.ts +19 -1
- package/src/input/settings-modal-data.ts +31 -37
- package/src/input/settings-modal-types.ts +5 -2
- package/src/input/tts-settings-actions.ts +27 -8
- package/src/input/voice-local-settings.ts +52 -0
- package/src/main.ts +42 -42
- package/src/panels/builtin/operations.ts +54 -1
- package/src/panels/builtin/shared.ts +12 -0
- package/src/panels/builtin-modals.ts +49 -32
- package/src/panels/diff-panel.ts +34 -1
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +120 -4
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +60 -44
- package/src/panels/fleet-read-model.ts +34 -3
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-stop.ts +38 -2
- package/src/panels/fleet-tabs.ts +2 -2
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/memory-modal.ts +149 -5
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/security-modal.ts +6 -0
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/renderer/settings-modal.ts +4 -2
- package/src/renderer/shell-surface.ts +3 -0
- package/src/renderer/tool-call.ts +12 -6
- package/src/renderer/ui-factory.ts +32 -0
- package/src/runtime/bootstrap-command-context.ts +3 -1
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +6 -6
- package/src/runtime/bootstrap-shell.ts +7 -0
- package/src/runtime/code-index-services.ts +15 -1
- package/src/runtime/durability-services.ts +36 -1
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/knowledge-services.ts +94 -0
- package/src/runtime/legacy-daemon-migration.ts +38 -6
- package/src/runtime/legacy-daemon-reconcile.ts +439 -0
- package/src/runtime/memory-governance-services.ts +79 -0
- package/src/runtime/notification-dispatch.ts +28 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +105 -107
- package/src/runtime/ui-services.ts +3 -0
- package/src/runtime/voice-setup-services.ts +104 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/version.ts +11 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// pairing-tailscale-gateway.ts
|
|
3
|
+
//
|
|
4
|
+
// The two tailscale verbs the pairing surfaces drive — a read-only detection
|
|
5
|
+
// (tailscale.get) and the one-action serve (tailscale.serve.run) — over the
|
|
6
|
+
// same generic operator invoke path the fleet acts use (resolveOperatorRpc ->
|
|
7
|
+
// sdk.operator.invoke), reaching the SAME daemon the command layer does.
|
|
8
|
+
//
|
|
9
|
+
// Both thunks degrade QUIETLY: when no daemon is reachable, the probe resolves
|
|
10
|
+
// to null (the pairing surface renders no tailscale affordance at all) rather
|
|
11
|
+
// than throwing into a render path. Absence is never a nag.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
15
|
+
import { resolveOperatorRpc } from '../input/commands/operator-rpc.ts';
|
|
16
|
+
import type {
|
|
17
|
+
PairingTailscaleServeReceipt,
|
|
18
|
+
PairingTailscaleStatus,
|
|
19
|
+
} from '../panels/modals/pairing-modal.ts';
|
|
20
|
+
|
|
21
|
+
export interface PairingTailscaleGatewayDeps {
|
|
22
|
+
readonly configManager: ConfigManager;
|
|
23
|
+
readonly homeDirectory: string | (() => string);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Probe tailscale (tailscale.get). Returns the honest status, or null when the
|
|
28
|
+
* daemon is unreachable / the call fails — the pairing surface then stays quiet.
|
|
29
|
+
*/
|
|
30
|
+
export async function probePairingTailscale(
|
|
31
|
+
deps: PairingTailscaleGatewayDeps,
|
|
32
|
+
): Promise<PairingTailscaleStatus | null> {
|
|
33
|
+
const rpc = resolveOperatorRpc(deps);
|
|
34
|
+
if (!rpc.available) return null;
|
|
35
|
+
try {
|
|
36
|
+
return (await rpc.sdk.operator.invoke('tailscale.get', {})) as PairingTailscaleStatus;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run tailscale.serve.run and return its receipt. Null when the daemon is
|
|
44
|
+
* unreachable; a failed serve returns a receipt with `ok:false` and an honest
|
|
45
|
+
* detail (never a throw the surface has to guess about).
|
|
46
|
+
*/
|
|
47
|
+
export async function runPairingTailscaleServe(
|
|
48
|
+
deps: PairingTailscaleGatewayDeps,
|
|
49
|
+
): Promise<PairingTailscaleServeReceipt | null> {
|
|
50
|
+
const rpc = resolveOperatorRpc(deps);
|
|
51
|
+
if (!rpc.available) return null;
|
|
52
|
+
const result = (await rpc.sdk.operator.invoke('tailscale.serve.run', {})) as {
|
|
53
|
+
readonly receipt: PairingTailscaleServeReceipt;
|
|
54
|
+
};
|
|
55
|
+
return result.receipt;
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// power-chip-source.ts — the topology-aware source of truth behind the
|
|
3
|
+
// always-visible "sleep disabled" chip.
|
|
4
|
+
//
|
|
5
|
+
// Embedded topology: the in-process PowerManager IS the daemon's, so reading
|
|
6
|
+
// getState() per render is already the whole truth; the local runtime bus's
|
|
7
|
+
// OPS_POWER_STATE_CHANGED just triggers an immediate repaint so a
|
|
8
|
+
// webui-originated toggle (which lands on the same in-process manager) lights
|
|
9
|
+
// the chip without waiting for the next natural render.
|
|
10
|
+
//
|
|
11
|
+
// External/adopted topology: the local manager is NOT the daemon's, and the
|
|
12
|
+
// adopted-daemon wire is plain HTTP (no realtime event bridge), so the chip
|
|
13
|
+
// POLLS power.status.get on an unref'd interval and renders the DAEMON's
|
|
14
|
+
// state — a webui-originated toggle on the daemon lights the TUI chip within
|
|
15
|
+
// one poll, and a TUI toggle (forwarded over power.keepAwake.set by
|
|
16
|
+
// power-keepawake-remote.ts) lights the webui's the same way. While the
|
|
17
|
+
// daemon's state is unknown (fetch pending/unreachable) the local projection
|
|
18
|
+
// is served rather than a fabricated one.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import type { PowerState } from '@pellux/goodvibes-sdk/platform/power';
|
|
22
|
+
import { IDLE_POWER_STATE, powerSurfaceFromEvent, powerSurfaceFromState, type PowerStateChangedPayload, type PowerSurfaceState } from './power-status.ts';
|
|
23
|
+
|
|
24
|
+
export interface PowerChipSourceDeps {
|
|
25
|
+
/** The in-process manager (the whole truth in embedded topology). */
|
|
26
|
+
readonly powerManager: { getState(): PowerState };
|
|
27
|
+
/** Subscribe to the LOCAL bus's OPS_POWER_STATE_CHANGED envelopes; returns unsubscribe. */
|
|
28
|
+
readonly onPowerEvent: (cb: (payload: PowerStateChangedPayload) => void) => () => void;
|
|
29
|
+
/** True only in the external/adopted-daemon topology. */
|
|
30
|
+
readonly isExternalDaemon: () => boolean;
|
|
31
|
+
/** Fetch the DAEMON's power.status.get (null when unreachable). */
|
|
32
|
+
readonly fetchDaemonPowerState: () => Promise<PowerState | null>;
|
|
33
|
+
/** Repaint request when the chip's source state changes. */
|
|
34
|
+
readonly render: () => void;
|
|
35
|
+
/** External-topology poll cadence (default 5000ms; unref'd). */
|
|
36
|
+
readonly pollIntervalMs?: number | undefined;
|
|
37
|
+
readonly timers?: { setInterval: typeof setInterval; clearInterval: typeof clearInterval } | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PowerChipSource {
|
|
41
|
+
/** The current chip projection — daemon-synced in external topology, local otherwise. */
|
|
42
|
+
get(): PowerSurfaceState;
|
|
43
|
+
/** Stop the poll + event subscription. */
|
|
44
|
+
stop(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPowerChipSource(deps: PowerChipSourceDeps): PowerChipSource {
|
|
48
|
+
const timers = deps.timers ?? { setInterval, clearInterval };
|
|
49
|
+
let daemonState: PowerSurfaceState | null = null;
|
|
50
|
+
let lastLocal: PowerSurfaceState = IDLE_POWER_STATE;
|
|
51
|
+
let polling = false;
|
|
52
|
+
|
|
53
|
+
// Local bus events: immediate repaint (embedded truth changes live).
|
|
54
|
+
const unsubscribe = deps.onPowerEvent((payload) => {
|
|
55
|
+
lastLocal = powerSurfaceFromEvent(payload);
|
|
56
|
+
deps.render();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const poll = async (): Promise<void> => {
|
|
60
|
+
if (!deps.isExternalDaemon()) {
|
|
61
|
+
if (daemonState !== null) { daemonState = null; deps.render(); }
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (polling) return;
|
|
65
|
+
polling = true;
|
|
66
|
+
try {
|
|
67
|
+
const state = await deps.fetchDaemonPowerState();
|
|
68
|
+
const next = state ? powerSurfaceFromState(state) : null;
|
|
69
|
+
const changed = JSON.stringify(next) !== JSON.stringify(daemonState);
|
|
70
|
+
daemonState = next;
|
|
71
|
+
if (changed) deps.render();
|
|
72
|
+
} finally {
|
|
73
|
+
polling = false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const interval = timers.setInterval(() => { void poll(); }, deps.pollIntervalMs ?? 5000);
|
|
77
|
+
(interval as { unref?: () => void }).unref?.();
|
|
78
|
+
void poll(); // initial sync so an adopted daemon's held keep-awake shows immediately
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
get(): PowerSurfaceState {
|
|
82
|
+
if (deps.isExternalDaemon() && daemonState !== null) return daemonState;
|
|
83
|
+
// Local truth: prefer a live event projection when one arrived, else read the manager.
|
|
84
|
+
try { return powerSurfaceFromState(deps.powerManager.getState()); } catch { return lastLocal; }
|
|
85
|
+
},
|
|
86
|
+
stop(): void {
|
|
87
|
+
unsubscribe();
|
|
88
|
+
timers.clearInterval(interval);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// power-status.ts — the surface-facing view of host sleep ownership (power.*).
|
|
3
|
+
//
|
|
4
|
+
// The SDK's PowerManager owns the policy: a keep-awake toggle (daemon-held,
|
|
5
|
+
// persisted), automatic work-signal inhibition ("held because X"), and the
|
|
6
|
+
// honest lid-split note when idle-sleep can be blocked but lid-close suspend
|
|
7
|
+
// can't. This module is the read-only projection every surface renders from —
|
|
8
|
+
// the always-visible "sleep disabled" chip, and the ops/status detail lines —
|
|
9
|
+
// seeded from powerManager.getState() and kept live by OPS_POWER_STATE_CHANGED.
|
|
10
|
+
//
|
|
11
|
+
// Owner ruling: the chip IS the safety mechanism. No timers, no AC-only
|
|
12
|
+
// options — the keep-awake toggle is exactly one boolean.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import { LID_SWITCH_HONEST_SPLIT, type PowerState } from '@pellux/goodvibes-sdk/platform/power';
|
|
16
|
+
|
|
17
|
+
/** The always-visible chip text shown while keep-awake holds (danger-mode idiom). */
|
|
18
|
+
export const SLEEP_DISABLED_CHIP = 'sleep disabled';
|
|
19
|
+
|
|
20
|
+
/** The flattened power state a surface renders from. */
|
|
21
|
+
export interface PowerSurfaceState {
|
|
22
|
+
/** The owner keep-awake toggle — the "sleep disabled" chip source. */
|
|
23
|
+
readonly keepAwake: boolean;
|
|
24
|
+
/** True while any sleep inhibitor (work or keep-awake) is held. */
|
|
25
|
+
readonly inhibited: boolean;
|
|
26
|
+
/** Live reasons the WORK inhibitor is held ("held because X"). */
|
|
27
|
+
readonly workReasons: readonly string[];
|
|
28
|
+
/** The honest-split note when part of the requested coverage was refused (verbatim). */
|
|
29
|
+
readonly note: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The neutral state (no inhibition, nothing held) — the pre-event seed default. */
|
|
33
|
+
export const IDLE_POWER_STATE: PowerSurfaceState = {
|
|
34
|
+
keepAwake: false,
|
|
35
|
+
inhibited: false,
|
|
36
|
+
workReasons: [],
|
|
37
|
+
note: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Flatten the full PowerState (power.status.get) into the surface projection. */
|
|
41
|
+
export function powerSurfaceFromState(state: PowerState): PowerSurfaceState {
|
|
42
|
+
return {
|
|
43
|
+
keepAwake: state.keepAwake.enabled,
|
|
44
|
+
inhibited: state.keepAwake.held || state.work.held,
|
|
45
|
+
workReasons: state.work.reasons,
|
|
46
|
+
note: state.keepAwake.note,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The OPS_POWER_STATE_CHANGED payload shape the surface consumes. */
|
|
51
|
+
export interface PowerStateChangedPayload {
|
|
52
|
+
readonly inhibited: boolean;
|
|
53
|
+
readonly keepAwake: boolean;
|
|
54
|
+
readonly workReasons: readonly string[];
|
|
55
|
+
readonly note?: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Flatten an OPS_POWER_STATE_CHANGED event payload into the surface projection. */
|
|
59
|
+
export function powerSurfaceFromEvent(ev: PowerStateChangedPayload): PowerSurfaceState {
|
|
60
|
+
return {
|
|
61
|
+
keepAwake: ev.keepAwake,
|
|
62
|
+
inhibited: ev.inhibited,
|
|
63
|
+
workReasons: ev.workReasons,
|
|
64
|
+
note: ev.note ?? null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The ops/status detail lines — the honest, plain-language account of sleep
|
|
70
|
+
* ownership. Order: the chip's meaning, then each "held because X" work reason,
|
|
71
|
+
* then the lid-split note VERBATIM when the SDK served it. Empty when nothing
|
|
72
|
+
* is held and no note applies.
|
|
73
|
+
*/
|
|
74
|
+
export function powerStatusLines(state: PowerSurfaceState): string[] {
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
if (state.keepAwake) {
|
|
77
|
+
lines.push(`${SLEEP_DISABLED_CHIP} — keep-awake is on (this host will not idle-sleep)`);
|
|
78
|
+
}
|
|
79
|
+
for (const reason of state.workReasons) {
|
|
80
|
+
lines.push(`held because ${reason}`);
|
|
81
|
+
}
|
|
82
|
+
if (state.note) {
|
|
83
|
+
// The SDK's honest lid-split line, rendered exactly as served.
|
|
84
|
+
lines.push(state.note);
|
|
85
|
+
}
|
|
86
|
+
if (lines.length === 0) {
|
|
87
|
+
lines.push('sleep is not being held; the host sleeps on its own schedule');
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the SDK served the honest lid-split note (idle blocked, lid-close is the OS's). */
|
|
93
|
+
export function hasLidSplitNote(state: PowerSurfaceState): boolean {
|
|
94
|
+
return state.note === LID_SWITCH_HONEST_SPLIT;
|
|
95
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stable-host.ts — a stable name for printed/QR pairing links.
|
|
3
|
+
*
|
|
4
|
+
* The legacy resolution used the first non-internal IPv4 in enumeration order
|
|
5
|
+
* (see getLocalNetworkIp in management-utils.ts). That address is bound to the
|
|
6
|
+
* current DHCP lease: when the lease changes, every printed link and every QR
|
|
7
|
+
* that encoded it stops resolving. This module resolves a name that survives a
|
|
8
|
+
* lease change where one exists, and only falls back to a routable address when
|
|
9
|
+
* no stable name is available.
|
|
10
|
+
*
|
|
11
|
+
* Resolution ladder (highest preference first):
|
|
12
|
+
* 1. Tailscale MagicDNS name — preferred when tailscale is up. It resolves on
|
|
13
|
+
* and off the LAN and is never bound to a DHCP lease.
|
|
14
|
+
* 2. mDNS `<hostname>.local` — a LAN name a DHCP change does not break.
|
|
15
|
+
* 3. Gateway-routed interface address — the IPv4 on the default-route
|
|
16
|
+
* interface (not merely the first non-internal one, which can land on a
|
|
17
|
+
* bridge/VPN). The last resort; DHCP-bound.
|
|
18
|
+
* 4. Loopback — nothing routable enumerated.
|
|
19
|
+
*
|
|
20
|
+
* The resolver (resolveStableHost) is a pure function of injected inputs so the
|
|
21
|
+
* ladder is testable under mocked interface / tailscale states; the probes that
|
|
22
|
+
* gather those inputs are impure and bounded, and degrade to undefined rather
|
|
23
|
+
* than throw when a tool is missing or slow.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { hostname as osHostname, networkInterfaces } from 'node:os';
|
|
27
|
+
|
|
28
|
+
export type StableHostKind = 'tailscale-magicdns' | 'mdns-local' | 'gateway-interface' | 'loopback';
|
|
29
|
+
|
|
30
|
+
export interface TailscaleState {
|
|
31
|
+
/** BackendState === 'Running'. */
|
|
32
|
+
readonly up: boolean;
|
|
33
|
+
/** Self.DNSName from `tailscale status --json`, trailing dot stripped. */
|
|
34
|
+
readonly magicDnsName?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StableHostInputs {
|
|
38
|
+
/** os.hostname(), possibly an FQDN — the `.local` name derives from its first label. */
|
|
39
|
+
readonly hostname: string;
|
|
40
|
+
/** The routable IPv4 on the default-route interface, when known. */
|
|
41
|
+
readonly gatewayInterfaceIp?: string | undefined;
|
|
42
|
+
/** First non-internal IPv4 in enumeration order — the legacy fallback address. */
|
|
43
|
+
readonly firstNonInternalIp?: string | undefined;
|
|
44
|
+
/** Tailscale state, when the CLI is present and answered in time. */
|
|
45
|
+
readonly tailscale?: TailscaleState | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ResolvedStableHost {
|
|
49
|
+
readonly host: string;
|
|
50
|
+
readonly kind: StableHostKind;
|
|
51
|
+
/** True when `host` is a name that survives a DHCP lease change. */
|
|
52
|
+
readonly stable: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripTrailingDot(name: string): string {
|
|
56
|
+
return name.replace(/\.+$/, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `<hostname>.local` from the first label of a hostname, or null when there is no usable label. */
|
|
60
|
+
export function mdnsLocalName(host: string): string | null {
|
|
61
|
+
const label = (host ?? '').trim().split('.')[0]?.trim();
|
|
62
|
+
if (!label || label.toLowerCase() === 'localhost') return null;
|
|
63
|
+
// Already a `.local` name (hostname was set to the mDNS form): keep it as-is.
|
|
64
|
+
if (host.trim().toLowerCase().endsWith('.local')) return stripTrailingDot(host.trim());
|
|
65
|
+
return `${label}.local`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the stable host from injected inputs. Pure — no I/O — so the ladder is
|
|
70
|
+
* exercised directly under mocked interface / tailscale states.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveStableHost(inputs: StableHostInputs): ResolvedStableHost {
|
|
73
|
+
const magic = inputs.tailscale?.up ? inputs.tailscale.magicDnsName?.trim() : undefined;
|
|
74
|
+
if (magic) return { host: stripTrailingDot(magic), kind: 'tailscale-magicdns', stable: true };
|
|
75
|
+
|
|
76
|
+
const local = mdnsLocalName(inputs.hostname);
|
|
77
|
+
if (local) return { host: local, kind: 'mdns-local', stable: true };
|
|
78
|
+
|
|
79
|
+
const routed = inputs.gatewayInterfaceIp?.trim() || inputs.firstNonInternalIp?.trim();
|
|
80
|
+
if (routed) return { host: routed, kind: 'gateway-interface', stable: false };
|
|
81
|
+
|
|
82
|
+
return { host: '127.0.0.1', kind: 'loopback', stable: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** First non-internal IPv4 in enumeration order (the legacy fallback address). */
|
|
86
|
+
export function firstNonInternalIpv4(nets: ReturnType<typeof networkInterfaces>): string | undefined {
|
|
87
|
+
for (const name of Object.keys(nets)) {
|
|
88
|
+
for (const netInfo of nets[name] ?? []) {
|
|
89
|
+
if (netInfo.family === 'IPv4' && !netInfo.internal) return netInfo.address;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse the `src <ip>` field out of a Linux `ip route get` line. */
|
|
96
|
+
export function parseIpRouteSrc(output: string): string | undefined {
|
|
97
|
+
const match = /\bsrc\s+(\d+\.\d+\.\d+\.\d+)/.exec(output);
|
|
98
|
+
return match?.[1];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse `Self.DNSName` + running state out of `tailscale status --json` output. */
|
|
102
|
+
export function parseTailscaleStatus(json: string): TailscaleState | undefined {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(json) as { BackendState?: unknown; Self?: { DNSName?: unknown } };
|
|
105
|
+
const up = parsed.BackendState === 'Running';
|
|
106
|
+
const dnsName = typeof parsed.Self?.DNSName === 'string' ? stripTrailingDot(parsed.Self.DNSName) : undefined;
|
|
107
|
+
return { up, ...(dnsName ? { magicDnsName: dnsName } : {}) };
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function probeGatewayInterfaceIp(): string | undefined {
|
|
114
|
+
try {
|
|
115
|
+
const out = execFileSync('ip', ['route', 'get', '1.1.1.1'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 });
|
|
116
|
+
return parseIpRouteSrc(out);
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function probeTailscaleState(): TailscaleState | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const out = execFileSync('tailscale', ['status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 });
|
|
125
|
+
return parseTailscaleStatus(out);
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Gather the impure inputs the resolver needs, each bounded and failure-tolerant. */
|
|
132
|
+
export function probeStableHostInputs(): StableHostInputs {
|
|
133
|
+
let nets: ReturnType<typeof networkInterfaces> = {};
|
|
134
|
+
try {
|
|
135
|
+
nets = networkInterfaces();
|
|
136
|
+
} catch {
|
|
137
|
+
nets = {};
|
|
138
|
+
}
|
|
139
|
+
const first = firstNonInternalIpv4(nets);
|
|
140
|
+
const gateway = probeGatewayInterfaceIp();
|
|
141
|
+
const tailscale = probeTailscaleState();
|
|
142
|
+
return {
|
|
143
|
+
hostname: safeHostname(),
|
|
144
|
+
...(gateway ? { gatewayInterfaceIp: gateway } : {}),
|
|
145
|
+
...(first ? { firstNonInternalIp: first } : {}),
|
|
146
|
+
...(tailscale ? { tailscale } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeHostname(): string {
|
|
151
|
+
try {
|
|
152
|
+
return osHostname();
|
|
153
|
+
} catch {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The stable host for a printed/QR link given the endpoint's bind host. A
|
|
160
|
+
* wildcard bind (0.0.0.0 / ::) resolves through the ladder; an explicit bind
|
|
161
|
+
* host is honored as-is (loopback stays loopback). `probe` is injectable for
|
|
162
|
+
* tests.
|
|
163
|
+
*/
|
|
164
|
+
export function stableUrlHostForBindHost(
|
|
165
|
+
host: string,
|
|
166
|
+
probe: () => StableHostInputs = probeStableHostInputs,
|
|
167
|
+
): ResolvedStableHost {
|
|
168
|
+
if (host === '0.0.0.0' || host === '::') return resolveStableHost(probe());
|
|
169
|
+
const bound = host || '127.0.0.1';
|
|
170
|
+
return { host: bound, kind: bound === '127.0.0.1' ? 'loopback' : 'gateway-interface', stable: false };
|
|
171
|
+
}
|
|
@@ -24,6 +24,13 @@ export interface StreamMetrics {
|
|
|
24
24
|
activeToolStartedAtMs: number | undefined;
|
|
25
25
|
/** Name of the currently executing tool; cleared when execution completes. */
|
|
26
26
|
activeToolName: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* callId of the currently executing tool call; cleared when execution
|
|
29
|
+
* completes. This is the real orchestrator callId (from TOOL_EXECUTING),
|
|
30
|
+
* so a per-tool cancel affordance targets exactly the running call — never
|
|
31
|
+
* the synthetic 'live' render id.
|
|
32
|
+
*/
|
|
33
|
+
activeToolCallId: string | undefined;
|
|
27
34
|
/**
|
|
28
35
|
* Epoch ms of the most recent STREAM_START or STREAM_DELTA; undefined when
|
|
29
36
|
* idle (no turn in flight). Read every render frame — not just on the
|
|
@@ -49,6 +56,16 @@ export interface StreamMetrics {
|
|
|
49
56
|
reconnectMaxAttempts: number | undefined;
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
/** The idle initial StreamMetrics — mutated in place by wireStreamEventMetrics handlers. */
|
|
60
|
+
export function createStreamMetrics(): StreamMetrics {
|
|
61
|
+
return {
|
|
62
|
+
startTime: 0, deltaCount: 0, tokenSpeed: 0, ttftMs: undefined, ttftRecorded: false,
|
|
63
|
+
activeToolStartedAtMs: undefined, activeToolName: undefined, activeToolCallId: undefined,
|
|
64
|
+
lastDeltaAtMs: undefined, stallEpisode: 0,
|
|
65
|
+
reconnectAttempt: undefined, reconnectMaxAttempts: undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/** Minimal orchestrator surface required for stream token-speed calculation. */
|
|
53
70
|
interface StreamOrchestrator {
|
|
54
71
|
readonly streamingOutputTokens: number;
|
|
@@ -443,6 +460,7 @@ export function wireStreamEventMetrics(
|
|
|
443
460
|
unsubs.push(events.tools.on('TOOL_EXECUTING', (ev) => {
|
|
444
461
|
metrics.activeToolStartedAtMs = ev.startedAt;
|
|
445
462
|
metrics.activeToolName = ev.tool;
|
|
463
|
+
metrics.activeToolCallId = ev.callId;
|
|
446
464
|
render();
|
|
447
465
|
}));
|
|
448
466
|
// On every tool-completion path, reset lastDeltaAtMs to "now" so the
|
|
@@ -456,16 +474,19 @@ export function wireStreamEventMetrics(
|
|
|
456
474
|
unsubs.push(events.tools.on('TOOL_SUCCEEDED', () => {
|
|
457
475
|
metrics.activeToolStartedAtMs = undefined;
|
|
458
476
|
metrics.activeToolName = undefined;
|
|
477
|
+
metrics.activeToolCallId = undefined;
|
|
459
478
|
metrics.lastDeltaAtMs = Date.now();
|
|
460
479
|
}));
|
|
461
480
|
unsubs.push(events.tools.on('TOOL_FAILED', () => {
|
|
462
481
|
metrics.activeToolStartedAtMs = undefined;
|
|
463
482
|
metrics.activeToolName = undefined;
|
|
483
|
+
metrics.activeToolCallId = undefined;
|
|
464
484
|
metrics.lastDeltaAtMs = Date.now();
|
|
465
485
|
}));
|
|
466
486
|
unsubs.push(events.tools.on('TOOL_CANCELLED', () => {
|
|
467
487
|
metrics.activeToolStartedAtMs = undefined;
|
|
468
488
|
metrics.activeToolName = undefined;
|
|
489
|
+
metrics.activeToolCallId = undefined;
|
|
469
490
|
metrics.lastDeltaAtMs = Date.now();
|
|
470
491
|
}));
|
|
471
492
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-budget-outcome.ts — render a turn-budget exhaustion as an honest budget
|
|
3
|
+
* line, distinct from an infrastructure failure.
|
|
4
|
+
*
|
|
5
|
+
* When a member agent spends its whole turn budget the SDK stamps a typed
|
|
6
|
+
* outcome — failureKind 'max_turns' with the ceiling that applied and which
|
|
7
|
+
* input set it — instead of leaving consumers to regex a prose message. This
|
|
8
|
+
* renders that typed outcome in plain language: a budget was reached (not a
|
|
9
|
+
* crash), with the number and where the number came from.
|
|
10
|
+
*/
|
|
11
|
+
export type TurnBudgetSource = 'default' | 'spawn-override' | 'policy-bound';
|
|
12
|
+
|
|
13
|
+
/** Plain-language provenance for a turn ceiling. */
|
|
14
|
+
export function describeTurnBudgetSource(source: TurnBudgetSource | undefined): string {
|
|
15
|
+
switch (source) {
|
|
16
|
+
case 'spawn-override':
|
|
17
|
+
return 'a per-spawn override';
|
|
18
|
+
case 'policy-bound':
|
|
19
|
+
return 'the policy cap (agents.maxTurnsCap)';
|
|
20
|
+
case 'default':
|
|
21
|
+
return 'the default limit (agents.maxTurns)';
|
|
22
|
+
default:
|
|
23
|
+
return 'the configured turn limit';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TurnBudgetOutcome {
|
|
28
|
+
readonly limit?: number | undefined;
|
|
29
|
+
readonly source?: TurnBudgetSource | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A short budget line: "reached its turn budget — 50 turns (the default limit
|
|
34
|
+
* (agents.maxTurns))". Honest about the fact that the run stopped because it hit
|
|
35
|
+
* a ceiling, not because something broke.
|
|
36
|
+
*/
|
|
37
|
+
export function formatTurnBudgetOutcome(outcome: TurnBudgetOutcome): string {
|
|
38
|
+
const source = describeTurnBudgetSource(outcome.source);
|
|
39
|
+
return outcome.limit !== undefined
|
|
40
|
+
? `reached its turn budget — ${outcome.limit} turn${outcome.limit === 1 ? '' : 's'} (${source})`
|
|
41
|
+
: `reached its turn budget (${source})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The machine-readable failure reason the SDK stamps on a turn-budget exhaustion. */
|
|
45
|
+
export const TURN_BUDGET_FAILURE_REASON = 'max_turns';
|
|
46
|
+
|
|
47
|
+
export function isTurnBudgetReason(reason: string | null | undefined): boolean {
|
|
48
|
+
return reason === TURN_BUDGET_FAILURE_REASON;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a failure-reason string for a surface that only has the reason (not the
|
|
53
|
+
* typed limit/source event fields), mapping the machine-readable 'max_turns' to
|
|
54
|
+
* a plain budget phrase distinct from an infrastructure failure. Any other
|
|
55
|
+
* reason passes through unchanged.
|
|
56
|
+
*/
|
|
57
|
+
export function describeFailureReason(reason: string | null | undefined): string {
|
|
58
|
+
if (!reason) return '';
|
|
59
|
+
return isTurnBudgetReason(reason) ? 'reached its turn budget' : reason;
|
|
60
|
+
}
|
|
@@ -23,3 +23,42 @@ export function createCancelGeneration(
|
|
|
23
23
|
return stoppedSpeech;
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/** The narrow orchestrator surface the per-tool cancel needs. */
|
|
28
|
+
export interface ToolCancelOrchestrator {
|
|
29
|
+
/** Cancel ONE in-flight tool call; false when no such call is running. */
|
|
30
|
+
cancelToolCall(callId: string): boolean;
|
|
31
|
+
/** The callIds of tool calls currently in flight. */
|
|
32
|
+
listRunningToolCalls(): readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the "cancel the running tool call" action: stops JUST the currently-
|
|
37
|
+
* running tool call, leaving the turn to continue. The in-process orchestrator
|
|
38
|
+
* holds the per-call AbortController — this is the local-session equivalent of
|
|
39
|
+
* the sessions.toolCalls.cancel wire verb. The cancelled call settles as a
|
|
40
|
+
* structured "cancelled by user" result (partial output preserved) the model
|
|
41
|
+
* adapts to in the same turn.
|
|
42
|
+
*
|
|
43
|
+
* Target selection is honest: the tracked active callId (the live transcript
|
|
44
|
+
* row) when known, else the sole in-flight call when exactly one is running,
|
|
45
|
+
* else nothing (returns false — never guesses which of several to stop).
|
|
46
|
+
* `onCancelled` fires only on a real cancellation, with the callId stopped.
|
|
47
|
+
*/
|
|
48
|
+
export function createCancelToolCall(
|
|
49
|
+
orchestrator: ToolCancelOrchestrator,
|
|
50
|
+
getActiveToolCallId: () => string | undefined,
|
|
51
|
+
onCancelled: (callId: string) => void,
|
|
52
|
+
): () => boolean {
|
|
53
|
+
return () => {
|
|
54
|
+
let target = getActiveToolCallId();
|
|
55
|
+
if (target === undefined) {
|
|
56
|
+
const running = orchestrator.listRunningToolCalls();
|
|
57
|
+
if (running.length === 1) target = running[0];
|
|
58
|
+
}
|
|
59
|
+
if (target === undefined) return false;
|
|
60
|
+
const cancelled = orchestrator.cancelToolCall(target);
|
|
61
|
+
if (cancelled) onCancelled(target);
|
|
62
|
+
return cancelled;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { UiRuntimeEvents, RuntimeEventBus } from '@/runtime/index.ts';
|
|
2
2
|
import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
|
|
3
3
|
import { buildCompactionReceiptBlock } from './compaction-receipt.ts';
|
|
4
|
+
import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
|
|
4
5
|
import { persistTurnAnchors, recordTurnAnchor, summarizeTurnLabel } from './rewind-turn-anchors.ts';
|
|
5
6
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
7
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
@@ -290,6 +291,11 @@ export function wireTurnEventHandlers(
|
|
|
290
291
|
// chain/owner/cohort surfaces.
|
|
291
292
|
if (payload.failureKind === 'cancelled') {
|
|
292
293
|
notifyCompletion('GoodVibes — WRFC chain cancelled', `chain ${payload.chainId.slice(0, 12)} cancelled: ${payload.reason}`, FORCE_NOTIFY_DURATION_MS);
|
|
294
|
+
} else if (payload.failureKind === 'max_turns') {
|
|
295
|
+
// A turn-budget exhaustion is a spent ceiling, not an infrastructure
|
|
296
|
+
// failure — narrate the limit and where it came from, from the typed
|
|
297
|
+
// event fields, never a regex of the prose reason.
|
|
298
|
+
notifyCompletion('GoodVibes — WRFC chain hit its turn budget', `chain ${payload.chainId.slice(0, 12)} ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`, FORCE_NOTIFY_DURATION_MS);
|
|
293
299
|
} else {
|
|
294
300
|
const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
|
|
295
301
|
notifyCompletion('GoodVibes — WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
|