diff --git a/slack-bridge/index.test.ts b/slack-bridge/index.test.ts index a38a2d5..08e7771 100644 --- a/slack-bridge/index.test.ts +++ b/slack-bridge/index.test.ts @@ -24,6 +24,34 @@ type CommandDefinition = { type EventHandler = (event: unknown, ctx: ExtensionContext) => Promise | unknown; +const BROKER_MANAGED_PINET_ENV_KEYS = [ + "PINET_BROKER_MANAGED", + "PINET_LAUNCH_SOURCE", + "PINET_BROKER_AGENT_ID", + "PINET_TMUX_SESSION", +] as const; + +const originalBrokerManagedPinetEnv = Object.fromEntries( + BROKER_MANAGED_PINET_ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record<(typeof BROKER_MANAGED_PINET_ENV_KEYS)[number], string | undefined>; + +beforeEach(() => { + for (const key of BROKER_MANAGED_PINET_ENV_KEYS) { + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of BROKER_MANAGED_PINET_ENV_KEYS) { + const value = originalBrokerManagedPinetEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + function stubIsTTY(stream: NodeJS.ReadStream | NodeJS.WriteStream, value: boolean): () => void { const target = stream as unknown as Record; const hadOwnProperty = Object.prototype.hasOwnProperty.call(target, "isTTY"); diff --git a/slack-bridge/index.ts b/slack-bridge/index.ts index 360130f..e5b20e6 100644 --- a/slack-bridge/index.ts +++ b/slack-bridge/index.ts @@ -79,6 +79,7 @@ import { createAgentCompletionRuntime } from "./agent-completion-runtime.js"; import { sendBrokerMessage } from "./broker/message-send.js"; import { type SlackBridgeRuntimeMode, + isBrokerManagedFollowerLaunch, resolveSlackBridgeStartupRuntimeMode, } from "./runtime-mode.js"; import { @@ -1498,6 +1499,7 @@ export default function (pi: ExtensionAPI) { maybeWarnSlackUserAccess(ctx); const startupMode = resolveSlackBridgeStartupRuntimeMode(settings, { brokerSocketExists: fs.existsSync(DEFAULT_SOCKET_PATH), + brokerManagedFollowerLaunch: isBrokerManagedFollowerLaunch(), }); try { diff --git a/slack-bridge/pinet-tools.test.ts b/slack-bridge/pinet-tools.test.ts index 1431d7e..358f0c4 100644 --- a/slack-bridge/pinet-tools.test.ts +++ b/slack-bridge/pinet-tools.test.ts @@ -1117,6 +1117,59 @@ describe("registerPinetTools", () => { }); }); + it("hides recently disconnected agents from pinet agents unless ghosts are requested", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-14T12:00:00Z")); + + const listBrokerAgents = vi.fn(() => [ + makeAgent({ id: "live", name: "Live Lynx" }), + makeAgent({ + id: "exited", + name: "Exited Egret", + disconnectedAt: "2026-04-14T11:59:59.000Z", + }), + ]); + const tools = registerWithDeps(createDeps({ listBrokerAgents })); + + const defaultResult = (await tools.get("pinet")?.execute("tool-call-agents-live", { + action: "agents", + args: { full: true }, + })) as { details: { data: { text: string; details: { agents: Array<{ id: string }> } } } }; + expect(defaultResult.details.data.details.agents.map((agent) => agent.id)).toEqual(["live"]); + expect(defaultResult.details.data.text).not.toContain("Exited Egret"); + + const withGhostsResult = (await tools.get("pinet")?.execute("tool-call-agents-ghosts", { + action: "agents", + args: { full: true, include_ghosts: true }, + })) as { details: { data: { text: string; details: { agents: Array<{ id: string }> } } } }; + expect(withGhostsResult.details.data.details.agents.map((agent) => agent.id)).toEqual([ + "live", + "exited", + ]); + expect(withGhostsResult.details.data.text).toContain("Exited Egret"); + }); + + it("passes the ghost visibility preference through follower agent listing", async () => { + const listFollowerAgents = vi.fn(async (_includeGhosts: boolean) => [ + makeAgent({ id: "agent-2" }), + ]); + const tools = registerWithDeps( + createDeps({ brokerRole: () => "follower", listFollowerAgents }), + ); + + await tools.get("pinet")?.execute("tool-call-follower-agents-live", { + action: "agents", + args: {}, + }); + await tools.get("pinet")?.execute("tool-call-follower-agents-ghosts", { + action: "agents", + args: { include_ghosts: true }, + }); + + expect(listFollowerAgents).toHaveBeenNthCalledWith(1, false); + expect(listFollowerAgents).toHaveBeenNthCalledWith(2, true); + }); + it("keeps pinet agents default cli details compact", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-14T12:00:00Z")); diff --git a/slack-bridge/pinet-tools.ts b/slack-bridge/pinet-tools.ts index b430c55..b3099be 100644 --- a/slack-bridge/pinet-tools.ts +++ b/slack-bridge/pinet-tools.ts @@ -1250,7 +1250,7 @@ function runPinetAgentsAction( throw new Error("Pinet is not running. Use /pinet start or /pinet follow first."); } - const includeGhosts = true; + const includeGhosts = params.include_ghosts === true; const recentGhostWindowMs = DEFAULT_HEARTBEAT_TIMEOUT_MS * 2; const nowMs = Date.now(); const hasHint = Boolean( @@ -1423,6 +1423,12 @@ export function registerPinetTools(pi: ExtensionAPI, deps: RegisterPinetToolsDep Type.String({ description: "Comma-separated required capability/tool tags" }), ), task: Type.Optional(Type.String({ description: "Optional natural-language task hint" })), + include_ghosts: Type.Optional( + Type.Boolean({ + description: + "Include recently disconnected/resumable agents. Defaults false so graceful exits do not look like actionable ghosts.", + }), + ), ...PINET_OUTPUT_OPTION_PARAMETERS, }), execute: (_id, params, output) => runPinetAgentsAction(params, deps, "pinet:agents", output), diff --git a/slack-bridge/runtime-mode.test.ts b/slack-bridge/runtime-mode.test.ts index ad58718..1014c59 100644 --- a/slack-bridge/runtime-mode.test.ts +++ b/slack-bridge/runtime-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isBrokerManagedFollowerLaunch, isPinetRuntimeMode, normalizeSlackBridgeRuntimeMode, resolveSlackBridgeStartupRuntimeMode, @@ -32,6 +33,26 @@ describe("isPinetRuntimeMode", () => { }); }); +describe("isBrokerManagedFollowerLaunch", () => { + it("detects broker-managed tmux follower launches", () => { + expect( + isBrokerManagedFollowerLaunch({ + PINET_BROKER_MANAGED: "1", + PINET_LAUNCH_SOURCE: "broker-tmux", + }), + ).toBe(true); + }); + + it("does not treat other broker-managed metadata as the tmux follower path", () => { + expect( + isBrokerManagedFollowerLaunch({ + PINET_BROKER_MANAGED: "1", + PINET_LAUNCH_SOURCE: "manual", + }), + ).toBe(false); + }); +}); + describe("resolveSlackBridgeStartupRuntimeMode", () => { it("defaults to off", () => { expect(resolveSlackBridgeStartupRuntimeMode({})).toBe("off"); @@ -75,6 +96,50 @@ describe("resolveSlackBridgeStartupRuntimeMode", () => { expect(resolveSlackBridgeStartupRuntimeMode({ runtimeMode: "broker" })).toBe("broker"); }); + it("keeps broker-managed follower launches off even when persistent settings request broker mode", () => { + expect( + resolveSlackBridgeStartupRuntimeMode( + { runtimeMode: "broker" }, + { brokerSocketExists: true, brokerManagedFollowerLaunch: true }, + ), + ).toBe("off"); + }); + + it("preserves explicit non-follower runtime mode over legacy autoFollow for broker-managed launches", () => { + for (const runtimeMode of ["off", "single", "broker"] as const) { + expect( + resolveSlackBridgeStartupRuntimeMode( + { runtimeMode, autoFollow: true }, + { brokerSocketExists: true, brokerManagedFollowerLaunch: true }, + ), + ).toBe("off"); + } + }); + + it("keeps broker-managed follower launches off when legacy autoConnect would start single-player mode", () => { + expect( + resolveSlackBridgeStartupRuntimeMode( + { autoConnect: true }, + { brokerSocketExists: true, brokerManagedFollowerLaunch: true }, + ), + ).toBe("off"); + }); + + it("still honors follower opt-in for broker-managed launches when a broker socket exists", () => { + expect( + resolveSlackBridgeStartupRuntimeMode( + { runtimeMode: "follower" }, + { brokerSocketExists: true, brokerManagedFollowerLaunch: true }, + ), + ).toBe("follower"); + expect( + resolveSlackBridgeStartupRuntimeMode( + { autoFollow: true }, + { brokerSocketExists: true, brokerManagedFollowerLaunch: true }, + ), + ).toBe("follower"); + }); + it("downgrades explicit follower mode to off when no broker socket exists", () => { expect( resolveSlackBridgeStartupRuntimeMode( diff --git a/slack-bridge/runtime-mode.ts b/slack-bridge/runtime-mode.ts index 1e20d38..46752d8 100644 --- a/slack-bridge/runtime-mode.ts +++ b/slack-bridge/runtime-mode.ts @@ -23,6 +23,11 @@ export function isPinetRuntimeMode(mode: SlackBridgeRuntimeMode): boolean { export interface ResolveSlackBridgeStartupRuntimeModeOptions { brokerSocketExists?: boolean; + brokerManagedFollowerLaunch?: boolean; +} + +export function isBrokerManagedFollowerLaunch(env = process.env): boolean { + return env.PINET_BROKER_MANAGED === "1" && env.PINET_LAUNCH_SOURCE === "broker-tmux"; } export function resolveSlackBridgeStartupRuntimeMode( @@ -32,6 +37,13 @@ export function resolveSlackBridgeStartupRuntimeMode( const explicitMode = normalizeSlackBridgeRuntimeMode(settings.runtimeMode); const brokerSocketExists = options.brokerSocketExists ?? true; + if (options.brokerManagedFollowerLaunch) { + if (explicitMode === "follower" || (!explicitMode && settings.autoFollow)) { + return brokerSocketExists ? "follower" : "off"; + } + return "off"; + } + if (explicitMode) { if (explicitMode === "follower" && !brokerSocketExists) { return "off";